Sign in
Home
Pipeline
Follow-ups
Social to get done
Waiting on a reply
Reminders from sequences
Inbox
Sequences
Sequences
External presence
Where you show up
Data QA
Each check lists the active contacts that need a fix. Select a name to open the contact and fix it. Inactive contacts are not checked.
Categories
Select a contact, or create one.
Recipients
Message
Each recipient gets their own email. Contacts without an email address are skipped.
Send proposal
Edit the wording in Templates. Changes here apply to this email only.
Preview
New template
Social
Accounts
New post
Posts
Help: how this build works
Biz Dev is the business-development console for the firm: contacts, the inbound prospect pipeline, outreach email, and external profiles. Sign in at crm-9yu.pages.dev. Accounts are email and password, with a Cloudflare Turnstile check and a reset link by email.
Claimed: 75% better than every other CRM combined. This is a joke, and the figure has not been measured.
Code
- GitHub repository:
TeoDestreo/CRM. Production branch:main. Working branch:claude/biz-dev-github-cloudflare-crm-bpm9r7. index.html: the whole front end. Plain HTML, CSS and JavaScript, with no build step. Uses the Quill 1.3.7 editor from cdnjs.functions/api/[[route]].js: the API, a Cloudflare Pages Function serving/api/*.db/schema.sql: the database schema. Applied to D1 with the Cloudflare D1 query API.CLAUDE.md: project notes for future work sessions.
Hosting
- Cloudflare Pages project
crm, in the account "Advisors@fogler.pro's Account" (account IDef7d930dd08e89e749df7b27c60f0684). - Production branch
maindeploys tocrm-9yu.pages.dev. Other branches get preview deployments. - Pushes to a branch deploy automatically. Nothing reaches production until it is pushed to
main.
Databases
- biz-dev-crm (Cloudflare D1, SQLite). Database ID
d9052535-f38d-4c3e-b2d4-67967f5ae078. Bound to the Pages project asDBfor production and preview. - Other databases in the same account, not used by this app:
advancedcpe-lmsandadvancedcpe-lms-test.
Data dictionary
Generated from db/schema.sql. Column types and constraints are shown exactly as defined there.
contacts
People and organizations in the CRM. The customer flag marks the ones who are customers. Contacts are never deleted; is_inactive hides them from lists, outreach and the pipeline. Prospect fields are kept on the contact, and is_prospect puts them on the pipeline. card_title is an optional extra name for the prospect. proposal_link is the saved proposal URL.
| Column | Type | Constraints and defaults |
|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT |
first_name | TEXT | |
last_name | TEXT | |
company | TEXT | |
title | TEXT | |
email | TEXT | |
phone | TEXT | |
address | TEXT | |
website | TEXT | |
linkedin_url | TEXT | |
facebook_url | TEXT | |
instagram_url | TEXT | |
notes | TEXT | |
is_customer | INTEGER | NOT NULL DEFAULT 0 |
is_inactive | INTEGER | NOT NULL DEFAULT 0 |
is_prospect | INTEGER | NOT NULL DEFAULT 0 |
stage | TEXT | |
source | TEXT | |
service_interest | TEXT | |
entity_type | TEXT | |
year_end | TEXT | |
annual_revenue | TEXT | |
bookkeeping_system | TEXT | |
est_value | REAL | |
next_step | TEXT | |
next_step_due | TEXT | |
card_title | TEXT | |
proposal_link | TEXT | |
google_contact_id | TEXT | |
created_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
updated_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
categories
User-defined labels for contacts, such as Referral Partner or Professional Contact.
| Column | Type | Constraints and defaults |
|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT |
name | TEXT | NOT NULL UNIQUE COLLATE NOCASE |
contact_categories
Links contacts to categories. Many to many.
| Column | Type | Constraints and defaults |
|---|---|---|
contact_id | INTEGER | NOT NULL REFERENCES contacts (id) ON DELETE CASCADE |
category_id | INTEGER | NOT NULL REFERENCES categories (id) ON DELETE CASCADE |
activities
Communication and history per contact: calls, texts, messages, emails, meetings, notes and stage changes. Channel and direction say where it went and which way.
| Column | Type | Constraints and defaults |
|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT |
contact_id | INTEGER | NOT NULL REFERENCES contacts (id) ON DELETE CASCADE |
type | TEXT | NOT NULL |
direction | TEXT | |
channel | TEXT | |
summary | TEXT | |
occurred_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
created_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
external_profiles
Directory and social profiles the firm maintains, with review dates.
| Column | Type | Constraints and defaults |
|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT |
name | TEXT | NOT NULL |
url | TEXT | |
category | TEXT | |
last_reviewed_at | TEXT | |
review_interval_days | INTEGER | NOT NULL DEFAULT 90 |
notes | TEXT | |
created_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
updated_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
users
Sign-in accounts. The email address is the username. Passwords are stored as salted hashes.
| Column | Type | Constraints and defaults |
|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT |
email | TEXT | NOT NULL UNIQUE COLLATE NOCASE |
password_hash | TEXT | |
password_salt | TEXT | |
password_iterations | INTEGER | |
failed_logins | INTEGER | NOT NULL DEFAULT 0 |
locked_until | TEXT | |
last_login_at | TEXT | |
created_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
updated_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
password_resets
Single-use password reset tokens. Only a SHA-256 hash of each token is stored.
| Column | Type | Constraints and defaults |
|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT |
user_id | INTEGER | NOT NULL REFERENCES users (id) ON DELETE CASCADE |
token_hash | TEXT | NOT NULL UNIQUE |
expires_at | TEXT | NOT NULL |
used_at | TEXT | |
created_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
pipeline_stages
Pipeline column names. The key is fixed and used by prospects; the label can be renamed.
| Column | Type | Constraints and defaults |
|---|---|---|
key | TEXT | PRIMARY KEY |
label | TEXT | NOT NULL |
position | INTEGER | NOT NULL |
Services and APIs
| Service | Used for | Configured where |
|---|---|---|
| Cloudflare Pages Functions | Runs the API at /api/* | Pages project crm, file functions/api/[[route]].js |
| Cloudflare D1 | Database storage | Binding DB to biz-dev-crm |
| Cloudflare Turnstile | Bot check on sign-in, forgot password and reset | Site key in index.html (public). Secret TURNSTILE_SECRET_KEY (Pages secret) |
| Cloudflare Email Sending (REST) | Outreach emails and password reset emails, sent from advisors@fogler.pro | Endpoint /accounts/{account}/email/sending/send. Secret CF_EMAIL_TOKEN. Domain fogler.pro, records on the cf-bounce subdomain |
| CRM access token | Bearer token for scripts and the API | Pages secret CRM_TOKEN |
| cdnjs (Quill) | Rich text editor, front end | index.html, version 1.3.7 |
| challenges.cloudflare.com | Turnstile widget script | index.html |
| Planned, not built: Gmail API | Sent and received mail for advisors@fogler.pro | Needs Google Workspace OAuth |
| Planned, not built: Google People API | Google Contacts import | Needs Google Workspace OAuth |
| Planned, not built: LinkedIn API, Meta Graph API | Social publishing (LinkedIn, Facebook, Instagram) | Needs app credentials for each platform |
| Twilio | Calls and texts for the firm's phone number. Calls ring the firm phone first, then connect. Inbound texts and calls are logged to the contact | Secrets TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN. Variables TWILIO_NUMBER, FIRM_PHONE. Webhooks on the number point at /api/twilio/sms and /api/twilio/voice |
| Planned, not built: Google Voice API | Calls and texts through Google Voice. Google has no public calling or texting API | Needs a workaround or another calling provider |
| Planned, not built: SMS provider API (e.g. Twilio) | Plain text messages | Needs a provider account |
| Planned, not built: WhatsApp Business Platform, Messenger Platform, Instagram Messaging | Messages on each platform (Meta) | Needs a Meta app and business verification |
| Not available: iMessage | Messages from Apple devices | Apple publishes no API for iMessage |
| Planned, not built: LinkedIn Messaging API | LinkedIn direct messages | Partner access only |
Secret values are never written to the repository or to this page. They live in the Cloudflare Pages settings.
API endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/auth/login | Sign in. Needs email, password and Turnstile token |
| POST | /api/auth/forgot | Email a reset link. Same reply whether or not the account exists |
| POST | /api/auth/reset | Set a new password from a reset link |
| POST | /api/auth/logout | End the session |
| GET, POST | /api/contacts/:id/follow-ups | List a contact's follow-ups (open first, soonest due first), or add one with due_date (YYYY-MM-DD) and note |
| PATCH | /api/follow-ups/:id | Mark a follow-up done or open again with "done": true or false. Follow-ups are never deleted |
| GET | /api/qa | Data quality checks: active contacts with no category, no email or phone, or a prospect missing an email or proposal link. Read only |
| GET | /api/dashboard | Counts, pipeline stages, profiles due, upcoming follow-ups |
| GET, POST | /api/contacts | List with filters (q, customer=1, category), or create |
| GET, PATCH | /api/contacts/:id | Contact detail with categories, activities and prospect; update, including is_inactive |
| POST | /api/prospects/dead | Flush the Dead column: adds the "Dead lead" category to each open prospect in it and takes them off the pipeline. Requires "confirm": true. Nothing is deleted |
| POST | /api/contacts/:id/activities | Log a call, email, meeting, note or other activity |
| GET, POST | /api/categories | List or create categories |
| DELETE | /api/categories/:id | Delete a category |
| GET, POST | /api/prospects | List pipeline entries, or create one (from an existing contact or a new one) |
| PATCH | /api/prospects/:id | Update fields or change stage. Use the Customer checkbox to make someone a customer |
| GET, POST | /api/profiles | List external profiles with review status, or create one |
| PATCH, DELETE | /api/profiles/:id | Update or delete a profile |
| POST | /api/profiles/:id/reviewed | Mark a profile as reviewed today |
| POST | /api/twilio/sms, /api/twilio/voice | Twilio webhooks for inbound texts and calls. Checked with Twilio's signature, not a session |
| POST | /api/twilio/text | Send a text to a contact. Requires "confirm": true |
| POST | /api/twilio/call | Ring the firm phone, then connect to a contact |
| POST | /api/outreach/send | Send one email. Requires "confirm": true. Optional html alongside the required text, and optional cc (up to 5 addresses) |
| GET | /api/templates?kind=outreach|proposal | List email templates |
| POST | /api/templates | Create a template: kind, name, subject, body_html |
| PATCH / DELETE | /api/templates/:id | Edit or delete a template |
All endpoints except /api/auth/* need a valid session cookie or the CRM_TOKEN bearer token.
Security
- Passwords: salted PBKDF2-SHA256 with 100,000 iterations. Stored as hashes only.
- Five failed sign-ins lock the account for 15 minutes.
- Reset links expire after one hour and work once. Only a SHA-256 hash of each link is stored.
- Sessions: a signed cookie that is HttpOnly, Secure and SameSite=Strict, lasting 30 days.
- Email sends require an explicit
confirmflag. The From address is fixed toadvisors@fogler.pro. - Merged contact values are HTML-escaped before they are placed in an email or the page.
Screen map
Every screen and its breadcrumb path. Contact pages add the contact's name as the last item.
| Screen | Breadcrumb path |
|---|
Build and deploy
- Edit
index.html,functions/api/[[route]].jsordb/schema.sql. - Commit and push to the working branch to get a preview deployment.
- Merge the branch to
mainto deploy production. - After a schema change, apply the new statements to
biz-dev-crm. Existing data is kept.
Import contacts from CSV
Any CSV works: a spreadsheet export, Google Contacts, or another CRM. Recognized columns: first name, last name, company, title, email, phone, address, website and notes. Other columns are ignored. Anyone whose email is already in the CRM is skipped.