diff --git a/apps/rowboat/app/actions/project.actions.ts b/apps/rowboat/app/actions/project.actions.ts index 5f042694..54849645 100644 --- a/apps/rowboat/app/actions/project.actions.ts +++ b/apps/rowboat/app/actions/project.actions.ts @@ -2,7 +2,8 @@ import { z } from 'zod'; import { container } from "@/di/container"; import { redirect } from "next/navigation"; -import { templates } from "../lib/project_templates"; +// Fetch library templates from the unified assistant templates repository +import { MongoDBAssistantTemplatesRepository } from "@/src/infrastructure/repositories/mongodb.assistant-templates.repository"; import { authCheck } from "./auth.actions"; import { ApiKey } from "@/src/entities/models/api-key"; import { Project } from "@/src/entities/models/project"; @@ -40,14 +41,17 @@ const updateLiveWorkflowController = container.resolve('revertToLiveWorkflowController'); export async function listTemplates() { - const templatesArray = Object.entries(templates) - .filter(([key]) => key !== 'default') // Exclude the default template - .map(([key, template]) => ({ - id: key, - ...template - })); - - return templatesArray; + const repo = new MongoDBAssistantTemplatesRepository(); + const result = await repo.list({ source: 'library', isPublic: true }, undefined, 100); + // Map to the shape expected by callers (tools at top-level) + return result.items.map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + category: item.category, + tools: (item as any).workflow?.tools || [], + copilotPrompt: item.copilotPrompt, + })); } export async function projectAuthCheck(projectId: string) { diff --git a/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts b/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts new file mode 100644 index 00000000..606f8dbc --- /dev/null +++ b/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository'; + +const repo = new MongoDBAssistantTemplatesRepository(); + +const ToggleLikeSchema = z.object({ + guestId: z.string().min(1), +}); + +export async function POST(req: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + // Prefer header like existing community route + const guestId = req.headers.get('x-guest-id') || undefined; + const body = !guestId ? await req.json().catch(() => ({})) : {}; + const parsed = ToggleLikeSchema.safeParse({ guestId: guestId || body.guestId }); + if (!parsed.success) { + return NextResponse.json({ error: 'Missing guestId' }, { status: 400 }); + } + + const { id } = await context.params; + const result = await repo.toggleLike(id, parsed.data.guestId); + return NextResponse.json(result); + } catch (error) { + console.error('Error toggling like:', error); + return NextResponse.json({ error: 'Failed to toggle like' }, { status: 500 }); + } +} + + diff --git a/apps/rowboat/app/api/assistant-templates/[id]/route.ts b/apps/rowboat/app/api/assistant-templates/[id]/route.ts new file mode 100644 index 00000000..81616fda --- /dev/null +++ b/apps/rowboat/app/api/assistant-templates/[id]/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository'; + +const repo = new MongoDBAssistantTemplatesRepository(); + +export async function GET(_req: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const item = await repo.fetch(id); + if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json(item); + } catch (error) { + console.error('Error fetching assistant template:', error); + return NextResponse.json({ error: 'Failed to fetch assistant template' }, { status: 500 }); + } +} + + diff --git a/apps/rowboat/app/api/assistant-templates/categories/route.ts b/apps/rowboat/app/api/assistant-templates/categories/route.ts new file mode 100644 index 00000000..58e5e120 --- /dev/null +++ b/apps/rowboat/app/api/assistant-templates/categories/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository'; + +const repo = new MongoDBAssistantTemplatesRepository(); + +export async function GET(_req: NextRequest) { + try { + const categories = await repo.getCategories(); + return NextResponse.json({ items: categories }); + } catch (error) { + console.error('Error fetching categories:', error); + return NextResponse.json({ error: 'Failed to fetch categories' }, { status: 500 }); + } +} + + diff --git a/apps/rowboat/app/api/community-assistants/route.ts b/apps/rowboat/app/api/assistant-templates/route.ts similarity index 53% rename from apps/rowboat/app/api/community-assistants/route.ts rename to apps/rowboat/app/api/assistant-templates/route.ts index 4a72ae9e..03389fae 100644 --- a/apps/rowboat/app/api/community-assistants/route.ts +++ b/apps/rowboat/app/api/assistant-templates/route.ts @@ -1,81 +1,85 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository'; -import { CommunityAssistant } from '@/src/entities/models/community-assistant'; +import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository'; +import { ensureLibraryTemplatesSeeded } from '@/app/lib/assistant_templates_seed'; import { authCheck } from '@/app/actions/auth.actions'; import { auth0 } from '@/app/lib/auth0'; import { USE_AUTH } from '@/app/lib/feature_flags'; -const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository(); +const repo = new MongoDBAssistantTemplatesRepository(); -// Schema for creating a community assistant -const CreateCommunityAssistantSchema = z.object({ +const ListSchema = z.object({ + category: z.string().optional(), + search: z.string().optional(), + featured: z.boolean().optional(), + source: z.enum(['library','community']).optional(), + cursor: z.string().optional(), + limit: z.number().min(1).max(50).default(20), +}); + +const CreateSchema = z.object({ name: z.string().min(1).max(100), description: z.string().min(1).max(500), category: z.string().min(1), tags: z.array(z.string()).max(10), isAnonymous: z.boolean().default(false), - workflow: z.any(), // Will be validated against Workflow schema + workflow: z.any(), copilotPrompt: z.string().optional(), thumbnailUrl: z.string().url().optional(), - estimatedComplexity: z.enum(['beginner', 'intermediate', 'advanced']).default('beginner'), -}); - -// Schema for listing community assistants -const ListCommunityAssistantsSchema = z.object({ - category: z.string().optional(), - search: z.string().optional(), - featured: z.boolean().optional(), - cursor: z.string().optional(), - limit: z.number().min(1).max(50).default(20), }); export async function GET(req: NextRequest) { try { + // Ensure library JSONs are seeded into the unified collection (idempotent) + await ensureLibraryTemplatesSeeded(); const { searchParams } = new URL(req.url); - const params = ListCommunityAssistantsSchema.parse({ + const params = ListSchema.parse({ category: searchParams.get('category') || undefined, search: searchParams.get('search') || undefined, featured: searchParams.get('featured') ? searchParams.get('featured') === 'true' : undefined, + source: (searchParams.get('source') as 'library' | 'community') || undefined, cursor: searchParams.get('cursor') || undefined, limit: searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 20, }); - const result = await communityAssistantsRepo.list({ - category: params.category, - search: params.search, - featured: params.featured, - isPublic: true, // Only show public assistants - }, params.cursor, params.limit); + // If source specified, query that subset; otherwise return combined from the unified collection + if (params.source === 'library' || params.source === 'community') { + const result = await repo.list({ + category: params.category, + search: params.search, + featured: params.featured, + isPublic: true, + source: params.source, + }, params.cursor, params.limit); + return NextResponse.json(result); + } - return NextResponse.json(result); + // No source: combine both subsets from the unified collection + const [lib, com] = await Promise.all([ + repo.list({ category: params.category, search: params.search, featured: params.featured, isPublic: true, source: 'library' }, undefined, params.limit), + repo.list({ category: params.category, search: params.search, featured: params.featured, isPublic: true, source: 'community' }, undefined, params.limit), + ]); + return NextResponse.json({ items: [...lib.items, ...com.items], nextCursor: null }); } catch (error) { - console.error('Error listing community assistants:', error); - return NextResponse.json( - { error: 'Failed to list community assistants' }, - { status: 500 } - ); + console.error('Error listing assistant templates:', error); + return NextResponse.json({ error: 'Failed to list assistant templates' }, { status: 500 }); } } export async function POST(req: NextRequest) { try { - // Get authenticated user let user; if (USE_AUTH) { user = await authCheck(); } else { - // For development/testing without auth user = { id: 'guest', email: 'guest@example.com' }; } const body = await req.json(); - const data = CreateCommunityAssistantSchema.parse(body); + const data = CreateSchema.parse(body); - // Get user display name from Auth0 session let authorName = 'Anonymous'; let authorEmail: string | undefined; - if (USE_AUTH) { try { const { user: auth0User } = await auth0.getSession() || {}; @@ -88,15 +92,12 @@ export async function POST(req: NextRequest) { } } - // Override with user choice - if (!data.isAnonymous) { - authorName = data.isAnonymous ? 'Anonymous' : authorName; - } else { + if (data.isAnonymous) { authorName = 'Anonymous'; authorEmail = undefined; } - const communityAssistant = await communityAssistantsRepo.create({ + const created = await repo.create({ name: data.name, description: data.description, category: data.category, @@ -108,28 +109,22 @@ export async function POST(req: NextRequest) { tags: data.tags, copilotPrompt: data.copilotPrompt, thumbnailUrl: data.thumbnailUrl, - estimatedComplexity: data.estimatedComplexity, - publishedAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), downloadCount: 0, likeCount: 0, featured: false, isPublic: true, likes: [], + source: 'community', }); - return NextResponse.json(communityAssistant); + return NextResponse.json(created); } catch (error) { - console.error('Error creating community assistant:', error); + console.error('Error creating assistant template:', error); if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ); + return NextResponse.json({ error: 'Invalid request data', details: error.errors }, { status: 400 }); } - return NextResponse.json( - { error: 'Failed to create community assistant' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to create assistant template' }, { status: 500 }); } } + + diff --git a/apps/rowboat/app/api/community-assistants/[id]/like/route.ts b/apps/rowboat/app/api/community-assistants/[id]/like/route.ts deleted file mode 100644 index 22294f47..00000000 --- a/apps/rowboat/app/api/community-assistants/[id]/like/route.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; -import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository'; -import { authCheck } from '@/app/actions/auth.actions'; -import { auth0 } from '@/app/lib/auth0'; -import { USE_AUTH } from '@/app/lib/feature_flags'; - -const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository(); - -const ToggleLikeSchema = z.object({ - liked: z.boolean(), -}); - -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - - // Try to parse body, but don't require it for toggle functionality - let body = {}; - try { - const text = await req.text(); - if (text) { - body = JSON.parse(text); - } - } catch (error) { - // If no body or invalid JSON, continue with empty body - } - - // Get user ID (works for both authenticated and guest users) - let userId: string; - let userEmail: string | undefined; - - if (USE_AUTH) { - try { - const user = await authCheck(); - userId = user.id; - userEmail = user.email; - } catch (error) { - // If not authenticated, use a consistent guest ID from headers or generate one - const guestId = req.headers.get('x-guest-id') || `guest-${crypto.randomUUID()}`; - userId = guestId; - } - } else { - // For development/testing without auth, use a consistent guest ID - const guestId = req.headers.get('x-guest-id') || `guest-${crypto.randomUUID()}`; - userId = guestId; - } - - // Verify the assistant exists and is public - const assistant = await communityAssistantsRepo.fetch(id); - if (!assistant || !assistant.isPublic) { - return NextResponse.json( - { error: 'Community assistant not found' }, - { status: 404 } - ); - } - - // Toggle the like - const result = await communityAssistantsRepo.toggleLike(id, userId, userEmail); - - return NextResponse.json({ - liked: result.liked, - likeCount: result.likeCount, - }); - } catch (error) { - console.error('Error toggling like:', error); - return NextResponse.json( - { error: 'Failed to toggle like' }, - { status: 500 } - ); - } -} - -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - - // Get user ID (works for both authenticated and guest users) - let userId: string; - - if (USE_AUTH) { - try { - const user = await authCheck(); - userId = user.id; - } catch (error) { - // If not authenticated, generate a guest ID - userId = `guest-${crypto.randomUUID()}`; - } - } else { - // For development/testing without auth - userId = `guest-${crypto.randomUUID()}`; - } - - // Get like count and user's like status - const [likeCount, userLiked] = await Promise.all([ - communityAssistantsRepo.getLikeCount(id), - communityAssistantsRepo.getUserLikes(id, userId), - ]); - - return NextResponse.json({ - likeCount, - liked: userLiked, - }); - } catch (error) { - console.error('Error getting like status:', error); - return NextResponse.json( - { error: 'Failed to get like status' }, - { status: 500 } - ); - } -} diff --git a/apps/rowboat/app/api/community-assistants/[id]/route.ts b/apps/rowboat/app/api/community-assistants/[id]/route.ts deleted file mode 100644 index af69797c..00000000 --- a/apps/rowboat/app/api/community-assistants/[id]/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository'; -import { authCheck } from '@/app/actions/auth.actions'; -import { USE_AUTH } from '@/app/lib/feature_flags'; - -const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository(); - -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - - const assistant = await communityAssistantsRepo.fetch(id); - - if (!assistant) { - return NextResponse.json( - { error: 'Community assistant not found' }, - { status: 404 } - ); - } - - // Only return public assistants - if (!assistant.isPublic) { - return NextResponse.json( - { error: 'Community assistant not found' }, - { status: 404 } - ); - } - - return NextResponse.json(assistant); - } catch (error) { - console.error('Error fetching community assistant:', error); - return NextResponse.json( - { error: 'Failed to fetch community assistant' }, - { status: 500 } - ); - } -} - -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - // Get authenticated user - let user; - if (USE_AUTH) { - user = await authCheck(); - } else { - // For development/testing without auth - user = { id: 'guest', email: 'guest@example.com' }; - } - - const { id } = await params; - const body = await req.json(); - const { action } = body; - - if (action === 'import') { - // Import the community assistant as a new project - const assistant = await communityAssistantsRepo.fetch(id); - - if (!assistant || !assistant.isPublic) { - return NextResponse.json( - { error: 'Community assistant not found' }, - { status: 404 } - ); - } - - // Increment download count - await communityAssistantsRepo.incrementDownloadCount(id); - - // Return the workflow data for project creation - return NextResponse.json({ - workflow: assistant.workflow, - name: assistant.name, - description: assistant.description, - }); - } - - return NextResponse.json( - { error: 'Invalid action' }, - { status: 400 } - ); - } catch (error) { - console.error('Error processing community assistant action:', error); - return NextResponse.json( - { error: 'Failed to process action' }, - { status: 500 } - ); - } -} diff --git a/apps/rowboat/app/api/community-assistants/categories/route.ts b/apps/rowboat/app/api/community-assistants/categories/route.ts deleted file mode 100644 index 617771af..00000000 --- a/apps/rowboat/app/api/community-assistants/categories/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository'; - -const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository(); - -export async function GET(req: NextRequest) { - try { - const categories = await communityAssistantsRepo.getCategories(); - - return NextResponse.json({ categories }); - } catch (error) { - console.error('Error fetching categories:', error); - return NextResponse.json( - { error: 'Failed to fetch categories' }, - { status: 500 } - ); - } -} diff --git a/apps/rowboat/app/api/templates/route.ts b/apps/rowboat/app/api/templates/route.ts deleted file mode 100644 index 3bba07fc..00000000 --- a/apps/rowboat/app/api/templates/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from 'next/server'; -import { templates } from '@/app/lib/project_templates'; - -export async function GET() { - // The templates are now dynamically loaded from JSON files in the templates folder - return NextResponse.json(templates); -} diff --git a/apps/rowboat/app/lib/assistant_templates_seed.ts b/apps/rowboat/app/lib/assistant_templates_seed.ts new file mode 100644 index 00000000..d577f3a4 --- /dev/null +++ b/apps/rowboat/app/lib/assistant_templates_seed.ts @@ -0,0 +1,52 @@ +import { db } from "@/app/lib/mongodb"; +import { prebuiltTemplates } from "@/app/lib/prebuilt-cards"; + +// idempotent seed: creates library (prebuilt) templates in DB if missing +// Uses name+authorName match to avoid duplicates; tags include a stable prebuilt key +export async function ensureLibraryTemplatesSeeded(): Promise { + try { + const collection = db.collection("assistant_templates"); + const now = new Date().toISOString(); + + const entries = Object.entries(prebuiltTemplates); + for (const [prebuiltKey, tpl] of entries) { + // minimal guard; only ingest valid workflow-like objects + if (!(tpl as any)?.agents || !Array.isArray((tpl as any).agents)) continue; + + const name = (tpl as any).name || prebuiltKey; + + // check if already present (by name + authorName Rowboat and special tag) + const existing = await collection.findOne({ name, authorName: "Rowboat", tags: { $in: [ `prebuilt:${prebuiltKey}`, "__library__" ] } }); + if (existing) continue; + + const doc = { + name, + description: (tpl as any).description || "", + category: (tpl as any).category || "Other", + authorId: "rowboat-system", + authorName: "Rowboat", + authorEmail: undefined, + isAnonymous: false, + workflow: tpl as any, + tags: ["__library__", `prebuilt:${prebuiltKey}`].filter(Boolean), + publishedAt: now, + lastUpdatedAt: now, + downloadCount: 0, + likeCount: 0, + featured: false, + isPublic: true, + likes: [] as string[], + copilotPrompt: (tpl as any).copilotPrompt || undefined, + thumbnailUrl: undefined, + source: 'library' as const, + } as const; + + await collection.insertOne(doc as any); + } + } catch (err) { + // best-effort seed; do not throw to avoid breaking requests + console.error("ensureLibraryTemplatesSeeded error:", err); + } +} + + diff --git a/apps/rowboat/app/lib/project_templates.ts b/apps/rowboat/app/lib/project_templates.ts index 3226d1c6..62a89810 100644 --- a/apps/rowboat/app/lib/project_templates.ts +++ b/apps/rowboat/app/lib/project_templates.ts @@ -1,50 +1,33 @@ import { WorkflowTemplate } from "./types/workflow_types"; import { z } from 'zod'; -import { prebuiltTemplates } from './prebuilt-cards'; -const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1"; - -// Build templates object using static imports so Vercel bundles them -function buildTemplates(): { [key: string]: z.infer } { - const templates: { [key: string]: z.infer } = {}; - - // Add default template - templates['default'] = { - name: 'Blank Template', - description: 'A blank canvas to build your agents.', - startAgent: "", - agents: [], - prompts: [], - tools: [ - { - name: "Generate Image", - description: "Generate an image using Google Gemini given a text prompt. Returns base64-encoded image data and any text parts.", - isGeminiImage: true, - parameters: { - type: 'object', - properties: { - prompt: { type: 'string', description: 'Text prompt describing the image to generate' }, - modelName: { type: 'string', description: 'Optional Gemini model override' }, - }, - required: ['prompt'], - additionalProperties: true, +// Provide a minimal default template to satisfy legacy code paths that +// still reference `templates.default`. Real templates are DB-backed. +const defaultTemplate: z.infer = { + name: 'Blank Template', + description: 'A blank canvas to build your assistant.', + startAgent: "", + agents: [], + prompts: [], + tools: [ + { + name: "Generate Image", + description: "Generate an image using Google Gemini given a text prompt. Returns base64-encoded image data and any text parts.", + isGeminiImage: true, + parameters: { + type: 'object', + properties: { + prompt: { type: 'string', description: 'Text prompt describing the image to generate' }, + modelName: { type: 'string', description: 'Optional Gemini model override' }, }, + required: ['prompt'], + additionalProperties: true, }, - ], - }; + }, + ], + pipelines: [], +}; - // Merge static prebuilt templates - Object.entries(prebuiltTemplates).forEach(([key, tpl]) => { - // Basic guard to avoid bad entries - if ((tpl as any)?.agents && Array.isArray((tpl as any).agents)) { - templates[key] = tpl as z.infer; - } - }); - - return templates; -} - -export const templates: { [key: string]: z.infer } = buildTemplates(); - -// Note: Prebuilt cards are now loaded from app/lib/prebuilt-cards/ directory -// starting_copilot_prompts has been removed as it was unused +export const templates: Record> = { + default: defaultTemplate, +}; diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx index 62090df2..ba41620e 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx @@ -53,7 +53,6 @@ interface TopBarProps { tags: string[]; isAnonymous: boolean; copilotPrompt: string; - estimatedComplexity: 'beginner' | 'intermediate' | 'advanced'; }; setCommunityData: (data: any) => void; onCommunityPublish: () => void; diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx index bda90b8e..58d30519 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx @@ -1642,7 +1642,6 @@ export function WorkflowEditor({ tags: [] as string[], isAnonymous: false, copilotPrompt: '', - estimatedComplexity: 'beginner' as 'beginner' | 'intermediate' | 'advanced', }); const [communityPublishing, setCommunityPublishing] = useState(false); const [communityPublishSuccess, setCommunityPublishSuccess] = useState(false); diff --git a/apps/rowboat/app/projects/components/build-assistant-section.tsx b/apps/rowboat/app/projects/components/build-assistant-section.tsx index b729b380..c19e0bd9 100644 --- a/apps/rowboat/app/projects/components/build-assistant-section.tsx +++ b/apps/rowboat/app/projects/components/build-assistant-section.tsx @@ -18,6 +18,7 @@ import { z } from "zod"; import Link from 'next/link'; import { CommunitySection } from '@/components/community/CommunitySection'; import { AssistantSection } from '@/components/common/AssistantSection'; +import { UnifiedTemplatesSection } from '@/components/common/UnifiedTemplatesSection'; const SHOW_PREBUILT_CARDS = process.env.NEXT_PUBLIC_SHOW_PREBUILT_CARDS !== 'false'; @@ -53,6 +54,9 @@ export function BuildAssistantSection() { const [templates, setTemplates] = useState([]); const [templatesLoading, setTemplatesLoading] = useState(false); const [templatesError, setTemplatesError] = useState(null); + const [communityTemplates, setCommunityTemplates] = useState([]); + const [communityTemplatesLoading, setCommunityTemplatesLoading] = useState(false); + const [communityTemplatesError, setCommunityTemplatesError] = useState(null); const [projects, setProjects] = useState[]>([]); const [projectsLoading, setProjectsLoading] = useState(false); const [currentPage, setCurrentPage] = useState(1); @@ -104,27 +108,127 @@ export function BuildAssistantSection() { } }; + const fetchCommunityTemplates = async () => { + setCommunityTemplatesLoading(true); + setCommunityTemplatesError(null); + try { + const response = await fetch('/api/assistant-templates?source=community'); + if (!response.ok) throw new Error('Failed to fetch community templates'); + const data = await response.json(); + setCommunityTemplates(data.items || []); + } catch (error) { + console.error('Error fetching community templates:', error); + setCommunityTemplatesError(error instanceof Error ? error.message : 'Failed to load community templates'); + } finally { + setCommunityTemplatesLoading(false); + } + }; + // Handle template selection const handleTemplateSelect = async (template: any) => { // Show a small non-blocking spinner on the clicked card setLoadingTemplateId(template.id); try { - await createProjectWithOptions({ - template: template.id, - // Prefer a card-specific copilot prompt if present on the template JSON - prompt: template.copilotPrompt || 'Explain this workflow', - router, - onError: () => { - // Clear loading state if creation fails - setLoadingTemplateId(null); - }, - }); + if (template.type === 'prebuilt') { + // Fetch full workflow from unified API, then create from JSON + const res = await fetch(`/api/assistant-templates/${template.id}`); + if (!res.ok) throw new Error('Failed to fetch template details'); + const data = await res.json(); + await createProjectFromJsonWithOptions({ + workflowJson: JSON.stringify(data.workflow), + router, + onSuccess: (_projectId) => {}, + onError: () => { + setLoadingTemplateId(null); + } + }); + } else if (template.type === 'community') { + // Handle community template import + await createProjectFromJsonWithOptions({ + workflowJson: JSON.stringify(template.workflow), + router, + onSuccess: (projectId) => { + router.push(`/projects/${projectId}/workflow`); + }, + onError: (error) => { + console.error('Error creating project from community template:', error); + setLoadingTemplateId(null); + } + }); + } } catch (_err) { // In case of unexpected error, clear loading state setLoadingTemplateId(null); } }; + // Stable guest id for like toggles + const getGuestId = () => { + try { + let guestId = sessionStorage.getItem('guestId'); + if (!guestId) { + guestId = `guest-${crypto.randomUUID()}`; + sessionStorage.setItem('guestId', guestId); + } + return guestId; + } catch (_e) { + return `guest-${crypto.randomUUID()}`; + } + }; + + // Handle template like (unified for library and community) + const handleTemplateLike = async (template: any) => { + try { + const guestId = getGuestId(); + const response = await fetch(`/api/assistant-templates/${template.id}/like`, { + method: 'POST', + headers: { 'x-guest-id': guestId }, + }); + + if (response.ok) { + const data = await response.json(); + if (template.type === 'community') { + setCommunityTemplates(prev => prev.map(t => + t.id === template.id + ? { ...t, likeCount: data.likeCount, isLiked: data.liked } + : t + )); + } else { + setTemplates(prev => prev.map(t => + t.id === template.id + ? { ...t, likeCount: data.likeCount, isLiked: data.liked } as any + : t + )); + } + } + } catch (err) { + console.error('Error toggling like:', err); + } + }; + + // Handle template share (for both library and community) + const handleTemplateShare = async (template: any) => { + try { + // Fetch workflow for the template and create a shared snapshot + const res = await fetch(`/api/assistant-templates/${template.id}`); + if (!res.ok) throw new Error('Failed to fetch template for sharing'); + const data = await res.json(); + + const shareResp = await fetch('/api/shared-workflow', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workflow: data.workflow }), + }); + if (!shareResp.ok) throw new Error('Failed to create shared workflow'); + const shareData = await shareResp.json(); + const url = `${window.location.origin}/projects?shared=${shareData.id}`; + await navigator.clipboard.writeText(url); + console.log('URL copied to clipboard'); + } catch (err) { + console.error('Failed to copy shared URL:', err); + } + }; + // Handle prompt card selection const handlePromptSelect = (promptText: string) => { setUserPrompt(promptText); @@ -148,6 +252,7 @@ export function BuildAssistantSection() { useEffect(() => { fetchTemplates(); + fetchCommunityTemplates(); fetchProjects(); }, []); @@ -186,19 +291,40 @@ export function BuildAssistantSection() { if (urlPrompt || urlTemplate) { setAutoCreateLoading(true); - createProjectWithOptions({ - template: urlTemplate || undefined, - prompt: urlPrompt || undefined, - router, - onError: (error) => { - console.error('Error auto-creating project:', error); - setAutoCreateLoading(false); - // Fall back to showing the form with the prompt pre-filled - if (urlPrompt) { - setUserPrompt(urlPrompt); - } + try { + const isMongoId = !!urlTemplate && /^[a-f0-9]{24}$/i.test(urlTemplate); + if (urlTemplate && isMongoId) { + // New-style share: template is an assistant-templates id + const res = await fetch(`/api/assistant-templates/${urlTemplate}`); + if (!res.ok) throw new Error('Failed to fetch shared template'); + const data = await res.json(); + await createProjectFromJsonWithOptions({ + workflowJson: JSON.stringify(data.workflow), + router, + onError: (error) => { + console.error('Error auto-creating project from template id:', error); + setAutoCreateLoading(false); + } + }); + } else { + // Legacy share using static key + await createProjectWithOptions({ + template: urlTemplate || undefined, + prompt: urlPrompt || undefined, + router, + onError: (error) => { + console.error('Error auto-creating project:', error); + setAutoCreateLoading(false); + if (urlPrompt) { + setUserPrompt(urlPrompt); + } + } + }); } - }); + } catch (err) { + console.error('Error handling template auto-create:', err); + setAutoCreateLoading(false); + } } }; @@ -464,34 +590,46 @@ export function BuildAssistantSection() { - {/* Pre-built Assistants Section - Only show for New Assistant tab */} + {/* Unified Templates Section - Only show for New Assistant tab */} {selectedTab === 'new' && SHOW_PREBUILT_CARDS && (
- ({ + ({ id: template.id, name: template.name, description: template.description, category: template.category || 'Other', tools: template.tools, - estimatedComplexity: template.estimatedComplexity + type: 'prebuilt' as const, + likeCount: (template as any).likeCount || 0, + isLiked: (template as any).isLiked || false, }))} - loading={templatesLoading} - error={templatesError} - onItemClick={handleTemplateSelect} + communityTemplates={communityTemplates.map(template => ({ + id: template.id, + name: template.name, + description: template.description, + category: template.category, + authorName: template.authorName, + isAnonymous: template.isAnonymous, + likeCount: template.likeCount, + createdAt: template.publishedAt, + isLiked: template.isLiked, + type: 'community' as const, + }))} + loading={templatesLoading || communityTemplatesLoading} + error={templatesError || communityTemplatesError} + onTemplateClick={handleTemplateSelect} + onRetry={() => { + fetchTemplates(); + fetchCommunityTemplates(); + }} loadingItemId={loadingTemplateId} - emptyMessage="No pre-built assistants available" + onLike={handleTemplateLike} + onShare={handleTemplateShare} getUniqueTools={getUniqueTools} />
)} - - {/* Community Assistants Section */} -
- -
)} diff --git a/apps/rowboat/components/common/AssistantCard.tsx b/apps/rowboat/components/common/AssistantCard.tsx index 2e0575b0..67f7b1e5 100644 --- a/apps/rowboat/components/common/AssistantCard.tsx +++ b/apps/rowboat/components/common/AssistantCard.tsx @@ -61,8 +61,8 @@ interface AssistantCardProps { onLike?: () => void; onShare?: () => void; isLiked?: boolean; - // Pre-built specific props - estimatedComplexity?: string; + // Template type indicator + templateType?: 'prebuilt' | 'community'; // Common props onClick?: () => void; loading?: boolean; @@ -83,14 +83,35 @@ export function AssistantCard({ onLike, onShare, isLiked = false, - estimatedComplexity, + templateType, onClick, loading = false, disabled = false, getUniqueTools }: AssistantCardProps) { const displayTools = getUniqueTools ? getUniqueTools({ tools }) : tools; - const isCommunity = authorName !== undefined; + const [isDescriptionExpanded, setIsDescriptionExpanded] = React.useState(false); + const [showDescriptionToggle, setShowDescriptionToggle] = React.useState(false); + const descriptionRef = React.useRef(null); + const [copied, setCopied] = React.useState(false); + React.useEffect(() => { + let t: any; + if (copied) { + t = setTimeout(() => setCopied(false), 1500); + } + return () => t && clearTimeout(t); + }, [copied]); + + React.useEffect(() => { + const el = descriptionRef.current; + if (!el) return; + // Measure if truncated (only when collapsed) + if (!isDescriptionExpanded) { + setShowDescriptionToggle(el.scrollHeight > el.clientHeight + 1); + } else { + setShowDescriptionToggle(true); + } + }, [description, isDescriptionExpanded]); const getCategoryColor = (category: string) => { const lowerCategory = category.toLowerCase(); @@ -124,11 +145,41 @@ export function AssistantCard({
{/* Title and Description */}
-
- {name} +
+
+ {name} +
+ {/* Template Type Badge */} + {templateType && ( + + {templateType === 'prebuilt' ? 'Library' : 'Community'} + + )}
-
- {description} +
+
+ {description} +
+ {showDescriptionToggle && ( + + )}
@@ -147,87 +198,79 @@ export function AssistantCard({ )}
- {/* Community-specific info */} - {isCommunity && ( -
-
- {isAnonymous ? 'Anonymous' : (authorName || 'Unknown')} - {createdAt && ( -
- - {getRelativeTime(createdAt)} -
- )} -
-
- - -
-
- )} +
+ + )} +
- {/* Tools */} - {displayTools.length > 0 && ( + {/* Author and interaction info */} +
-
- Tools: -
-
- {displayTools.slice(0, 4).map((tool) => ( - tool.logo && ( - - ) - ))} - {displayTools.length > 4 && ( - - +{displayTools.length - 4} - - )} -
-
- )} - - {/* Complexity for pre-built templates */} - {estimatedComplexity && ( -
- - {estimatedComplexity} + + {authorName ? (isAnonymous ? 'Anonymous' : authorName) : 'Rowboat'} + {createdAt && ( +
+ + {getRelativeTime(createdAt)} +
+ )}
- )} +
+ + +
+
+ ); diff --git a/apps/rowboat/components/common/AssistantSection.tsx b/apps/rowboat/components/common/AssistantSection.tsx index 3f0994db..6dd57d02 100644 --- a/apps/rowboat/components/common/AssistantSection.tsx +++ b/apps/rowboat/components/common/AssistantSection.tsx @@ -20,8 +20,6 @@ interface AssistantItem { likeCount?: number; createdAt?: string; isLiked?: boolean; - // Pre-built specific - estimatedComplexity?: string; } interface AssistantSectionProps { @@ -225,7 +223,6 @@ export function AssistantSection({ isAnonymous={item.isAnonymous} likeCount={item.likeCount} createdAt={item.createdAt} - estimatedComplexity={item.estimatedComplexity} onClick={() => onItemClick?.(item)} loading={loadingItemId === item.id} getUniqueTools={getUniqueTools} diff --git a/apps/rowboat/components/common/UnifiedTemplatesSection.tsx b/apps/rowboat/components/common/UnifiedTemplatesSection.tsx new file mode 100644 index 00000000..b20b2be4 --- /dev/null +++ b/apps/rowboat/components/common/UnifiedTemplatesSection.tsx @@ -0,0 +1,357 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { Input } from "@heroui/react"; +import { Search, Filter } from 'lucide-react'; +import { AssistantCard } from './AssistantCard'; +import { Button } from "@/components/ui/button"; + +interface TemplateItem { + id: string; + name: string; + description: string; + category: string; + tools?: Array<{ + name: string; + logo?: string; + }>; + // Community-specific + authorName?: string; + isAnonymous?: boolean; + likeCount?: number; + createdAt?: string; + isLiked?: boolean; + // Template type indicator + type: 'prebuilt' | 'community'; +} + +interface UnifiedTemplatesSectionProps { + prebuiltTemplates: TemplateItem[]; + communityTemplates: TemplateItem[]; + loading?: boolean; + error?: string | null; + onTemplateClick?: (item: TemplateItem) => void; + onRetry?: () => void; + loadingItemId?: string | null; + onLike?: (item: TemplateItem) => void; + onShare?: (item: TemplateItem) => void; + getUniqueTools?: (item: TemplateItem) => Array<{ name: string; logo?: string }>; +} + +export function UnifiedTemplatesSection({ + prebuiltTemplates, + communityTemplates, + loading = false, + error = null, + onTemplateClick, + onRetry, + loadingItemId = null, + onLike, + onShare, + getUniqueTools +}: UnifiedTemplatesSectionProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedType, setSelectedType] = useState<'all' | 'prebuilt' | 'community'>('all'); + const [selectedCategories, setSelectedCategories] = useState>(new Set()); + const [sortBy, setSortBy] = useState<'popular' | 'newest' | 'alphabetical'>('popular'); + + // Combine all templates + const allTemplates = useMemo(() => { + const combined = [ + ...prebuiltTemplates.map(t => ({ ...t, type: 'prebuilt' as const })), + ...communityTemplates.map(t => ({ ...t, type: 'community' as const })) + ]; + return combined; + }, [prebuiltTemplates, communityTemplates]); + + // Get available categories + const availableCategories = useMemo(() => { + const categories = new Set(allTemplates.map(item => item.category)); + return Array.from(categories).sort(); + }, [allTemplates]); + + // Filter and sort templates + const filteredTemplates = useMemo(() => { + let filtered = [...allTemplates]; + + // Apply search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(item => + item.name.toLowerCase().includes(query) || + item.description.toLowerCase().includes(query) || + item.category.toLowerCase().includes(query) + ); + } + + // Apply type filter + if (selectedType !== 'all') { + filtered = filtered.filter(item => item.type === selectedType); + } + + // Apply category filter + if (selectedCategories.size > 0) { + filtered = filtered.filter(item => selectedCategories.has(item.category)); + } + + // Apply sorting + filtered.sort((a, b) => { + switch (sortBy) { + case 'newest': + if (a.createdAt && b.createdAt) { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + return 0; + case 'alphabetical': + return a.name.localeCompare(b.name); + case 'popular': + default: + // For prebuilt templates, use a default order + // For community templates, use like count + if (a.type === 'community' && b.type === 'community') { + return (b.likeCount || 0) - (a.likeCount || 0); + } + if (a.type === 'prebuilt' && b.type === 'prebuilt') { + return a.name.localeCompare(b.name); + } + // Prebuilt templates first, then community + if (a.type === 'prebuilt' && b.type === 'community') return -1; + if (a.type === 'community' && b.type === 'prebuilt') return 1; + return 0; + } + }); + + return filtered; + }, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy]); + + // Handle category toggle + const toggleCategory = (category: string) => { + setSelectedCategories(prev => { + const newSet = new Set(prev); + if (newSet.has(category)) { + newSet.delete(category); + } else { + newSet.add(category); + } + return newSet; + }); + }; + + // Clear all filters + const clearFilters = () => { + setSearchQuery(''); + setSelectedType('all'); + setSelectedCategories(new Set()); + setSortBy('popular'); + }; + + // Check if any filters are active + const hasActiveFilters = useMemo(() => { + return searchQuery || selectedType !== 'all' || selectedCategories.size > 0; + }, [searchQuery, selectedType, selectedCategories]); + + if (loading) { + return ( +
+
+

+ Templates +

+

+ Discover and use pre-built and community templates. +

+
+
+
+
+

Loading templates...

+
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ Templates +

+

+ Discover and use pre-built and community templates. +

+
+
+

{error}

+ {onRetry && ( + + )} +
+
+ ); + } + + return ( +
+
+

+ Templates +

+

+ Discover and use pre-built and community templates. +

+
+ + {/* Filters */} +
+ {/* Search and Type Filters */} +
+
+ setSearchQuery(e.target.value)} + startContent={} + className="max-w-md" + classNames={{ + input: "focus:outline-none focus:ring-0 focus:border-gray-300 dark:focus:border-gray-600", + inputWrapper: "focus-within:ring-0 focus-within:ring-offset-0 focus-within:border-gray-300 dark:focus-within:border-gray-600" + }} + /> +
+ +
+ {/* Type Filter Pills */} +
+ {[ + { key: 'all', label: 'All', count: allTemplates.length }, + { key: 'prebuilt', label: 'Library', count: prebuiltTemplates.length }, + { key: 'community', label: 'Community', count: communityTemplates.length } + ].map(({ key, label, count }) => ( + + ))} +
+ + {/* Sort Dropdown */} +
+ +
+ + + +
+
+
+
+ + {/* Category Filters */} +
+
+ + Categories: +
+ {availableCategories.map((category) => ( + + ))} +
+ + {/* Clear Filters Button */} + {hasActiveFilters && ( +
+ +
+ )} +
+ + {/* Results */} +
+ {filteredTemplates.length === 0 ? ( +
+

+ {searchQuery || selectedType !== 'all' || selectedCategories.size > 0 + ? 'No templates found matching your filters' + : 'No templates available' + } +

+ {(searchQuery || selectedType !== 'all' || selectedCategories.size > 0) && ( + + )} +
+ ) : ( + <> +
+ Showing {filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''} +
+
+ {filteredTemplates.map((item) => ( + onTemplateClick?.(item)} + loading={loadingItemId === item.id} + getUniqueTools={getUniqueTools} + onLike={() => onLike?.(item)} + onShare={() => onShare?.(item)} + isLiked={item.isLiked} + templateType={item.type} + /> + ))} +
+ + )} +
+
+ ); +} diff --git a/apps/rowboat/components/community/CommunitySection.tsx b/apps/rowboat/components/community/CommunitySection.tsx index a4e3c533..8f68a59e 100644 --- a/apps/rowboat/components/community/CommunitySection.tsx +++ b/apps/rowboat/components/community/CommunitySection.tsx @@ -25,7 +25,6 @@ interface CommunityAssistant { likes: string[]; copilotPrompt?: string; thumbnailUrl?: string | null; - estimatedComplexity: 'beginner' | 'intermediate' | 'advanced'; } interface CommunitySectionProps { @@ -48,8 +47,8 @@ export function CommunitySection({ onImport }: CommunitySectionProps) { const params = new URLSearchParams(); if (filters?.searchQuery) params.append('search', filters.searchQuery); if (filters?.selectedCategory) params.append('category', filters.selectedCategory); - - const url = `/api/community-assistants?${params}`; + params.append('source', 'community'); + const url = `/api/assistant-templates?${params}`; const response = await fetch(url); if (!response.ok) throw new Error('Failed to fetch assistants'); @@ -107,7 +106,7 @@ export function CommunitySection({ onImport }: CommunitySectionProps) { try { const guestId = getGuestId(); - const response = await fetch(`/api/community-assistants/${assistant.id}/like`, { + const response = await fetch(`/api/assistant-templates/${assistant.id}/like`, { method: 'POST', headers: { 'x-guest-id': guestId, @@ -144,7 +143,7 @@ export function CommunitySection({ onImport }: CommunitySectionProps) { const assistant = assistants.find(a => a.id === item.id); if (!assistant) return; - const url = `${window.location.origin}/community-assistants/${assistant.id}`; + const url = `${window.location.origin}/assistant-templates/${assistant.id}`; navigator.clipboard.writeText(url).then(() => { // You could add a toast notification here console.log('URL copied to clipboard'); diff --git a/apps/rowboat/src/application/use-cases/projects/create-project.use-case.ts b/apps/rowboat/src/application/use-cases/projects/create-project.use-case.ts index 283d8602..097fdbba 100644 --- a/apps/rowboat/src/application/use-cases/projects/create-project.use-case.ts +++ b/apps/rowboat/src/application/use-cases/projects/create-project.use-case.ts @@ -95,6 +95,29 @@ export class CreateProjectUseCase implements ICreateProjectUseCase { } } + // Ensure the Gemini Image tool is always available by default + const hasImageTool = (workflow.tools || []).some(t => t.isGeminiImage || t.name === 'Generate Image'); + if (!hasImageTool) { + const imageTool = { + name: 'Generate Image', + description: 'Generate an image using Google Gemini given a text prompt. Returns base64-encoded image data and any text parts.', + isGeminiImage: true, + parameters: { + type: 'object' as const, + properties: { + prompt: { type: 'string', description: 'Text prompt describing the image to generate' }, + modelName: { type: 'string', description: 'Optional Gemini model override' }, + }, + required: ['prompt'], + additionalProperties: true, + }, + }; + workflow = { + ...workflow, + tools: [...(workflow.tools || []), imageTool] as any, + }; + } + // create project secret const secret = crypto.randomBytes(32).toString('hex'); diff --git a/apps/rowboat/src/entities/models/community-assistant.ts b/apps/rowboat/src/entities/models/assistant-template.ts similarity index 61% rename from apps/rowboat/src/entities/models/community-assistant.ts rename to apps/rowboat/src/entities/models/assistant-template.ts index 98069d53..bd528bb5 100644 --- a/apps/rowboat/src/entities/models/community-assistant.ts +++ b/apps/rowboat/src/entities/models/assistant-template.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { Workflow } from "../../../app/lib/types/workflow_types"; -export const CommunityAssistant = z.object({ +export const AssistantTemplate = z.object({ id: z.string(), name: z.string(), description: z.string(), @@ -19,21 +19,24 @@ export const CommunityAssistant = z.object({ featured: z.boolean().default(false), isPublic: z.boolean().default(true), // Social features - likes: z.array(z.string()).default([]), // Array of user IDs who liked it + likes: z.array(z.string()).default([]), // Template-like metadata copilotPrompt: z.string().optional(), thumbnailUrl: z.string().optional(), - estimatedComplexity: z.enum(['beginner', 'intermediate', 'advanced']).default('beginner'), + // New field to indicate source of template + source: z.enum(["library", "community"]), }); -export type CommunityAssistant = z.infer; +export type AssistantTemplate = z.infer; -export const CommunityAssistantLike = z.object({ +export const AssistantTemplateLike = z.object({ id: z.string(), assistantId: z.string(), - userId: z.string(), // Can be guest ID for anonymous users - userEmail: z.string().optional(), // For logged-in users + userId: z.string(), + userEmail: z.string().optional(), createdAt: z.string().datetime(), }); -export type CommunityAssistantLike = z.infer; +export type AssistantTemplateLike = z.infer; + + diff --git a/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts b/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts new file mode 100644 index 00000000..896473cf --- /dev/null +++ b/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import { Filter, ObjectId } from "mongodb"; +import { db } from "@/app/lib/mongodb"; +import { AssistantTemplate, AssistantTemplateLike } from "@/src/entities/models/assistant-template"; +import { PaginatedList } from "@/src/entities/common/paginated-list"; + +const DocSchema = AssistantTemplate.omit({ id: true }); +const LikeDocSchema = AssistantTemplateLike.omit({ id: true }); + +export class MongoDBAssistantTemplatesRepository { + private readonly collection = db.collection>("assistant_templates"); + private readonly likesCollection = db.collection>("assistant_template_likes"); + + async create(data: Omit, 'id' | 'publishedAt' | 'lastUpdatedAt'>): Promise> { + const now = new Date().toISOString(); + const _id = new ObjectId(); + const doc: z.infer = { ...data, publishedAt: now, lastUpdatedAt: now } as any; + await this.collection.insertOne({ ...doc, _id }); + return { ...doc, id: _id.toString() } as any; + } + + async fetch(id: string): Promise | null> { + const result = await this.collection.findOne({ _id: new ObjectId(id) }); + if (!result) return null; + return { ...result, id: result._id.toString() } as any; + } + + async list(filters: { + category?: string; + search?: string; + featured?: boolean; + isPublic?: boolean; + authorId?: string; + source?: 'library' | 'community'; + } = {}, cursor?: string, limit: number = 20): Promise>>> { + const query: Filter> = {}; + if (filters.category) query.category = filters.category; + if (filters.featured !== undefined) query.featured = filters.featured; + if (filters.isPublic !== undefined) query.isPublic = filters.isPublic; + if (filters.authorId) query.authorId = filters.authorId; + if (filters.source) query.source = filters.source; + if (filters.search) { + query.$or = [ + { name: { $regex: filters.search, $options: 'i' } }, + { description: { $regex: filters.search, $options: 'i' } }, + { tags: { $in: [new RegExp(filters.search, 'i')] } }, + ]; + } + + const skip = cursor ? parseInt(cursor) : 0; + const results = await this.collection.find(query).sort({ publishedAt: -1 }).skip(skip).limit(limit).toArray(); + const items = results.map(r => ({ ...r, id: r._id.toString() })); + const nextCursor = results.length === limit ? (skip + limit).toString() : null; + return { items, nextCursor } as any; + } + + async toggleLike(assistantId: string, userId: string, userEmail?: string): Promise<{ liked: boolean; likeCount: number }> { + const existingLike = await this.likesCollection.findOne({ assistantId, userId }); + if (existingLike) { + await this.likesCollection.deleteOne({ _id: existingLike._id }); + await this.collection.updateOne({ _id: new ObjectId(assistantId) }, { $inc: { likeCount: -1 }, $pull: { likes: userId } }); + return { liked: false, likeCount: await this.getLikeCount(assistantId) }; + } else { + const now = new Date().toISOString(); + await this.likesCollection.insertOne({ assistantId, userId, userEmail, createdAt: now } as any); + await this.collection.updateOne({ _id: new ObjectId(assistantId) }, { $inc: { likeCount: 1 }, $addToSet: { likes: userId } }); + return { liked: true, likeCount: await this.getLikeCount(assistantId) }; + } + } + + async getLikeCount(assistantId: string): Promise { + const result = await this.collection.findOne({ _id: new ObjectId(assistantId) }, { projection: { likeCount: 1 } }); + return result?.likeCount || 0; + } + + async getCategories(): Promise { + const categories = await this.collection.distinct('category', { isPublic: true }); + return categories.filter(Boolean); + } +} + + diff --git a/apps/rowboat/src/infrastructure/repositories/mongodb.community-assistants.repository.ts b/apps/rowboat/src/infrastructure/repositories/mongodb.community-assistants.repository.ts deleted file mode 100644 index 8c3badd1..00000000 --- a/apps/rowboat/src/infrastructure/repositories/mongodb.community-assistants.repository.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { z } from "zod"; -import { Filter, ObjectId } from "mongodb"; -import { db } from "@/app/lib/mongodb"; -import { CommunityAssistant, CommunityAssistantLike } from "@/src/entities/models/community-assistant"; -import { PaginatedList } from "@/src/entities/common/paginated-list"; -import { NotFoundError } from "@/src/entities/errors/common"; - -/** - * MongoDB document schema for CommunityAssistant. - * Excludes the 'id' field as it's represented by MongoDB's '_id'. - */ -const DocSchema = CommunityAssistant.omit({ id: true }); - -/** - * MongoDB document schema for CommunityAssistantLike. - */ -const LikeDocSchema = CommunityAssistantLike.omit({ id: true }); - -/** - * MongoDB implementation of the CommunityAssistants repository. - */ -export class MongoDBCommunityAssistantsRepository { - private readonly collection = db.collection>("community_assistants"); - private readonly likesCollection = db.collection>("community_assistant_likes"); - - async create(data: Omit, 'id'>): Promise> { - const now = new Date().toISOString(); - const _id = new ObjectId(); - - const doc: z.infer = { - ...data, - publishedAt: now, - lastUpdatedAt: now, - }; - - await this.collection.insertOne({ - ...doc, - _id, - }); - - return { - ...doc, - id: _id.toString(), - }; - } - - async fetch(id: string): Promise | null> { - const result = await this.collection.findOne({ _id: new ObjectId(id) }); - if (!result) return null; - - return { - ...result, - id: result._id.toString(), - }; - } - - async list(filters: { - category?: string; - search?: string; - featured?: boolean; - isPublic?: boolean; - authorId?: string; - } = {}, cursor?: string, limit: number = 20): Promise>>> { - const query: Filter> = {}; - - if (filters.category) { - query.category = filters.category; - } - - if (filters.featured !== undefined) { - query.featured = filters.featured; - } - - if (filters.isPublic !== undefined) { - query.isPublic = filters.isPublic; - } - - if (filters.authorId) { - query.authorId = filters.authorId; - } - - if (filters.search) { - query.$or = [ - { name: { $regex: filters.search, $options: 'i' } }, - { description: { $regex: filters.search, $options: 'i' } }, - { tags: { $in: [new RegExp(filters.search, 'i')] } }, - ]; - } - - const skip = cursor ? parseInt(cursor) : 0; - const results = await this.collection - .find(query) - .sort({ publishedAt: -1 }) - .skip(skip) - .limit(limit) - .toArray(); - - const items = results.map(result => ({ - ...result, - id: result._id.toString(), - })); - - const nextCursor = results.length === limit ? (skip + limit).toString() : null; - - return { - items, - nextCursor, - }; - } - - async update(id: string, data: Partial, 'id' | 'publishedAt'>>): Promise | null> { - const now = new Date().toISOString(); - const updateData = { - ...data, - lastUpdatedAt: now, - }; - - const result = await this.collection.findOneAndUpdate( - { _id: new ObjectId(id) }, - { $set: updateData }, - { returnDocument: 'after' } - ); - - if (!result) return null; - - return { - ...result, - id: result._id.toString(), - }; - } - - async delete(id: string): Promise { - const result = await this.collection.deleteOne({ _id: new ObjectId(id) }); - return result.deletedCount > 0; - } - - async incrementDownloadCount(id: string): Promise { - await this.collection.updateOne( - { _id: new ObjectId(id) }, - { $inc: { downloadCount: 1 } } - ); - } - - async toggleLike(assistantId: string, userId: string, userEmail?: string): Promise<{ liked: boolean; likeCount: number }> { - const likeId = new ObjectId(); - const now = new Date().toISOString(); - - // Check if user already liked this assistant - const existingLike = await this.likesCollection.findOne({ - assistantId: assistantId, - userId, - }); - - if (existingLike) { - // Unlike: remove the like - await this.likesCollection.deleteOne({ _id: existingLike._id }); - await this.collection.updateOne( - { _id: new ObjectId(assistantId) }, - { - $inc: { likeCount: -1 }, - $pull: { likes: userId } - } - ); - return { liked: false, likeCount: await this.getLikeCount(assistantId) }; - } else { - // Like: add the like - await this.likesCollection.insertOne({ - _id: likeId, - assistantId: assistantId, - userId, - userEmail, - createdAt: now, - }); - await this.collection.updateOne( - { _id: new ObjectId(assistantId) }, - { - $inc: { likeCount: 1 }, - $addToSet: { likes: userId } - } - ); - return { liked: true, likeCount: await this.getLikeCount(assistantId) }; - } - } - - async getLikeCount(assistantId: string): Promise { - const result = await this.collection.findOne( - { _id: new ObjectId(assistantId) }, - { projection: { likeCount: 1 } } - ); - return result?.likeCount || 0; - } - - async getUserLikes(assistantId: string, userId: string): Promise { - const like = await this.likesCollection.findOne({ - assistantId: assistantId, - userId, - }); - return !!like; - } - - async getCategories(): Promise { - const categories = await this.collection.distinct('category', { isPublic: true }); - return categories.filter(Boolean); - } -}