Sign in
Home
Follow-ups due in the next 7 days
Pipeline
Categories
Select a contact, or create one.
New inbound prospect
Recipients
Message
Each recipient gets their own email. Contacts without an email address are skipped.
Social
Pretend task completed. For this to really be done, you'll need the LinkedIn API and the Meta (Facebook and Instagram) Graph API.
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 the firm does business with or may do business with.
| 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 | |
relationship | TEXT | NOT NULL DEFAULT 'future' CHECK (relationship IN ('customer', 'future')) |
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')) |
prospects
Inbound sales opportunities, one per contact. The stage drives the pipeline board.
| Column | Type | Constraints and defaults |
|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT |
contact_id | INTEGER | NOT NULL UNIQUE REFERENCES contacts (id) ON DELETE CASCADE |
stage | TEXT | NOT NULL DEFAULT 'new' CHECK (stage IN ('new', 'contacted', 'qualifying', 'proposal_sent', 'follow_up', 'won', 'lost')) |
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 | |
created_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
updated_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')) |
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 |
| 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 | /api/dashboard | Counts, pipeline stages, profiles due, upcoming follow-ups |
| GET, POST | /api/contacts | List with filters (q, relationship, category), or create |
| GET, PATCH, DELETE | /api/contacts/:id | Contact detail with categories, activities and prospect; update; delete |
| 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. Stage won makes the contact 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/outreach/send | Send one email. Requires "confirm": true. Optional html alongside the required text |
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.