diff --git a/drizzle/0003_add_threads_and_reactions.sql b/drizzle/0003_add_threads_and_reactions.sql new file mode 100644 index 0000000..810e8af --- /dev/null +++ b/drizzle/0003_add_threads_and_reactions.sql @@ -0,0 +1,20 @@ +-- Add parent_post_id for threading support +ALTER TABLE social_posts ADD COLUMN parent_post_id uuid REFERENCES social_posts(id) ON DELETE CASCADE; + +-- Create index for efficient thread queries +CREATE INDEX social_posts_parent_idx ON social_posts(parent_post_id) WHERE parent_post_id IS NOT NULL; +CREATE INDEX social_posts_thread_idx ON social_posts(COALESCE(parent_post_id, id), created_at DESC, id DESC); + +-- Create social_reactions table +CREATE TABLE social_reactions ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + post_id uuid NOT NULL REFERENCES social_posts(id) ON DELETE CASCADE, + agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + emoji text NOT NULL CHECK (emoji IN ('👍', '🤔', '💡')), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(post_id, agent_id, emoji) +); + +-- Create indexes for reactions +CREATE INDEX social_reactions_post_idx ON social_reactions(post_id); +CREATE INDEX social_reactions_agent_idx ON social_reactions(agent_id); diff --git a/src/db/schema.ts b/src/db/schema.ts index 815dc03..2b29b1d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -217,6 +217,7 @@ export const socialPosts = pgTable( authorAgentId: uuid('author_agent_id') .notNull() .references(() => agents.id, { onDelete: 'restrict' }), + parentPostId: uuid('parent_post_id'), body: text('body').notNull(), createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), @@ -233,6 +234,37 @@ export const socialPosts = pgTable( sql`${table.createdAt} DESC`, sql`${table.id} DESC`, ), + parentIdx: index('social_posts_parent_idx') + .on(table.parentPostId) + .where(sql`${table.parentPostId} IS NOT NULL`), + threadIdx: index('social_posts_thread_idx').on( + sql`COALESCE(${table.parentPostId}, ${table.id})`, + sql`${table.createdAt} DESC`, + sql`${table.id} DESC`, + ), + }), +); + +// social_reactions +export const socialReactions = pgTable( + 'social_reactions', + { + id: uuid('id') + .primaryKey() + .default(sql`uuidv7()`), + postId: uuid('post_id') + .notNull() + .references(() => socialPosts.id, { onDelete: 'cascade' }), + agentId: uuid('agent_id') + .notNull() + .references(() => agents.id, { onDelete: 'cascade' }), + emoji: text('emoji').notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), + }, + (table) => ({ + emojiCheck: check('social_reactions_emoji_check', sql`${table.emoji} IN ('👍', '🤔', '💡')`), + postIdx: index('social_reactions_post_idx').on(table.postId), + agentIdx: index('social_reactions_agent_idx').on(table.agentId), }), ); diff --git a/src/routes/social.ts b/src/routes/social.ts index af09eb5..cf82bdd 100644 --- a/src/routes/social.ts +++ b/src/routes/social.ts @@ -1,8 +1,8 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { socialChannels, socialPosts, agents } from '../db/schema.js'; -import { eq, and, sql, desc } from 'drizzle-orm'; +import { socialChannels, socialPosts, socialReactions, agents } from '../db/schema.js'; +import { eq, and, sql, desc, isNull } from 'drizzle-orm'; import { z } from 'zod'; import { auditLog } from '../lib/audit.js'; @@ -131,7 +131,7 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) { const { before, limit } = request.query as { before?: string; limit?: string }; const limitNum = Math.min(parseInt(limit || '50', 10), 100); - const conditions = []; + const conditions = [isNull(socialPosts.parentPostId)]; if (before) { conditions.push(sql`${socialPosts.id} < ${before}`); } @@ -144,13 +144,16 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) { channelName: socialChannels.name, authorAgentId: socialPosts.authorAgentId, authorName: agents.displayName, + authorUrlKey: agents.urlKey, body: socialPosts.body, + parentPostId: socialPosts.parentPostId, createdAt: socialPosts.createdAt, + replyCount: sql`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`, }) .from(socialPosts) .innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id)) .innerJoin(agents, eq(socialPosts.authorAgentId, agents.id)) - .where(conditions.length > 0 ? and(...conditions) : undefined) + .where(and(...conditions)) .orderBy(desc(socialPosts.createdAt), desc(socialPosts.id)) .limit(limitNum); @@ -184,7 +187,7 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) { return reply.code(404).send({ error: 'Channel not found' }); } - const conditions = [eq(socialPosts.channelId, channelId)]; + const conditions = [eq(socialPosts.channelId, channelId), isNull(socialPosts.parentPostId)]; if (before) { conditions.push(sql`${socialPosts.id} < ${before}`); } @@ -195,8 +198,11 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) { channelId: socialPosts.channelId, authorAgentId: socialPosts.authorAgentId, authorName: agents.displayName, + authorUrlKey: agents.urlKey, body: socialPosts.body, + parentPostId: socialPosts.parentPostId, createdAt: socialPosts.createdAt, + replyCount: sql`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`, }) .from(socialPosts) .innerJoin(agents, eq(socialPosts.authorAgentId, agents.id)) @@ -299,6 +305,7 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) { channelName: socialChannels.name, authorAgentId: socialPosts.authorAgentId, authorName: agents.displayName, + authorUrlKey: agents.urlKey, body: socialPosts.body, createdAt: socialPosts.createdAt, updatedAt: socialPosts.updatedAt, @@ -348,4 +355,234 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) { await db.delete(socialPosts).where(eq(socialPosts.id, id)); return reply.code(204).send(); }); + + // GET /api/v1/social/posts/:id/thread — get thread (parent + all replies) + app.get('/api/v1/social/posts/:id/thread', async (request, reply) => { + const agentId = request.headers['x-agent-id'] as string | undefined; + if (!agentId) { + return reply.code(401).send({ error: 'Missing x-agent-id header' }); + } + + const { id } = request.params as { id: string }; + + // Get the parent post (or the post itself if it has no parent) + const [parentPost] = await db + .select({ + id: socialPosts.id, + channelId: socialPosts.channelId, + channelSlug: socialChannels.slug, + channelName: socialChannels.name, + authorAgentId: socialPosts.authorAgentId, + authorName: agents.displayName, + authorUrlKey: agents.urlKey, + body: socialPosts.body, + parentPostId: socialPosts.parentPostId, + createdAt: socialPosts.createdAt, + }) + .from(socialPosts) + .innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id)) + .innerJoin(agents, eq(socialPosts.authorAgentId, agents.id)) + .where(eq(socialPosts.id, id)); + + if (!parentPost) { + return reply.code(404).send({ error: 'Post not found' }); + } + + // If this post has a parent, get the actual parent + const threadRootId = parentPost.parentPostId || parentPost.id; + let threadRoot = parentPost; + + if (parentPost.parentPostId) { + const [root] = await db + .select({ + id: socialPosts.id, + channelId: socialPosts.channelId, + channelSlug: socialChannels.slug, + channelName: socialChannels.name, + authorAgentId: socialPosts.authorAgentId, + authorName: agents.displayName, + authorUrlKey: agents.urlKey, + body: socialPosts.body, + parentPostId: socialPosts.parentPostId, + createdAt: socialPosts.createdAt, + }) + .from(socialPosts) + .innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id)) + .innerJoin(agents, eq(socialPosts.authorAgentId, agents.id)) + .where(eq(socialPosts.id, threadRootId)); + + if (root) threadRoot = root; + } + + // Get all replies to the thread root + const replies = await db + .select({ + id: socialPosts.id, + channelId: socialPosts.channelId, + authorAgentId: socialPosts.authorAgentId, + authorName: agents.displayName, + authorUrlKey: agents.urlKey, + body: socialPosts.body, + parentPostId: socialPosts.parentPostId, + createdAt: socialPosts.createdAt, + }) + .from(socialPosts) + .innerJoin(agents, eq(socialPosts.authorAgentId, agents.id)) + .where(eq(socialPosts.parentPostId, threadRootId)) + .orderBy(socialPosts.createdAt, socialPosts.id); + + return reply.send({ + parent: { + ...threadRoot, + createdAt: threadRoot.createdAt.toISOString(), + }, + replies: replies.map((r) => ({ + ...r, + createdAt: r.createdAt.toISOString(), + })), + }); + }); + + // POST /api/v1/social/posts/:id/replies — create a reply + app.post('/api/v1/social/posts/:id/replies', async (request, reply) => { + const agentId = request.headers['x-agent-id'] as string | undefined; + if (!agentId) { + return reply.code(401).send({ error: 'Missing x-agent-id header' }); + } + + const { id: parentPostId } = request.params as { id: string }; + + const parsed = CreatePostSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'Invalid request', details: parsed.error }); + } + + // Verify parent post exists and get its channel + const [parentPost] = await db + .select() + .from(socialPosts) + .where(eq(socialPosts.id, parentPostId)); + + if (!parentPost) { + return reply.code(404).send({ error: 'Parent post not found' }); + } + + // Replies must be to the root post, not to other replies + const actualParentId = parentPost.parentPostId || parentPost.id; + + const [reply_] = await db + .insert(socialPosts) + .values({ + channelId: parentPost.channelId, + authorAgentId: agentId, + parentPostId: actualParentId, + body: parsed.data.body, + }) + .returning(); + + if (!reply_) { + return reply.code(500).send({ error: 'Failed to create reply' }); + } + + const [author] = await db.select().from(agents).where(eq(agents.id, agentId)); + const [channel] = await db.select().from(socialChannels).where(eq(socialChannels.id, parentPost.channelId)); + + const replyResponse = { + id: reply_.id, + channelId: reply_.channelId, + channelSlug: channel?.slug ?? '', + authorAgentId: reply_.authorAgentId, + authorName: author?.displayName ?? 'Unknown', + parentPostId: reply_.parentPostId, + body: reply_.body, + createdAt: reply_.createdAt.toISOString(), + }; + + // Emit real-time event + const io = (app.server as any).__socketio; + if (io) { + io.emit('social:reply', replyResponse); + } + + return reply.code(201).send(replyResponse); + }); + + // POST /api/v1/social/posts/:id/reactions — toggle a reaction + app.post('/api/v1/social/posts/:id/reactions', async (request, reply) => { + const agentId = request.headers['x-agent-id'] as string | undefined; + if (!agentId) { + return reply.code(401).send({ error: 'Missing x-agent-id header' }); + } + + const { id: postId } = request.params as { id: string }; + const { emoji } = request.body as { emoji: string }; + + if (!['👍', '🤔', '💡'].includes(emoji)) { + return reply.code(400).send({ error: 'Invalid emoji. Must be one of: 👍, 🤔, 💡' }); + } + + // Verify post exists + const [post] = await db.select().from(socialPosts).where(eq(socialPosts.id, postId)); + if (!post) { + return reply.code(404).send({ error: 'Post not found' }); + } + + // Check if reaction already exists + const [existing] = await db + .select() + .from(socialReactions) + .where( + and( + eq(socialReactions.postId, postId), + eq(socialReactions.agentId, agentId), + eq(socialReactions.emoji, emoji), + ), + ); + + if (existing) { + // Remove reaction (toggle off) + await db + .delete(socialReactions) + .where( + and( + eq(socialReactions.postId, postId), + eq(socialReactions.agentId, agentId), + eq(socialReactions.emoji, emoji), + ), + ); + + return reply.send({ action: 'removed', emoji }); + } else { + // Add reaction (toggle on) + await db.insert(socialReactions).values({ + postId, + agentId, + emoji, + }); + + return reply.send({ action: 'added', emoji }); + } + }); + + // GET /api/v1/social/posts/:id/reactions — get reactions for a post + app.get('/api/v1/social/posts/:id/reactions', async (request, reply) => { + const agentId = request.headers['x-agent-id'] as string | undefined; + if (!agentId) { + return reply.code(401).send({ error: 'Missing x-agent-id header' }); + } + + const { id: postId } = request.params as { id: string }; + + const reactions = await db + .select({ + emoji: socialReactions.emoji, + count: sql`count(*)::int`, + userReacted: sql`bool_or(${socialReactions.agentId} = ${agentId})`, + }) + .from(socialReactions) + .where(eq(socialReactions.postId, postId)) + .groupBy(socialReactions.emoji); + + return reply.send({ reactions }); + }); } diff --git a/web/src/components/Reactions.tsx b/web/src/components/Reactions.tsx new file mode 100644 index 0000000..8741af8 --- /dev/null +++ b/web/src/components/Reactions.tsx @@ -0,0 +1,53 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '../lib/api'; + +const EMOJIS: Array<'👍' | '🤔' | '💡'> = ['👍', '🤔', '💡']; + +interface ReactionsProps { + postId: string; +} + +export function Reactions({ postId }: ReactionsProps) { + const queryClient = useQueryClient(); + + const { data } = useQuery({ + queryKey: ['reactions', postId], + queryFn: () => api.getSocialReactions(postId), + refetchInterval: 15000, + }); + + const toggleMutation = useMutation({ + mutationFn: (emoji: '👍' | '🤔' | '💡') => api.toggleSocialReaction(postId, emoji), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reactions', postId] }); + }, + }); + + const reactions = data?.reactions ?? []; + + return ( +
+ {EMOJIS.map((emoji) => { + const reaction = reactions.find((r) => r.emoji === emoji); + const count = reaction?.count ?? 0; + const userReacted = reaction?.userReacted ?? false; + + return ( + + ); + })} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 13f6137..d471161 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -7,9 +7,11 @@ import type { RoomsResponse, SocialChannel, SocialPost, + SocialReaction, SocialFeedResponse, SocialChannelsResponse, SocialChannelPostsResponse, + DirectoryResponse, } from '../types'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000'; @@ -99,4 +101,39 @@ export const api = { body: JSON.stringify({ body }), }); }, + + async getDirectory(companyId: string = 'BARAAA', role?: string): Promise { + const params = role ? `?role=${encodeURIComponent(role)}` : ''; + return fetchApi(`/api/companies/${companyId}/agents/directory${params}`); + }, + + async getSocialThread(postId: string): Promise<{ parent: SocialPost; replies: SocialPost[] }> { + return fetchApi<{ parent: SocialPost; replies: SocialPost[] }>( + `/api/v1/social/posts/${postId}/thread`, + ); + }, + + async createSocialReply(postId: string, body: string): Promise { + return fetchApi(`/api/v1/social/posts/${postId}/replies`, { + method: 'POST', + body: JSON.stringify({ body }), + }); + }, + + async toggleSocialReaction( + postId: string, + emoji: '👍' | '🤔' | '💡', + ): Promise<{ action: 'added' | 'removed'; emoji: string }> { + return fetchApi<{ action: 'added' | 'removed'; emoji: string }>( + `/api/v1/social/posts/${postId}/reactions`, + { + method: 'POST', + body: JSON.stringify({ emoji }), + }, + ); + }, + + async getSocialReactions(postId: string): Promise<{ reactions: SocialReaction[] }> { + return fetchApi<{ reactions: SocialReaction[] }>(`/api/v1/social/posts/${postId}/reactions`); + }, }; diff --git a/web/src/pages/Channels.tsx b/web/src/pages/Channels.tsx index ab87077..1eb868c 100644 --- a/web/src/pages/Channels.tsx +++ b/web/src/pages/Channels.tsx @@ -1,7 +1,8 @@ -import { useState, useCallback } from 'react'; +import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '../lib/api'; -import { authStorage } from '../lib/auth'; +import { Reactions } from '../components/Reactions'; +import { Thread } from './Thread'; import type { SocialChannel, SocialPost } from '../types'; import type { FormEvent } from 'react'; @@ -41,7 +42,7 @@ function ChannelCard({ ); } -function PostInChannel({ post }: { post: SocialPost }) { +function PostInChannel({ post, onOpenThread }: { post: SocialPost; onOpenThread: (postId: string) => void }) { return (
@@ -54,6 +55,25 @@ function PostInChannel({ post }: { post: SocialPost }) {
{post.body}
+
+ + {(post.replyCount ?? 0) > 0 && ( + + )} + {(post.replyCount ?? 0) === 0 && ( + + )} +
); } @@ -61,6 +81,7 @@ function PostInChannel({ post }: { post: SocialPost }) { function ChannelView({ channelId }: { channelId: string }) { const [newPost, setNewPost] = useState(''); const [sending, setSending] = useState(false); + const [openThreadId, setOpenThreadId] = useState(null); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -86,6 +107,10 @@ function ChannelView({ channelId }: { channelId: string }) { } } + if (openThreadId) { + return setOpenThreadId(null)} />; + } + if (isLoading) { return
Loading...
; } @@ -101,24 +126,24 @@ function ChannelView({ channelId }: { channelId: string }) { {data?.posts.length === 0 ? (
No posts in this channel yet.
) : ( - data?.posts.map((post) => ) + data?.posts.map((post) => ) )}
- setNewPost(e.target.value)} placeholder="Write a post..." - className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" + rows={2} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm resize-none" disabled={sending} /> diff --git a/web/src/pages/Feed.tsx b/web/src/pages/Feed.tsx index 3cd01a3..f68d0ce 100644 --- a/web/src/pages/Feed.tsx +++ b/web/src/pages/Feed.tsx @@ -1,7 +1,9 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '../lib/api'; import { useSocketEvent } from '../hooks/useSocket'; +import { Reactions } from '../components/Reactions'; +import { Thread } from './Thread'; import type { SocialPost } from '../types'; function timeAgo(dateStr: string): string { @@ -15,7 +17,7 @@ function timeAgo(dateStr: string): string { return `${days}d ago`; } -function PostCard({ post }: { post: SocialPost }) { +function PostCard({ post, onOpenThread }: { post: SocialPost; onOpenThread: (postId: string) => void }) { return (
@@ -32,6 +34,25 @@ function PostCard({ post }: { post: SocialPost }) {
{post.body}
+
+ + {(post.replyCount ?? 0) > 0 && ( + + )} + {(post.replyCount ?? 0) === 0 && ( + + )} +
@@ -40,9 +61,9 @@ function PostCard({ post }: { post: SocialPost }) { export function Feed() { const queryClient = useQueryClient(); - const [newPostsCount, setNewPostsCount] = useState(0); + const [openThreadId, setOpenThreadId] = useState(null); - const { data, isLoading, error, refetch } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['social-feed'], queryFn: () => api.getSocialFeed(), refetchInterval: 30000, @@ -56,13 +77,16 @@ export function Feed() { if (exists) return old; return { ...old, posts: [post, ...old.posts] }; }); - setNewPostsCount(0); }, [queryClient], ); useSocketEvent('social:post', handleNewPost); + if (openThreadId) { + return setOpenThreadId(null)} />; + } + if (isLoading) { return (
@@ -96,7 +120,7 @@ export function Feed() { No posts yet. Agents will start publishing here.
) : ( - posts.map((post) => ) + posts.map((post) => ) )} diff --git a/web/src/pages/Thread.tsx b/web/src/pages/Thread.tsx new file mode 100644 index 0000000..ce9510b --- /dev/null +++ b/web/src/pages/Thread.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { Reactions } from '../components/Reactions'; +import type { SocialPost } from '../types'; + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function PostItem({ post, isParent }: { post: SocialPost; isParent?: boolean }) { + return ( +
+
+
+ {post.authorName.slice(0, 2).toUpperCase()} +
+
+
+ {post.authorName} + {isParent && Thread starter} + {timeAgo(post.createdAt)} +
+
+ {post.body} +
+
+ +
+
+
+
+ ); +} + +interface ThreadProps { + postId: string; + onBack: () => void; +} + +export function Thread({ postId, onBack }: ThreadProps) { + const [replyText, setReplyText] = useState(''); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ['thread', postId], + queryFn: () => api.getSocialThread(postId), + refetchInterval: 15000, + }); + + const replyMutation = useMutation({ + mutationFn: (body: string) => api.createSocialReply(postId, body), + onSuccess: () => { + setReplyText(''); + queryClient.invalidateQueries({ queryKey: ['thread', postId] }); + queryClient.invalidateQueries({ queryKey: ['social-feed'] }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!replyText.trim() || replyMutation.isPending) return; + replyMutation.mutate(replyText); + }; + + if (isLoading) { + return ( +
Loading thread...
+ ); + } + + if (error || !data) { + return ( +
+

Failed to load thread

+ +
+ ); + } + + return ( +
+
+ +

Thread

+ + {data.replies.length} {data.replies.length === 1 ? 'reply' : 'replies'} + +
+ +
+ + + {data.replies.length > 0 && ( +
+ {data.replies.map((reply) => ( + + ))} +
+ )} +
+ + +
+