Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/soft-apples-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Fix profile completion goal to only show for paid members, handle personal workspaces
4 changes: 4 additions & 0 deletions .changeset/wicked-memes-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Admin collaboration features for outreach: add insight button, year reference cleanup, vendor membership goal, admin nudge/override
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ server/node_modules

# Misc
.DS_Store
cookies.txt
.conductor_port
.env.local
.env.development.local
Expand Down
442 changes: 422 additions & 20 deletions server/public/admin-users.html

Large diffs are not rendered by default.

36 changes: 30 additions & 6 deletions server/src/addie/services/outbound-planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,23 +210,42 @@ export class OutboundPlanner {
}
}

// PRIORITY 2: Profile completion (critical setup step)
if (caps && !caps.profile_complete) {
// PRIORITY 2: Profile completion (only for paid members - profiles are only visible to members)
// Skip for personal workspaces since those aren't real company profiles
if (caps && !caps.profile_complete && ctx.user.is_member && !ctx.company?.is_personal_workspace) {
const profileGoal = goals.find(g =>
g.name.toLowerCase().includes('profile') && g.category === 'admin'
);
if (profileGoal) {
return {
goal: profileGoal,
reason: 'Profile not complete - key capability to unlock',
reason: 'Profile not complete - visible to other members once set up',
priority_score: 85,
alternative_goals: goals.filter(g => g.id !== profileGoal.id).slice(0, 3),
decision_method: 'rule_match',
};
}
}

// PRIORITY 3: Working group discovery (for engaged users with none)
// PRIORITY 3: Vendor membership (tech companies benefit from profile visibility)
// Only for non-members at vendor-type companies
const vendorTypes = ['adtech', 'ai', 'data'];
if (ctx.user.is_mapped && !ctx.user.is_member && ctx.company?.type && vendorTypes.includes(ctx.company.type)) {
const vendorGoal = goals.find(g =>
g.name.toLowerCase().includes('vendor') && g.category === 'invitation'
);
if (vendorGoal) {
return {
goal: vendorGoal,
reason: 'Tech vendor not a member - profiles visible to members would help their business',
priority_score: 75,
alternative_goals: goals.filter(g => g.id !== vendorGoal.id).slice(0, 3),
decision_method: 'rule_match',
};
}
}

// PRIORITY 4: Working group discovery (for engaged users with none)
if (caps && caps.account_linked && caps.working_group_count === 0 && caps.slack_message_count_30d > 5) {
const wgGoal = goals.find(g =>
g.name.toLowerCase().includes('working group') && g.category === 'education'
Expand All @@ -242,7 +261,7 @@ export class OutboundPlanner {
}
}

// PRIORITY 4: Re-engagement for dormant users
// PRIORITY 5: Re-engagement for dormant users
if (caps && caps.last_active_days_ago !== null && caps.last_active_days_ago > 30) {
const reengageGoal = goals.find(g =>
g.name.toLowerCase().includes('re-engage') || g.name.toLowerCase().includes('dormant')
Expand Down Expand Up @@ -433,8 +452,13 @@ Respond ONLY with valid JSON (no markdown code blocks):
buildMessage(goal: OutreachGoal, ctx: PlannerContext, linkUrl?: string): string {
let message = goal.message_template;

// Extract first name from display name (e.g., "Julie Lorin" -> "Julie")
// Handle edge cases: empty strings, single-char names (like "J."), etc.
const rawFirstName = ctx.user.display_name?.trim().split(' ')[0];
const firstName = rawFirstName && rawFirstName.length > 1 ? rawFirstName : 'there';

// Replace placeholders
message = message.replace(/\{\{user_name\}\}/g, ctx.user.display_name ?? 'there');
message = message.replace(/\{\{user_name\}\}/g, firstName);
message = message.replace(/\{\{company_name\}\}/g, ctx.company?.name ?? 'your company');
message = message.replace(/\{\{link_url\}\}/g, linkUrl ?? '');

Expand Down
177 changes: 174 additions & 3 deletions server/src/addie/services/proactive-outreach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,16 @@ async function buildPlannerContext(candidate: OutreachCandidate): Promise<Planne
canContactUser(candidate.slack_user_id),
]);

// Get company info if user is mapped
// Get company info and membership status if user is mapped
let company: PlannerContext['company'] | undefined;
let isMember = false;
if (candidate.workos_user_id) {
const orgResult = await query<{
name: string;
company_types: string[] | null;
subscription_status: string | null;
}>(
`SELECT o.name, o.company_types
`SELECT o.name, o.company_types, o.subscription_status
FROM organization_memberships om
JOIN organizations o ON o.workos_organization_id = om.workos_organization_id
WHERE om.workos_user_id = $1
Expand All @@ -188,10 +190,14 @@ async function buildPlannerContext(candidate: OutreachCandidate): Promise<Planne
);
if (orgResult.rows[0]) {
const org = orgResult.rows[0];
const isPersonalWorkspace = org.name.toLowerCase().endsWith("'s workspace") ||
org.name.toLowerCase().endsWith("'s workspace");
company = {
name: org.name,
name: isPersonalWorkspace ? 'your account' : org.name,
type: org.company_types?.[0] ?? 'unknown',
is_personal_workspace: isPersonalWorkspace,
};
isMember = org.subscription_status === 'active';
}
}

Expand All @@ -206,6 +212,7 @@ async function buildPlannerContext(candidate: OutreachCandidate): Promise<Planne
workos_user_id: candidate.workos_user_id ?? undefined,
display_name: candidate.slack_display_name ?? candidate.slack_real_name ?? undefined,
is_mapped: !!candidate.workos_user_id,
is_member: isMember,
engagement_score: engagementScore,
insights: insights.map(i => ({
type: i.insight_type_name ?? 'unknown',
Expand Down Expand Up @@ -717,6 +724,170 @@ export async function manualOutreach(
return outreachResult;
}

/**
* Manual outreach with a specific goal (admin override)
*
* Allows admins to send outreach using a specific goal instead of the planner's recommendation.
* Optionally records admin context as an insight before sending.
*
* @param slackUserId - Target user's Slack ID
* @param goalId - Specific goal ID to use
* @param adminContext - Optional context from admin to record as insight
* @param triggeredBy - Admin who triggered the outreach
*/
export async function manualOutreachWithGoal(
slackUserId: string,
goalId: number,
adminContext?: string,
triggeredBy?: { id: string; name: string; email: string }
): Promise<OutreachResult> {
const planner = getOutboundPlanner();

// Look up user
const result = await query<SlackUserMapping>(
`SELECT * FROM slack_user_mappings WHERE slack_user_id = $1`,
[slackUserId]
);

if (result.rows.length === 0) {
return { success: false, error: 'User not found' };
}

const user = result.rows[0];

// Get the specific goal
const goal = await outboundDb.getGoal(goalId);
if (!goal) {
return { success: false, error: 'Goal not found' };
}

// Record admin context as insight if provided
if (adminContext && adminContext.trim()) {
try {
// Find or create admin_context insight type
const adminContextType = await insightsDb.getInsightTypeByName('admin_context');
if (adminContextType) {
await insightsDb.addInsight({
slack_user_id: slackUserId,
insight_type_id: adminContextType.id,
value: adminContext.trim(),
confidence: 'high',
source_type: 'manual',
});
logger.info({
slackUserId,
adminId: triggeredBy?.id,
contextLength: adminContext.length,
}, 'Recorded admin context as insight');
}
} catch (error) {
// Don't fail outreach if insight recording fails
logger.warn({ error, slackUserId }, 'Failed to record admin context insight');
}
}

// Build context for message generation
const candidate: OutreachCandidate = {
slack_user_id: user.slack_user_id,
slack_email: user.slack_email,
slack_display_name: user.slack_display_name,
slack_real_name: user.slack_real_name,
workos_user_id: user.workos_user_id,
last_outreach_at: user.last_outreach_at,
priority: calculatePriority(user),
};
const ctx = await buildPlannerContext(candidate);

// Build the message from the goal template
const linkUrl = `https://agenticadvertising.org/auth/login?slack_user_id=${encodeURIComponent(slackUserId)}`;
const message = planner.buildMessage(goal, ctx, linkUrl);

// DRY_RUN mode: log but don't send
if (OUTREACH_MODE === 'dry_run') {
logger.info({
mode: 'dry_run',
candidate: slackUserId,
goal: goal.name,
goalId: goal.id,
triggeredBy: triggeredBy?.id,
hasAdminContext: !!adminContext,
message: message.substring(0, 100) + '...',
}, 'DRY RUN: Would send admin-override outreach');
return { success: true };
}

// Open DM channel
const channelId = await openDmChannel(slackUserId);
if (!channelId) {
return { success: false, error: 'Failed to open DM channel' };
}

// Send message
const messageTs = await sendDmMessage(channelId, message);
if (!messageTs) {
return { success: false, error: 'Failed to send DM message' };
}

// Map goal category to outreach type
const outreachType: OutreachType = goal.category === 'admin' ? 'account_link'
: goal.category === 'information' ? 'insight_goal'
: 'custom';

// Record outreach in member_outreach (legacy tracking)
const outreach = await insightsDb.recordOutreach({
slack_user_id: slackUserId,
outreach_type: outreachType,
dm_channel_id: channelId,
initial_message: message,
});

// Record goal attempt with admin override reason
await outboundDb.recordGoalAttempt({
slack_user_id: slackUserId,
goal_id: goal.id,
planner_reason: `Admin override${adminContext ? ' with context' : ''}`,
planner_score: 100, // Max priority for admin override
decision_method: 'admin_override',
outreach_id: outreach.id,
});

// Update user's last_outreach_at
await updateLastOutreach(slackUserId);

// Auto-assign admin as owner if outreach was successful
if (triggeredBy) {
try {
await assignUserStakeholder({
slackUserId,
workosUserId: user.workos_user_id || undefined,
stakeholderId: triggeredBy.id,
stakeholderName: triggeredBy.name,
stakeholderEmail: triggeredBy.email,
role: 'owner',
reason: 'outreach',
});
} catch (error) {
logger.warn({ error, slackUserId }, 'Failed to auto-assign user after outreach');
}
}

logger.info({
outreachId: outreach.id,
slackUserId,
goalId: goal.id,
goalName: goal.name,
triggeredBy: triggeredBy?.id,
hasAdminContext: !!adminContext,
decision_method: 'admin_override',
}, 'Sent admin-override outreach');

return {
success: true,
outreach_id: outreach.id,
dm_channel_id: channelId,
};
}

/**
* Get current outreach mode
*/
Expand Down
1 change: 1 addition & 0 deletions server/src/addie/services/rehearsal-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ export class RehearsalService {
slack_user_id: `rehearsal_${persona.name?.replace(/\s+/g, '_').toLowerCase() ?? 'user'}`,
display_name: persona.name,
is_mapped: isMapped,
is_member: true, // Rehearsals assume member context
engagement_score: persona.engagement_score ?? 50,
insights: (persona.existing_insights ?? []).map(i => ({
type: i.type,
Expand Down
6 changes: 5 additions & 1 deletion server/src/addie/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export type OutcomeType = 'success' | 'defer' | 'clarify' | 'decline' | 'escalat
/**
* Planner decision method
*/
export type PlannerDecisionMethod = 'rule_match' | 'llm';
export type PlannerDecisionMethod = 'rule_match' | 'llm' | 'admin_override';

/**
* Rehearsal session status
Expand Down Expand Up @@ -363,6 +363,8 @@ export interface PlannerContext {
workos_user_id?: string;
display_name?: string;
is_mapped: boolean;
/** Whether the user's org has an active AgenticAdvertising.org membership */
is_member: boolean;
engagement_score: number;
insights: Array<{
type: string;
Expand All @@ -375,6 +377,8 @@ export interface PlannerContext {
type: string;
size?: string;
offerings?: string[];
/** Whether this is a real company org vs an auto-generated personal workspace */
is_personal_workspace?: boolean;
};
/** What capabilities has/hasn't this member unlocked? */
capabilities?: MemberCapabilities;
Expand Down
11 changes: 11 additions & 0 deletions server/src/db/insights-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,17 @@ export class InsightsDatabase {
return result.rows[0] || null;
}

/**
* Get insight type by name
*/
async getInsightTypeByName(name: string): Promise<MemberInsightType | null> {
const result = await query<MemberInsightType>(
'SELECT * FROM member_insight_types WHERE name = $1',
[name]
);
return result.rows[0] || null;
}

/**
* Update an insight type
*/
Expand Down
37 changes: 37 additions & 0 deletions server/src/db/migrations/134_cleanup_year_references.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- ============================================================================
-- Migration: 134_cleanup_year_references.sql
-- Description: Clean up old 2025/2026 references from early migrations
--
-- Migration 127 created "Learn 2025/2026 Goals" with insight type "goals_2025"
-- Migration 129 added "Learn 2026 Plans" with "plans_2026" which is better
-- Migration 130 added the plans_2026 insight type
--
-- This migration:
-- 1. Disables the old "Learn 2025/2026 Goals" goal (keep for history)
-- 2. Updates the insight type name from goals_2025 to plans_2025 for clarity
-- 3. Adds admin_context insight type for admin-provided context
-- ============================================================================

-- Disable the old goal so it's not selected by planner
-- We keep it rather than delete because there may be history records
UPDATE outreach_goals
SET is_enabled = FALSE
WHERE name = 'Learn 2025/2026 Goals';

-- Rename the old insight type for clarity (2025 data stays as 2025)
UPDATE member_insight_types
SET name = 'plans_2025',
description = 'Member plans for agentic advertising in 2025 (historical)'
WHERE name = 'goals_2025';

-- Add admin_context insight type if not exists
INSERT INTO member_insight_types (name, description, example_values, is_active, created_by)
SELECT
'admin_context',
'Context provided by admin about this member',
ARRAY['Focused on publisher sales agent implementation', 'High likelihood of membership', 'Key contact at company'],
TRUE,
'system'
WHERE NOT EXISTS (
SELECT 1 FROM member_insight_types WHERE name = 'admin_context'
);
Loading