diff --git a/src/app/(dashboard)/WelcomeBannerRight.tsx b/src/app/(dashboard)/WelcomeBannerRight.tsx
new file mode 100644
index 00000000..7d827a35
--- /dev/null
+++ b/src/app/(dashboard)/WelcomeBannerRight.tsx
@@ -0,0 +1,138 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { FileText, Sparkles, Users, Target, MessageSquare } from 'lucide-react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useUser } from '@auth0/nextjs-auth0/client';
+import { motion } from 'framer-motion';
+import CreateSessionInputClient from './CreateSessionInputClient';
+import OnboardingChat from '@/components/OnboardingChat';
+import {
+ Dialog,
+ DialogContent,
+} from '@/components/ui/dialog';
+import { usePostHog } from 'posthog-js/react';
+
+const SKIP_KEY_PREFIX = 'harmonica_onboarding_skipped_';
+
+interface WelcomeBannerRightProps {
+ showOnboarding: boolean;
+}
+
+const CONTEXT_HINTS = [
+ { icon: Users, text: 'Your team & participants' },
+ { icon: Target, text: 'Goals & decision style' },
+ { icon: MessageSquare, text: 'Facilitation preferences' },
+];
+
+export default function WelcomeBannerRight({ showOnboarding }: WelcomeBannerRightProps) {
+ const posthog = usePostHog();
+ const [showPrompt, setShowPrompt] = useState(false);
+ const [showDialog, setShowDialog] = useState(false);
+ const { user } = useUser();
+ const router = useRouter();
+
+ const skipKey = user?.sub ? `${SKIP_KEY_PREFIX}${user.sub}` : '';
+
+ useEffect(() => {
+ if (showOnboarding && skipKey) {
+ const skipped = localStorage.getItem(skipKey);
+ if (!skipped) {
+ setShowPrompt(true);
+ }
+ }
+ }, [showOnboarding, skipKey]);
+
+ if (showPrompt) {
+ return (
+ <>
+
+
+ Personalize your AI facilitator
+
+
+ A quick 2-minute chat so every session starts with the right context.
+
+
+
+ {CONTEXT_HINTS.map((hint, i) => (
+
+
+ {hint.text}
+
+ ))}
+
+
+
+ setShowDialog(true)}
+ className="bg-zinc-900 hover:bg-zinc-800 text-white shadow-sm"
+ size="sm"
+ >
+
+ Get started
+
+ {
+ posthog?.capture('onboarding_skipped');
+ if (skipKey) localStorage.setItem(skipKey, '1');
+ setShowPrompt(false);
+ }}
+ className="text-xs text-zinc-400 hover:text-zinc-600 transition-colors"
+ >
+ Skip for now
+
+
+
+
+
+ e.preventDefault()}
+ onEscapeKeyDown={(e) => e.preventDefault()}
+ >
+ {
+ setShowDialog(false);
+ setShowPrompt(false);
+ router.refresh();
+ }}
+ onSkip={() => {
+ setShowDialog(false);
+ }}
+ embedded
+ />
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ What do you want to find out?
+
+
+
+ Templates
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx
index 4bdb7ae5..1833df16 100644
--- a/src/app/(dashboard)/page.tsx
+++ b/src/app/(dashboard)/page.tsx
@@ -6,7 +6,6 @@ import { WorkspacesTable } from './workspaces-table';
import * as db from '@/lib/db';
import Link from 'next/link';
import { cache } from 'react';
-import { randomBytes, createHash } from 'crypto';
import ErrorPage from '@/components/Error';
import { getGeneratedMetadata } from 'app/api/metadata';
import { Card, CardContent } from '@/components/ui/card';
@@ -17,6 +16,7 @@ import { getSession } from '@auth0/nextjs-auth0';
import ProjectsGrid from './ProjectsGrid';
import { Textarea } from '@/components/ui/textarea';
import CreateSessionInputClient from './CreateSessionInputClient';
+import WelcomeBannerRight from './WelcomeBannerRight';
export const dynamic = 'force-dynamic'; // getHostSessions is using auth, which can only be done client side
export const revalidate = 300; // Revalidate the data every 5 minutes (or on page reload)
@@ -30,7 +30,7 @@ const sessionCache = cache(async () => {
if (!userId) {
console.warn('No user ID found');
- return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false };
+ return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false, hasHarmonicaMd: false };
}
// Query sessions with permissions check
@@ -96,7 +96,8 @@ const sessionCache = cache(async () => {
);
const adminApiKeys = await db.getApiKeysForUser(userId);
- return { hostSessions, workspacesWithSessions, hasApiKeys: adminApiKeys.length > 0 };
+ const harmonicaMd = await db.getUserHarmonicaMd(userId);
+ return { hostSessions, workspacesWithSessions, hasApiKeys: adminApiKeys.length > 0, hasHarmonicaMd: !!harmonicaMd };
}
const hostSessionIds = userResources
.filter((r) => r.resource_type === 'SESSION')
@@ -131,25 +132,12 @@ const sessionCache = cache(async () => {
hostSessions,
);
- // Auto-generate default API key for new users
- let apiKeys = await db.getApiKeysForUser(userId);
- if (hostSessions.length === 0 && workspacesWithSessions.length === 0 && apiKeys.length === 0) {
- const rawKey = `hm_live_${randomBytes(16).toString('hex')}`;
- const keyHash = createHash('sha256').update(rawKey).digest('hex');
- const keyPrefix = rawKey.slice(0, 12);
- await db.createApiKey({
- user_id: userId,
- key_hash: keyHash,
- key_prefix: keyPrefix,
- name: 'Default',
- });
- apiKeys = [{ id: '', key_hash: '', key_prefix: keyPrefix, name: 'Default', user_id: userId } as any];
- }
-
- return { hostSessions, workspacesWithSessions, hasApiKeys: apiKeys.length > 0 };
+ const apiKeys = await db.getApiKeysForUser(userId);
+ const harmonicaMd = await db.getUserHarmonicaMd(userId);
+ return { hostSessions, workspacesWithSessions, hasApiKeys: apiKeys.length > 0, hasHarmonicaMd: !!harmonicaMd };
} catch (error) {
console.error('Failed to fetch host sessions: ', error);
- return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false };
+ return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false, hasHarmonicaMd: false };
}
});
@@ -206,7 +194,7 @@ export default async function Dashboard({
}: {
searchParams?: { page?: string };
}) {
- const { hostSessions, workspacesWithSessions, hasApiKeys } = await sessionCache();
+ const { hostSessions, workspacesWithSessions, hasApiKeys, hasHarmonicaMd } = await sessionCache();
if (!hostSessions) {
return ;
}
@@ -227,16 +215,9 @@ export default async function Dashboard({
{/* Right column */}
-
- What do you want to find out?
-
-
-
- Templates
-
-
-
-
+
{/* Main dashboard content */}
diff --git a/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts
index aa8b000c..1971f9dc 100644
--- a/src/app/api/auth/[auth0]/route.ts
+++ b/src/app/api/auth/[auth0]/route.ts
@@ -1,3 +1,7 @@
-import { handleAuth } from '@auth0/nextjs-auth0';
+import { handleAuth, handleLogout } from '@auth0/nextjs-auth0';
-export const GET = handleAuth();
\ No newline at end of file
+export const GET = handleAuth({
+ logout: handleLogout({
+ logoutParams: { federated: '' },
+ }),
+});
\ No newline at end of file
diff --git a/src/app/api/llamaUtils.ts b/src/app/api/llamaUtils.ts
index 023f9588..eb9d44be 100644
--- a/src/app/api/llamaUtils.ts
+++ b/src/app/api/llamaUtils.ts
@@ -9,6 +9,8 @@ import {
getHostSessionById,
updateUserSession,
increaseSessionsCount,
+ getPermissions,
+ getUserHarmonicaMd,
} from '@/lib/db';
import { initializeCrossPollination } from '@/lib/crossPollination';
import { getLLM } from '@/lib/modelConfig';
@@ -196,9 +198,25 @@ export async function handleGenerateAnswer(
const basicFacilitationPrompt = await getPromptInstructions(
'BASIC_FACILITATION_PROMPT',
);
+
+ // Fetch the session owner's HARMONICA.md for organizational context
+ let harmonicaMdBlock = '';
+ try {
+ const perms = await getPermissions(messageData.sessionId, 'SESSION');
+ const owner = perms.find(p => p.role === 'owner');
+ if (owner?.user_id) {
+ const md = await getUserHarmonicaMd(owner.user_id);
+ if (md) {
+ harmonicaMdBlock = `Organizational Context (HARMONICA.md):\n${md}\n\n`;
+ }
+ }
+ } catch (e) {
+ console.warn('Failed to fetch HARMONICA.md for session:', e);
+ }
+
// Format context data
const sessionContext = `
-System Instructions:
+${harmonicaMdBlock}System Instructions:
${sessionData?.prompt || basicFacilitationPrompt}
Session Information:
diff --git a/src/app/api/onboarding/chat/route.ts b/src/app/api/onboarding/chat/route.ts
new file mode 100644
index 00000000..4ba19326
--- /dev/null
+++ b/src/app/api/onboarding/chat/route.ts
@@ -0,0 +1,100 @@
+import { NextResponse } from 'next/server';
+import { getSession } from '@auth0/nextjs-auth0';
+import { getLLM } from '@/lib/modelConfig';
+import { ChatMessage } from 'llamaindex';
+
+export const maxDuration = 120;
+
+const ONBOARDING_SYSTEM_PROMPT = `You are Harmonica's onboarding assistant. Your job is to have a warm, conversational chat with a new user to learn about their team and goals, then generate a structured HARMONICA.md document that will give context to the AI facilitator in all their future sessions.
+
+CONVERSATION STRUCTURE:
+Ask 3-4 focused questions, ONE at a time. Wait for the user's response before asking the next question.
+
+1. Start by warmly welcoming them and asking about their team or organization — who are they, what do they do?
+2. Ask what they're hoping to achieve with Harmonica — what kind of discussions or decisions do they need help with?
+3. Ask about the people who will participate in their sessions — roles, expertise, any group dynamics to be aware of.
+4. Ask about their preferences for how the AI should facilitate — tone, structure, what good outcomes look like.
+
+STYLE:
+- Be warm, concise, and genuinely curious
+- Use natural follow-up questions based on their answers
+- Keep each message short (2-3 sentences max + your question)
+- Don't ask multiple questions at once
+
+GENERATING THE DOCUMENT:
+After 3-4 exchanges (when you have enough context), tell the user you'll generate their HARMONICA.md and output it wrapped in tags. Fill in all 8 sections based on what they shared. For sections where you have no information, write a helpful placeholder like "Not yet specified — you can add details here later."
+
+
+# HARMONICA.md
+
+## About
+[Who is this group/org? Brief description]
+
+## Goals & Strategy
+[What they're working towards]
+
+## Participants
+[Who typically participates in sessions]
+
+## Vocabulary
+[Domain-specific terminology, acronyms, jargon]
+
+## Prior Decisions
+[Context about existing decisions or settled questions]
+
+## Facilitation Preferences
+[How the AI should facilitate — tone, structure, style]
+
+## Constraints
+[Decision processes, limits, regulatory requirements]
+
+## Success Patterns
+[What good session outcomes look like for this group]
+
+
+After outputting the document, tell the user they can review and edit each section before saving.`;
+
+export async function POST(req: Request) {
+ const session = await getSession();
+ if (!session?.user?.sub) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { messages } = await req.json();
+
+ if (!messages || !Array.isArray(messages)) {
+ return NextResponse.json({ error: 'Messages array required' }, { status: 400 });
+ }
+
+ // Return a hardcoded greeting for the initial call (no LLM needed, instant response)
+ if (messages.length === 0) {
+ return NextResponse.json({
+ response: "Hey there! Welcome to Harmonica! I'm here to help set up your AI facilitator so it runs great sessions for your team.\n\nFirst things first — tell me about your team or organization. Who are you, and what do you do?",
+ });
+ }
+
+ try {
+ const llm = getLLM('MAIN', 0.5, 4096);
+ const formattedMessages: ChatMessage[] = [
+ { role: 'system', content: ONBOARDING_SYSTEM_PROMPT },
+ ...messages.map((m: { role: string; content: string }) => ({
+ role: m.role as 'user' | 'assistant',
+ content: m.content,
+ })),
+ ];
+
+ const response = await llm.chat({
+ messages: formattedMessages,
+ distinctId: session.user.sub,
+ operation: 'onboarding_chat',
+ });
+
+ return NextResponse.json({ response });
+ } catch (error) {
+ console.error('Onboarding chat error:', error);
+ return NextResponse.json(
+ { error: 'Failed to generate response' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/chat/StandaloneChat.tsx b/src/app/chat/StandaloneChat.tsx
index 2f428923..ee0acc2f 100644
--- a/src/app/chat/StandaloneChat.tsx
+++ b/src/app/chat/StandaloneChat.tsx
@@ -15,6 +15,7 @@ import { SessionModal } from '@/components/chat/SessionModal';
import { ChatInterface } from '@/components/chat/ChatInterface';
import { QuestionInfo } from 'app/create/types';
import { usePermissions } from '@/lib/permissions';
+import { usePostHog } from 'posthog-js/react';
const StandaloneChat = () => {
const [message, setMessage] = useState({
@@ -24,6 +25,7 @@ Please type your name or "anonymous" if you prefer
`,
});
+ const posthog = usePostHog();
const { user } = useUser();
const searchParams = useSearchParams();
const sessionId = searchParams.get('s');
@@ -78,6 +80,7 @@ Please type your name or "anonymous" if you prefer
increaseSessionsCount(sessionId!, 'num_finished');
})
.then(() => {
+ posthog?.capture('session_completed', { session_id: sessionId });
setIsLoading(false);
setUserFinished(true);
});
diff --git a/src/app/create/choose-template.tsx b/src/app/create/choose-template.tsx
index 51dbabf2..996fd320 100644
--- a/src/app/create/choose-template.tsx
+++ b/src/app/create/choose-template.tsx
@@ -110,7 +110,7 @@ export default function ChooseTemplate({
Still need help?
- Read Beginners Guide
+ Read Beginners Guide
diff --git a/src/app/create/creationFlow.tsx b/src/app/create/creationFlow.tsx
index 1811ea66..9d5f3d85 100644
--- a/src/app/create/creationFlow.tsx
+++ b/src/app/create/creationFlow.tsx
@@ -21,6 +21,7 @@ import { Step, STEPS } from './types';
import { createPromptContent } from 'app/api/utils';
import { getPromptInstructions } from '@/lib/promptActions';
import { linkSessionsToWorkspace } from '@/lib/workspaceActions';
+import { usePostHog } from 'posthog-js/react';
export const maxDuration = 60; // Hosting function timeout, in seconds
@@ -36,6 +37,7 @@ export type VersionedPrompt = {
const enabledSteps = [true, false, false];
export default function CreationFlow() {
+ const posthog = usePostHog();
const route = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
@@ -251,6 +253,12 @@ IMPORTANT:
const sessionId = sessionIds[0];
await db.setPermission(sessionId, 'owner');
+ posthog?.capture('session_created', {
+ session_id: sessionId,
+ template_id: templateId || null,
+ has_template: !!templateId,
+ });
+
// Set cookie
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + 30);
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx
index c9d0e34b..14b9442f 100644
--- a/src/app/navigation.tsx
+++ b/src/app/navigation.tsx
@@ -20,7 +20,7 @@ export default function Navigation() {
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index f66c2e0f..f39aec1c 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -27,6 +27,10 @@ function InvitationProcessor() {
`Checking invitations: IsLoading:${isLoading}; hasUser: ${!!user}`,
);
if (!isLoading && user) {
+ // Skip if account is being deleted — prevents syncCurrentUser from
+ // re-creating the user record during the deletion→logout window
+ if (sessionStorage.getItem('account_deleting')) return;
+
const processInvitations = async () => {
try {
// processUserInvitations now includes syncCurrentUser internally
diff --git a/src/app/settings/ApiKeysTab.tsx b/src/app/settings/ApiKeysTab.tsx
index 107942a4..c3b5b195 100644
--- a/src/app/settings/ApiKeysTab.tsx
+++ b/src/app/settings/ApiKeysTab.tsx
@@ -235,6 +235,43 @@ export default function ApiKeysTab() {
+ {/* MCP Install Instructions — always visible */}
+
+
+
+
+
Connect to Claude Code
+
+
+ Run this in your terminal to add Harmonica as an MCP server. Replace <YOUR_API_KEY> with your API key.
+
+
+
+ {getInstallCommand('')}
+
+
{
+ await navigator.clipboard.writeText(getInstallCommand(''));
+ setCopiedInstall(true);
+ setTimeout(() => setCopiedInstall(false), 2000);
+ }}
+ >
+ {copiedInstall ? (
+
+ ) : (
+
+ )}
+
+
+
+ Works with any MCP-compatible client (Claude Code, Cursor, Windsurf). The full command with your key is shown when you create a new API key.
+
+
+
+
{/* Create Key Dialog */}
;
+
+function parseSections(markdown: string): SectionValues {
+ const values: SectionValues = {};
+ const sectionMap: Record = {
+ 'about': 'about',
+ 'goals & strategy': 'goals',
+ 'goals': 'goals',
+ 'participants': 'participants',
+ 'vocabulary': 'vocabulary',
+ 'prior decisions': 'prior_decisions',
+ 'prior decisions & context': 'prior_decisions',
+ 'facilitation preferences': 'facilitation',
+ 'constraints': 'constraints',
+ 'success patterns': 'success',
+ };
+
+ const lines = markdown.split('\n');
+ let currentKey = '';
+
+ for (const line of lines) {
+ const headerMatch = line.match(/^##\s+(.+)$/);
+ if (headerMatch) {
+ const title = headerMatch[1].trim().toLowerCase();
+ currentKey = sectionMap[title] || '';
+ continue;
+ }
+ if (currentKey && line.trim() !== '# HARMONICA.md') {
+ values[currentKey] = ((values[currentKey] || '') + '\n' + line).trim();
+ }
+ }
+
+ return values;
+}
+
+function assembleSections(values: SectionValues): string {
+ const parts = ['# HARMONICA.md', ''];
+ for (const section of SECTIONS) {
+ const content = values[section.key]?.trim();
+ if (content) {
+ parts.push(`## ${section.title}`);
+ parts.push(content);
+ parts.push('');
+ }
+ }
+ return parts.join('\n').trim();
+}
+
+export default function HarmonicaMdTab() {
+ const [sections, setSections] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [hasContent, setHasContent] = useState(false);
+ const [showRegenerate, setShowRegenerate] = useState(false);
+ const [expanded, setExpanded] = useState>({});
+ const [error, setError] = useState(null);
+
+ const totalChars = assembleSections(sections).length;
+ const isOverLimit = totalChars > CHAR_LIMIT;
+
+ useEffect(() => {
+ loadContent();
+ }, []);
+
+ const loadContent = async () => {
+ setLoading(true);
+ try {
+ const content = await fetchHarmonicaMd();
+ if (content) {
+ setSections(parseSections(content));
+ setHasContent(true);
+ // Expand sections that have content
+ const exp: Record = {};
+ const parsed = parseSections(content);
+ for (const s of SECTIONS) {
+ if (parsed[s.key]?.trim()) exp[s.key] = true;
+ }
+ setExpanded(exp);
+ } else {
+ // Expand first 3 sections for new users
+ setExpanded({ about: true, goals: true, participants: true });
+ }
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSave = useCallback(async () => {
+ const content = assembleSections(sections);
+ if (isOverLimit) return;
+
+ setSaving(true);
+ setError(null);
+ try {
+ const result = await saveHarmonicaMd(content);
+ if (result.success) {
+ setSaved(true);
+ setHasContent(true);
+ setTimeout(() => setSaved(false), 2000);
+ } else {
+ setError(result.message || 'Failed to save');
+ }
+ } catch (e) {
+ setError('Failed to save');
+ console.error(e);
+ } finally {
+ setSaving(false);
+ }
+ }, [sections, isOverLimit]);
+
+ const handleRegenerateComplete = () => {
+ setShowRegenerate(false);
+ loadContent();
+ };
+
+ const toggleSection = (key: string) => {
+ setExpanded(prev => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+ HARMONICA.md
+
+
+ Persistent context about your organization that the AI facilitator uses in every session.
+ Like CLAUDE.md for code — but for group facilitation.
+
+
+
setShowRegenerate(true)}
+ className="shrink-0"
+ >
+
+ {hasContent ? 'Regenerate' : 'Generate with AI'}
+
+
+
+
+ {SECTIONS.map((section) => {
+ const isExpanded = expanded[section.key];
+ return (
+
+
toggleSection(section.key)}
+ className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/50 transition-colors"
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {section.title}
+ {!isExpanded && sections[section.key]?.trim() && (
+
+ — {sections[section.key]?.trim().slice(0, 60)}...
+
+ )}
+
+ {sections[section.key]?.trim() && (
+
+ )}
+
+ {isExpanded && (
+
+
{section.description}
+
+ )}
+
+ );
+ })}
+
+ {error && (
+ {error}
+ )}
+
+
+
+ {totalChars.toLocaleString()} / {CHAR_LIMIT.toLocaleString()} characters
+
+
+ {saving ? (
+
+ ) : saved ? (
+ <>
+
+ Saved
+ >
+ ) : (
+ 'Save'
+ )}
+
+
+
+
+
+
+
+
+ setShowRegenerate(false)}
+ embedded
+ />
+
+
+ >
+ );
+}
diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts
index f54210c5..dd36fb1a 100644
--- a/src/app/settings/actions.ts
+++ b/src/app/settings/actions.ts
@@ -12,6 +12,8 @@ const workspaceSessionsTable = 'workspace_sessions';
const permissionsTable = 'permissions';
const hostSessionsTable = 'host_db';
const workspacesTable = 'workspaces';
+const apiKeysTable = 'api_keys';
+const sessionRatingsTable = 'session_ratings';
export async function fetchUserData() {
const session = await getSession();
@@ -313,6 +315,51 @@ export async function deleteUserData(existingUserData?: any) {
}
}
+export async function fetchHarmonicaMd(): Promise {
+ const session = await getSession();
+ if (!session?.user?.sub) return null;
+
+ try {
+ const db = await getDbInstance();
+ const result = await db
+ .selectFrom(usersTable)
+ .select('harmonica_md')
+ .where('id', '=', session.user.sub)
+ .executeTakeFirst();
+ return result?.harmonica_md || null;
+ } catch (error) {
+ console.error('Error fetching HARMONICA.md:', error);
+ return null;
+ }
+}
+
+export async function saveHarmonicaMd(content: string) {
+ const session = await getSession();
+ if (!session?.user?.sub) {
+ return { success: false, message: 'Unauthorized' };
+ }
+
+ const trimmed = content.trim();
+ if (trimmed.length > 6000) {
+ return { success: false, message: 'Content exceeds 6000 character limit' };
+ }
+
+ try {
+ const db = await getDbInstance();
+ await db
+ .updateTable(usersTable)
+ .set({ harmonica_md: trimmed || null })
+ .where('id', '=', session.user.sub)
+ .execute();
+
+ revalidatePath('/settings');
+ return { success: true };
+ } catch (error) {
+ console.error('Error saving HARMONICA.md:', error);
+ return { success: false, message: 'Failed to save' };
+ }
+}
+
export async function deleteUserAccount(existingUserData?: any) {
const session = await getSession();
@@ -345,7 +392,19 @@ export async function deleteUserAccount(existingUserData?: any) {
.deleteFrom('invitations')
.where('created_by', '=', userSub)
.execute();
-
+
+ // Delete API keys
+ await db
+ .deleteFrom(apiKeysTable)
+ .where('user_id', '=', userSub)
+ .execute();
+
+ // Delete session ratings
+ await db
+ .deleteFrom(sessionRatingsTable)
+ .where('user_id', '=', userSub)
+ .execute();
+
// Finally delete the user record
await db
.deleteFrom(usersTable)
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 2f550a9c..f4f06a8f 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -15,6 +15,15 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+} from '@/components/ui/alert-dialog';
import { LoaderCircle, Check, Mail, KeyRound, Download, Trash2, AlertTriangle } from 'lucide-react';
import {
fetchUserData,
@@ -25,6 +34,7 @@ import {
} from './actions';
import { useRouter, useSearchParams } from 'next/navigation';
import ApiKeysTab from './ApiKeysTab';
+import HarmonicaMdTab from './HarmonicaMdTab';
export default function SettingsPage() {
const { user, error: userError, isLoading: userLoading } = useUser();
@@ -48,6 +58,8 @@ export default function SettingsPage() {
const [exportLoading, setExportLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [accountDeleteLoading, setAccountDeleteLoading] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteConfirmText, setDeleteConfirmText] = useState('');
useEffect(() => {
if (user && !userLoading) {
@@ -187,37 +199,25 @@ export default function SettingsPage() {
};
const handleDeleteAccount = async () => {
- if (
- !confirm(
- 'WARNING: This will permanently delete your account and all data. This action CANNOT be undone.'
- )
- ) {
- return;
- }
-
- if (
- !confirm(
- 'Please confirm once more. You will be logged out immediately after deletion.'
- )
- ) {
- return;
- }
-
setAccountDeleteLoading(true);
try {
+ // Prevent InvitationProcessor from re-creating the user during deletion
+ sessionStorage.setItem('account_deleting', 'true');
const result = await deleteUserAccount();
if (result.success) {
showMessage('Account deleted. Redirecting to logout...', 'success');
setTimeout(() => {
- router.push('/api/auth/logout');
+ window.location.href = '/api/auth/logout';
}, 2000);
} else {
+ sessionStorage.removeItem('account_deleting');
showMessage(result.message || 'Failed to delete account', 'error');
+ setAccountDeleteLoading(false);
}
} catch (error) {
+ sessionStorage.removeItem('account_deleting');
showMessage('Failed to delete account', 'error');
console.error(error);
- } finally {
setAccountDeleteLoading(false);
}
};
@@ -278,6 +278,7 @@ export default function SettingsPage() {
Profile
+ HARMONICA.md
Account
API Keys
@@ -410,6 +411,11 @@ export default function SettingsPage() {
)}
+ {/* ── HARMONICA.md Tab ── */}
+
+
+
+
{/* ── Account Tab ── */}
{/* Usage overview */}
@@ -508,22 +514,57 @@ export default function SettingsPage() {
You will be logged out immediately.
-
- {accountDeleteLoading ? (
- <>
-
- Deleting...
- >
- ) : (
- 'Delete account'
- )}
-
+ {
+ setDeleteDialogOpen(open);
+ if (!open) setDeleteConfirmText('');
+ }}>
+ setDeleteDialogOpen(true)}
+ size="sm"
+ className="shrink-0"
+ >
+ Delete account
+
+
+
+ Delete account
+
+ This will permanently delete your account ({user?.email}) and all associated data. This action cannot be undone.
+
+
+
+
+ Type DELETE to confirm
+
+ setDeleteConfirmText(e.target.value)}
+ placeholder="DELETE"
+ disabled={accountDeleteLoading}
+ autoComplete="off"
+ />
+
+
+ Cancel
+
+ {accountDeleteLoading ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ 'Delete account'
+ )}
+
+
+
+
diff --git a/src/app/test-chat/page.tsx b/src/app/test-chat/page.tsx
index 70e17b3f..d0ee91b9 100644
--- a/src/app/test-chat/page.tsx
+++ b/src/app/test-chat/page.tsx
@@ -313,7 +313,7 @@ Please type your name or "anonymous" if you prefer
Finish
diff --git a/src/app/workspace/footerConfig.ts b/src/app/workspace/footerConfig.ts
index a6bfc246..4b8a2cf5 100644
--- a/src/app/workspace/footerConfig.ts
+++ b/src/app/workspace/footerConfig.ts
@@ -12,7 +12,7 @@ export const footerConfigs: Record = {
quickLinks: [
{
label: 'Help Center',
- url: 'https://oldspeak.notion.site/Help-Center-fcf198f4683b4e3099beddf48971bd40?pvs=4',
+ url: 'https://help.harmonica.chat',
},
{ label: 'Contact Us', url: 'https://t.me/harmonica_support' },
],
@@ -52,12 +52,12 @@ export const footerConfigs: Record = {
},
{
label: 'Harmonica Help Center',
- url: 'https://oldspeak.notion.site/Help-Center-fcf198f4683b4e3099beddf48971bd40?pvs=4',
+ url: 'https://help.harmonica.chat',
},
{ label: 'Contact Harmonica', url: 'https://t.me/harmonica_support' },
{
label: 'Privacy Policy',
- url: 'https://oldspeak.notion.site/Harmonica-Privacy-Policy-195fc9ee9681808fae1cfcf903836cf1',
+ url: 'https://help.harmonica.chat/privacy',
},
],
contact: {
diff --git a/src/components/ConnectAIBanner.tsx b/src/components/ConnectAIBanner.tsx
index 324a6ac8..42327243 100644
--- a/src/components/ConnectAIBanner.tsx
+++ b/src/components/ConnectAIBanner.tsx
@@ -1,65 +1,158 @@
'use client';
import { useState, useEffect } from 'react';
-import Link from 'next/link';
-import { X, Plug } from 'lucide-react';
+import { X, Plug, Copy, Check, LoaderCircle, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
+import { useUser } from '@auth0/nextjs-auth0/client';
+import { motion, AnimatePresence } from 'framer-motion';
-const STORAGE_KEY = 'harmonica_connect_ai_dismissed';
+const DISMISS_KEY_PREFIX = 'harmonica_connect_ai_dismissed_';
interface ConnectAIBannerProps {
hasApiKeys: boolean;
}
+const getInstallCommand = (key: string) =>
+ `claude mcp add-json harmonica '{"command":"npx","args":["-y","harmonica-mcp"],"env":{"HARMONICA_API_KEY":"${key}"}}'`;
+
export default function ConnectAIBanner({ hasApiKeys }: ConnectAIBannerProps) {
const [dismissed, setDismissed] = useState(true);
+ const [generating, setGenerating] = useState(false);
+ const [apiKey, setApiKey] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const { user } = useUser();
+
+ const dismissKey = user?.sub ? `${DISMISS_KEY_PREFIX}${user.sub}` : '';
useEffect(() => {
- setDismissed(!!localStorage.getItem(STORAGE_KEY));
- }, []);
+ if (dismissKey) {
+ setDismissed(!!localStorage.getItem(dismissKey));
+ }
+ }, [dismissKey]);
if (hasApiKeys || dismissed) return null;
const handleDismiss = () => {
- localStorage.setItem(STORAGE_KEY, 'true');
+ if (dismissKey) localStorage.setItem(dismissKey, 'true');
setDismissed(true);
};
+ const handleGenerate = async () => {
+ setGenerating(true);
+ try {
+ const res = await fetch('/api/v1/api-keys', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'MCP Server' }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setApiKey(data.key);
+ }
+ } catch (e) {
+ console.error('Failed to generate API key:', e);
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handleCopy = async () => {
+ if (!apiKey) return;
+ await navigator.clipboard.writeText(getInstallCommand(apiKey));
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2500);
+ };
+
return (
-
+
+ {/* Header row */}
-
-
+
Connect your AI tools
-
- Use Harmonica from Claude Code, Cursor, or any MCP-compatible
- agent.
+
+ Use Harmonica from Claude Code, Cursor, or any MCP-compatible agent.
-
+ {!apiKey && (
- Set up API key
+ {generating ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ 'Get install command'
+ )}
-
+ )}
-
+
-
+
+ {/* Expanded install command */}
+
+ {apiKey && (
+
+
+
+
+
+ Run this in your terminal to connect Harmonica to Claude Code:
+
+
+
+
+ {getInstallCommand(apiKey)}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ This created an API key named “MCP Server” in your account. Manage keys in Settings.
+
+
+
+ )}
+
+
);
}
diff --git a/src/components/OnboardingChat.tsx b/src/components/OnboardingChat.tsx
new file mode 100644
index 00000000..105187bb
--- /dev/null
+++ b/src/components/OnboardingChat.tsx
@@ -0,0 +1,356 @@
+'use client';
+
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { LoaderCircle, Send, ArrowRight, X } from 'lucide-react';
+import { saveHarmonicaMd } from 'app/settings/actions';
+import { usePostHog } from 'posthog-js/react';
+
+interface Message {
+ role: 'user' | 'assistant';
+ content: string;
+}
+
+interface OnboardingChatProps {
+ onComplete: () => void;
+ onSkip: () => void;
+ embedded?: boolean;
+}
+
+const HARMONICA_MD_REGEX = /
([\s\S]*?)<\/HARMONICA_MD>/;
+
+// Section definitions for the review editor
+const SECTION_LABELS: Record = {
+ about: { title: 'About', description: 'Your team or organization' },
+ goals: { title: 'Goals & Strategy', description: 'What you\'re working towards' },
+ participants: { title: 'Participants', description: 'Who joins your sessions' },
+ vocabulary: { title: 'Vocabulary', description: 'Domain terminology' },
+ prior_decisions: { title: 'Prior Decisions', description: 'What\'s already settled' },
+ facilitation: { title: 'Facilitation Preferences', description: 'How the AI should facilitate' },
+ constraints: { title: 'Constraints', description: 'Limits and requirements' },
+ success: { title: 'Success Patterns', description: 'What good outcomes look like' },
+};
+
+const SECTION_MAP: Record = {
+ 'about': 'about',
+ 'goals & strategy': 'goals',
+ 'goals': 'goals',
+ 'participants': 'participants',
+ 'vocabulary': 'vocabulary',
+ 'prior decisions': 'prior_decisions',
+ 'prior decisions & context': 'prior_decisions',
+ 'facilitation preferences': 'facilitation',
+ 'constraints': 'constraints',
+ 'success patterns': 'success',
+};
+
+function parseHarmonicaMdSections(raw: string): Record {
+ const values: Record = {};
+ const lines = raw.split('\n');
+ let currentKey = '';
+
+ for (const line of lines) {
+ const headerMatch = line.match(/^##\s+(.+)$/);
+ if (headerMatch) {
+ const title = headerMatch[1].trim().toLowerCase();
+ currentKey = SECTION_MAP[title] || '';
+ continue;
+ }
+ if (currentKey && !line.startsWith('# HARMONICA')) {
+ values[currentKey] = ((values[currentKey] || '') + '\n' + line).trim();
+ }
+ }
+
+ return values;
+}
+
+function assembleSectionsToMarkdown(sections: Record): string {
+ const sectionOrder = ['about', 'goals', 'participants', 'vocabulary', 'prior_decisions', 'facilitation', 'constraints', 'success'];
+ const parts = ['# HARMONICA.md', ''];
+ for (const key of sectionOrder) {
+ const label = SECTION_LABELS[key];
+ const content = sections[key]?.trim();
+ if (content && label) {
+ parts.push(`## ${label.title}`);
+ parts.push(content);
+ parts.push('');
+ }
+ }
+ return parts.join('\n').trim();
+}
+
+function stripHarmonicaMdTag(text: string): string {
+ // Return the message content before the tag, or a friendly fallback
+ const beforeTag = text.split('')[0].trim();
+ return beforeTag || 'Here\'s your HARMONICA.md! Review each section below and save when you\'re ready.';
+}
+
+export default function OnboardingChat({ onComplete, onSkip, embedded = false }: OnboardingChatProps) {
+ const posthog = usePostHog();
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [phase, setPhase] = useState<'chatting' | 'reviewing' | 'completed'>('chatting');
+ const [reviewSections, setReviewSections] = useState>({});
+ const [reviewMessage, setReviewMessage] = useState('');
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+ const hasFetched = useRef(false);
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages, phase]);
+
+ // Auto-start: get the first assistant message
+ useEffect(() => {
+ if (hasFetched.current) return;
+ hasFetched.current = true;
+
+ const initChat = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch('/api/onboarding/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ messages: [] }),
+ });
+ const data = await res.json();
+ if (data.response) {
+ setMessages([{ role: 'assistant', content: data.response }]);
+ }
+ } catch (e) {
+ console.error('Failed to start onboarding chat:', e);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ initChat();
+ }, []);
+
+ const sendMessage = useCallback(async () => {
+ const text = input.trim();
+ if (!text || isLoading) return;
+
+ const userMessage: Message = { role: 'user', content: text };
+ const updatedMessages = [...messages, userMessage];
+ setMessages(updatedMessages);
+ setInput('');
+ setIsLoading(true);
+
+ try {
+ const res = await fetch('/api/onboarding/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ messages: updatedMessages.map(m => ({ role: m.role, content: m.content })),
+ }),
+ });
+ const data = await res.json();
+
+ if (data.response) {
+ const assistantMessage: Message = { role: 'assistant', content: data.response };
+ setMessages([...updatedMessages, assistantMessage]);
+
+ // Check if the response contains HARMONICA_MD
+ const match = data.response.match(HARMONICA_MD_REGEX);
+ if (match) {
+ const rawMd = match[1].trim();
+ setReviewSections(parseHarmonicaMdSections(rawMd));
+ setReviewMessage(stripHarmonicaMdTag(data.response));
+ setPhase('reviewing');
+ }
+ }
+ } catch (e) {
+ console.error('Failed to send message:', e);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [input, messages, isLoading]);
+
+ const handleSave = async () => {
+ const markdown = assembleSectionsToMarkdown(reviewSections);
+ setIsSaving(true);
+ try {
+ const result = await saveHarmonicaMd(markdown);
+ if (result.success) {
+ posthog?.capture('onboarding_completed', {
+ sections_filled: Object.values(reviewSections).filter(s => s.trim()).length,
+ });
+ setPhase('completed');
+ setTimeout(onComplete, 1500);
+ }
+ } catch (e) {
+ console.error('Failed to save:', e);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ sendMessage();
+ }
+ };
+
+ const questionCount = messages.filter(m => m.role === 'assistant').length;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {phase === 'reviewing' ? 'Review your HARMONICA.md' : phase === 'completed' ? 'All set!' : 'Set up your facilitator context'}
+
+ {phase === 'chatting' && (
+
+ {questionCount === 0 ? 'Getting started...' : `Question ${questionCount} of ~4`}
+
+ )}
+
+
+ {phase === 'chatting' && !embedded && (
+
+ Skip for now
+
+ )}
+
+
+ {/* Chat phase */}
+ {phase === 'chatting' && (
+ <>
+
+ {messages.map((msg, i) => (
+
+ ))}
+ {isLoading && (
+
+ )}
+
+
+
+ {/* Input */}
+
+
+
+
+ {navigator?.platform?.includes('Mac') ? '⌘' : 'Ctrl'}+Enter to send
+
+
+ >
+ )}
+
+ {/* Review phase */}
+ {phase === 'reviewing' && (
+
+
{reviewMessage}
+
+ {Object.entries(SECTION_LABELS).map(([key, { title, description }]) => {
+ const content = reviewSections[key] || '';
+ if (!content.trim() && !['about', 'goals', 'facilitation'].includes(key)) return null;
+ return (
+
+
+ {title}
+
+
+ );
+ })}
+
+
+
setPhase('chatting')}
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Back to chat
+
+
+ {isSaving ? (
+
+ ) : (
+
+ )}
+ {isSaving ? 'Saving...' : 'Save & Continue'}
+
+
+
+ )}
+
+ {/* Completed phase */}
+ {phase === 'completed' && (
+
+
+
HARMONICA.md saved
+
+ Your AI facilitator now knows your context
+
+
+ )}
+
+ );
+}
diff --git a/src/components/SessionResult/ShareSession.tsx b/src/components/SessionResult/ShareSession.tsx
index 19aeb133..55f06de2 100644
--- a/src/components/SessionResult/ShareSession.tsx
+++ b/src/components/SessionResult/ShareSession.tsx
@@ -5,13 +5,15 @@ import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Share } from '../icons';
import { Copy } from 'lucide-react';
+import { usePostHog } from 'posthog-js/react';
export default function ShareSession({
makeSessionId,
}: {
makeSessionId: string;
}) {
- const [chatUrl, setChatUrl] = useState('');
+ const posthog = usePostHog();
+ const [chatUrl, setChatUrl] = useState('');
const [showToast, setShowToast] = useState(false);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
@@ -24,6 +26,7 @@ export default function ShareSession({
navigator.clipboard
.writeText(url)
.then(() => {
+ posthog?.capture('session_shared', { session_id: makeSessionId });
setShowToast(true);
setTimeout(() => setShowToast(false), 2000);
})
diff --git a/src/components/chat/ChatInterface.tsx b/src/components/chat/ChatInterface.tsx
index 8be4a095..9cb6ef39 100644
--- a/src/components/chat/ChatInterface.tsx
+++ b/src/components/chat/ChatInterface.tsx
@@ -7,6 +7,7 @@ import { RatingModal } from './RatingModal';
import { useState, useEffect, useRef } from 'react';
import { updateUserSession, increaseSessionsCount } from '@/lib/db';
import { usePermissions } from '@/lib/permissions';
+import { usePostHog } from 'posthog-js/react';
interface ChatInterfaceProps {
hostData: {
@@ -38,6 +39,7 @@ export const ChatInterface = ({
userContext,
questions,
}: ChatInterfaceProps) => {
+ const posthog = usePostHog();
const { hasMinimumRole } = usePermissions(hostData.id || '');
const mainPanelRef = useRef(null);
const [showRating, setShowRating] = useState(false);
@@ -93,6 +95,7 @@ export const ChatInterface = ({
});
await increaseSessionsCount(userSessionId, 'num_finished');
+ posthog?.capture('session_completed', { session_id: hostData.id });
} catch (error) {
console.error('Error updating session:', error);
}
diff --git a/src/components/user.tsx b/src/components/user.tsx
index 33cd3572..38c0b6c4 100644
--- a/src/components/user.tsx
+++ b/src/components/user.tsx
@@ -27,6 +27,7 @@ import {
import { CheckCircle2, XCircle } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { createStripeSession } from '@/lib/stripe';
+import { fetchUserData } from 'app/settings/actions';
export default function User() {
const { user } = useUser();
@@ -35,8 +36,18 @@ export default function User() {
const [showPricing, setShowPricing] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const [showCanceled, setShowCanceled] = useState(false);
+ const [dbName, setDbName] = useState(null);
const { status, isActive, expiresAt, isLoading } = useSubscription();
+ // Fetch display name from DB
+ useEffect(() => {
+ if (user) {
+ fetchUserData().then(data => {
+ if (data?.user?.name) setDbName(data.user.name);
+ }).catch(() => {});
+ }
+ }, [user]);
+
// Check URL parameters on component mount
useEffect(() => {
setShowSuccess(searchParams.get('stripe_success') === 'true');
@@ -79,7 +90,7 @@ export default function User() {
size="sm"
>
- {user.name || 'Account'}
+ {dbName || user.name || 'Account'}
diff --git a/src/db/migrations/036_20260221_add_harmonica_md.ts b/src/db/migrations/036_20260221_add_harmonica_md.ts
new file mode 100644
index 00000000..90233e8a
--- /dev/null
+++ b/src/db/migrations/036_20260221_add_harmonica_md.ts
@@ -0,0 +1,15 @@
+import { Kysely } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .alterTable('users')
+ .addColumn('harmonica_md', 'text')
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema
+ .alterTable('users')
+ .dropColumn('harmonica_md')
+ .execute();
+}
diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts
index a580c452..f96230d4 100644
--- a/src/hooks/useChat.ts
+++ b/src/hooks/useChat.ts
@@ -8,6 +8,7 @@ import { UserProfile, useUser } from '@auth0/nextjs-auth0/client';
import { Message } from '@/lib/schema';
import { getUserNameFromContext } from '@/lib/clientUtils';
import { getUserSessionById, getAllChatMessagesInOrder } from '@/lib/db';
+import { usePostHog } from 'posthog-js/react';
export interface UseChatOptions {
sessionIds?: string[];
@@ -52,6 +53,7 @@ export function useChat(options: UseChatOptions) {
} = options;
const isTesting = false;
+ const posthog = usePostHog();
const [errorMessage, setErrorMessage] = useState<{
title: string;
message: string;
@@ -229,6 +231,10 @@ export function useChat(options: UseChatOptions) {
.insertUserSessions(data)
.then((userIds) => {
if (userIds[0] && setUserSessionId) setUserSessionId(userIds[0]);
+ posthog?.capture('participant_joined', {
+ session_id: sessionId,
+ is_authenticated: !!user?.sub,
+ });
return userIds[0]; // Return the userId, just in case setUserSessionId is not fast enough
})
.catch((error) => {
diff --git a/src/lib/db.ts b/src/lib/db.ts
index ce8cfcfa..997d837f 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -1341,7 +1341,6 @@ export async function upsertUser(userData: s.NewUser): Promise {
.onConflict((oc) =>
oc.column('id').doUpdateSet({
email: userData.email,
- name: userData.name,
avatar_url: userData.avatar_url,
last_login: sql`CURRENT_TIMESTAMP`,
metadata: userData.metadata,
@@ -1381,6 +1380,33 @@ export async function getUserById(id: string): Promise {
}
}
+export async function getUserHarmonicaMd(userId: string): Promise {
+ try {
+ const db = await dbPromise;
+ const result = await db
+ .selectFrom(usersTableName)
+ .select('harmonica_md')
+ .where('id', '=', userId)
+ .executeTakeFirst();
+ return result?.harmonica_md || null;
+ } catch (error) {
+ console.error('Error getting HARMONICA.md:', error);
+ return null;
+ }
+}
+
+export async function updateUserHarmonicaMd(
+ userId: string,
+ content: string | null,
+): Promise {
+ const db = await dbPromise;
+ await db
+ .updateTable(usersTableName)
+ .set({ harmonica_md: content })
+ .where('id', '=', userId)
+ .execute();
+}
+
/**
* Get a user by email
*/
diff --git a/src/lib/schema.ts b/src/lib/schema.ts
index a5390717..3830e80c 100644
--- a/src/lib/schema.ts
+++ b/src/lib/schema.ts
@@ -131,6 +131,7 @@ export interface UsersTable {
last_login: Generated;
created_at: Generated;
metadata?: JSON;
+ harmonica_md?: string | null;
// Subscription fields
subscription_status: 'FREE' | 'PRO' | 'ENTERPRISE';
subscription_id?: string;
diff --git a/src/lib/serverUtils.ts b/src/lib/serverUtils.ts
index ff6b8248..47dd1500 100644
--- a/src/lib/serverUtils.ts
+++ b/src/lib/serverUtils.ts
@@ -6,6 +6,7 @@ import { getSession } from '@auth0/nextjs-auth0';
import { NewUser, NewHostSession } from './schema';
import { updateResourcePermission } from 'app/actions/permissions';
import { getPromptInstructions } from '@/lib/promptsCache';
+import { getPostHogClient } from '@/lib/posthog-server';
export async function isAdmin(user: UserProfile) {
console.log('Admin IDs: ', process.env.ADMIN_ID);
@@ -45,6 +46,9 @@ export async function syncCurrentUser(): Promise {
return false;
}
+ // Check if this is a new user before upserting
+ const existingUser = await db.getUserById(sub);
+
// Create or update user record
const userData: NewUser = {
id: sub,
@@ -55,6 +59,20 @@ export async function syncCurrentUser(): Promise {
};
const result = await db.upsertUser(userData);
+
+ // Track new signups
+ if (!existingUser && result) {
+ const posthog = getPostHogClient();
+ posthog?.capture({
+ distinctId: sub,
+ event: 'user_signed_up',
+ properties: {
+ email: userEmail,
+ auth_provider: sub.split('|')[0] || 'unknown',
+ },
+ });
+ }
+
return !!result;
} catch (error) {
console.error('Error syncing user:', error);