Platform Manual
Architecture, workflows, and user guide
81
Active projects
2,643
Tasks tracked
7
Fleet components
~$44
Per month (Azure)
BFAM Builders · Odin Research · Version 2.1 · October 2026

Contents

1. Platform overview 2. Architecture — the fleet 3. Data model 4. URLs and access 5. Workflows 6. Portfolio dashboard 7. Permit dashboard 8. Upcoming Projects (order planner) 9. Managing projects 10. Permitting workflow 11. Mural decision tree 12. Swimlane board 13. Quoting tool 14. Client access & Construction Updates 15. Admin guide 16. Deployment 17. Developer guide
01Platform overview

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:

https://bfam-fulcrum.centralus.cloudapp.azure.com

What Fulcrum replaces

WasNow
Permitting_Workflow_Tracker.xlsxLive task checklists per project
Email Transfer.xlsxSite details, contacts, activity log
Teams threadsStructured notes + automated notifications
Manual status updatesReal-time dashboard
Shared SharePoint spreadsheetClient read-only access (Construction Updates)
Manual quote reformattingBuilt-in quoting tool

Project types

TypeDescriptionPermits
RereLights and minor changesElectrical (sometimes), Mural (sometimes)
RemodelFull exterior, 8-day crewBuilding (stamped plans), Electrical, Mural
02Architecture — the fleet

Fulcrum is a monorepo where each component is named after a Rebel Alliance ship. Each ship has a single responsibility.

Ghost
Core API — the command center
FastAPI · Python · 16 routers · Pydantic v2
Tantive
Database — carries the cargo
PostgreSQL · SQLAlchemy · Alembic · 16 tables
Falcon
Integrations — connects everything
Teams · Smartsheet · Graph API · Key Vault
Phantom
Background jobs — runs quietly
Azure Functions · scheduled triggers
Radiant
Reporting — delivers information
Excel export · Jinja2 email · weekly reports
Razor
Frontend — what everyone sees
HTML · CSS · JS · 11 pages
Sentinel
Auth — controls who boards
Azure AD · MSAL · internal roles + Client B2B guests
03Data model
TableDescriptionKey fields
sitesPhysical locationsstore_number, address, jurisdiction, portal_url, portal_username
usersBFAM team membersemail, display_name, role_id, azure_oid, is_active
rolesInternal role lookupname (Admin, PM, Permitting, Site Supervisor)
projectsActive jobssite_id, project_type, status, is_archived, coordinator_name, site_supervisor_id
track_templatesPhase/track definitions — Building Permit, Electrical Permit, Mural Authorization, Pre-Con, Construction, Financial, or any admin-added onekey, name, color, steps (workflow), tickets (its ticket list)
project_tracksOne track_template attached to one projectproject_id, track_template_id, current_status, permit_number (permit tracks only)
ticket_templatesA track's ordered ticket list (data, not an enum — Due Diligence, Demo, Estimate, etc.)track_template_id, key, name, sequence
track_ticketsOne 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_visibilityPer-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)
tasksLegacy — pre-ticket checklist items, kept read-only pending a cleanup pass; API unregisteredpermit_id, name, status, sequence, assigned_to_id, due_date
task_dependenciesLegacy — task locking logictask_id, depends_on_task_id, required_status
task_status_historyLegacy — immutable change logtask_id, old_status, new_status, changed_by_id, changed_at
contactsAHJ and other contacts (construction personnel only)site_id, project_id, name, phone, email, contact_type
notesActivity log (construction personnel only)project_id, task_id, author_id, body, category, visible_to_client, created_at
track_notesPer-track internal scratch notes (construction personnel only) — rich text, not the Activity Log; see section 9project_track_id, author_id, body_html, created_at
photosProject photosproject_id, category, caption, visible_to_client, uploaded_by_id
daily_logsSite daily logsproject_id, log_date, notes, weather_conditions, created_by
quotesProject quotesproject_id, created_by, total, line_items (JSONB)
client_organizationsExternal client companies (e.g. a franchisee group)name
client_usersClient-side people (Azure AD B2B guests)client_organization_id, name, email, azure_oid
client_project_accessWhich org can see which projectclient_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.

RoleCan do
AdminEverything — manage users, roles, client orgs/access grants, edit all data, reveal credentials
PMCreate projects, assign Site Supervisors, edit projects/tasks/permits/notes
PermittingCreate projects, grant client org project access, edit tasks/permits/notes
Site SupervisorFull read/write on tasks, notes, photos, daily logs, contacts
ClientExternal 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.
All four internal roles are full read/write on everything above — there is no internal read-only tier. The old 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:

todo in_progress int_blocked ext_blocked done

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.

04URLs and access
URLWhoWhat
/All internal usersPortfolio dashboard — all projects
/project.html?id=XInternal (full); Client (read-only, no Activity Log/AHJ Contacts)Project detail — permit tracks, tasks
/updates?project_id=XInternal (full); Client (client-visible only)Construction Updates — photos, daily logs, quotes
/photos.html?project_id=XInternal (full); Client (client-visible only)Full photo gallery
/board.htmlAll internal usersSwimlane task board
/permit-dashboardAll internal usersPermit dashboard — building/mural status across all projects
/upcomingAll internal usersUpcoming Projects — Reimage Schedule start weeks with sheet metal / materials / graphics order-by dates and "ordered" checks
/quote.htmlInternal, gated per-quote by projectQuoting tool
/adminAdmin onlyUser management, site editing, templates
/docsAll internal usersThis manual
All pages require Microsoft login. BFAM team members sign in with their existing @bfambuilders.com accounts. Client contacts sign in as Azure AD B2B guests (their own company email, invited into the BFAM tenant) — there is no separate password or login page.
The old unauthenticated /checkers portal is retired. Client access now goes through /admin's Clients tab (Admin only) — see "Adding a client contact" below. There are two separate steps, in order, and skipping the first one is the most common mistake: creating the Fulcrum-side row does not by itself let anyone sign in.

BFAM team

NameEmailRole
Chad Buttreychadwick@bfambuilders.comAdmin
Ted Petersonted.peterson@bfambuilders.comAdmin
Jaci Maysjaci.mays@bfambuilders.comAdmin
Joshjosh@bfambuilders.comPM
Dan Fentondan.fenton@bfambuilders.comPermitting
Ronaldronald@bfambuilders.comSite Supervisor
Meadowmeadow@bfambuilders.comSite Supervisor
Juliejulie@bfambuilders.comSite 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.

05Workflows

New project workflow

1
Create the project
Click "+ New project" on the dashboard. Enter site details, type, coordinator, and permit tracks.
2
Tasks created automatically
Fulcrum reads JSON templates and creates the full task checklist. Tasks start as Not started or Locked.
3
Assign coordinator and tasks
Click "Unassigned" on the project detail page to assign from the user dropdown.
4
Work through the checklist
Update task statuses as work progresses. Each change is logged with timestamp and user.
5
Permits issued
Teams notification fires automatically when permits are complete.
6
Construction handoff
Project moves to Construction. Pre-construction logistics tasks created. CF handoff is manual.
7
Archive on completion
Archive the project from dashboard or detail page. Data is preserved, never deleted.

Adding a client contact

Two separate systems, in this order. Doing only step 4 (the common mistake) creates a row that can never sign in; doing only step 1 leaves them able to reach the tenant but with no client_users row, so Fulcrum 403s them as "not authorized."
1
Invite the guest in Azure AD (outside Fulcrum)
Entra admin center (entra.microsoft.com) → Identity → Users → All users → New user → Invite external user. Enter their real email (their own company address — this does not have to be @checkers.com specifically, any external address works) and send.
2
They accept the invite
The invite email lands in their inbox; they click through to accept. Until this happens they don't exist as a guest in BFAM's tenant yet, and signing in fails at Azure's login screen with AADSTS90072 ("does not exist in tenant ... needs to be added as an external user"), before Fulcrum is ever reached.
3
Create (or find) the client organization
Admin → Clients. Add a client_organizations row per external company (e.g. "Checkers Corporate") — one org can hold multiple contacts.
4
Add the client user
Under that org, "+ Add user" with their name and the exact email Azure will send as the login token's 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).
5
Grant project access
Same org card, "Project access" — check whichever project(s) apply (there's a Select all for granting everything at once) and click "+ Grant access to selected." A Client identity sees only what's granted here — nothing is inferred from anything else.
6
Verify before considering them onboarded
Each granted project row has a "View as →" link — opens that project exactly as any client of that org would see it, no login required. Use this to confirm the grant looks right before telling the contact their access is ready.

Automated workflows (Phantom)

TriggerAction
Every hourSmartsheet sync — pulls construction dates from Checkers
Monday 7am ChicagoWeekly report (Critical/At Risk tickets) emailed to team
Daily 8am ChicagoEscalation — Teams ping for tickets Critical-flagged 3+ days (At Risk never pings)
Ticket → Critical, 3+ daysTeams notification, re-pinged at most once per ~20h while still Critical
All permits issuedTeams notification to #construction-handoff
06Portfolio dashboard

The main view at / shows all active projects at a glance.

ElementDescription
Summary cardsTotal projects, permits complete, in progress, mural pending
Filter buttonsAll / Rere only / Remodel only / Complete / In progress
Progress bars% of tasks complete per permit track
Status badgesGreen=complete, Blue=in progress, Amber=ext blocked, Red=flagged, Gray=not started
+ New projectOpens new project modal
Board linkNavigates to swimlane board
Docs linkOpens this manual
Show archivedToggle archived projects
Dark modeSun/moon toggle — persists across all pages
Fulcrum logoClick from any page to return here
Sign outSigns out of Microsoft account
07Permit dashboard

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.

ColumnWhat it shows
BuildingTask-completion progress bar for the building permit track
Mural approvalThe mural track's formal status — not_submitted, submitted, under_review, approved, rejected, or expired — not a task-completion percentage
ConstructionConstruction 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
DetailsOpens 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.

All figures are fetched live from /projects/summary, /projects/, and /sites/ on page load — there is no cached or hand-maintained snapshot behind this page.
08Upcoming Projects (order planner)

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

ItemLead timeOrder by
Sheet metal order10 business daysTwo weeks before start (a Monday start → the Monday two weeks earlier)
Materials order5 business daysOne week before start
Graphics application2 business daysThe 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.

09Managing projects

Project detail page

PanelWhat you can do
Project headerSee name, type, status, address. Click coordinator to assign.
TracksCollapsible. 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 & checklistEach 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 logAll notes newest-first, filterable by category. Add note at bottom, with a "Share with client" checkbox. Construction personnel only.
AHJ & ContactsJurisdiction contacts. + Add contact to add new ones. Construction personnel only.
Construction updatesLink out to the /updates page for this project — photos, the schedule, documents, daily logs, and quotes live there.
Archive buttonNext to status badge. Confirms before archiving.
This page is construction-personnel-only as of the phases work (2026-10-08) — a Client identity is redirected straight to /updates?project_id=… (Photos + Gantt only) the moment they land here, same as a real Client never seeing it at all. See section 14.

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.

10Permitting workflow

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:

TrackTickets
Building PermitDue Diligence · Contractor Reg · Submittal · Plans
Electrical PermitDue Diligence · Contractor Reg · Submittal
Mural AuthorizationDue 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.

Entering a permit number: click the permit number area in the track header (shows "Add permit #" if empty). Type the number and press Enter.
11Mural approval decision tree

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.

Jurisdiction-specific notes are in the Submittal ticket's own notes, or the track's Notes drawer — check these before submitting to know what each AHJ has accepted or rejected historically.
12Swimlane board

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
Use the person filter before weekly meetings — shows exactly what each coordinator is working on and what's flagged. If a track you expect isn't showing tickets here, check your "What you see" settings — it may be set to header-only.
13Quoting tool

The quoting tool at /quote.html replaces the manual process of reformatting IAM quotes into BFAM format.

Creating a quote

1
Select a project
Pick an existing project or enter a new site name.
2
Add line items
Description, category, quantity, unit, unit cost, markup %. Total calculates automatically.
3
Import from IAM (optional)
Paste tab-separated IAM quote data into the import area. Line items populate automatically.
4
Review summary
Subtotal, markup amount, grand total, and margin % shown in the summary panel.
5
Export or save
Copy to clipboard (TSV for Excel), download as CSV, or save to the project in Fulcrum.

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.

14Client access & Construction Updates

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.

As of the 2026-10-08 phases work, a Client's view is narrower than it used to be: tickets, the project detail page, documents, daily logs, and quotes are all construction-personnel-only now. A Client's home is the construction dashboard; per site, they get Photos + the schedule Gantt only.

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.py is 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
Requesting a project that isn't in that org's 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."
The old unauthenticated /checkers portal (open to anyone with the URL, no login) is retired in favor of this. See the warning under section 4 for how to grant a new org access today.
15Admin guide

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.

After editing 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

cd ~/fulcrum && source venv/bin/activate PYTHONPATH=/home/fulcrum/fulcrum python3 -c " from falcon.keyvault import set_secret set_secret('portal-pw-STORENUMBER', 'the-password') "
16Deployment
Production: https://bfam-fulcrum.centralus.cloudapp.azure.com — Azure VM, Central US

Infrastructure

ServiceDetailsCost/mo
Azure VMfulcrum-vm-01, Standard_F1als_v7, Ubuntu 24.04~$44
PostgreSQLLocal on VM (not managed service)$0
SSLLet's Encrypt via Certbot — auto-renews$0
DNSbfam-fulcrum.centralus.cloudapp.azure.com$0
Total~$44/mo

Deploy an update

# On your WSL machine — push changes git add . && git commit -m "your message" && git push # On the Azure VM — pull, migrate, restart ssh fulcrum@20.29.87.140 cd ~/fulcrum && git pull source venv/bin/activate PYTHONPATH=/home/fulcrum/fulcrum alembic -c tantive/alembic.ini upgrade head sudo systemctl restart fulcrum
Always run 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

# Check Ghost status sudo systemctl status fulcrum # View live logs sudo journalctl -u fulcrum -f # Restart Ghost sudo systemctl restart fulcrum # Restart Nginx sudo systemctl restart nginx # Check PostgreSQL sudo systemctl status postgresql # Database backup pg_dump -U fulcrum -h 127.0.0.1 fulcrum > backup_$(date +%Y%m%d).sql

Environment variables

All config lives in ~/fulcrum/.env on the Azure VM. Never commit this file. Key variables:

DATABASE_URL=postgresql+psycopg2://fulcrum:fulcrum@127.0.0.1:5432/fulcrum APP_ENV=production AZURE_TENANT_ID=07949940-2dd4-4dae-939b-59b0aad66c4a AZURE_CLIENT_ID=a40edb8e-bb0e-4e00-b190-e7ee2eee1c55
17Developer guide

Repository

github.com/tedbp19/fulcrum — private. Main branch is production.

Local development

cd ~/fulcrum source venv/bin/activate # activate Python env # Start PostgreSQL (WSL quirk) sudo -u postgres /usr/lib/postgresql/16/bin/pg_ctl \ -D /var/lib/postgresql/16/main \ -o "-c config_file=/etc/postgresql/16/main/postgresql.conf" \ -l /var/log/postgresql/postgresql-16-main.log start # Start Ghost uvicorn ghost.main:app --reload --host 0.0.0.0 --port 8000
For local dev, set 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

cd ~/fulcrum # Generate migration after model changes PYTHONPATH=/home/tedbp/fulcrum alembic -c tantive/alembic.ini \ revision --autogenerate -m "description" # Apply migrations PYTHONPATH=/home/tedbp/fulcrum alembic -c tantive/alembic.ini upgrade head
After generating a migration, check for the PostgreSQL enum 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.

GitHub repository structure

fulcrum/ ├── ghost/ FastAPI backend (main.py, routers/, schemas/) ├── tantive/ Database (models/, alembic/, database.py) ├── falcon/ Integrations (teams.py, graph.py, keyvault.py) ├── phantom/ Azure Functions background jobs ├── radiant/ Reporting (blockers.py, excel_export.py) ├── razor/ Frontend HTML/CSS/JS + assets/auth.js ├── sentinel/ Auth (auth.py, middleware.py, users.json) ├── templates/ JSON permit task templates ├── scripts/ Migration scripts (run in numeric order; see scripts/migrate/) ├── infra/ Nginx + systemd configs └── data/ Local data files (gitignored)