Files
campaign-command-center/src/lib/db.ts
Chris Olson d2d5632086 Implement Phase 2 features for Campaign Command Center
Adds 14 new features across the dashboard:
- Deal Risk Scoring (0-100) with risk factors on pipeline deals
- Account Health Score with multi-factor analysis on account cards
- Next Best Action Engine on Executive Overview
- Account Notes with pin/edit/delete in activity overlay and account detail
- Stakeholder Contact Map with role, sentiment, email, LinkedIn
- Competitive Intelligence Tracker with multi-select competitors and filtering
- CSV/Excel Export on accounts, activities, and pipeline pages
- Data Quality Dashboard in admin with completeness scoring
- Campaign Playbook Templates with 5 default plays and account progress tracking
- Goals & District Targets with quarterly tracking and progress bars
- Weekly Status Report (printable) and Executive Summary (printable)
- Activity Effectiveness Scoring showing pipeline conversion by activity type
- Mobile Activity FAB for quick activity logging on mobile
- Multi-User Auth (NextAuth + Google OAuth, @broadcom.com domain, role-based)

Also adds Phase 2 API route, scoring utilities, sidebar navigation updates,
and database schema extensions for notes, contacts, snapshots, playbooks,
and district targets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-31 18:07:46 -04:00

805 lines
38 KiB
TypeScript

import Database from 'better-sqlite3';
import path from 'path';
import {
PipelineRecord,
AccountRecord,
ActivityRecord,
TargetRecord,
ImplementationRecord,
MetricTarget,
DashboardData,
AccountNote,
Contact,
Snapshot,
PipelineSnapshot,
Playbook,
PlaybookProgress,
DistrictTarget,
} from '@/types/data';
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
let _db: Database.Database | null = null;
function getDb(): Database.Database {
if (!_db) {
const fs = require('fs');
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
_db = new Database(DB_PATH);
_db.pragma('journal_mode = WAL');
initSchema(_db);
migrateSchema(_db);
seedDefaultPlaybooks();
}
return _db;
}
function migrateSchema(db: Database.Database) {
const cols = db.prepare("PRAGMA table_info(accounts)").all() as { name: string }[];
const colNames = new Set(cols.map(c => c.name));
if (!colNames.has('Competitors')) {
db.exec("ALTER TABLE accounts ADD COLUMN Competitors TEXT DEFAULT NULL");
}
if (!colNames.has('Google_Drive_URL')) {
db.exec("ALTER TABLE accounts ADD COLUMN Google_Drive_URL TEXT DEFAULT NULL");
}
if (!colNames.has('Campaign_Artifacts_URL')) {
db.exec("ALTER TABLE accounts ADD COLUMN Campaign_Artifacts_URL TEXT DEFAULT NULL");
}
}
function initSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS accounts (
Account_Name TEXT PRIMARY KEY,
District_Name TEXT NOT NULL DEFAULT '',
Tier TEXT NOT NULL DEFAULT '',
Priority TEXT NOT NULL DEFAULT '',
AgentMinder_Status TEXT NOT NULL DEFAULT 'Not Touched',
Current_ARR_USD REAL,
Touch_Count INTEGER DEFAULT 0,
Date_First_Touched TEXT,
Date_Last_Touched TEXT,
MAP_In_Place_YN TEXT,
Company_URL TEXT,
Area_Sales_Leader TEXT,
DM TEXT,
AD TEXT,
Logo_URL TEXT,
Next_Renewal_Date TEXT,
Next_Renewal_EAR REAL,
Anchor_Contract_Date TEXT,
Anchor_Contract_EAR REAL
);
CREATE TABLE IF NOT EXISTS pipeline (
Opportunity_ID TEXT PRIMARY KEY,
Account_Name TEXT NOT NULL,
District_Name TEXT NOT NULL DEFAULT '',
Stage TEXT NOT NULL DEFAULT '01-Qualified',
Forecast_Category TEXT NOT NULL DEFAULT 'Pipeline',
Amount_USD REAL NOT NULL DEFAULT 0,
Closed_Amount_USD REAL,
Probability_Pct INTEGER NOT NULL DEFAULT 0,
Created_Date TEXT NOT NULL,
Expected_Close_Date TEXT NOT NULL DEFAULT '',
Closed_Date TEXT,
Champion_Name TEXT,
Economic_Buyer TEXT,
Next_Step TEXT,
Next_Step_Date TEXT,
Primary_Objection TEXT,
Competitor TEXT,
Source_Play TEXT,
Product TEXT NOT NULL DEFAULT 'AgentMinder',
Deal_Type TEXT,
Status TEXT DEFAULT 'Open',
Stage_Entered_Date TEXT,
Tier TEXT,
MAP_In_Place_YN TEXT
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Activity_ID TEXT,
Activity_Date TEXT NOT NULL,
Activity_Type TEXT NOT NULL,
Account_Name TEXT NOT NULL,
District_Name TEXT NOT NULL DEFAULT '',
Contact_Name TEXT,
Notes TEXT,
Play TEXT,
Channel TEXT,
Persona TEXT,
Outcome TEXT,
Logged_By TEXT
);
CREATE TABLE IF NOT EXISTS implementations (
Implementation_ID TEXT PRIMARY KEY,
Opportunity_ID TEXT,
Account_Name TEXT NOT NULL,
District_Name TEXT,
Tier TEXT,
Closed_Date TEXT,
Closed_Amount_USD REAL,
Kickoff_Date TEXT,
Go_Live_Target TEXT,
Go_Live_Actual TEXT,
Deployment_Status TEXT NOT NULL DEFAULT 'Not Started',
Health_RYG TEXT,
Milestones_Complete INTEGER DEFAULT 0,
Milestones_Total INTEGER DEFAULT 5,
Onboarding_Owner TEXT,
Risk_Notes TEXT
);
CREATE TABLE IF NOT EXISTS metric_targets (
Target_ID TEXT PRIMARY KEY,
Period_Type TEXT NOT NULL,
Period_Label TEXT NOT NULL DEFAULT '',
District_Name TEXT NOT NULL DEFAULT '',
Metric TEXT NOT NULL,
Target_Value REAL NOT NULL DEFAULT 0,
Notes TEXT
);
CREATE TABLE IF NOT EXISTS account_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
note_text TEXT NOT NULL,
next_action TEXT,
is_pinned INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
created_by TEXT
);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
Contact_Name TEXT NOT NULL,
Role TEXT,
Sentiment TEXT,
Email TEXT,
LinkedIn_URL TEXT
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
period_type TEXT NOT NULL,
total_accounts INTEGER DEFAULT 0,
accounts_touched INTEGER DEFAULT 0,
total_pipeline REAL DEFAULT 0,
open_opps INTEGER DEFAULT 0,
closed_won_amount REAL DEFAULT 0,
total_activities INTEGER DEFAULT 0,
accounts_by_priority_json TEXT,
pipeline_by_stage_json TEXT,
district_data_json TEXT
);
CREATE TABLE IF NOT EXISTS pipeline_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
Opportunity_ID TEXT NOT NULL,
Account_Name TEXT NOT NULL,
Stage TEXT NOT NULL,
Amount_USD REAL NOT NULL DEFAULT 0,
Forecast_Category TEXT
);
CREATE TABLE IF NOT EXISTS playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
play_name TEXT NOT NULL UNIQUE,
description TEXT,
steps_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS playbook_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
playbook_id INTEGER NOT NULL,
current_step INTEGER DEFAULT 0,
status TEXT DEFAULT 'In Progress',
started_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (playbook_id) REFERENCES playbooks(id)
);
CREATE TABLE IF NOT EXISTS district_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
District_Name TEXT NOT NULL,
quarter TEXT NOT NULL,
activities_per_week REAL DEFAULT 0,
accounts_touched INTEGER DEFAULT 0,
pipeline_generated REAL DEFAULT 0,
UNIQUE(District_Name, quarter)
);
CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name);
CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name);
CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name);
CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(Activity_Date);
CREATE INDEX IF NOT EXISTS idx_impl_account ON implementations(Account_Name);
CREATE INDEX IF NOT EXISTS idx_notes_account ON account_notes(Account_Name);
CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(Account_Name);
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_pipeline_snap_date ON pipeline_snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_playbook_progress_account ON playbook_progress(Account_Name);
`);
}
export function getDashboardData(): DashboardData {
const db = getDb();
return {
pipeline: db.prepare('SELECT * FROM pipeline').all() as PipelineRecord[],
accounts: db.prepare('SELECT * FROM accounts').all() as AccountRecord[],
activities: db.prepare('SELECT id, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By FROM activities ORDER BY Activity_Date DESC').all() as (ActivityRecord & { id: number })[],
targets: buildTargetsFromImplementations(db),
implementations: db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[],
metricTargets: db.prepare('SELECT * FROM metric_targets').all() as MetricTarget[],
lastRefreshed: new Date().toISOString(),
};
}
function buildTargetsFromImplementations(db: Database.Database): TargetRecord[] {
const impls = db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[];
return impls.map(impl => ({
Account_Name: impl.Account_Name,
Implementation_Stage: impl.Deployment_Status || 'Not Started',
Go_Live_Date: impl.Go_Live_Target || impl.Go_Live_Actual || null,
Health_Status: impl.Health_RYG || null,
Notes: impl.Risk_Notes || null,
}));
}
export function getDbStats() {
const db = getDb();
return {
accounts: (db.prepare('SELECT COUNT(*) as c FROM accounts').get() as { c: number }).c,
pipeline: (db.prepare('SELECT COUNT(*) as c FROM pipeline').get() as { c: number }).c,
activities: (db.prepare('SELECT COUNT(*) as c FROM activities').get() as { c: number }).c,
targets: (db.prepare('SELECT COUNT(*) as c FROM implementations').get() as { c: number }).c,
metricTargets: (db.prepare('SELECT COUNT(*) as c FROM metric_targets').get() as { c: number }).c,
};
}
export function updateLastTouched(accountName: string, date: string) {
const db = getDb();
const acct = db.prepare('SELECT Date_First_Touched, Touch_Count FROM accounts WHERE Account_Name = ?').get(accountName) as { Date_First_Touched: string | null; Touch_Count: number | null } | undefined;
if (!acct) return;
const updates: Record<string, unknown> = {
Account_Name: accountName,
Date_Last_Touched: date,
Touch_Count: (acct.Touch_Count || 0) + 1,
};
if (!acct.Date_First_Touched) {
updates.Date_First_Touched = date;
}
db.prepare(`
UPDATE accounts SET
Date_Last_Touched = @Date_Last_Touched,
Touch_Count = @Touch_Count
${!acct.Date_First_Touched ? ', Date_First_Touched = @Date_First_Touched' : ''}
WHERE Account_Name = @Account_Name
`).run(updates);
}
export function recalcAccountTouchData() {
const db = getDb();
const activities = db.prepare(`
SELECT Account_Name, MIN(Activity_Date) as first_touch, MAX(Activity_Date) as last_touch, COUNT(*) as touch_count
FROM activities
GROUP BY Account_Name
`).all() as { Account_Name: string; first_touch: string; last_touch: string; touch_count: number }[];
const update = db.prepare(`
UPDATE accounts SET
Date_First_Touched = @first_touch,
Date_Last_Touched = @last_touch,
Touch_Count = @touch_count
WHERE Account_Name = @Account_Name
`);
const tx = db.transaction(() => {
for (const a of activities) {
update.run(a);
}
});
tx();
}
export function recalcAccountStatus() {
const db = getDb();
db.exec(`
UPDATE accounts SET AgentMinder_Status = CASE
WHEN Account_Name IN (SELECT DISTINCT Account_Name FROM pipeline WHERE Status = 'Open') THEN '20% - Research'
WHEN Account_Name IN (SELECT DISTINCT Account_Name FROM activities) THEN '10% - Prospect'
ELSE 'Not Touched'
END
WHERE AgentMinder_Status = 'Not Touched'
OR AgentMinder_Status IS NULL
`);
}
export function addActivity(record: {
Activity_Date: string;
Activity_Type: string;
Account_Name: string;
District_Name: string;
Contact_Name?: string | null;
Notes?: string | null;
Play?: string | null;
Channel?: string | null;
Persona?: string | null;
Outcome?: string | null;
Logged_By?: string | null;
}) {
const db = getDb();
db.prepare(`
INSERT INTO activities (Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By)
VALUES (@Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By)
`).run({
Activity_Date: record.Activity_Date,
Activity_Type: record.Activity_Type,
Account_Name: record.Account_Name,
District_Name: record.District_Name,
Contact_Name: record.Contact_Name || null,
Notes: record.Notes || null,
Play: record.Play || null,
Channel: record.Channel || null,
Persona: record.Persona || null,
Outcome: record.Outcome || null,
Logged_By: record.Logged_By || null,
});
updateLastTouched(record.Account_Name, record.Activity_Date);
}
export function importData(data: {
accounts?: Record<string, string | null>[];
pipeline?: Record<string, string | null>[];
activities?: Record<string, string | null>[];
implementations?: Record<string, string | null>[];
metricTargets?: Record<string, string | null>[];
}): Record<string, number> {
const db = getDb();
const counts: Record<string, number> = {};
const tx = db.transaction(() => {
if (data.accounts?.length) {
const stmt = db.prepare(`
INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, MAP_In_Place_YN, Company_URL, Area_Sales_Leader, DM, AD, Logo_URL, Next_Renewal_Date, Next_Renewal_EAR, Anchor_Contract_Date, Anchor_Contract_EAR)
VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @Touch_Count, @Date_First_Touched, @Date_Last_Touched, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD, @Logo_URL, @Next_Renewal_Date, @Next_Renewal_EAR, @Anchor_Contract_Date, @Anchor_Contract_EAR)
ON CONFLICT(Account_Name) DO UPDATE SET
District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority,
AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD,
Touch_Count=excluded.Touch_Count, Date_First_Touched=excluded.Date_First_Touched,
Date_Last_Touched=excluded.Date_Last_Touched, MAP_In_Place_YN=excluded.MAP_In_Place_YN,
Company_URL=excluded.Company_URL, Area_Sales_Leader=excluded.Area_Sales_Leader,
DM=excluded.DM, AD=excluded.AD, Logo_URL=excluded.Logo_URL,
Next_Renewal_Date=excluded.Next_Renewal_Date, Next_Renewal_EAR=excluded.Next_Renewal_EAR,
Anchor_Contract_Date=excluded.Anchor_Contract_Date, Anchor_Contract_EAR=excluded.Anchor_Contract_EAR
`);
let c = 0;
for (const r of data.accounts) {
if (!r.Account_Name) continue;
stmt.run(r);
c++;
}
counts.accounts = c;
}
if (data.pipeline?.length) {
const stmt = db.prepare(`
INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product, Deal_Type, Status, Stage_Entered_Date, Tier, MAP_In_Place_YN)
VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product, @Deal_Type, @Status, @Stage_Entered_Date, @Tier, @MAP_In_Place_YN)
ON CONFLICT(Opportunity_ID) DO UPDATE SET
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage,
Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD,
Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct,
Created_Date=excluded.Created_Date, Expected_Close_Date=excluded.Expected_Close_Date,
Closed_Date=excluded.Closed_Date, Champion_Name=excluded.Champion_Name,
Economic_Buyer=excluded.Economic_Buyer, Next_Step=excluded.Next_Step,
Next_Step_Date=excluded.Next_Step_Date, Primary_Objection=excluded.Primary_Objection,
Competitor=excluded.Competitor, Source_Play=excluded.Source_Play, Product=excluded.Product,
Deal_Type=excluded.Deal_Type, Status=excluded.Status, Stage_Entered_Date=excluded.Stage_Entered_Date,
Tier=excluded.Tier, MAP_In_Place_YN=excluded.MAP_In_Place_YN
`);
let c = 0;
for (const r of data.pipeline) {
if (!r.Opportunity_ID) continue;
stmt.run(r);
c++;
}
counts.pipeline = c;
}
if (data.activities?.length) {
db.prepare('DELETE FROM activities').run();
const stmt = db.prepare(`
INSERT INTO activities (Activity_ID, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By)
VALUES (@Activity_ID, @Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By)
`);
let c = 0;
for (const r of data.activities) {
if (!r.Account_Name || !r.Activity_Date) continue;
stmt.run(r);
c++;
}
counts.activities = c;
}
if (data.implementations?.length) {
const stmt = db.prepare(`
INSERT INTO implementations (Implementation_ID, Opportunity_ID, Account_Name, District_Name, Tier, Closed_Date, Closed_Amount_USD, Kickoff_Date, Go_Live_Target, Go_Live_Actual, Deployment_Status, Health_RYG, Milestones_Complete, Milestones_Total, Onboarding_Owner, Risk_Notes)
VALUES (@Implementation_ID, @Opportunity_ID, @Account_Name, @District_Name, @Tier, @Closed_Date, @Closed_Amount_USD, @Kickoff_Date, @Go_Live_Target, @Go_Live_Actual, @Deployment_Status, @Health_RYG, @Milestones_Complete, @Milestones_Total, @Onboarding_Owner, @Risk_Notes)
ON CONFLICT(Implementation_ID) DO UPDATE SET
Opportunity_ID=excluded.Opportunity_ID, Account_Name=excluded.Account_Name,
District_Name=excluded.District_Name, Tier=excluded.Tier,
Closed_Date=excluded.Closed_Date, Closed_Amount_USD=excluded.Closed_Amount_USD,
Kickoff_Date=excluded.Kickoff_Date, Go_Live_Target=excluded.Go_Live_Target,
Go_Live_Actual=excluded.Go_Live_Actual, Deployment_Status=excluded.Deployment_Status,
Health_RYG=excluded.Health_RYG, Milestones_Complete=excluded.Milestones_Complete,
Milestones_Total=excluded.Milestones_Total, Onboarding_Owner=excluded.Onboarding_Owner,
Risk_Notes=excluded.Risk_Notes
`);
let c = 0;
for (const r of data.implementations) {
if (!r.Implementation_ID || !r.Account_Name) continue;
stmt.run(r);
c++;
}
counts.implementations = c;
}
if (data.metricTargets?.length) {
const stmt = db.prepare(`
INSERT INTO metric_targets (Target_ID, Period_Type, Period_Label, District_Name, Metric, Target_Value, Notes)
VALUES (@Target_ID, @Period_Type, @Period_Label, @District_Name, @Metric, @Target_Value, @Notes)
ON CONFLICT(Target_ID) DO UPDATE SET
Period_Type=excluded.Period_Type, Period_Label=excluded.Period_Label,
District_Name=excluded.District_Name, Metric=excluded.Metric,
Target_Value=excluded.Target_Value, Notes=excluded.Notes
`);
let c = 0;
for (const r of data.metricTargets) {
if (!r.Target_ID) continue;
stmt.run(r);
c++;
}
counts.metricTargets = c;
}
});
tx();
return counts;
}
export function deleteAccount(name: string) {
const db = getDb();
db.prepare('DELETE FROM accounts WHERE Account_Name = ?').run(name);
}
export function deletePipeline(id: string) {
const db = getDb();
db.prepare('DELETE FROM pipeline WHERE Opportunity_ID = ?').run(id);
}
export function deleteActivity(id: number) {
const db = getDb();
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
}
export function deleteTarget(accountName: string) {
const db = getDb();
db.prepare('DELETE FROM implementations WHERE Account_Name = ?').run(accountName);
}
export function upsertAccount(record: Record<string, unknown>) {
const db = getDb();
db.prepare(`INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, MAP_In_Place_YN, Company_URL, Area_Sales_Leader, DM, AD)
VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD)
ON CONFLICT(Account_Name) DO UPDATE SET
District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority,
AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD,
MAP_In_Place_YN=excluded.MAP_In_Place_YN, Company_URL=excluded.Company_URL,
Area_Sales_Leader=excluded.Area_Sales_Leader, DM=excluded.DM, AD=excluded.AD
`).run({
Account_Name: record.Account_Name || '',
District_Name: record.District_Name || '',
Tier: record.Tier || '',
Priority: record.Priority || '',
AgentMinder_Status: record.AgentMinder_Status || 'Not Touched',
Current_ARR_USD: record.Current_ARR_USD ?? null,
MAP_In_Place_YN: record.MAP_In_Place_YN ?? null,
Company_URL: record.Company_URL ?? null,
Area_Sales_Leader: record.Area_Sales_Leader ?? null,
DM: record.DM ?? null,
AD: record.AD ?? null,
});
}
export function upsertPipeline(record: Record<string, unknown>) {
const db = getDb();
db.prepare(`INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product)
VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product)
ON CONFLICT(Opportunity_ID) DO UPDATE SET
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage,
Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD,
Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct,
Expected_Close_Date=excluded.Expected_Close_Date, Closed_Date=excluded.Closed_Date,
Champion_Name=excluded.Champion_Name, Economic_Buyer=excluded.Economic_Buyer,
Next_Step=excluded.Next_Step, Next_Step_Date=excluded.Next_Step_Date,
Primary_Objection=excluded.Primary_Objection, Competitor=excluded.Competitor,
Source_Play=excluded.Source_Play
`).run({
Opportunity_ID: record.Opportunity_ID || '',
Account_Name: record.Account_Name || '',
District_Name: record.District_Name || '',
Stage: record.Stage || '01-Qualified',
Forecast_Category: record.Forecast_Category || 'Pipeline',
Amount_USD: record.Amount_USD ?? 0,
Closed_Amount_USD: record.Closed_Amount_USD ?? null,
Probability_Pct: record.Probability_Pct ?? 0,
Created_Date: record.Created_Date || new Date().toISOString().split('T')[0],
Expected_Close_Date: record.Expected_Close_Date || '',
Closed_Date: record.Closed_Date ?? null,
Champion_Name: record.Champion_Name ?? null,
Economic_Buyer: record.Economic_Buyer ?? null,
Next_Step: record.Next_Step ?? null,
Next_Step_Date: record.Next_Step_Date ?? null,
Primary_Objection: record.Primary_Objection ?? null,
Competitor: record.Competitor ?? null,
Source_Play: record.Source_Play ?? null,
Product: record.Product || 'AgentMinder',
});
}
// --- Account Notes ---
export function getAccountNotes(accountName: string): AccountNote[] {
const db = getDb();
return db.prepare('SELECT * FROM account_notes WHERE Account_Name = ? ORDER BY is_pinned DESC, created_at DESC').all(accountName) as AccountNote[];
}
export function addAccountNote(note: { Account_Name: string; note_text: string; next_action?: string | null; is_pinned?: number; created_by?: string | null }): AccountNote {
const db = getDb();
const result = db.prepare(`INSERT INTO account_notes (Account_Name, note_text, next_action, is_pinned, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)`).run(
note.Account_Name, note.note_text, note.next_action || null, note.is_pinned || 0, new Date().toISOString(), note.created_by || null
);
return db.prepare('SELECT * FROM account_notes WHERE id = ?').get(result.lastInsertRowid) as AccountNote;
}
export function deleteAccountNote(id: number) {
getDb().prepare('DELETE FROM account_notes WHERE id = ?').run(id);
}
export function updateAccountNote(id: number, updates: { note_text?: string; next_action?: string | null; is_pinned?: number }) {
const db = getDb();
const sets: string[] = [];
const vals: unknown[] = [];
if (updates.note_text !== undefined) { sets.push('note_text = ?'); vals.push(updates.note_text); }
if (updates.next_action !== undefined) { sets.push('next_action = ?'); vals.push(updates.next_action); }
if (updates.is_pinned !== undefined) { sets.push('is_pinned = ?'); vals.push(updates.is_pinned); }
if (sets.length === 0) return;
vals.push(id);
db.prepare(`UPDATE account_notes SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
}
// --- Contacts ---
export function getContacts(accountName: string): Contact[] {
const db = getDb();
return db.prepare('SELECT * FROM contacts WHERE Account_Name = ? ORDER BY Contact_Name').all(accountName) as Contact[];
}
export function getAllContacts(): Contact[] {
return getDb().prepare('SELECT * FROM contacts ORDER BY Account_Name, Contact_Name').all() as Contact[];
}
export function upsertContact(contact: { id?: number; Account_Name: string; Contact_Name: string; Role?: string | null; Sentiment?: string | null; Email?: string | null; LinkedIn_URL?: string | null }): Contact {
const db = getDb();
if (contact.id) {
db.prepare(`UPDATE contacts SET Contact_Name=?, Role=?, Sentiment=?, Email=?, LinkedIn_URL=? WHERE id=?`).run(
contact.Contact_Name, contact.Role || null, contact.Sentiment || null, contact.Email || null, contact.LinkedIn_URL || null, contact.id
);
return db.prepare('SELECT * FROM contacts WHERE id = ?').get(contact.id) as Contact;
}
const result = db.prepare(`INSERT INTO contacts (Account_Name, Contact_Name, Role, Sentiment, Email, LinkedIn_URL) VALUES (?, ?, ?, ?, ?, ?)`).run(
contact.Account_Name, contact.Contact_Name, contact.Role || null, contact.Sentiment || null, contact.Email || null, contact.LinkedIn_URL || null
);
return db.prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid) as Contact;
}
export function deleteContact(id: number) {
getDb().prepare('DELETE FROM contacts WHERE id = ?').run(id);
}
// --- Snapshots ---
export function getSnapshots(periodType?: string): Snapshot[] {
const db = getDb();
if (periodType) return db.prepare('SELECT * FROM snapshots WHERE period_type = ? ORDER BY snapshot_date DESC').all(periodType) as Snapshot[];
return db.prepare('SELECT * FROM snapshots ORDER BY snapshot_date DESC').all() as Snapshot[];
}
export function captureSnapshot(periodType: 'weekly' | 'monthly' | 'quarterly') {
const db = getDb();
const today = new Date().toISOString().split('T')[0];
const existing = db.prepare('SELECT id FROM snapshots WHERE snapshot_date = ? AND period_type = ?').get(today, periodType);
if (existing) return existing;
const accounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[];
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Status != 'Closed Lost'").all() as PipelineRecord[];
const activities = db.prepare('SELECT * FROM activities').all() as ActivityRecord[];
const openPipeline = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won');
const priorityCounts: Record<string, number> = {};
accounts.forEach(a => { priorityCounts[a.Priority || 'Unset'] = (priorityCounts[a.Priority || 'Unset'] || 0) + 1; });
const stageCounts: Record<string, { count: number; amount: number }> = {};
openPipeline.forEach(p => {
if (!stageCounts[p.Stage]) stageCounts[p.Stage] = { count: 0, amount: 0 };
stageCounts[p.Stage].count++;
stageCounts[p.Stage].amount += p.Amount_USD;
});
const districtData: Record<string, { activities: number; pipeline: number; touched: number }> = {};
activities.forEach(a => {
if (!districtData[a.District_Name]) districtData[a.District_Name] = { activities: 0, pipeline: 0, touched: 0 };
districtData[a.District_Name].activities++;
});
openPipeline.forEach(p => {
if (!districtData[p.District_Name]) districtData[p.District_Name] = { activities: 0, pipeline: 0, touched: 0 };
districtData[p.District_Name].pipeline += p.Amount_USD;
});
const touchedAccounts = new Set(activities.map(a => a.Account_Name));
const result = db.prepare(`INSERT INTO snapshots (snapshot_date, period_type, total_accounts, accounts_touched, total_pipeline, open_opps, closed_won_amount, total_activities, accounts_by_priority_json, pipeline_by_stage_json, district_data_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
today, periodType, accounts.length, touchedAccounts.size,
openPipeline.reduce((s, p) => s + p.Amount_USD, 0), openPipeline.length,
closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0), activities.length,
JSON.stringify(priorityCounts), JSON.stringify(stageCounts), JSON.stringify(districtData)
);
// Also snapshot individual pipeline records for waterfall tracking
const pipeSnap = db.prepare(`INSERT INTO pipeline_snapshots (snapshot_date, Opportunity_ID, Account_Name, Stage, Amount_USD, Forecast_Category) VALUES (?, ?, ?, ?, ?, ?)`);
const tx = db.transaction(() => {
for (const p of pipeline) {
pipeSnap.run(today, p.Opportunity_ID, p.Account_Name, p.Stage, p.Amount_USD, p.Forecast_Category);
}
});
tx();
return { id: result.lastInsertRowid };
}
// --- Pipeline Snapshots (for waterfall) ---
export function getPipelineSnapshots(date?: string): PipelineSnapshot[] {
const db = getDb();
if (date) return db.prepare('SELECT * FROM pipeline_snapshots WHERE snapshot_date = ?').all(date) as PipelineSnapshot[];
return db.prepare('SELECT DISTINCT snapshot_date FROM pipeline_snapshots ORDER BY snapshot_date DESC').all() as PipelineSnapshot[];
}
// --- Playbooks ---
export function getPlaybooks(): Playbook[] {
return getDb().prepare('SELECT * FROM playbooks ORDER BY play_name').all() as Playbook[];
}
export function upsertPlaybook(playbook: { id?: number; play_name: string; description?: string | null; steps_json: string }) {
const db = getDb();
if (playbook.id) {
db.prepare('UPDATE playbooks SET play_name=?, description=?, steps_json=? WHERE id=?').run(playbook.play_name, playbook.description || null, playbook.steps_json, playbook.id);
} else {
db.prepare('INSERT INTO playbooks (play_name, description, steps_json) VALUES (?, ?, ?) ON CONFLICT(play_name) DO UPDATE SET description=excluded.description, steps_json=excluded.steps_json').run(playbook.play_name, playbook.description || null, playbook.steps_json);
}
}
export function getPlaybookProgress(accountName?: string): PlaybookProgress[] {
const db = getDb();
if (accountName) return db.prepare('SELECT * FROM playbook_progress WHERE Account_Name = ?').all(accountName) as PlaybookProgress[];
return db.prepare('SELECT * FROM playbook_progress ORDER BY updated_at DESC').all() as PlaybookProgress[];
}
export function upsertPlaybookProgress(progress: { id?: number; Account_Name: string; playbook_id: number; current_step: number; status: string }) {
const db = getDb();
const now = new Date().toISOString();
if (progress.id) {
db.prepare('UPDATE playbook_progress SET current_step=?, status=?, updated_at=? WHERE id=?').run(progress.current_step, progress.status, now, progress.id);
} else {
db.prepare('INSERT INTO playbook_progress (Account_Name, playbook_id, current_step, status, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(
progress.Account_Name, progress.playbook_id, progress.current_step, progress.status, now, now
);
}
}
export function seedDefaultPlaybooks() {
const db = getDb();
const existing = db.prepare('SELECT COUNT(*) as c FROM playbooks').get() as { c: number };
if (existing.c > 0) return;
const playbooks = [
{ play_name: 'New Logo Acquisition', description: 'Full sales cycle for net-new accounts', steps_json: JSON.stringify(['Research & Targeting', 'Initial Outreach', 'Discovery Meeting', 'Demo / Workshop', 'Executive Alignment', 'Proposal & Negotiation', 'Close']) },
{ play_name: 'Attach / Cross-Sell', description: 'Expand within existing accounts', steps_json: JSON.stringify(['Account Review & Whitespace ID', 'Champion Alignment', 'Discovery / Needs Assessment', 'Demo / POC', 'Business Case', 'Close']) },
{ play_name: 'Renewal Defense', description: 'Protect existing contracts approaching renewal', steps_json: JSON.stringify(['Renewal Assessment', 'Stakeholder Re-engagement', 'Value Realization Review', 'QBR / Executive Briefing', 'Renewal Negotiation', 'Contract Execution']) },
{ play_name: 'Competitive Displacement', description: 'Displace an incumbent competitor', steps_json: JSON.stringify(['Competitive Intel Gathering', 'Pain Point Discovery', 'Differentiation Demo', 'Executive Sponsor Alignment', 'POC / Bake-off', 'Proposal & Close']) },
{ play_name: 'Inbound Response', description: 'Respond to inbound interest or RFP', steps_json: JSON.stringify(['Qualify Inbound', 'Discovery Call', 'Technical Deep Dive', 'Proposal / RFP Response', 'Negotiation', 'Close']) },
];
const stmt = db.prepare('INSERT INTO playbooks (play_name, description, steps_json) VALUES (?, ?, ?)');
const tx = db.transaction(() => { for (const p of playbooks) stmt.run(p.play_name, p.description, p.steps_json); });
tx();
}
// --- District Targets ---
export function getDistrictTargets(quarter?: string): DistrictTarget[] {
const db = getDb();
if (quarter) return db.prepare('SELECT * FROM district_targets WHERE quarter = ? ORDER BY District_Name').all(quarter) as DistrictTarget[];
return db.prepare('SELECT * FROM district_targets ORDER BY quarter DESC, District_Name').all() as DistrictTarget[];
}
export function upsertDistrictTarget(target: { District_Name: string; quarter: string; activities_per_week: number; accounts_touched: number; pipeline_generated: number }) {
const db = getDb();
db.prepare(`INSERT INTO district_targets (District_Name, quarter, activities_per_week, accounts_touched, pipeline_generated) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(District_Name, quarter) DO UPDATE SET activities_per_week=excluded.activities_per_week, accounts_touched=excluded.accounts_touched, pipeline_generated=excluded.pipeline_generated`).run(
target.District_Name, target.quarter, target.activities_per_week, target.accounts_touched, target.pipeline_generated
);
}
// --- Data Quality ---
export function getDataQualityReport() {
const db = getDb();
const accounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[];
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Status != 'Closed Lost' AND Stage NOT IN ('06-Closed Won', '07-Closed Lost')").all() as PipelineRecord[];
const issues: { type: string; severity: string; message: string; table: string; record_id: string }[] = [];
for (const a of accounts) {
if (!a.Priority || a.Priority === '') issues.push({ type: 'missing_field', severity: 'medium', message: `Missing Priority`, table: 'accounts', record_id: a.Account_Name });
if (!a.Tier || a.Tier === '') issues.push({ type: 'missing_field', severity: 'medium', message: `Missing Tier`, table: 'accounts', record_id: a.Account_Name });
if (!a.AD) issues.push({ type: 'missing_field', severity: 'low', message: `Missing AD assignment`, table: 'accounts', record_id: a.Account_Name });
if (!a.Next_Renewal_Date) issues.push({ type: 'missing_field', severity: 'low', message: `Missing Renewal Date`, table: 'accounts', record_id: a.Account_Name });
}
for (const p of pipeline) {
if (!p.Next_Step) issues.push({ type: 'missing_field', severity: 'high', message: `No Next Step defined`, table: 'pipeline', record_id: p.Opportunity_ID });
if (p.Expected_Close_Date && p.Expected_Close_Date < new Date().toISOString().split('T')[0]) issues.push({ type: 'stale_data', severity: 'high', message: `Close date in the past`, table: 'pipeline', record_id: p.Opportunity_ID });
if (!p.Champion_Name) issues.push({ type: 'missing_field', severity: 'medium', message: `No Champion identified`, table: 'pipeline', record_id: p.Opportunity_ID });
}
const totalFields = accounts.length * 5 + pipeline.length * 3;
const missingFields = issues.filter(i => i.type === 'missing_field').length;
const completeness = totalFields > 0 ? Math.round(((totalFields - missingFields) / totalFields) * 100) : 100;
return { issues, completeness, totalAccounts: accounts.length, totalPipeline: pipeline.length };
}
export function upsertTarget(record: Record<string, unknown>) {
const db = getDb();
const id = (record.Implementation_ID as string) || `IMPL-${Date.now()}`;
db.prepare(`INSERT INTO implementations (Implementation_ID, Account_Name, District_Name, Deployment_Status, Health_RYG, Go_Live_Target, Go_Live_Actual, Risk_Notes)
VALUES (@Implementation_ID, @Account_Name, @District_Name, @Deployment_Status, @Health_RYG, @Go_Live_Target, @Go_Live_Actual, @Risk_Notes)
ON CONFLICT(Implementation_ID) DO UPDATE SET
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name,
Deployment_Status=excluded.Deployment_Status, Health_RYG=excluded.Health_RYG,
Go_Live_Target=excluded.Go_Live_Target, Go_Live_Actual=excluded.Go_Live_Actual,
Risk_Notes=excluded.Risk_Notes
`).run({
Implementation_ID: id,
Account_Name: record.Account_Name || '',
District_Name: record.District_Name ?? null,
Deployment_Status: record.Deployment_Status || record.Implementation_Stage || 'Not Started',
Health_RYG: record.Health_RYG || record.Health_Status || null,
Go_Live_Target: record.Go_Live_Target || record.Go_Live_Date || null,
Go_Live_Actual: record.Go_Live_Actual ?? null,
Risk_Notes: record.Risk_Notes || record.Notes || null,
});
}