← back to Ventura Corridor

db/migrations/012_pitch_event_log.sql

86 lines

-- Migration 012 — pitch_event_log audit trail
-- Every change to a pitch's lifecycle (status, outreach_channel, sent/replied/closed/won)
-- writes a row to pitch_event_log via trigger. This unblocks future analytics
-- (cycle time, time-to-first-reply, time-to-close), legal-compliance recall
-- ("when did Steve first contact this firm?"), and lifecycle visualization.

CREATE TABLE IF NOT EXISTS pitch_event_log (
  id           BIGSERIAL PRIMARY KEY,
  pitch_id     BIGINT       NOT NULL REFERENCES pitches(id) ON DELETE CASCADE,
  event_type   TEXT         NOT NULL,  -- 'status_change' | 'channel_change' | 'sent' | 'replied' | 'closed' | 'won' | 'lost' | 'reply_text' | 'followup_scheduled' | 'created'
  old_value    TEXT,
  new_value    TEXT,
  source       TEXT,                   -- typically NULL (we don't track operator yet); reserved
  occurred_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_pel_pitch        ON pitch_event_log (pitch_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_pel_event_type   ON pitch_event_log (event_type, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_pel_occurred_at  ON pitch_event_log (occurred_at DESC);

-- Trigger: fire on UPDATE of any tracked column
CREATE OR REPLACE FUNCTION trg_pitch_event_log_fn() RETURNS trigger AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    INSERT INTO pitch_event_log (pitch_id, event_type, new_value)
      VALUES (NEW.id, 'created', NEW.status);
    RETURN NEW;
  END IF;

  IF NEW.status IS DISTINCT FROM OLD.status THEN
    INSERT INTO pitch_event_log (pitch_id, event_type, old_value, new_value)
      VALUES (NEW.id, 'status_change', OLD.status, NEW.status);
  END IF;

  IF NEW.outreach_channel IS DISTINCT FROM OLD.outreach_channel THEN
    INSERT INTO pitch_event_log (pitch_id, event_type, old_value, new_value)
      VALUES (NEW.id, 'channel_change', OLD.outreach_channel, NEW.outreach_channel);
  END IF;

  IF NEW.sent_at IS DISTINCT FROM OLD.sent_at AND NEW.sent_at IS NOT NULL THEN
    INSERT INTO pitch_event_log (pitch_id, event_type, new_value)
      VALUES (NEW.id, 'sent', COALESCE(NEW.outreach_channel, 'unspecified'));
  END IF;

  IF NEW.replied_at IS DISTINCT FROM OLD.replied_at AND NEW.replied_at IS NOT NULL THEN
    INSERT INTO pitch_event_log (pitch_id, event_type, new_value)
      VALUES (NEW.id, 'replied', COALESCE(NEW.reply_channel, NEW.outreach_channel, 'unspecified'));
  END IF;

  IF NEW.closed_at IS DISTINCT FROM OLD.closed_at AND NEW.closed_at IS NOT NULL THEN
    INSERT INTO pitch_event_log (pitch_id, event_type, new_value)
      VALUES (NEW.id, 'closed', NEW.status);
  END IF;

  IF NEW.reply_text IS DISTINCT FROM OLD.reply_text AND NEW.reply_text IS NOT NULL THEN
    INSERT INTO pitch_event_log (pitch_id, event_type, new_value)
      VALUES (NEW.id, 'reply_text', LEFT(NEW.reply_text, 200));
  END IF;

  IF NEW.next_followup_at IS DISTINCT FROM OLD.next_followup_at AND NEW.next_followup_at IS NOT NULL THEN
    INSERT INTO pitch_event_log (pitch_id, event_type, new_value)
      VALUES (NEW.id, 'followup_scheduled', NEW.next_followup_at::text);
  END IF;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_pitch_event_log ON pitches;
CREATE TRIGGER trg_pitch_event_log
  AFTER INSERT OR UPDATE ON pitches
  FOR EACH ROW EXECUTE FUNCTION trg_pitch_event_log_fn();

-- View: cycle-time analytics (time from sent to first reply / close)
CREATE OR REPLACE VIEW v_pitch_cycle_time AS
SELECT
  p.id, p.business_id, b.name, p.outreach_channel, p.status,
  p.sent_at, p.replied_at, p.closed_at,
  EXTRACT(EPOCH FROM (p.replied_at - p.sent_at))/3600  AS hours_to_reply,
  EXTRACT(EPOCH FROM (p.closed_at  - p.sent_at))/86400 AS days_to_close,
  EXTRACT(EPOCH FROM (p.closed_at  - p.replied_at))/86400 AS days_reply_to_close
FROM pitches p
JOIN businesses b ON b.id = p.business_id
WHERE p.sent_at IS NOT NULL
ORDER BY p.sent_at DESC;