Skip to content
Open
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
1,268 changes: 221 additions & 1,047 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"llamaindex": "^0.12.1",
"lucide-react": "^0.381.0",
"marked": "^15.0.12",
"nanoid": "^5.1.6",
"next": "^14.2.33",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
Expand Down
59 changes: 59 additions & 0 deletions src/app/actions/share-transcript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use server';

import { nanoid } from 'nanoid';
import { getDbInstance } from '@/lib/db';
import { sendEmail } from '@/lib/emailService';

export async function generateShareToken(userSessionId: string): Promise<string> {
const db = await getDbInstance();
const token = nanoid(21);

await db
.insertInto('transcript_share_tokens')
.values({ token, user_session_id: userSessionId })
.execute();

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://app.harmonica.chat';
return `${baseUrl}/transcript/${token}?access=public`;
}

export async function sendTranscriptEmail(
userSessionId: string,
recipientEmail: string,
sessionTopic: string
): Promise<{ success: boolean; url: string }> {
const url = await generateShareToken(userSessionId);
const appUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://app.harmonica.chat';

// Mirrors the invitation email format from sendInvitation()
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px 8px 0 0;">
<img src="${appUrl}/harmonica.png" alt="Harmonica Logo" style="height: 40px;" />
</div>
<div style="padding: 20px; border: 1px solid #e0e0e0; border-radius: 0 0 8px 8px;">
<h2>Your session transcript is ready</h2>
<p>Hello,</p>
<p>Your transcript from the <strong>${sessionTopic}</strong> session on Harmonica is now available to view.</p>
<p>To view your transcript, click the button below. No account is required.</p>
<p style="margin: 25px 0;">
<a href="${url}" style="background-color: #0070f3; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; display: inline-block;">
View transcript
</a>
</p>
<p style="color: #666; font-size: 0.9em;">If you have any questions, please contact the session host.</p>
</div>
<div style="text-align: center; color: #666; font-size: 0.8em; margin-top: 20px;">
<p>© ${new Date().getFullYear()} Harmonica. All rights reserved.</p>
</div>
</div>
`;

const success = await sendEmail({
to: recipientEmail,
subject: `Your transcript from the ${sessionTopic} session on Harmonica`,
html,
});

return { success, url };
}
115 changes: 115 additions & 0 deletions src/app/transcript/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { notFound } from 'next/navigation';
import { getDbInstance, getAllChatMessagesInOrder } from '@/lib/db';
import type { Metadata } from 'next';

export const metadata: Metadata = {
title: 'Session Transcript | Harmonica',
robots: { index: false, follow: false },
};

export default async function TranscriptPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const db = await getDbInstance();

// Validate token and get user_session_id
const shareToken = await db
.selectFrom('transcript_share_tokens')
.where('token', '=', token)
.selectAll()
.executeTakeFirst();

if (!shareToken) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<div className="text-center p-8">
<h1 className="text-2xl font-bold text-gray-900">Link Invalid</h1>
<p className="text-gray-500 mt-2">
This transcript link doesn't exist.
</p>
</div>
</div>
);
}

// Fetch the specific user session (single participant)
const userSession = await db
.selectFrom('user_db')
.where('id', '=', shareToken.user_session_id)
.selectAll()
.executeTakeFirst();

if (!userSession) {
notFound();
}

// Fetch parent session for topic
const session = await db
.selectFrom('host_db')
.where('id', '=', userSession.session_id)
.select(['topic'])
.executeTakeFirst();

// Fetch messages for THIS participant only
const messages = await getAllChatMessagesInOrder(userSession.thread_id);

return (
<main className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6 max-w-3xl">
<header className="mb-8 pt-8">
<div className="flex items-center gap-3 mb-4">
<img
src="/harmonica.png"
alt="Harmonica"
className="h-8 w-auto"
/>
</div>
<p className="text-sm text-gray-500 uppercase tracking-wide">
Session Transcript
</p>
<h1 className="text-2xl font-bold text-gray-900 mt-1">
{session?.topic || 'Session'}
</h1>
{userSession.user_name && (
<p className="text-gray-600 mt-1">{userSession.user_name}</p>
)}
</header>

<div className="space-y-4 pb-12">
{messages.map((msg) => (
<div
key={msg.id}
className={`p-4 rounded-lg ${
msg.role === 'user'
? 'bg-blue-50 border border-blue-100 ml-8'
: 'bg-white border border-gray-200 mr-8 shadow-sm'
}`}
>
<p className="text-xs font-medium text-gray-500 mb-2">
{msg.role === 'user' ? 'You' : 'Harmonica'}
</p>
<p className="text-sm text-gray-800 whitespace-pre-wrap">
{msg.content}
</p>
</div>
))}
</div>

{messages.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">No messages in this transcript.</p>
</div>
)}

<footer className="border-t border-gray-200 pt-6 pb-8 text-center">
<p className="text-xs text-gray-400">
© {new Date().getFullYear()} Harmonica. All rights reserved.
</p>
</footer>
</div>
</main>
);
}
54 changes: 45 additions & 9 deletions src/components/SessionResult/ParticipantSessionRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { getAllChatMessagesInOrder, getThreadRating } from '@/lib/db';
import { ParticipantsTableData } from './SessionParticipantsTable';
import { Spinner } from '../icons';
import { Switch } from '../ui/switch';
import { MessageSquare, Star, X } from 'lucide-react';
import { MessageSquare, Share2, X } from 'lucide-react';
import { generateShareToken } from '../../app/actions/share-transcript';
import { toast } from 'hooks/use-toast';

const EMOJI_RATINGS = [
{
Expand Down Expand Up @@ -50,6 +52,28 @@ export default function ParicipantSessionRow({
const [messages, setMessages] = useState<Message[]>([]);
const [rating, setRating] = useState<SessionRating | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSharing, setIsSharing] = useState(false);

const handleShareClick = async () => {
setIsSharing(true);
try {
const url = await generateShareToken(userData.id);
await navigator.clipboard.writeText(url);
toast({
title: 'Transcript link copied',
description: 'The shareable link has been copied to your clipboard.',
});
} catch (error) {
console.error('Error generating share link:', error);
toast({
title: 'Failed to generate share link',
description: 'An error occurred while creating the share link.',
variant: 'destructive',
});
} finally {
setIsSharing(false);
}
};

const handleIncludeInSummaryUpdate = (updatedIncluded: boolean) => {
onIncludeChange(userData.id, updatedIncluded);
Expand Down Expand Up @@ -184,14 +208,26 @@ export default function ParicipantSessionRow({
</div>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={handleCloseClick}
className="rounded-full hover:bg-gray-100"
>
<X className="h-5 w-5 text-gray-500" />
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleShareClick}
disabled={isSharing}
className="rounded-full hover:bg-gray-100"
title="Share transcript"
>
<Share2 className="h-5 w-5 text-gray-500" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleCloseClick}
className="rounded-full hover:bg-gray-100"
>
<X className="h-5 w-5 text-gray-500" />
</Button>
</div>
</div>

<div className="flex-1 overflow-auto p-4 rounded-b-xl">
Expand Down
23 changes: 23 additions & 0 deletions src/db/migrations/032_add_transcript_share_tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('transcript_share_tokens')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn('token', 'varchar(21)', (col) =>
col.notNull().unique())
.addColumn('user_session_id', 'uuid', (col) =>
col.notNull().references('user_sessions.id').onDelete('cascade'))
.execute();

await db.schema
.createIndex('idx_transcript_share_tokens_token')
.on('transcript_share_tokens')
.column('token')
.execute();
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('transcript_share_tokens').execute();
}
1 change: 1 addition & 0 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface Databases {
[promptTypesTableName]: s.PromptTypesTable;
session_files: s.SessionFilesTable;
[sessionRatingsTableName]: s.SessionRatingsTable;
transcript_share_tokens: s.TranscriptShareTokensTable;
}

const dbPromise = (async () => {
Expand Down
9 changes: 9 additions & 0 deletions src/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,15 @@ export type SessionRating = Selectable<SessionRatingsTable>;
export type NewSessionRating = Insertable<SessionRatingsTable>;
export type SessionRatingUpdate = Updateable<SessionRatingsTable>;

export interface TranscriptShareTokensTable {
id: Generated<string>;
token: string;
user_session_id: string;
}

export type TranscriptShareToken = Selectable<TranscriptShareTokensTable>;
export type NewTranscriptShareToken = Insertable<TranscriptShareTokensTable>;

export async function createDbInstance<T extends Record<string, any>>() {
try {
const url = process.env.POSTGRES_URL;
Expand Down