Fulcrum is a project and permitting management platform built by Odin Research for BFAM Builders. It manages the full lifecycle of QSR exterior remodel projects — from initial permitting through construction completion — and is deployed at:
What Fulcrum replaces
| Was | Now |
|---|---|
| Permitting_Workflow_Tracker.xlsx | Live task checklists per project |
| Email Transfer.xlsx | Site details, contacts, activity log |
| Teams threads | Structured notes + automated notifications |
| Manual status updates | Real-time dashboard |
| Shared SharePoint spreadsheet | Client read-only access (Construction Updates) |
| Manual quote reformatting | Built-in quoting tool |
Project types
| Type | Description | Permits |
|---|---|---|
| Rere | Lights and minor changes | Electrical (sometimes), Mural (sometimes) |
| Remodel | Full exterior, 8-day crew | Building (stamped plans), Electrical, Mural |
Fulcrum is a monorepo where each component is named after a Rebel Alliance ship. Each ship has a single responsibility.
| Table | Description | Key fields |
|---|---|---|
sites | Physical locations | store_number, address, jurisdiction, portal_url, portal_username |
users | BFAM team members | email, display_name, role_id, azure_oid, is_active |
roles | Internal role lookup | name (Admin, PM, Permitting, Site Supervisor) |
projects | Active jobs | site_id, project_type, status, is_archived, coordinator_name, site_supervisor_id |
permits | Permit tracks | project_id, permit_type, status, permit_number |
tasks | Checklist items | permit_id, name, status, sequence, assigned_to_id, due_date |
task_dependencies | Task locking logic | task_id, depends_on_task_id, required_status |
task_status_history | Immutable change log | task_id, old_status, new_status, changed_by_id, changed_at |
contacts | AHJ and other contacts (construction personnel only) | site_id, project_id, name, phone, email, contact_type |
notes | Activity log (construction personnel only) | project_id, task_id, author_id, body, category, visible_to_client, created_at |
photos | Project photos | project_id, category, caption, visible_to_client, uploaded_by_id |
daily_logs | Site daily logs | project_id, log_date, notes, weather_conditions, created_by |
quotes | Project quotes | project_id, created_by, total, line_items (JSONB) |
client_organizations | External client companies (e.g. a franchisee group) | name |
client_users | Client-side people (Azure AD B2B guests) | client_organization_id, name, email, azure_oid |
client_project_access | Which org can see which project | client_organization_id, project_id |
Roles & identities
Every signed-in identity is either internal (a BFAM team member, one of the four roles below) or Client (an external contact tied to a client_organization via client_project_access grants — not a role on the users table at all). GET /users/me returns a discriminated shape (kind: "internal" or "client") that every Razor page branches on to decide what to render.
| Role | Can do |
|---|---|
Admin | Everything — manage users, roles, client orgs/access grants, edit all data, reveal credentials |
PM | Create projects, assign Site Supervisors, edit projects/tasks/permits/notes |
Permitting | Create projects, grant client org project access, edit tasks/permits/notes |
Site Supervisor | Full read/write on tasks, notes, photos, daily logs, contacts |
Client | External guest — read-only project.html (permit status, no Activity Log/AHJ Contacts) plus Construction Updates (client-visible photos, daily logs, quotes) for only the projects their organization has been granted |
viewer role (BFAM's own read-only accounts) was migrated to Site Supervisor, a real increase in access.Task statuses
| URL | Who | What |
|---|---|---|
/ | All internal users | Portfolio dashboard — all projects |
/project.html?id=X | Internal (full); Client (read-only, no Activity Log/AHJ Contacts) | Project detail — permit tracks, tasks |
/updates?project_id=X | Internal (full); Client (client-visible only) | Construction Updates — photos, daily logs, quotes |
/photos.html?project_id=X | Internal (full); Client (client-visible only) | Full photo gallery |
/board.html | All internal users | Swimlane task board |
/quote.html | Internal, gated per-quote by project | Quoting tool |
/admin | Admin only | User management, site editing, templates |
/docs | All internal users | This manual |
BFAM team
| Name | Role | |
|---|---|---|
| Chad Buttrey | chadwick@bfambuilders.com | Admin |
| Ted Peterson | ted.peterson@bfambuilders.com | Admin |
| Jaci Mays | jaci.mays@bfambuilders.com | Admin |
| Josh | josh@bfambuilders.com | PM |
| Dan Fenton | dan.fenton@bfambuilders.com | Permitting |
| Ronald | ronald@bfambuilders.com | Site Supervisor |
| Meadow | meadow@bfambuilders.com | Site Supervisor |
| Julie | julie@bfambuilders.com | Site Supervisor |
Checkers corporate
Kevin Sorensen (sorensenk@checkers.com) and Russell Bernstine (bernstiner@checkers.com) previously had internal viewer accounts against the old /checkers portal. That portal and those accounts are retired — the migration deactivates both rather than auto-converting them, since a real Client identity needs an organization and explicit per-project grants first. A "Checkers Corporate" client_organizations row now exists (Admin → Clients) with per-project grants; add each contact as a client_users row under it once their Azure AD B2B guest invite has been accepted (see "Adding a client contact" below) — Russell hadn't been added there as of this writing.
New project workflow
Adding a client contact
client_users row, so Fulcrum 403s them as "not authorized."AADSTS90072 ("does not exist in tenant ... needs to be added as an external user"), before Fulcrum is ever reached.client_organizations row per external company (e.g. "Checkers Corporate") — one org can hold multiple contacts.preferred_username claim. For a plain guest invite that's usually just their real email; for some B2B flows it's the mangled tenant UPN instead (e.g. name_company.com#EXT#@<tenant>.onmicrosoft.com) — if in doubt, have them attempt to sign in once and check what email the 403 error names, then edit the row to match (name/email are both editable inline, blur to save).Automated workflows (Phantom)
| Trigger | Action |
|---|---|
| Every hour | Smartsheet sync — pulls construction dates from Checkers |
| Monday 7am Chicago | Weekly blocker report emailed to team |
| Daily 8am Chicago | Escalation — Teams ping for tasks blocked 3+ days |
| Task → Blocked | Immediate Teams notification |
| All permits issued | Teams notification to #construction-handoff |
The main view at / shows all active projects at a glance.
| Element | Description |
|---|---|
| Summary cards | Total projects, permits complete, in progress, mural pending |
| Filter buttons | All / Rere only / Remodel only / Complete / In progress |
| Progress bars | % of tasks complete per permit track |
| Status badges | Green=complete, Blue=in progress, Amber=ext blocked, Red=blocked, Gray=not started |
| + New project | Opens new project modal |
| Board link | Navigates to swimlane board |
| Docs link | Opens this manual |
| Show archived | Toggle archived projects |
| Dark mode | Sun/moon toggle — persists across all pages |
| Sign out | Signs out of Microsoft account |
Project detail page
| Panel | What you can do |
|---|---|
| Project header | See name, type, status, address. Click coordinator to assign. |
| Permit tracks | Collapsible checklists. Click status dropdown to update. Hover task for × remove. Click permit number to edit. |
| + Add task | Add ad hoc tasks below each permit track. |
| Activity log | All notes newest-first, filterable by category. Add note at bottom, with a "Share with client" checkbox. Construction personnel only — never shown to a Client identity. |
| AHJ & Contacts | Jurisdiction contacts. + Add contact to add new ones. Construction personnel only — never shown to a Client identity. |
| Construction updates | Link out to the /updates page for this project — photos, daily logs, and quotes now live there instead of on this page. |
| Archive button | Next to status badge. Confirms before archiving. Hidden for a Client identity. |
Archiving vs deleting
Projects are never hard-deleted. Archive hides them from the dashboard but preserves all data. Toggle "Show archived" to restore. Use the Unarchive button on an archived project to make it active again.
Building permit — 14 tasks
| # | Task |
|---|---|
| 1 | Identify AHJ + GIS lookup |
| 2 | Look up requirements on city/county website |
| 3 | Check for online portal, create account if available |
| 4 | Submit via email/mail if no portal |
| 5 | Discuss registration, plan submittal, additional requirements with AHJ |
| 6 | Pull all required submittals and workflow |
| 7 | Request required docs (insurance cert, bond, tax office, NOC, LOA) |
| 8 | Complete contractor registration |
| 9 | Submit registration (license, COI, BTR, bond) |
| 10 | Coordinate sub-contractor registration |
| 11 | Complete and submit permit application |
| 12 | Coordinate with electrical and other sub trades |
| 13 | Complete and hold all submission documents |
| 14 | Pay fees and respond to all plan review comments |
The mural permit follows a decision tree — options are tried in order, stopping when one is approved. Locked options unlock automatically when the previous is rejected.
| Option | Description | Unlocks when |
|---|---|---|
| 1 | Mural on both sides | Always available first |
| 2 | Mural on one side | Option 1 rejected |
| 3 | Chevron painted | Option 2 rejected |
| 4 | Parallelogram painted | Option 3 rejected |
The board at /board.html shows all actionable tasks across all projects in five columns: Not started, In progress, Ext. blocked, Blocked, Complete.
- Filter by person using the dropdown — shows one coordinator's full workload
- Filter by project type (Rere/Remodel)
- Locked and N/A tasks are hidden — only actionable tasks shown
- Click any card to open the project detail page
- Overdue tasks show in red
The quoting tool at /quote.html replaces the manual process of reformatting IAM quotes into BFAM format.
Creating a quote
Saved quotes appear in the Quotes panel on the /updates Construction Updates page for the project. Click any saved quote to reopen and edit it.
Fulcrum has no separate client login. External contacts (a franchisee's ops team, a corporate contact like Checkers) are invited as Azure AD B2B guests into the same tenant BFAM already uses — they sign in with their own company email, same Microsoft sign-in flow as everyone else. A guest is only ever a Client identity: it belongs to a client_organizations row, and can only see the specific projects that organization has been granted via client_project_access — no organization-wide or portfolio-wide view.
What a Client sees
/project.html?id=X — read-only
- Project name, type, status, address
- Permit tracks and task-level status
- Link to Construction Updates
/updates?project_id=X
- Photos marked
visible_to_client - Daily logs (read-only)
- Quotes (read-only)
What a Client never sees
- Activity Log (internal notes) — construction personnel only, on any page, not even a filtered view
- AHJ & Contacts
- Portal credentials, any other project's data, or any edit/create control
client_project_access grants returns a plain 404, the same as a project that doesn't exist — a Client should never be able to tell the difference between "not yours" and "doesn't exist."Admin page at /admin — accessible to Admin role only.
Users section
- Lists all users with name, email, role, and status
- Change role via dropdown — saves immediately
- Deactivate blocks login without deleting data
- Delete permanently removes the user and their allowlist entry
- Add user form creates DB record and adds to allowlist
- You cannot deactivate or delete yourself
Sites section
Click any field to edit inline — address, jurisdiction, portal URL, portal username. Changes save on Enter or blur.
Templates section
View the JSON task templates that define permitting checklists. Read-only in the UI. To modify templates, edit the JSON files in templates/ on the server.
sentinel/users.json manually on the server, always validate it before restarting Ghost: python3 -c "from sentinel.middleware import validate_users_json; validate_users_json()"Adding portal passwords to Key Vault
Infrastructure
| Service | Details | Cost/mo |
|---|---|---|
| Azure VM | fulcrum-vm-01, Standard_F1als_v7, Ubuntu 24.04 | ~$44 |
| PostgreSQL | Local on VM (not managed service) | $0 |
| SSL | Let's Encrypt via Certbot — auto-renews | $0 |
| DNS | bfam-fulcrum.centralus.cloudapp.azure.com | $0 |
| Total | ~$44/mo |
Deploy an update
alembic upgrade head before restarting if the update includes new migrations — check git log --stat for new files under tantive/alembic/versions/. Take a pg_dump backup first for any migration that drops a column or deactivates data, not just an additive one.Useful commands on the VM
Environment variables
All config lives in ~/fulcrum/.env on the Azure VM. Never commit this file. Key variables:
Repository
github.com/tedbp19/fulcrum — private. Main branch is production.
Local development
APP_ENV=development and comment out AZURE_TENANT_ID in .env to enable the auth bypass. Never deploy with these settings.Database migrations
create_type=False issue — autogenerate doesn't handle shared enum types correctly. See existing migrations for the pattern.Adding a permit template
Drop a new JSON file in templates/ following the existing format. Run scripts/migrate/04_seed_tasks.py to instantiate tasks for existing projects.