🎣 Brotherhood Sign In

⚠️ CHECK YOUR SPAM / JUNK FOLDER.
Gmail, Outlook, and Yahoo often filter our sign-in email on the first send. If you don't see it in your inbox within a minute, look in spam and mark it "Not spam" so future ones land in the inbox.

Note: Google sign-in is for administrators only. Brotherhood members — use the email option below to request a sign-in link.

or
New here? Request access →

Request access

⚠️ CHECK YOUR SPAM / JUNK FOLDER.
After you submit, we'll email you a sign-in link. Gmail, Outlook, and Yahoo often filter the first email into spam. If you don't see it in your inbox within a minute, look in spam and mark it "Not spam."

Note: Google sign-up is for administrators only. Brotherhood members — use the email option below.

or
Already approved? Sign in →

⏳ Waiting for approval

Hi — your account is registered. An admin will assign your role shortly. You'll have access as soon as they do.

🎣 Brotherhood Fishing Trip — Signups

September 2026 · $175 per person · live signup & payment tracker

Users

Approve new sign-ups and assign roles. pending = no access. user = read-only. manager = can edit dues / recon. super_user = everything + can manage users.

Loading…

Recent activity

Sign-ins and admin changes — newest first. Hover a row for details.

Loading…

📋 System Map — How this dashboard protects your data

A whiteboard walkthrough of how sign-ins, edits, and audit trails flow through the system. Use this to onboard a new co-admin or to answer "what happens when…?" questions.

1Who can sign in
Member registers
/api/register
→
brotherhood_users
role = pending
→
super_user approves
Users tab
→
role: user / manager / super_user
Roles ladder: pending (no access) → user (read-only) → manager (edit dues/recon/comms) → super_user (everything + manage users + Treasury + Trip Expenses). Treasury and Trip Expenses are super_user-only (locked 2026-06-30) — manager can't see those tabs or hit any of their endpoints.
2How "who's online" is tracked
User opens
dashboard
→
auth.js
requireRole()
↓
PATCH brotherhood_users.last_seen_at = now()
INSERT brotherhood_access_log (action, ip, ua, metadata)
Where you see it: Users tab → "Last seen" column (relative time). Hover for absolute time. Updates on every authed request, so refreshing reflects activity instantly.
3Audit trail — who changed what
🏦 Reconcile a deposit
action: recon-allocate
metadata: { txn_description, txn_amount, txn_date, allocations_count, allocations_total, categories }
📦 Archive a signup
action: fishing-archive / -unarchive
metadata: { submission_id, name, archived }
🔗 Bank alias change
action: recon-alias-create / -delete
metadata: { bank_name, member_id, signup_name }
👤 User role change
action: user-role-change
metadata: { target_email, prior_role, new_role }
📧 Sent sign-in link
action: users-notify
metadata: who it was sent to
📨 Sent broadcast email
action: comm-send
metadata: { subject, filter, sent, failed }
📝 Communications template change
action: comm-template-create / -update / -delete
metadata: { id, name }
💰 Treasury config update
action: treasury-config-update
metadata: { key, value } — e.g., carryover change
🍔 Trip expense changes
action: trip-expense-create / -update / -delete / -toggle-reimbursed
metadata: { id, vendor?, amount?, category?, reimbursed? }
📸 Receipt photo + AI extract
actions: receipt-extract · receipt-delete
metadata: { path, bytes, ok, error } / { path }
🏦 Debit import + match
actions: trip-debits-import · trip-expense-match-debit · trip-expense-unmatch-debit
metadata: { parsed, inserted, auto_matched } / { id, transaction_id }
Where you see it: Users tab → "Recent activity" panel. Mutation rows tint cream; sign-ins stay white. Filter dropdown narrows the view. Last 200 events.
4Two admins, one transaction — who wins?
10:00
Admin A opens transaction X
stores updated_at = 09:45
Admin B opens transaction X
stores updated_at = 09:45
10:01
Admin B saves
server: 09:45 == current? ✓
bumps updated_at → 10:01
10:02
Admin A saves
server: 09:45 == 10:01? ✗
returns 409 Conflict
↓
Admin A sees:
"⚠ Someone else edited this — reload?"
No silent overwrite
Why this matters: Before this guard, last-write-wins. Admin A's reconciliation would silently overwrite Admin B's — and nobody would know until later. Now the second saver is forced to refresh and re-evaluate.
5How fresh is what I'm seeing?
⏱ Trip Status / Fishing list
Auto-poll every 5 min · refreshes on every recon save · ↻ button forces immediate fetch
⏱ Reconciliation tab
Refreshes after every save · ↻ button forces immediate fetch · no auto-poll (admin-driven)
⏱ Users + Activity
Loads on tab switch · ↻ button forces immediate fetch · last 200 events
If two admins are editing live, the loser of a stale-edit conflict (above) is forced to reload. For non-conflicting work, the 5-minute auto-poll keeps Trip Status / Fishing in sync without manual refresh.
6Where everything lives (Supabase)
brotherhood_users
id · email · name · role · approved_at · approved_by · last_seen_at
brotherhood_access_log
user_id · email · action · ip · user_agent · metadata (jsonb) · created_at
brotherhood_bank_transactions
id · transaction_date · description · amount · notes · updated_at ← stale-edit guard
brotherhood_bank_allocations
transaction_id · category · amount · signup_id · member_id · fiscal_year · month
fishing_signups · fishing_trip_payments
Source of truth for who's signed up. The legacy paid flag is no longer driven by the UI — Paid status is now derived from reconciled allocations ≥ $175.
brotherhood_comm_templates
id · name · subject · body · created_at · updated_at — reusable email templates for the Communications tab.
brotherhood_communications
sent_at · sent_by · subject · body · recipient_filter · recipient_count · recipients (jsonb per-recipient delivery status) · template_id · status — every send is logged here.
brotherhood_config
key · value · description · updated_at · updated_by — editable constants. Currently holds carryover_2025_close; future config goes here too.
brotherhood_trip_expenses
trip_year · expense_date · vendor · amount · category · paid_by · reimbursable · reimbursed · notes · receipt_image_path (nullable; points into the receipts bucket) · bank_transaction_id (nullable FK → brotherhood_bank_transactions; set when a receipt is matched to a card debit).
storage bucket: brotherhood-receipts
Private Supabase Storage. Layout: receipts/<year>/<uuid>.jpg. Read access via signed URLs (1 hour) through /api/receipt-view; only manager+ may upload.
Bolded fields were added for the audit + concurrency + communications + treasury system. Everything else predates this work.
7Fits any screen — phone, tablet, laptop
📱 Mobile (≤ 600px)
Tabs row wraps to 2 lines · auth card has side gutters · Users / Activity tables swipe horizontally · System Map cards stack arrows vertically · 16px input fonts so iOS doesn't auto-zoom
💻 Tablet (600–900px)
All tabs visible on one row · System Map cards 1-column · standard table widths
🖥️ Desktop (≥ 900px)
System Map cards 2-column · Recon dropdowns inline · full table layouts · max content width ~1100px (centered)
🛡️ Safety net
html, body { overflow-x: hidden; } + body { max-width: 100vw; } means even an unexpected wide element can't cause a full-page horizontal scroll
How to test: open the live URL on your phone in portrait + landscape, a tablet, and a laptop. Each tab should fit edge-to-edge without horizontal scroll. If anything spills, that's a bug worth reporting.
8How code changes go live
Code change
index.html or
functions/api/*.js
→
git push to main
GitHub
pvme2013-png/fishing-trip-signups
→
Cloudflare auto-build
~60–90 sec
then live worldwide
Rollback: if a deploy breaks something, Cloudflare Pages → Deployments → click any prior deploy → "Rollback to this deployment". Reverts the live site in seconds. Then fix the bug and push again. Schema changes (Supabase migrations) are NOT reverted by a Cloudflare rollback — those need a separate migration to undo.
9How a reminder email actually goes out
Manager clicks Send
Communications tab
→
/api/communications-send
Pages Function
resolves recipients
→
brotherhood-send-comm
Supabase Edge Function
Gmail SMTP via App Password
Recipients get email from pvme2013@gmail.com → row logged in brotherhood_communications with per-recipient delivery status
Filters available: all signed up · unpaid · partial paid · fully paid · dashboard users · custom paste-list. Templates live in brotherhood_comm_templates and can be edited from the Communications tab. Secrets: BROTHERHOOD_GMAIL_USER + BROTHERHOOD_GMAIL_APP_PASSWORD on the Edge Function (App Password must be exactly 16 chars).
10Cross-checking paid status against the bank
🧾 Per-person drill-down
Click a name on Fishing or a card on Trip Status → modal lists every bank transaction allocated to that person — date, description, txn amount, portion fed to the trip.
🔎 Discrepancies panel
Top of Reconciliation tab. Flags overages (allocated > $175), archived people with allocations, legacy "paid" flags without enough reconciled money, and orphan allocations pointing at deleted signups. Click a row to open the drill-down.
Endpoints: /api/signup-payments (drill-down) and /api/recon-discrepancies (audit). Both manager+ only.
11Paid column is read-only — reconciliation is the source of truth
Member sends Zelle
to the Brotherhood account
→
Treasurer imports CSV
Recon tab, Zelle-received only
→
Allocate to signup
brotherhood_bank_allocations
→
Paid checkbox auto-checks
when total ≥ $175
Why this matters: nobody can click the Paid box to mark someone paid — the only way it turns on is a real reconciled deposit. Eliminates the class of bug where someone gets marked paid by accident with no money in the bank to back it up. CSV import auto-dedupes against (transaction_date, description, amount, balance) so you can paste the same statement twice safely.
📧Communications tab — sending reminders & updates
✏️ Compose
Pick a recipient filter, optionally load a template (auto-fills Subject + Body), edit, expand Preview recipients to see exactly who'll get it, then Send. Confirms with a sample of names before sending.
🎯 Recipient filters
Send to one person… (signup picker) · All signed up · Unpaid · Partial paid · Fully paid · Send to one prospect… · All active prospects · All dashboard users · Custom (paste your own list). Live count updates as you switch.
📧📱 Email vs SMS channel
Top-of-Compose toggle. Email sends through Gmail SMTP and logs every recipient. SMS opens your phone's Messages app via sms: URLs — one tap per person, you press Send. SMS is not logged (we can't see Messages). Recipients with no phone are skipped.
📝 Manage templates
Add/edit/delete reusable templates. Ships with Payment reminder, Trip deadline reminder, General update, Thank you for paying, Still in? — Sept 10-13 RSVP, Invite — Come fish with us (Sept 10-13) — edit any of them to match your voice.
📜 Sent log
Every send shows up newest-first with subject, recipient filter, count, and a Sent / Partial / Failed badge. Click a row to drill into per-recipient delivery status (✓ or ✗ + error reason).
🧮 Why recipient count < signup count
resolveRecipients dedupes by lowercase email. If two active signups share an address (real or fake), only one gets the message. Cross-check the Fishing tab against the Compose preview if numbers disagree — it usually means a duplicate email worth investigating.
Who can see it: manager + super_user only — the tab is hidden for read-only users. From address: pvme2013@gmail.com with display name "Brotherhood Fishing Trip". Limit: Gmail SMTP allows ~500/day, plenty for ~50 members. First emails to new recipients can land in spam — same caveat as the magic-link sign-in flow.
📋Prospects — saving people you meet so you can invite them later
Meet someone IRL
church, BNI, work,
cookout, anywhere
→
Add on Comms tab
📋 Prospects panel
name + email/phone + notes
→
brotherhood_prospects
status = new
Use Send to one prospect… or All active prospects filter → send the invite via Email or SMS → status auto-flips to invited, invited_count bumps, last_invited_at stamps with now.
📥 Required fields
Name (required) and at least one of email or phone. Notes are optional but useful — they show up in the prospect list as a memory hook (e.g., "Met at church Sunday").
🔄 Status states
New (just added) → Invited (sent at least once) → Signed up (manually flipped when they show on the Fishing tab) / Declined / Archived. Only New + Invited are pickable in Comms; the others are hidden until you mark them otherwise.
🎯 Tokens
{first}, {name}, {trip_cost} all work. {paid} and {balance} come out as $0.00 for prospects — they haven't paid because they're not on the roster yet. Use the Invite — Come fish with us template, which doesn't reference money owed.
📱 Phone-only is OK
A prospect with only a phone number works in SMS mode — the resolver dedupes by prospect id (not email) so phone-only contacts aren't silently dropped. They won't show up in email-channel sends though, since there's no address to deliver to.
Why this exists: the old workflow was "remember to text them later" — which means you forget. Now you capture them at the moment with one form, then re-invite from a dropdown. Auto-tracked invited count tells you who's been hit how many times so you don't accidentally over-pester one person.
💰Treasury tab — top-line Brotherhood finances
🏛 Top tiles
Carryover from 2025 (editable, default $1,324.76 per your books) · Bank balance (latest balance column from brotherhood_bank_transactions) · Net Brotherhood = carryover + reconciled income − trip expenses.
🎣 Fishing Trip row
Collected = sum of fishing_trip allocations · Expected = active signups × $175 · Outstanding = Expected − Collected · Trip expenses = sum of receipts logged for the current year.
💵 Dues + Other income
Dues collected = sum of dues_annual + dues_monthly allocations · Other = anything reconciled to an unrecognized category.
⚙️ Edit carryover
Expander panel writes to brotherhood_config via /api/treasury POST. Audit-logged as treasury-config-update.
Why bank balance ≠ Net Brotherhood: Net is what the books say you should have (carryover + everything you've reconciled and tracked). Bank balance is what the bank actually shows. Any gap is either un-imported transactions or the known $40.41 historical discrepancy.
Who can see it: super_user only — the tab is hidden for manager and user roles, and /api/treasury rejects anything below super_user. Locked down 2026-06-30 so dues/carryover/bank-balance figures stay with the bookkeeper.
🍔Trip Expenses tab — receipts for the fishing trip
🧾 Logging a receipt
Fill the Add-receipt form (date, vendor, amount, category, paid by, year, reimbursable, notes). Categories: 🍔 food · ⛽ fuel · 🏨 lodging · 🎣 gear · 🪱 bait/tackle · 📋 license/fees · 📦 other. Year defaults to current; change it if you're back-logging.
💸 Reimbursement tracking
Tick Reimbursable when a member paid out of pocket. Row stays highlighted yellow until you check the "owed → ✓ paid back" checkbox on the row. The Owed to members tile sums every unpaid reimbursable.
🔗 Future: card-debit matching
Schema already has a nullable bank_transaction_id on each receipt. Phase 2 will let you click an unreconciled debit on the Recon tab and pick which receipt it covers — same flow as the deposit-allocation UI today.
📅 Trip year scoping
All endpoints filter by trip_year. Change the year field at the top to view a prior year's receipts without losing them. Treasury tile reads the current year by default.
Who can see it: super_user only — the tab is hidden for manager and user roles, and every Trip Expenses / Treasury / receipt endpoint enforces super_user at the server. Locked down 2026-06-30.
📸Snap a receipt → fields fill themselves in
Tap "📸 Snap or upload receipt"
on Trip Expenses tab
mobile camera opens directly
→
Browser resizes image
≤ 1280px JPEG
keeps under 5MB
→
/api/receipt-extract
stores image →
calls Claude vision
Vendor · Date · Amount · Category · Notes pre-fill the form → user reviews → clicks Add → row saved with 📎 link to original photo
🤖 Model
Claude Haiku 4.5 with vision. Prompt asks for strict JSON only; assistant turn is pre-filled with { to force JSON output. Cost: ~$0.001 per receipt.
🗄 Storage
Private Supabase Storage bucket brotherhood-receipts. Service-role only for write; viewing goes through /api/receipt-view which returns a 1-hour signed URL.
🚦 Status copy
Driven by what came back: green ✓ Read! = both amount + vendor populated · orange ⚠ filled what we could = one missing · orange ⚠ couldn't read = both missing or non-receipt image. No more lying "✓" when nothing useful extracted.
🗑️ Discard photo
Red × on the thumbnail → confirm → calls /api/receipt-delete which removes the image from Storage (path-validated to receipts/ prefix). Useful when you snap the wrong thing — no orphan images.
🔐 Secret
ANTHROPIC_API_KEY lives in Cloudflare Pages env vars (Production). If it's missing or invalid, the upload still succeeds but auto-fill is skipped.
Why this matters: typing in vendor + date + amount for 30 receipts after a trip is the boring tax. Now it's "snap, glance, save" — ~5 seconds per receipt instead of 30+. The model is good but not perfect (especially with thermal-paper fading), so the human review step is non-negotiable.
🏦Receipts → bank debits: closing the reconciliation loop
Paste bank CSV
Trip Expenses tab
"🏦 Match to bank debits"
→
/api/trip-debits-import
Keep Debits · exclude
internal transfers · dedupe
→
Auto-match pass
exact amount + ±3 days
+ single candidate → link
Unmatched debits highlight yellow → user clicks Match → picker sorts receipts by exact-amount-first, then date proximity → Match this sets brotherhood_trip_expenses.bank_transaction_id
📥 Import filter
KEEP: any row where transaction_type = Debit. EXCLUDE: Preauthorized Withdrawal to TRUIST BANK (internal savings transfer). Credits go through the Recon tab unchanged.
🤝 Auto-match rule
After insert, walks each new debit. If exactly ONE unmatched receipt matches both amount (within $0.005) and date (±3 days), link it. Ambiguous (multiple candidates) stays manual to avoid wrong guesses.
🧮 Recon tab impact
/api/recon now filters transaction_type=eq.Credit. Debits live in the same table but only surface on the Trip Expenses tab — clean separation per user's "keep trip stuff out of Recon" rule.
↩️ Unmatch
Each matched row has an "Unmatch" button. Sets bank_transaction_id = null — the debit goes back to the unmatched bucket; the receipt stays in the receipts list.
End-to-end loop: shop → 📸 snap receipt (auto-extracts vendor/date/amount/category) → wait for the bank statement → paste it here → auto-link runs → manually match leftovers. Every receipt with a 🏦 matched pill is fully reconciled against the bank.
12What happens when an archived person re-signs up
Jotform submission
/api/jotform-webhook
→
name_norm matches
an archived row?
→
Auto-unarchive
payment history preserved
logged: "Re-signup — unarchived"
Why: previously a duplicate (by normalized name) was silently dropped — even when the existing row was archived. That meant a member coming back the following year would just vanish. Now they're surfaced back to the active list automatically. Check the webhook_log table to see when this triggers.

Enter Passcode

For the brotherhood only.

Total signed up
—
all-time
Paid
—
—
$ Collected
—
manual paid + reconciled
$ Outstanding
—
remaining to collect
Trip year
—
0 selected
Loading signups…
⚠️ Showing cached signups. Jotform is temporarily unavailable. Paid status and money totals are still live from Supabase.
🔄 Treasurer: Re-sync from Jotform (replaces all signups)
Pulls Jotform's authoritative list and replaces the signup table with real submission IDs. Run this once after the daily quota clears to drop the synthetic CSV IDs. New signups stream in automatically via the webhook — you should rarely need this button.
🛟 Treasurer: Restore signups from a Jotform CSV (emergency)
Use this only if both the webhook AND Jotform's API are unavailable. Paste the Jotform Tables CSV export and the names will appear on the dashboard.
—
Fully paid
—
Partial
—
Not started
$—
Collected of $—
Loading payment status…

Treasurer Access

Different passcode from the fishing trip.

Members
—
active
$ Expected
—
members × $120/yr
$ Collected
—
paid this fiscal year
$ Outstanding
—
still owed
—
Loading dues…
+ Add member
📋 Paste from Excel

Copy rows from your Excel sheet (with a header row) and paste below. Columns should include Name and one column per month (Mar, Apr, …, Feb). Put any non-empty value (e.g., 1, x, $10) in a cell to mark that month as paid.

Treasurer Access

Reconciliation uses the same passcode as Dues.

$ Reconciled
—
allocated to a category
$ Unreconciled
—
still needs allocation
$ Trips
—
$ Dues
—
$ Rental
—
$ Other
—
🔎 Discrepancies 0

Anything that doesn't add up — over-allocated, archived-but-paid, or legacy "paid" flags not backed by reconciliation. Click a row to drill into the person.

Loading…
📋 Paste / import bank CSV

Paste your Truist export — either the raw CSV file contents OR copy the rows from Excel/Numbers (tab-separated). Headers are optional. Duplicates are skipped automatically, so you can paste a full statement every time.

Loading transactions…
🔗 Bank-name aliases

Aliases auto-match bank names to members. Example: "JOYFUL REAL ESTATE LLC" → Rothstein Campbell.

Members Passcode

Same passcode as the fishing trip.

—
Members
—
$ Group Total
—
all contributions this year
$ Trips
—
$ Dues
—
Loading…

💰 Treasury

Top-line snapshot of Brotherhood finances. Numbers live from the bank + reconciliation data; the carryover figure is editable below.

Carryover from 2025
—
brought into 2026 (editable)
Bank balance
—
as of —
Net Brotherhood
—
carryover + 2026 net activity

🎣 Fishing Trip 2026

Collected
—
reconciled to trip
Expected
—
— signups × $175
Outstanding
—
not yet reconciled
Trip expenses
—
spent so far this year

💵 Dues + Other income

Dues collected
—
all-time reconciled dues
Other income
—
uncategorized allocations
⚙️ Edit carryover figure

Stored in brotherhood_config under key carryover_2025_close. Defaults to $1,324.76 per your books. Bank-actual carryover was $1,284.35 (the $40.41 gap is acknowledged and intentionally not chased).

🍔 Trip Expenses

Log purchases for the fishing trip — food, fuel, gear, lodging, etc. Mark items as reimbursable if someone other than the Brotherhood account paid for them, then check them off when reimbursed.

Total spent
—
trip year —
Owed to members
—
reimbursable + not yet paid back
Receipts logged
—
this trip year

Add receipt

We'll read the photo and pre-fill the fields below for you to review.
Loading…
🏦 Match to bank debits 0

Paste your bank CSV here. We'll import the debits (purchases) — internal transfers (Preauthorized Withdrawal to TRUIST) are filtered out. After import, exact-amount + same-week matches link automatically; ambiguous ones get a Match button.

Loading…
Loading…

📧 Communications

Send reminders, deadlines, and updates to Brotherhood members. Emails come from pvme2013@gmail.com.

Compose

Send via:
📱 How text-message (SMS) mode works — read this first
  • This uses your phone's built-in Messages app — not a paid texting service. Charges are your normal carrier rates, not the dashboard's.
  • SMS has no subject line. Only the Body field is used. Personalization tokens like {first} still work.
  • You have to press Send in Messages yourself. The dashboard prepares the text, opens Messages with the number + body pre-filled — nothing leaves your phone until you tap the blue arrow.
  • Browsers can only open one text at a time. For more than one person, you'll see a "Tap to compose" button per recipient — go down the list one by one.
  • For groups, the fastest workflow is on your phone (not laptop). Open this page on your iPhone/Android and tap each "Compose" button.
  • SMS sends are NOT recorded in the Sent log — we can't see what your Messages app does after we hand off. Email sends still log normally.
  • Recipients with no phone number on file are skipped automatically.
—
Picking a template fills Subject and Body below.
Email + phone + balance for the picked person are loaded so tokens like {first} and {balance} personalize correctly.
Don't see them? Scroll down to Prospects (people you've met) and add them first. After a successful send, the prospect's "invited" count bumps and status changes from new → invited.
Plain text. Line breaks are preserved.
Personalization tokens (substituted per-recipient at send time): {first} {name} {last} {email} {paid} {balance} {trip_cost}
Preview recipients (—)
—
📱 Tap a name to open Messages with that person's text pre-filled:
Reminder: nothing sends until you tap the blue Send arrow in your Messages app. Go down the list one at a time.
📋 Prospects (people you've met)
What this is: a place to save people you've met in person — at church, work, BNI, anywhere — so you can invite them to the trip later without losing their info. They aren't signed up yet, so {paid}/{balance} tokens are blank for them. After a successful send, their invited count bumps automatically and their status moves new → invited.

Add a new prospect

All prospects

Loading…
Manage templates
Loading…

Add new template

Sent log
Loading…
Loading…