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 |
track_templates | Phase/track definitions — Building Permit, Electrical Permit, Mural Authorization, Pre-Con, Construction, Financial, or any admin-added one | key, name, color, steps (workflow), tickets (its ticket list) |
project_tracks | One track_template attached to one project | project_id, track_template_id, current_status, permit_number (permit tracks only) |
ticket_templates | A track's ordered ticket list (data, not an enum — Due Diligence, Demo, Estimate, etc.) | track_template_id, key, name, sequence |
track_tickets | One ticket per ticket_template on a project_track, plus ad-hoc ones (ticket_template_id NULL, named by title) | project_track_id, ticket_template_id, title, status, flag, flagged_since, flag_set_at, assigned_to_id, due_date, body_html |
user_track_visibility | Per-user override of which tracks/tickets a user sees (no row = role default) | user_id, track_template_id, visibility (hidden / track_only / track_and_tickets) |
tasks | Legacy — pre-ticket checklist items, kept read-only pending a cleanup pass; API unregistered | permit_id, name, status, sequence, assigned_to_id, due_date |
task_dependencies | Legacy — task locking logic | task_id, depends_on_task_id, required_status |
task_status_history | Legacy — 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 |
track_notes | Per-track internal scratch notes (construction personnel only) — rich text, not the Activity Log; see section 9 | project_track_id, author_id, body_html, 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 — the construction dashboard (their sites + construction status) and, per site, Photos + the schedule Gantt only. No tickets, no project page, no Activity Log, no documents/daily logs/quotes. |
viewer role (BFAM's own read-only accounts) was migrated to Site Supervisor, a real increase in access.Ticket statuses
Each track's tickets carry one of these, plus an independent Flag — Critical (red), At Risk (amber), or Resolved (green), rendered as a bold outline (the ticket's fill color is its track, not its flag). Resolved auto-clears 14 days after it's set:
Track rollup (dashboards)
A track's progress bar is done tickets / total; its color, in priority order: a Critical flag or any Int Blocked ticket → red ("Critical" — one coarse "needs attention" color on the dashboards; the board and project page show the actual flag/status); else an At Risk flag or any Ext Blocked ticket → amber ("At risk"); else all done → green; else any started → blue; else gray (not started). A Resolved flag never affects this.
Construction is the exception. Its progress comes from the project's schedule Gantt (Construction Updates page), not its tickets: when a project has a schedule with tasks, the bar is complete leaf tasks / total, and the color is green when every task is complete, blue when any task is complete or in progress, red when any task is Blocked. A Critical or At Risk flag on a Construction ticket still outranks the Gantt. The same conclusion is written to the track's own status badge on the project page after every schedule change (logged to the Activity Log as the person who changed the task) — forward only, so a schedule with nothing started yet leaves a manually set status alone. A project with no schedule falls back to the ticket rollup above.
Visibility settings
The gear icon in the header (dashboard, board, project page) opens "What you see" — per track, pick hidden, track only (just its status), or track and tickets (tickets also appear as dashboard sub-rows and board cards). One shared setting (GET/PUT /users/me/track-visibility) — a change on one page applies everywhere on the next load. No row yet for a track = the role default: Permitting sees permit tracks' tickets, Site Supervisor sees Pre-Con/Construction's, Admin/PM see everything; anyone can opt into anything.
| 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 |
/permit-dashboard | All internal users | Permit dashboard — building/mural status across all projects |
/upcoming | All internal users | Upcoming Projects — Reimage Schedule start weeks with sheet metal / materials / graphics order-by dates and "ordered" checks |
/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 report (Critical/At Risk tickets) emailed to team |
| Daily 8am Chicago | Escalation — Teams ping for tickets Critical-flagged 3+ days (At Risk never pings) |
| Ticket → Critical, 3+ days | Teams notification, re-pinged at most once per ~20h while still Critical |
| 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=flagged, 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 |
| Fulcrum logo | Click from any page to return here |
| Sign out | Signs out of Microsoft account |
A second, permitting-focused view at /permit-dashboard, reachable from the portfolio dashboard. Where the main dashboard (section 6) rolls every track up into one progress bar, this page breaks a project's Building and Mural tracks out into their own columns, plus the formal permit-workflow state for each — not just task-completion percentage.
| Column | What it shows |
|---|---|
| Building | Task-completion progress bar for the building permit track |
| Mural approval | The mural track's formal status — not_submitted, submitted, under_review, approved, rejected, or expired — not a task-completion percentage |
| Construction | Construction progress from the project's schedule Gantt — Complete when every task is done, In progress once any task is started, Critical if a task is Blocked (see Track rollup). Not started for a project with no schedule yet |
| Details | Opens that project's detail page (/project.html?id=X) |
The gear icon in the header opens the same What you see settings as the main dashboard, board and project page (Visibility settings): a track you set to hidden drops its column here — Building removes the Building permit column, Mural removes both Mural columns, Construction removes Construction. One setting per user, in effect on every page.
/upcoming (BFAM team only; "Upcoming" in the header) is a timeline of every project with a construction start date, one row each, with the order-by date for each long-lead item counted back from that start in business days (Monday–Friday, skipping company holidays — vendor lead time, not the crew calendar the schedule Gantt uses).
| Item | Lead time | Order by |
|---|---|---|
| Sheet metal order | 10 business days | Two weeks before start (a Monday start → the Monday two weeks earlier) |
| Materials order | 5 business days | One week before start |
| Graphics application | 2 business days | The Thursday before a Monday start |
Lead times are set under Admin → Material lead times (the same values that seed a schedule template's material-order milestones).
Where the start date comes from
If the project has a schedule in Fulcrum, its construction start date. Otherwise the start week from the Smartsheet Reimage Schedule, which is loaded with scripts/migrate/49_import_reimage_schedule.py (export the sheet to Excel, run the script — dry run first, then --apply) and re-run whenever the sheet changes. A row tagged "(sheet week)" is using the sheet's Monday rather than a Fulcrum schedule date. Projects on the sheet whose store number isn't in Fulcrum, or whose project is archived, are listed by the script and don't appear on the page.
Marks
Each item has a checkbox and a diamond on the timeline — tick either when the order is placed (it fills in, with who and when in the tooltip). Hover an item for n/a to mark it not needed on that project (no mural, no graphics); it drops off the timeline and out of the overdue/open counts, and shows undo to bring it back. Rere projects only need the Materials order; the other two are marked n/a on every Rere by scripts/migrate/51_mark_rere_nonmaterial_not_required.py (re-run it after adding a Rere).
Read-only access for a graphics installer
A Client organization with Can view Upcoming ticked (Admin → Client organizations) can open the page too, read-only: only their granted projects, only the Graphics application date ("Apply by"), no checkboxes or n/a. Their Dashboard link goes to the Client construction home and a project name opens its Construction Updates page (photos + Gantt). The can_view_upcoming flag is on client_organizations; the API refuses every order write for a Client identity regardless.
Permit status on each row
Under the start line, Building and (when the project has the track) Electrical show the permit's formal status — ProjectTrack.current_status, the same value the permit dashboard shows — green when Approved, yellow otherwise (red if Rejected/Expired). If any shown permit isn't approved, the block gets a yellow border when the construction start is within 30 days and a red border within 14 days; the "Permits open" tile counts those projects. Complete and unscheduled projects never get a border. Not shown on a Client's read-only view.
Sheet-metal BOMs
A project row shows its tracker building type (Short Wings, 2017 Proto, Beaman Round (Mod), …) and, when a BOM workbook exists for that type, a BOM ↗ link that downloads it; Master BOM ↗ in the toolbar is the cross-project workbook with the monthly roll-up. The files live in uploads/bom/ on the server, one per building type, named by key — short_wing.xlsx, proto_2017.xlsx, beaman_round_mod.xlsx, champion_fl_proto.xlsx, short_awning.xlsx, beaman_round.xlsx, low_cost_short_wing.xlsx, champion_mod.xlsx (also .pdf) — plus master.xlsx. Drop a file in and the link appears; a type with no file shows no link. The design-type-to-key map is ghost/bom_storage.py. Internal only: a Client's read-only view never links a BOM.
Views
Timeline (default) is the row-per-project Gantt. Calendar lays the same dates on a month grid — one chip per order-by date (hollow = open, filled = ordered, red = overdue) and an amber chip for each construction start; chips toggle the order the same way the timeline's diamonds do, a start chip opens the project. Map plots every scheduled or in-progress site with coordinates on an OpenStreetMap base (Leaflet, served from /static/assets/vendor/leaflet/): red = in progress or starting within 2 weeks, yellow = within a month, green = more than a month out; a dot's popup has the start, permit lines, order dates and a link to the project. Tiles load from tile.openstreetmap.org, which the nginx Content-Security-Policy's img-src must allow (infra/nginx/fulcrum.conf) — without it the dots still render over a blank background and the page says so. The choice is remembered per browser.
Filters
All is scheduled + in progress — the working set. Scheduled = start date in the future; In progress = started and not complete; Complete = the project, its Construction track, or its whole schedule is complete; Not on the schedule = no start date anywhere; Everything = all of the above. Plus a state filter, search, and "Hide fully ordered". The red line is today; an open item whose order-by date has passed is outlined red and counted in the Orders overdue tile.
Project detail page
| Panel | What you can do |
|---|---|
| Project header | See name, type, status, address. Click coordinator to assign. |
| Tracks | Collapsible. Each track shows its tickets as rows — ticket name, status, a colored flag outline if flagged, owner, due date; the row is filled with the track's own pastel color. Visible per your "What you see" settings (gear icon): hidden, header only, or header + tickets. Click a row to open the ticket drawer (slides in from the left) and edit status, flag, assignee, due date, and the ticket's notes/checklist. |
| Ticket notes & checklist | Each ticket has one rich-text notes document (bold, italic, strikethrough, lists, color) with an interactive checklist — new checklist lines are seeded from the matching permit template (permit tracks only); tick lines off as work gets done, or edit freely. Sanitized server-side before it's stored. |
| Activity log | All notes newest-first, filterable by category. Add note at bottom, with a "Share with client" checkbox. Construction personnel only. |
| AHJ & Contacts | Jurisdiction contacts. + Add contact to add new ones. Construction personnel only. |
| Construction updates | Link out to the /updates page for this project — photos, the schedule, documents, daily logs, and quotes live there. |
| Archive button | Next to status badge. Confirms before archiving. |
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.
Tracks and their tickets
A track's ticket list is data (ticket_templates, editable in Admin → Templates), not a fixed set — adding, renaming, or reordering a track's tickets never needs a code change. As of 2026-10-08:
| Track | Tickets |
|---|---|
| Building Permit | Due Diligence · Contractor Reg · Submittal · Plans |
| Electrical Permit | Due Diligence · Contractor Reg · Submittal |
| Mural Authorization | Due Diligence · Contractor Reg · Submittal |
| Pre-Con (every project) | Sub Bids · Subs Awarded · Sub Documents · Material Orders · Schedule · Start Date Set |
| Construction (every project) | Demo · Framing (inspection) · Sheeting (inspection) · MEP Rough · Finishes · MEP Final · Building Final · Certificate of Completion |
| Financial (every project) | Estimate · Executed Contract · Permit invoice · Lien Waivers / Documents · Final Invoice |
Every track has a templates/*.json file (edit in Admin → Templates): the three permit tracks ship with real default checklists for Due Diligence/Contractor Reg/Submittal; Pre-Con, Construction and Financial ship with every ticket's list empty, so a new ticket there starts blank until the template is filled in. The old standalone Plans track is retired; its job is the Building Permit's Plans ticket.
Removing a track
Remove on a track's header (project page, internal only) calls DELETE /project-tracks/{id}: the track and everything under it — tickets and their checklists, track notes, activity-log notes, permit number/dates, jurisdiction COI links — are deleted, with an Activity Log entry recording who did it. Not reversible; the track can be re-added from "+ Add track" but comes back fresh. This is different from hiding a track in "What you see", which is per-viewer and changes nothing on the project. The three always-on tracks (Pre-Con, Construction, Financial) are only attached at project creation, so removing one stays removed.
Ad-hoc tickets
"+ Add ticket" at the bottom of any track's ticket list (project page) adds a one-off ticket to that project's track only — it has its own name and does not change the track's template or appear on any other project. Ad-hoc tickets behave exactly like standard ones otherwise (status, flag, owner, due date, notes/checklist, board card, dashboard sub-row) and sort after the standard set. They're the only tickets that can be renamed or deleted — both from the drawer's header. A standard ticket is named by its template and goes away with its track.
The 4 design options (tried in order, stopping at the first approved) live as checklist lines inside the Mural track's Submittal ticket, pre-filled on a new track: Mural both sides → Mural one side → Chevron painted → Parallelogram painted. Check one off once it's approved; if the AHJ rejects an option instead, use the ticket's rich-text editor to strike it through and add a note, then move to the next line.
The board at /board.html shows one card per ticket across every project you've chosen to see tickets for ("What you see", gear icon), in five columns: To Do, In Progress, Int Blocked, Ext Blocked, Done. Each card fills with its track's pastel color; a flag (Critical/At Risk/Resolved) adds a bold outline on top.
- Filter by person, project type (Rere/Remodel), project (type to search), or ticket (a dynamic list of every ticket name currently visible to you)
- "Flagged only" toggle — a flagged ticket always shows its outline, in whichever column its status puts it
- Click a card to open the ticket drawer right there on the board (with an "Open project →" link), or drag it to a different column to change its status
- Overdue tickets show their due date 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
/construction — their home
Sites are grouped by state in collapsible sections (count and status badges on each header; groups with active work start open, the rest closed, remembered per browser), with a status filter, a search box and Expand/Collapse all.
- Every site their organization has been granted
- Each one's Construction track status (same rollup badge as the internal dashboard)
- Schedule span, where a schedule exists
- Opens to that site's Photos + Gantt
/updates?project_id=X
- Photos marked
visible_to_client - The schedule Gantt (tasks marked
client_visible)
What a Client never sees
- Tickets, the ticket drawer, or /board.html —
ghost/routers/tickets.pyis internal-only end to end - The project detail page — landing on /project.html?id=X redirects straight to that project's Photos + Gantt instead
- Activity Log, AHJ & Contacts, Documents, Daily logs, Quotes, Contractors — all construction-personnel-only now, on any page
- Portal credentials, any other organization'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
Last seen is stamped on every authenticated request, at most once per five minutes (sentinel/last_seen.py), for internal users and client users alike — "Never" means the account has not signed in since the column was added on 2026-10-27. It answers "has this person been in Fulcrum lately?", not what they looked at.
- 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.API reference (Swagger / ReDoc)
With APP_ENV=development, Ghost serves an interactive OpenAPI reference at /api/docs (Swagger UI) and /api/redoc (ReDoc), plus the raw schema at /openapi.json. All three are disabled in production (docs_url/redoc_url/openapi_url are None unless _IS_DEV) — an unauthenticated scanner found the schema wide open at one point and used it to enumerate every real endpoint. Run Ghost locally to browse the API; it isn't reachable on the production URL.
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 — its checklist object is keyed by the track's own ticket keys (due_diligence/contractor_reg/submittal/plans; see Admin → Track Templates for a track's exact ticket list) and becomes the default checklist seeded into new tickets of that kind. Easiest done through Admin → Templates' editor rather than hand-editing the file. Existing tracks aren't affected automatically; use the ticket drawer's "Load checklist from template" link, or POST /project-tracks/{id}/seed-tickets, on a track whose ticket notes are still blank.
Adding or renaming a track's tickets
Admin → Track Templates — each card lists that track's tickets under "Tickets"; rename one inline, delete one (refused while any track_tickets row still references it), or add one (seeds a blank todo ticket onto every project that already has this track, not just future ones). The card's color swatch sets the pastel fill every ticket of this track gets on the board and project page.