From ef26c7b3176c86ed2e80d80ecdc10dc32e6e57d6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 7 Jan 2026 20:12:56 -0800 Subject: [PATCH 1/3] feat: add Addie CMS tools for perspectives management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new tools for Addie to support content management workflow: Member-level tools: - create_perspective: Create draft articles/links (saved as draft for admin review) - get_my_perspectives: View user's own perspectives by status Admin-level tools: - list_perspective_drafts: View drafts awaiting publication - publish_perspective: Publish a draft to make it live - update_perspective: Edit any perspective's content/metadata - archive_perspective: Remove content from public view Also adds: - GET /api/me/perspectives endpoint for listing user's perspectives - POST /api/me/perspectives endpoint for creating drafts Closes #670 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/src/addie/mcp/admin-tools.ts | 362 +++++++++++++++++++++++++++ server/src/addie/mcp/member-tools.ts | 184 ++++++++++++++ server/src/http.ts | 133 ++++++++++ tests/addie/admin-tools.test.ts | 217 ++++++++++++++++ tests/addie/member-tools.test.ts | 77 ++++++ 5 files changed, 973 insertions(+) create mode 100644 tests/addie/admin-tools.test.ts diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 2616576105..5aa991203e 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -1701,6 +1701,123 @@ Returns: Search counts, impressions, clicks, introduction requests/sent, top sea }, }, }, + + // ============================================ + // PERSPECTIVE / CMS MANAGEMENT TOOLS + // ============================================ + { + name: 'list_perspective_drafts', + description: `List all perspective drafts waiting for review and publication. + +USE THIS when admin asks: +- "What drafts are waiting for review?" +- "Show me pending articles" +- "Are there any perspectives to publish?" + +Returns perspectives with status='draft' sorted by creation date.`, + usage_hints: 'Use to see what content needs admin review before publishing.', + input_schema: { + type: 'object' as const, + properties: { + limit: { + type: 'number', + description: 'Maximum number to return (default: 20)', + }, + }, + }, + }, + { + name: 'publish_perspective', + description: `Publish a draft perspective to make it live on the website. + +USE THIS when admin wants to: +- Publish a member's draft article +- Make content live on the site +- Approve submitted content + +Changes status from 'draft' to 'published' and sets published_at.`, + usage_hints: 'Use after reviewing a draft. Get the ID from list_perspective_drafts.', + input_schema: { + type: 'object' as const, + properties: { + perspective_id: { + type: 'string', + description: 'UUID of the perspective to publish', + }, + }, + required: ['perspective_id'], + }, + }, + { + name: 'update_perspective', + description: `Update an existing perspective's content or metadata. + +USE THIS when admin wants to: +- Edit an article's title, content, or excerpt +- Fix typos or errors +- Update category or tags +- Change author information + +Can update both draft and published perspectives.`, + usage_hints: 'Use to make edits to any perspective. Get the ID from list_perspective_drafts or list_perspectives.', + input_schema: { + type: 'object' as const, + properties: { + perspective_id: { + type: 'string', + description: 'UUID of the perspective to update', + }, + title: { + type: 'string', + description: 'New title (optional)', + }, + content: { + type: 'string', + description: 'New content in markdown (optional)', + }, + excerpt: { + type: 'string', + description: 'New excerpt/summary (optional)', + }, + category: { + type: 'string', + description: 'New category (optional)', + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'New tags array (optional)', + }, + author_name: { + type: 'string', + description: 'New author name (optional)', + }, + }, + required: ['perspective_id'], + }, + }, + { + name: 'archive_perspective', + description: `Archive a perspective to remove it from public view without deleting. + +USE THIS when admin wants to: +- Remove outdated content from the site +- Hide content that's no longer relevant +- Unpublish without deleting + +Changes status to 'archived'. Can be reversed by updating status back to 'published'.`, + usage_hints: 'Use to remove content from public view. It can be unarchived later.', + input_schema: { + type: 'object' as const, + properties: { + perspective_id: { + type: 'string', + description: 'UUID of the perspective to archive', + }, + }, + required: ['perspective_id'], + }, + }, ]; /** @@ -6194,5 +6311,250 @@ Use add_committee_leader to assign a leader.`; } }); + // ============================================ + // PERSPECTIVE / CMS MANAGEMENT HANDLERS + // ============================================ + + handlers.set('list_perspective_drafts', async (input) => { + const adminError = requireAdminFromContext(); + if (adminError) return adminError; + + try { + const limit = Math.min((input.limit as number) || 20, 50); + const pool = getPool(); + + const result = await pool.query( + `SELECT id, slug, content_type, title, excerpt, author_name, author_user_id, + category, tags, created_at + FROM perspectives + WHERE status = 'draft' AND working_group_id IS NULL + ORDER BY created_at DESC + LIMIT $1`, + [limit] + ); + + if (result.rows.length === 0) { + return '📋 No perspective drafts pending review.'; + } + + let response = `## Perspective Drafts Pending Review\n\n`; + response += `Found ${result.rows.length} draft(s) awaiting publication:\n\n`; + + for (const p of result.rows) { + response += `### 📝 ${p.title}\n`; + response += `**ID:** \`${p.id}\`\n`; + response += `**Type:** ${p.content_type} | **Author:** ${p.author_name || 'Unknown'}\n`; + response += `**Created:** ${new Date(p.created_at).toLocaleDateString()}\n`; + if (p.category) response += `**Category:** ${p.category}\n`; + if (p.tags && p.tags.length > 0) response += `**Tags:** ${p.tags.join(', ')}\n`; + if (p.excerpt) response += `**Excerpt:** ${p.excerpt}\n`; + response += `\n`; + } + + response += `\n_Use \`publish_perspective\` with the ID to publish a draft._`; + + return response; + } catch (error) { + logger.error({ error }, 'Error listing perspective drafts'); + return '❌ Failed to list perspective drafts. Please try again.'; + } + }); + + handlers.set('publish_perspective', async (input) => { + const adminError = requireAdminFromContext(); + if (adminError) return adminError; + + const perspectiveId = input.perspective_id as string; + + if (!perspectiveId) { + return '❌ perspective_id is required.'; + } + + try { + const pool = getPool(); + + // First check if perspective exists and is a draft + const checkResult = await pool.query( + `SELECT id, slug, title, status, author_name FROM perspectives WHERE id = $1`, + [perspectiveId] + ); + + if (checkResult.rows.length === 0) { + return `❌ Perspective with ID \`${perspectiveId}\` not found.`; + } + + const perspective = checkResult.rows[0]; + + if (perspective.status === 'published') { + return `â„šī¸ "${perspective.title}" is already published.`; + } + + // Publish the perspective + const result = await pool.query( + `UPDATE perspectives + SET status = 'published', published_at = NOW(), updated_at = NOW() + WHERE id = $1 + RETURNING id, slug, title, status, published_at`, + [perspectiveId] + ); + + const published = result.rows[0]; + + let response = `✅ **Published successfully!**\n\n`; + response += `**Title:** ${published.title}\n`; + response += `**Author:** ${perspective.author_name || 'Unknown'}\n`; + response += `**Published:** ${new Date(published.published_at).toLocaleString()}\n`; + response += `**URL:** https://agenticadvertising.org/perspectives/${published.slug}`; + + logger.info({ perspectiveId, title: published.title }, 'Perspective published by admin'); + + return response; + } catch (error) { + logger.error({ error, perspectiveId }, 'Error publishing perspective'); + return '❌ Failed to publish perspective. Please try again.'; + } + }); + + handlers.set('update_perspective', async (input) => { + const adminError = requireAdminFromContext(); + if (adminError) return adminError; + + const perspectiveId = input.perspective_id as string; + + if (!perspectiveId) { + return '❌ perspective_id is required.'; + } + + try { + const pool = getPool(); + + // Check if perspective exists + const checkResult = await pool.query( + `SELECT id, title FROM perspectives WHERE id = $1`, + [perspectiveId] + ); + + if (checkResult.rows.length === 0) { + return `❌ Perspective with ID \`${perspectiveId}\` not found.`; + } + + // Build update query dynamically based on provided fields + const updates: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (input.title !== undefined) { + updates.push(`title = $${paramIndex++}`); + values.push(input.title); + } + if (input.content !== undefined) { + updates.push(`content = $${paramIndex++}`); + values.push(input.content); + } + if (input.excerpt !== undefined) { + updates.push(`excerpt = $${paramIndex++}`); + values.push(input.excerpt); + } + if (input.category !== undefined) { + updates.push(`category = $${paramIndex++}`); + values.push(input.category); + } + if (input.tags !== undefined) { + updates.push(`tags = $${paramIndex++}`); + values.push(input.tags); + } + if (input.author_name !== undefined) { + updates.push(`author_name = $${paramIndex++}`); + values.push(input.author_name); + } + + if (updates.length === 0) { + return '❌ No fields to update. Provide at least one field (title, content, excerpt, category, tags, author_name).'; + } + + updates.push(`updated_at = NOW()`); + values.push(perspectiveId); + + const query = ` + UPDATE perspectives + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING id, slug, title, status + `; + + const result = await pool.query(query, values); + const updated = result.rows[0]; + + const updatedFields = Object.keys(input).filter(k => k !== 'perspective_id'); + + let response = `✅ **Perspective updated!**\n\n`; + response += `**Title:** ${updated.title}\n`; + response += `**Status:** ${updated.status}\n`; + response += `**Updated fields:** ${updatedFields.join(', ')}`; + + logger.info({ perspectiveId, updatedFields }, 'Perspective updated by admin'); + + return response; + } catch (error) { + logger.error({ error, perspectiveId }, 'Error updating perspective'); + return '❌ Failed to update perspective. Please try again.'; + } + }); + + handlers.set('archive_perspective', async (input) => { + const adminError = requireAdminFromContext(); + if (adminError) return adminError; + + const perspectiveId = input.perspective_id as string; + + if (!perspectiveId) { + return '❌ perspective_id is required.'; + } + + try { + const pool = getPool(); + + // Check if perspective exists + const checkResult = await pool.query( + `SELECT id, title, status, author_name FROM perspectives WHERE id = $1`, + [perspectiveId] + ); + + if (checkResult.rows.length === 0) { + return `❌ Perspective with ID \`${perspectiveId}\` not found.`; + } + + const perspective = checkResult.rows[0]; + + if (perspective.status === 'archived') { + return `â„šī¸ "${perspective.title}" is already archived.`; + } + + // Archive the perspective + const result = await pool.query( + `UPDATE perspectives + SET status = 'archived', updated_at = NOW() + WHERE id = $1 + RETURNING id, slug, title, status`, + [perspectiveId] + ); + + const archived = result.rows[0]; + + let response = `✅ **Perspective archived!**\n\n`; + response += `**Title:** ${archived.title}\n`; + response += `**Author:** ${perspective.author_name || 'Unknown'}\n`; + response += `**Previous status:** ${perspective.status}\n\n`; + response += `_The perspective is no longer visible on the public site. Use \`update_perspective\` to change status back to 'published' if needed._`; + + logger.info({ perspectiveId, title: archived.title, previousStatus: perspective.status }, 'Perspective archived by admin'); + + return response; + } catch (error) { + logger.error({ error, perspectiveId }, 'Error archiving perspective'); + return '❌ Failed to archive perspective. Please try again.'; + } + }); + return handlers; } diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 1603c986f9..432111d892 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -353,6 +353,73 @@ export const MEMBER_TOOLS: AddieTool[] = [ required: ['working_group_slug', 'title', 'content'], }, }, + { + name: 'create_perspective', + description: + 'Create a new perspective (article or link) as a draft. The current user will be set as the author. Drafts can be published later by an admin. Use this when a member wants to write content for AgenticAdvertising.org.', + usage_hints: 'use for "write an article", "create a post", "submit content", "draft a perspective"', + input_schema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Title of the perspective', + }, + content: { + type: 'string', + description: 'Content in markdown format (for articles)', + }, + content_type: { + type: 'string', + enum: ['article', 'link'], + description: 'Type of content: article (original content) or link (external article). Default: article', + }, + excerpt: { + type: 'string', + description: 'Short summary/excerpt for the perspective (optional, auto-generated if not provided)', + }, + external_url: { + type: 'string', + description: 'URL for link-type perspectives (required if content_type is link)', + }, + external_site_name: { + type: 'string', + description: 'Name of the external site (for link-type, e.g., "AdExchanger", "TechCrunch")', + }, + category: { + type: 'string', + description: 'Category for the perspective (e.g., "opinion", "research", "announcement")', + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Tags for the perspective (e.g., ["ctv", "measurement", "privacy"])', + }, + }, + required: ['title'], + }, + }, + { + name: 'get_my_perspectives', + description: + "Get the current user's perspectives (articles/posts they've authored). Shows drafts, published, and archived content.", + usage_hints: 'use for "show my articles", "what have I written", "my drafts", "my perspectives"', + input_schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['draft', 'published', 'archived', 'all'], + description: 'Filter by status. Default: all', + }, + limit: { + type: 'number', + description: 'Maximum number to return (default 10)', + }, + }, + required: [], + }, + }, // ============================================ // ACCOUNT LINKING @@ -1183,6 +1250,123 @@ export function createMemberToolHandlers( return `✅ Post created successfully in the "${slug}" working group!\n\n**Title:** ${title}\n\nYour post is now visible to other working group members.`; }); + handlers.set('create_perspective', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to create perspectives. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const title = input.title as string; + const content = input.content as string | undefined; + const contentType = (input.content_type as string) || 'article'; + const excerpt = input.excerpt as string | undefined; + const externalUrl = input.external_url as string | undefined; + const externalSiteName = input.external_site_name as string | undefined; + const category = input.category as string | undefined; + const tags = input.tags as string[] | undefined; + + // Validate content_type requirements + if (contentType === 'link' && !externalUrl) { + return 'For link-type perspectives, an external_url is required.'; + } + + if (contentType === 'article' && !content) { + return 'For article-type perspectives, content is required.'; + } + + // Get user's name for author info + const userName = memberContext.workos_user.first_name && memberContext.workos_user.last_name + ? `${memberContext.workos_user.first_name} ${memberContext.workos_user.last_name}` + : memberContext.workos_user.email?.split('@')[0] || 'Anonymous'; + + const body: Record = { + title, + content, + content_type: contentType, + excerpt, + external_url: externalUrl, + external_site_name: externalSiteName, + category, + tags: tags || [], + author_name: userName, + author_user_id: memberContext.workos_user.workos_user_id, + status: 'draft', // Always create as draft + }; + + const result = await callApi('POST', '/api/me/perspectives', memberContext, body); + + if (!result.ok) { + return `Failed to create perspective: ${result.error}`; + } + + const perspective = result.data as { id: string; slug: string; title: string }; + + let response = `✅ **Perspective draft created!**\n\n`; + response += `**Title:** ${title}\n`; + response += `**Type:** ${contentType}\n`; + response += `**Status:** Draft\n\n`; + response += `Your perspective has been saved as a draft. An admin will review and publish it.\n`; + response += `You can view and edit your drafts using the \`get_my_perspectives\` command.`; + + return response; + }); + + handlers.set('get_my_perspectives', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to see your perspectives. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const statusFilter = (input.status as string) || 'all'; + const limit = (input.limit as number) || 10; + + let queryParams = `limit=${limit}`; + if (statusFilter && statusFilter !== 'all') { + queryParams += `&status=${encodeURIComponent(statusFilter)}`; + } + + const result = await callApi('GET', `/api/me/perspectives?${queryParams}`, memberContext); + + if (!result.ok) { + return `Failed to fetch your perspectives: ${result.error}`; + } + + const perspectives = result.data as Array<{ + id: string; + slug: string; + title: string; + content_type: string; + status: string; + created_at: string; + published_at?: string; + excerpt?: string; + }>; + + if (perspectives.length === 0) { + const statusLabel = statusFilter !== 'all' ? ` with status "${statusFilter}"` : ''; + return `You don't have any perspectives${statusLabel} yet. Use \`create_perspective\` to write one!`; + } + + let response = `## Your Perspectives\n\n`; + perspectives.forEach((p) => { + const statusEmoji = p.status === 'published' ? '✅' : p.status === 'draft' ? '📝' : 'đŸ“Ļ'; + response += `### ${statusEmoji} ${p.title}\n`; + response += `**Type:** ${p.content_type} | **Status:** ${p.status}\n`; + response += `**Created:** ${new Date(p.created_at).toLocaleDateString()}`; + if (p.published_at) { + response += ` | **Published:** ${new Date(p.published_at).toLocaleDateString()}`; + } + response += `\n`; + if (p.excerpt) { + response += `${p.excerpt}\n`; + } + if (p.status === 'published') { + response += `**Read more:** https://agenticadvertising.org/perspectives/${p.slug}\n`; + } + response += `\n`; + }); + + return response; + }); + // ============================================ // ACCOUNT LINKING // ============================================ diff --git a/server/src/http.ts b/server/src/http.ts index ec84dd3365..0c56faf761 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -4885,6 +4885,139 @@ Disallow: /api/admin/ } }); + // ======================================== + // User Perspectives Routes (for Addie CMS tools) + // ======================================== + + // GET /api/me/perspectives - Get current user's perspectives + this.app.get('/api/me/perspectives', requireAuth, async (req, res) => { + try { + const user = req.user!; + const status = req.query.status as string | undefined; + const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); + + const pool = getPool(); + + let query = ` + SELECT id, slug, content_type, title, subtitle, category, excerpt, + external_url, external_site_name, author_name, author_title, + status, published_at, created_at, updated_at, tags + FROM perspectives + WHERE author_user_id = $1 AND working_group_id IS NULL + `; + const params: (string | number)[] = [user.id]; + + if (status && status !== 'all') { + query += ` AND status = $${params.length + 1}`; + params.push(status); + } + + query += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`; + params.push(limit); + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + logger.error({ err: error }, 'GET /api/me/perspectives error'); + res.status(500).json({ + error: 'Failed to get perspectives', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/me/perspectives - Create a new perspective (as draft) + this.app.post('/api/me/perspectives', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { + title, + content, + content_type = 'article', + excerpt, + external_url, + external_site_name, + category, + tags = [], + author_name, + author_user_id, + } = req.body; + + if (!title) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'title is required', + }); + } + + // Validate content_type + const validContentTypes = ['article', 'link']; + if (!validContentTypes.includes(content_type)) { + return res.status(400).json({ + error: 'Invalid content_type', + message: 'content_type must be: article or link', + }); + } + + // Validate content_type requirements + if (content_type === 'link' && !external_url) { + return res.status(400).json({ + error: 'Missing external_url', + message: 'external_url is required for link type perspectives', + }); + } + + if (content_type === 'article' && !content) { + return res.status(400).json({ + error: 'Missing content', + message: 'content is required for article type perspectives', + }); + } + + // Generate slug from title + const baseSlug = title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .substring(0, 100); + + // Add timestamp suffix to make unique + const slug = `${baseSlug}-${Date.now().toString(36)}`; + + // Use provided author info or derive from user + const finalAuthorName = author_name || + (user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.email?.split('@')[0] || 'Anonymous'); + const finalAuthorUserId = author_user_id || user.id; + + const pool = getPool(); + const result = await pool.query( + `INSERT INTO perspectives ( + slug, content_type, title, content, excerpt, + external_url, external_site_name, category, tags, + author_name, author_user_id, status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'draft') + RETURNING *`, + [ + slug, content_type, title, content, excerpt, + external_url, external_site_name, category, tags, + finalAuthorName, finalAuthorUserId, + ] + ); + + logger.info({ perspectiveId: result.rows[0].id, userId: user.id, title }, 'User created perspective draft'); + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error({ err: error }, 'POST /api/me/perspectives error'); + res.status(500).json({ + error: 'Failed to create perspective', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + // GET /api/me/agreements - Get user's agreement acceptance history this.app.get('/api/me/agreements', requireAuth, async (req, res) => { try { diff --git a/tests/addie/admin-tools.test.ts b/tests/addie/admin-tools.test.ts new file mode 100644 index 0000000000..f75b9b7f15 --- /dev/null +++ b/tests/addie/admin-tools.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for Addie admin tools + * + * Tests the tool definitions and handler logic that can be tested + * without external dependencies (API calls, database, etc.) + */ + +import { describe, it, expect, beforeAll, jest } from '@jest/globals'; + +// Set required environment variables before any imports that depend on them +process.env.WORKOS_API_KEY = 'sk_test_mock_workos_key'; +process.env.WORKOS_CLIENT_ID = 'client_mock_id'; +process.env.WORKOS_REDIRECT_URI = 'http://localhost:3000/callback'; + +// Mock the db client to prevent actual database connections +jest.mock('../../server/src/db/client.js', () => ({ + getPool: jest.fn().mockReturnValue({ + query: jest.fn().mockRejectedValue(new Error('Database not available in tests')), + }), + query: jest.fn().mockRejectedValue(new Error('Database not available in tests')), +})); + +// Import the tool definitions after mocks are set up +import { ADMIN_TOOLS, createAdminToolHandlers } from '../../server/src/addie/mcp/admin-tools.js'; + +describe('ADMIN_TOOLS definitions', () => { + it('exports an array of tools', () => { + expect(Array.isArray(ADMIN_TOOLS)).toBe(true); + expect(ADMIN_TOOLS.length).toBeGreaterThan(0); + }); + + it('all tools have required properties', () => { + for (const tool of ADMIN_TOOLS) { + expect(tool).toHaveProperty('name'); + expect(tool).toHaveProperty('description'); + expect(tool).toHaveProperty('input_schema'); + expect(typeof tool.name).toBe('string'); + expect(typeof tool.description).toBe('string'); + expect(tool.input_schema).toHaveProperty('type', 'object'); + expect(tool.input_schema).toHaveProperty('properties'); + } + }); + + // ============================================ + // PERSPECTIVE / CMS TOOLS + // ============================================ + + it('has list_perspective_drafts tool', () => { + const tool = ADMIN_TOOLS.find(t => t.name === 'list_perspective_drafts'); + expect(tool).toBeDefined(); + expect(tool?.input_schema.properties).toHaveProperty('limit'); + expect(tool?.description).toContain('drafts'); + expect(tool?.description).toContain('review'); + }); + + it('has publish_perspective tool', () => { + const tool = ADMIN_TOOLS.find(t => t.name === 'publish_perspective'); + expect(tool).toBeDefined(); + expect(tool?.input_schema.properties).toHaveProperty('perspective_id'); + expect(tool?.input_schema.required).toContain('perspective_id'); + expect(tool?.description).toContain('Publish'); + }); + + it('has update_perspective tool', () => { + const tool = ADMIN_TOOLS.find(t => t.name === 'update_perspective'); + expect(tool).toBeDefined(); + expect(tool?.input_schema.properties).toHaveProperty('perspective_id'); + expect(tool?.input_schema.properties).toHaveProperty('title'); + expect(tool?.input_schema.properties).toHaveProperty('content'); + expect(tool?.input_schema.properties).toHaveProperty('excerpt'); + expect(tool?.input_schema.properties).toHaveProperty('category'); + expect(tool?.input_schema.properties).toHaveProperty('tags'); + expect(tool?.input_schema.properties).toHaveProperty('author_name'); + expect(tool?.input_schema.required).toContain('perspective_id'); + }); + + it('has archive_perspective tool', () => { + const tool = ADMIN_TOOLS.find(t => t.name === 'archive_perspective'); + expect(tool).toBeDefined(); + expect(tool?.input_schema.properties).toHaveProperty('perspective_id'); + expect(tool?.input_schema.required).toContain('perspective_id'); + expect(tool?.description).toContain('Archive'); + expect(tool?.description).toContain('remove'); + }); +}); + +describe('createAdminToolHandlers', () => { + it('returns a Map of handlers', () => { + const handlers = createAdminToolHandlers(null); + expect(handlers).toBeInstanceOf(Map); + expect(handlers.size).toBeGreaterThan(0); + }); + + it('has a handler for each tool', () => { + const handlers = createAdminToolHandlers(null); + for (const tool of ADMIN_TOOLS) { + expect(handlers.has(tool.name)).toBe(true); + expect(typeof handlers.get(tool.name)).toBe('function'); + } + }); + + describe('perspective handlers require admin access', () => { + const perspectiveTools = [ + 'list_perspective_drafts', + 'publish_perspective', + 'update_perspective', + 'archive_perspective', + ]; + + it.each(perspectiveTools)('%s returns admin error when not admin', async (toolName) => { + // Non-admin context + const handlers = createAdminToolHandlers({ + is_mapped: true, + is_member: true, + workos_user: { + workos_user_id: 'user_123', + email: 'test@example.com', + }, + org_membership: { + role: 'member', // Not admin + }, + }); + + const handler = handlers.get(toolName)!; + const result = await handler({ perspective_id: 'test-id', limit: 10 }); + + expect(result).toContain('admin access'); + }); + }); + + describe('publish_perspective handler', () => { + it('returns error when perspective_id is missing', async () => { + const handlers = createAdminToolHandlers({ + is_mapped: true, + is_member: true, + workos_user: { + workos_user_id: 'user_123', + email: 'admin@example.com', + }, + org_membership: { + role: 'admin', + }, + }); + + const handler = handlers.get('publish_perspective')!; + const result = await handler({}); + + expect(result).toContain('perspective_id is required'); + }); + }); + + describe('update_perspective handler', () => { + it('returns error when perspective_id is missing', async () => { + const handlers = createAdminToolHandlers({ + is_mapped: true, + is_member: true, + workos_user: { + workos_user_id: 'user_123', + email: 'admin@example.com', + }, + org_membership: { + role: 'admin', + }, + }); + + const handler = handlers.get('update_perspective')!; + const result = await handler({ title: 'New Title' }); + + expect(result).toContain('perspective_id is required'); + }); + + it('returns error when no fields to update', async () => { + const handlers = createAdminToolHandlers({ + is_mapped: true, + is_member: true, + workos_user: { + workos_user_id: 'user_123', + email: 'admin@example.com', + }, + org_membership: { + role: 'admin', + }, + }); + + const handler = handlers.get('update_perspective')!; + // Only providing perspective_id without any fields to update + // Since the handler checks if perspective exists first (requires DB), + // and we don't mock that, the error will be different + // This test just verifies the handler runs without crashing + const result = await handler({ perspective_id: 'test-id' }); + + // Result will contain an error since there's no DB + expect(typeof result).toBe('string'); + }); + }); + + describe('archive_perspective handler', () => { + it('returns error when perspective_id is missing', async () => { + const handlers = createAdminToolHandlers({ + is_mapped: true, + is_member: true, + workos_user: { + workos_user_id: 'user_123', + email: 'admin@example.com', + }, + org_membership: { + role: 'admin', + }, + }); + + const handler = handlers.get('archive_perspective')!; + const result = await handler({}); + + expect(result).toContain('perspective_id is required'); + }); + }); +}); diff --git a/tests/addie/member-tools.test.ts b/tests/addie/member-tools.test.ts index 003f305746..c6c57e8d86 100644 --- a/tests/addie/member-tools.test.ts +++ b/tests/addie/member-tools.test.ts @@ -98,6 +98,39 @@ describe('MEMBER_TOOLS definitions', () => { expect(tool?.input_schema.properties).toHaveProperty('link_url'); }); + it('has create_perspective tool', () => { + const tool = MEMBER_TOOLS.find(t => t.name === 'create_perspective'); + expect(tool).toBeDefined(); + expect(tool?.input_schema.required).toContain('title'); + expect(tool?.input_schema.properties).toHaveProperty('title'); + expect(tool?.input_schema.properties).toHaveProperty('content'); + expect(tool?.input_schema.properties).toHaveProperty('content_type'); + expect(tool?.input_schema.properties).toHaveProperty('excerpt'); + expect(tool?.input_schema.properties).toHaveProperty('external_url'); + expect(tool?.input_schema.properties).toHaveProperty('external_site_name'); + expect(tool?.input_schema.properties).toHaveProperty('category'); + expect(tool?.input_schema.properties).toHaveProperty('tags'); + // content_type should support article and link + const contentTypeProp = tool?.input_schema.properties.content_type as { enum?: string[] }; + expect(contentTypeProp?.enum).toContain('article'); + expect(contentTypeProp?.enum).toContain('link'); + }); + + it('has get_my_perspectives tool', () => { + const tool = MEMBER_TOOLS.find(t => t.name === 'get_my_perspectives'); + expect(tool).toBeDefined(); + expect(tool?.input_schema.properties).toHaveProperty('status'); + expect(tool?.input_schema.properties).toHaveProperty('limit'); + // status should support draft, published, archived, all + const statusProp = tool?.input_schema.properties.status as { enum?: string[] }; + expect(statusProp?.enum).toContain('draft'); + expect(statusProp?.enum).toContain('published'); + expect(statusProp?.enum).toContain('archived'); + expect(statusProp?.enum).toContain('all'); + // All fields are optional + expect(tool?.input_schema.required).toEqual([]); + }); + it('has get_account_link tool', () => { const tool = MEMBER_TOOLS.find(t => t.name === 'get_account_link'); expect(tool).toBeDefined(); @@ -276,6 +309,8 @@ describe('createMemberToolHandlers', () => { 'get_my_profile', 'update_my_profile', 'create_working_group_post', + 'create_perspective', + 'get_my_perspectives', ]; it.each(userScopedTools)('%s returns auth error when not logged in', async (toolName) => { @@ -288,4 +323,46 @@ describe('createMemberToolHandlers', () => { expect(result).toContain('agenticadvertising.org'); }); }); + + describe('create_perspective handler', () => { + it('validates that link-type perspectives require external_url', async () => { + const handlers = createMemberToolHandlers({ + is_mapped: true, + is_member: true, + workos_user: { + workos_user_id: 'user_123', + email: 'test@example.com', + }, + }); + + const handler = handlers.get('create_perspective')!; + const result = await handler({ + title: 'Test Link', + content_type: 'link', + // Missing external_url + }); + + expect(result).toContain('external_url is required'); + }); + + it('validates that article-type perspectives require content', async () => { + const handlers = createMemberToolHandlers({ + is_mapped: true, + is_member: true, + workos_user: { + workos_user_id: 'user_123', + email: 'test@example.com', + }, + }); + + const handler = handlers.get('create_perspective')!; + const result = await handler({ + title: 'Test Article', + content_type: 'article', + // Missing content + }); + + expect(result).toContain('content is required'); + }); + }); }); From ddbc6a9fcbf8dd5cd16e2094637f7ee57e8b7215 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 7 Jan 2026 20:23:27 -0800 Subject: [PATCH 2/3] chore: add changeset for CMS tools feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/addie-cms-tools.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/addie-cms-tools.md diff --git a/.changeset/addie-cms-tools.md b/.changeset/addie-cms-tools.md new file mode 100644 index 0000000000..1c7e065832 --- /dev/null +++ b/.changeset/addie-cms-tools.md @@ -0,0 +1,4 @@ +--- +--- + +Add Addie CMS tools for perspectives management. Members can create draft articles and view their own content. Admins can publish, update, and archive perspectives. From 7c6a18d7b62aa06f564ec749dfad9c49b78b9d6c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 8 Jan 2026 07:01:01 -0800 Subject: [PATCH 3/3] refactor: extract perspectives routes into dedicated modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create server/src/routes/admin/perspectives.ts for admin CRUD operations - Create server/src/routes/perspectives.ts for public and user routes - Remove ~550 lines of inline routes from http.ts - Add security fixes from code review: - Add status parameter validation in user perspectives query - Fix author_user_id override vulnerability (always use authenticated user) - Add URL scheme validation and 10s timeout for fetch-url endpoint - Return 201 status for admin POST (consistency with user route) - Update admin-tools.ts to use API instead of direct DB queries 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/perspectives-routes-refactor.md | 4 + server/src/addie/mcp/admin-tools.ts | 383 ++++++++---- server/src/http.ts | 694 +-------------------- server/src/routes/admin/index.ts | 1 + server/src/routes/admin/perspectives.ts | 427 +++++++++++++ server/src/routes/perspectives.ts | 332 ++++++++++ 6 files changed, 1047 insertions(+), 794 deletions(-) create mode 100644 .changeset/perspectives-routes-refactor.md create mode 100644 server/src/routes/admin/perspectives.ts create mode 100644 server/src/routes/perspectives.ts diff --git a/.changeset/perspectives-routes-refactor.md b/.changeset/perspectives-routes-refactor.md new file mode 100644 index 0000000000..2bb9b2989d --- /dev/null +++ b/.changeset/perspectives-routes-refactor.md @@ -0,0 +1,4 @@ +--- +--- + +Refactor perspectives routes from http.ts into dedicated route files. diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 5aa991203e..c4f24abe82 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -206,6 +206,77 @@ export function isAdmin(memberContext: MemberContext | null): boolean { return memberContext?.org_membership?.role === 'admin'; } +// Admin API key for internal tool calls (bypasses session auth) +const ADMIN_API_KEY = process.env.ADMIN_API_KEY; + +/** + * Get the base URL for internal API calls + * Uses BASE_URL env var in production, falls back to localhost for development + */ +function getBaseUrl(): string { + if (process.env.BASE_URL) { + return process.env.BASE_URL; + } + const port = process.env.PORT || process.env.CONDUCTOR_PORT || '3000'; + return `http://localhost:${port}`; +} + +/** + * Make an authenticated admin API call + * Uses ADMIN_API_KEY for authentication + */ +async function callAdminApi( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: Record +): Promise<{ ok: boolean; status: number; data?: unknown; error?: string }> { + const baseUrl = getBaseUrl(); + const url = `${baseUrl}${path}`; + + if (!ADMIN_API_KEY) { + logger.warn('ADMIN_API_KEY not configured - admin API calls will fail'); + return { + ok: false, + status: 0, + error: 'Admin API key not configured', + }; + } + + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${ADMIN_API_KEY}`, + }; + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(10000), + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorData = data as { error?: string; message?: string }; + return { + ok: false, + status: response.status, + error: errorData.message || errorData.error || `HTTP ${response.status}`, + }; + } + + return { ok: true, status: response.status, data }; + } catch (error) { + logger.error({ error, url, method }, 'Addie: Admin API call failed'); + return { + ok: false, + status: 0, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + /** * Compute the unified lifecycle stage for an organization. * This combines prospect_status and subscription_status into a single view. @@ -6321,26 +6392,43 @@ Use add_committee_leader to assign a leader.`; try { const limit = Math.min((input.limit as number) || 20, 50); - const pool = getPool(); - const result = await pool.query( - `SELECT id, slug, content_type, title, excerpt, author_name, author_user_id, - category, tags, created_at - FROM perspectives - WHERE status = 'draft' AND working_group_id IS NULL - ORDER BY created_at DESC - LIMIT $1`, - [limit] - ); - - if (result.rows.length === 0) { + // Call admin API to get all perspectives + const result = await callAdminApi('GET', '/api/admin/perspectives'); + + if (!result.ok) { + logger.error({ error: result.error }, 'Error fetching perspectives from API'); + return `❌ Failed to list perspective drafts: ${result.error}`; + } + + const allPerspectives = result.data as Array<{ + id: string; + slug: string; + content_type: string; + title: string; + excerpt?: string; + author_name?: string; + category?: string; + tags?: string[]; + created_at: string; + status: string; + working_group_id?: string; + }>; + + // Filter for drafts (excluding working group posts) and apply limit + const drafts = allPerspectives + .filter(p => p.status === 'draft' && !p.working_group_id) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .slice(0, limit); + + if (drafts.length === 0) { return '📋 No perspective drafts pending review.'; } let response = `## Perspective Drafts Pending Review\n\n`; - response += `Found ${result.rows.length} draft(s) awaiting publication:\n\n`; + response += `Found ${drafts.length} draft(s) awaiting publication:\n\n`; - for (const p of result.rows) { + for (const p of drafts) { response += `### 📝 ${p.title}\n`; response += `**ID:** \`${p.id}\`\n`; response += `**Type:** ${p.content_type} | **Author:** ${p.author_name || 'Unknown'}\n`; @@ -6371,39 +6459,72 @@ Use add_committee_leader to assign a leader.`; } try { - const pool = getPool(); + // First get the perspective to check its status and get required fields + const getResult = await callAdminApi('GET', `/api/admin/perspectives/${perspectiveId}`); - // First check if perspective exists and is a draft - const checkResult = await pool.query( - `SELECT id, slug, title, status, author_name FROM perspectives WHERE id = $1`, - [perspectiveId] - ); - - if (checkResult.rows.length === 0) { - return `❌ Perspective with ID \`${perspectiveId}\` not found.`; - } - - const perspective = checkResult.rows[0]; + if (!getResult.ok) { + if (getResult.status === 404) { + return `❌ Perspective with ID \`${perspectiveId}\` not found.`; + } + return `❌ Failed to fetch perspective: ${getResult.error}`; + } + + const perspective = getResult.data as { + id: string; + slug: string; + content_type: string; + title: string; + subtitle?: string; + category?: string; + excerpt?: string; + content?: string; + external_url?: string; + external_site_name?: string; + author_name?: string; + author_title?: string; + featured_image_url?: string; + status: string; + published_at?: string; + display_order?: number; + tags?: string[]; + metadata?: Record; + }; if (perspective.status === 'published') { return `â„šī¸ "${perspective.title}" is already published.`; } - // Publish the perspective - const result = await pool.query( - `UPDATE perspectives - SET status = 'published', published_at = NOW(), updated_at = NOW() - WHERE id = $1 - RETURNING id, slug, title, status, published_at`, - [perspectiveId] - ); + // Publish the perspective via PUT API + const updateResult = await callAdminApi('PUT', `/api/admin/perspectives/${perspectiveId}`, { + slug: perspective.slug, + content_type: perspective.content_type, + title: perspective.title, + subtitle: perspective.subtitle, + category: perspective.category, + excerpt: perspective.excerpt, + content: perspective.content, + external_url: perspective.external_url, + external_site_name: perspective.external_site_name, + author_name: perspective.author_name, + author_title: perspective.author_title, + featured_image_url: perspective.featured_image_url, + status: 'published', + published_at: new Date().toISOString(), + display_order: perspective.display_order, + tags: perspective.tags, + metadata: perspective.metadata, + }); + + if (!updateResult.ok) { + return `❌ Failed to publish perspective: ${updateResult.error}`; + } - const published = result.rows[0]; + const published = updateResult.data as typeof perspective; let response = `✅ **Published successfully!**\n\n`; response += `**Title:** ${published.title}\n`; response += `**Author:** ${perspective.author_name || 'Unknown'}\n`; - response += `**Published:** ${new Date(published.published_at).toLocaleString()}\n`; + response += `**Published:** ${new Date(published.published_at || '').toLocaleString()}\n`; response += `**URL:** https://agenticadvertising.org/perspectives/${published.slug}`; logger.info({ perspectiveId, title: published.title }, 'Perspective published by admin'); @@ -6426,66 +6547,71 @@ Use add_committee_leader to assign a leader.`; } try { - const pool = getPool(); + // First get the perspective to check it exists and get required fields + const getResult = await callAdminApi('GET', `/api/admin/perspectives/${perspectiveId}`); - // Check if perspective exists - const checkResult = await pool.query( - `SELECT id, title FROM perspectives WHERE id = $1`, - [perspectiveId] - ); + if (!getResult.ok) { + if (getResult.status === 404) { + return `❌ Perspective with ID \`${perspectiveId}\` not found.`; + } + return `❌ Failed to fetch perspective: ${getResult.error}`; + } + + const perspective = getResult.data as { + id: string; + slug: string; + content_type: string; + title: string; + subtitle?: string; + category?: string; + excerpt?: string; + content?: string; + external_url?: string; + external_site_name?: string; + author_name?: string; + author_title?: string; + featured_image_url?: string; + status: string; + published_at?: string; + display_order?: number; + tags?: string[]; + metadata?: Record; + }; - if (checkResult.rows.length === 0) { - return `❌ Perspective with ID \`${perspectiveId}\` not found.`; - } + // Check if any fields to update were provided + const updatableFields = ['title', 'content', 'excerpt', 'category', 'tags', 'author_name']; + const updatedFields = updatableFields.filter(k => input[k] !== undefined); - // Build update query dynamically based on provided fields - const updates: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - if (input.title !== undefined) { - updates.push(`title = $${paramIndex++}`); - values.push(input.title); - } - if (input.content !== undefined) { - updates.push(`content = $${paramIndex++}`); - values.push(input.content); - } - if (input.excerpt !== undefined) { - updates.push(`excerpt = $${paramIndex++}`); - values.push(input.excerpt); - } - if (input.category !== undefined) { - updates.push(`category = $${paramIndex++}`); - values.push(input.category); - } - if (input.tags !== undefined) { - updates.push(`tags = $${paramIndex++}`); - values.push(input.tags); - } - if (input.author_name !== undefined) { - updates.push(`author_name = $${paramIndex++}`); - values.push(input.author_name); - } - - if (updates.length === 0) { + if (updatedFields.length === 0) { return '❌ No fields to update. Provide at least one field (title, content, excerpt, category, tags, author_name).'; } - updates.push(`updated_at = NOW()`); - values.push(perspectiveId); - - const query = ` - UPDATE perspectives - SET ${updates.join(', ')} - WHERE id = $${paramIndex} - RETURNING id, slug, title, status - `; + // Merge input updates with existing perspective data + const updateResult = await callAdminApi('PUT', `/api/admin/perspectives/${perspectiveId}`, { + slug: perspective.slug, + content_type: perspective.content_type, + title: input.title !== undefined ? input.title : perspective.title, + subtitle: perspective.subtitle, + category: input.category !== undefined ? input.category : perspective.category, + excerpt: input.excerpt !== undefined ? input.excerpt : perspective.excerpt, + content: input.content !== undefined ? input.content : perspective.content, + external_url: perspective.external_url, + external_site_name: perspective.external_site_name, + author_name: input.author_name !== undefined ? input.author_name : perspective.author_name, + author_title: perspective.author_title, + featured_image_url: perspective.featured_image_url, + status: perspective.status, + published_at: perspective.published_at, + display_order: perspective.display_order, + tags: input.tags !== undefined ? input.tags : perspective.tags, + metadata: perspective.metadata, + }); - const result = await pool.query(query, values); - const updated = result.rows[0]; + if (!updateResult.ok) { + return `❌ Failed to update perspective: ${updateResult.error}`; + } - const updatedFields = Object.keys(input).filter(k => k !== 'perspective_id'); + const updated = updateResult.data as typeof perspective; let response = `✅ **Perspective updated!**\n\n`; response += `**Title:** ${updated.title}\n`; @@ -6512,42 +6638,77 @@ Use add_committee_leader to assign a leader.`; } try { - const pool = getPool(); - - // Check if perspective exists - const checkResult = await pool.query( - `SELECT id, title, status, author_name FROM perspectives WHERE id = $1`, - [perspectiveId] - ); - - if (checkResult.rows.length === 0) { - return `❌ Perspective with ID \`${perspectiveId}\` not found.`; - } + // First get the perspective to check its status and get required fields + const getResult = await callAdminApi('GET', `/api/admin/perspectives/${perspectiveId}`); - const perspective = checkResult.rows[0]; + if (!getResult.ok) { + if (getResult.status === 404) { + return `❌ Perspective with ID \`${perspectiveId}\` not found.`; + } + return `❌ Failed to fetch perspective: ${getResult.error}`; + } + + const perspective = getResult.data as { + id: string; + slug: string; + content_type: string; + title: string; + subtitle?: string; + category?: string; + excerpt?: string; + content?: string; + external_url?: string; + external_site_name?: string; + author_name?: string; + author_title?: string; + featured_image_url?: string; + status: string; + published_at?: string; + display_order?: number; + tags?: string[]; + metadata?: Record; + }; if (perspective.status === 'archived') { return `â„šī¸ "${perspective.title}" is already archived.`; } - // Archive the perspective - const result = await pool.query( - `UPDATE perspectives - SET status = 'archived', updated_at = NOW() - WHERE id = $1 - RETURNING id, slug, title, status`, - [perspectiveId] - ); + const previousStatus = perspective.status; + + // Archive the perspective via PUT API + const updateResult = await callAdminApi('PUT', `/api/admin/perspectives/${perspectiveId}`, { + slug: perspective.slug, + content_type: perspective.content_type, + title: perspective.title, + subtitle: perspective.subtitle, + category: perspective.category, + excerpt: perspective.excerpt, + content: perspective.content, + external_url: perspective.external_url, + external_site_name: perspective.external_site_name, + author_name: perspective.author_name, + author_title: perspective.author_title, + featured_image_url: perspective.featured_image_url, + status: 'archived', + published_at: perspective.published_at, + display_order: perspective.display_order, + tags: perspective.tags, + metadata: perspective.metadata, + }); + + if (!updateResult.ok) { + return `❌ Failed to archive perspective: ${updateResult.error}`; + } - const archived = result.rows[0]; + const archived = updateResult.data as typeof perspective; let response = `✅ **Perspective archived!**\n\n`; response += `**Title:** ${archived.title}\n`; response += `**Author:** ${perspective.author_name || 'Unknown'}\n`; - response += `**Previous status:** ${perspective.status}\n\n`; + response += `**Previous status:** ${previousStatus}\n\n`; response += `_The perspective is no longer visible on the public site. Use \`update_perspective\` to change status back to 'published' if needed._`; - logger.info({ perspectiveId, title: archived.title, previousStatus: perspective.status }, 'Perspective archived by admin'); + logger.info({ perspectiveId, title: archived.title, previousStatus }, 'Perspective archived by admin'); return response; } catch (error) { diff --git a/server/src/http.ts b/server/src/http.ts index 0c56faf761..4e2f37f211 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -50,7 +50,8 @@ import { sendAccountLinkedMessage, invalidateMemberContextCache, getAddieBoltRou import { createSlackRouter } from "./routes/slack.js"; import { createWebhooksRouter } from "./routes/webhooks.js"; import { createWorkOSWebhooksRouter } from "./routes/workos-webhooks.js"; -import { createAdminSlackRouter, createAdminEmailRouter, createAdminFeedsRouter, createAdminNotificationChannelsRouter, createAdminUsersRouter, createAdminSettingsRouter } from "./routes/admin/index.js"; +import { createAdminSlackRouter, createAdminEmailRouter, createAdminFeedsRouter, createAdminNotificationChannelsRouter, createAdminUsersRouter, createAdminSettingsRouter, createAdminPerspectivesRouter } from "./routes/admin/index.js"; +import { createPerspectivesRouter, createUserPerspectivesRouter } from "./routes/perspectives.js"; import { processFeedsToFetch } from "./addie/services/feed-fetcher.js"; import { processAlerts } from "./addie/services/industry-alerts.js"; import { createBillingRouter } from "./routes/billing.js"; @@ -58,11 +59,10 @@ import { createPublicBillingRouter } from "./routes/billing-public.js"; import { createOrganizationsRouter } from "./routes/organizations.js"; import { createEventsRouter } from "./routes/events.js"; import { createLatestRouter } from "./routes/latest.js"; -import { decodeHtmlEntities } from "./utils/html-entities.js"; import { createCommitteeRouters } from "./routes/committees.js"; import { sendWelcomeEmail, sendUserSignupEmail, emailDb } from "./notifications/email.js"; import { emailPrefsDb } from "./db/email-preferences-db.js"; -import { queuePerspectiveLink, processPendingResources, processRssPerspectives, processCommunityArticles } from "./addie/services/content-curator.js"; +import { processPendingResources, processRssPerspectives, processCommunityArticles } from "./addie/services/content-curator.js"; import { sendCommunityReplies } from "./addie/services/community-articles.js"; import { sendChannelMessage } from "./slack/client.js"; import { runTaskReminderJob } from "./addie/jobs/task-reminder.js"; @@ -788,6 +788,14 @@ export class HTTPServer { this.app.use('/api/admin/users', adminUsersRouter); // Admin Users: /api/admin/users/* const adminSettingsRouter = createAdminSettingsRouter(); this.app.use('/api/admin/settings', adminSettingsRouter); // Admin Settings: /api/admin/settings/* + const adminPerspectivesRouter = createAdminPerspectivesRouter(); + this.app.use('/api/admin/perspectives', adminPerspectivesRouter); // Admin Perspectives: /api/admin/perspectives/* + + // Mount public and user perspectives routes + const perspectivesRouter = createPerspectivesRouter(); + this.app.use('/api/perspectives', perspectivesRouter); // Public Perspectives: /api/perspectives/* + const userPerspectivesRouter = createUserPerspectivesRouter(); + this.app.use('/api/me/perspectives', userPerspectivesRouter); // User Perspectives: /api/me/perspectives/* // Mount billing routes (admin) const { pageRouter: billingPageRouter, apiRouter: billingApiRouter } = createBillingRouter(); @@ -3236,390 +3244,6 @@ export class HTTPServer { } }); - // ======================================== - // Perspectives Admin Routes - // ======================================== - - // GET /api/admin/perspectives - List all perspectives - this.app.get('/api/admin/perspectives', requireAuth, requireAdmin, async (req, res) => { - try { - const pool = getPool(); - const result = await pool.query( - `SELECT * FROM perspectives - ORDER BY display_order ASC, published_at DESC NULLS LAST, created_at DESC` - ); - - res.json(result.rows); - } catch (error) { - logger.error({ err: error }, 'Get all perspectives error:'); - res.status(500).json({ - error: 'Failed to get perspectives', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // GET /api/admin/perspectives/:id - Get single perspective - this.app.get('/api/admin/perspectives/:id', requireAuth, requireAdmin, async (req, res) => { - try { - const { id } = req.params; - const pool = getPool(); - const result = await pool.query( - 'SELECT * FROM perspectives WHERE id = $1', - [id] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ - error: 'Perspective not found', - message: `No perspective found with id ${id}` - }); - } - - res.json(result.rows[0]); - } catch (error) { - logger.error({ err: error }, 'Get perspective error:'); - res.status(500).json({ - error: 'Failed to get perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // POST /api/admin/perspectives/fetch-url - Fetch URL metadata for auto-fill - this.app.post('/api/admin/perspectives/fetch-url', requireAuth, requireAdmin, async (req, res) => { - try { - const { url } = req.body; - - if (!url) { - return res.status(400).json({ - error: 'URL required', - message: 'Please provide a URL to fetch' - }); - } - - // Fetch the page - const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; AgenticAdvertising/1.0)', - 'Accept': 'text/html,application/xhtml+xml' - }, - redirect: 'follow' - }); - - if (!response.ok) { - throw new Error(`Failed to fetch URL: ${response.status}`); - } - - const html = await response.text(); - - // Extract metadata from HTML - const titleMatch = html.match(/]*>([^<]+)<\/title>/i); - const ogTitleMatch = html.match(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) - || html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i); - const ogDescMatch = html.match(/]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i) - || html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i); - const descMatch = html.match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i) - || html.match(/]+content=["']([^"']+)["'][^>]+name=["']description["']/i); - const ogSiteMatch = html.match(/]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i) - || html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i); - - // Determine title (prefer og:title, then ) - let title = ogTitleMatch?.[1] || titleMatch?.[1] || ''; - title = decodeHtmlEntities(title.trim()); - - // Determine description (prefer og:description, then meta description) - let excerpt = ogDescMatch?.[1] || descMatch?.[1] || ''; - excerpt = decodeHtmlEntities(excerpt.trim()); - - // Site name from og:site_name or parse from URL - let site_name = ogSiteMatch?.[1] || ''; - if (!site_name) { - try { - const parsedUrl = new URL(url); - site_name = parsedUrl.hostname.replace('www.', ''); - // Capitalize first letter - site_name = site_name.charAt(0).toUpperCase() + site_name.slice(1); - } catch { - // ignore URL parse errors - } - } - site_name = decodeHtmlEntities(site_name); - - res.json({ - title, - excerpt, - site_name - }); - - } catch (error) { - logger.error({ err: error }, 'Fetch URL metadata error:'); - res.status(500).json({ - error: 'Failed to fetch URL', - message: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); - - // POST /api/admin/perspectives - Create new perspective - this.app.post('/api/admin/perspectives', requireAuth, requireAdmin, async (req, res) => { - try { - const { - slug, - content_type = 'article', - title, - subtitle, - category, - excerpt, - content, - external_url, - external_site_name, - author_name, - author_title, - featured_image_url, - status = 'draft', - published_at, - display_order = 0, - tags = [], - metadata = {}, - } = req.body; - - const validContentTypes = ['article', 'link']; - const validStatuses = ['draft', 'published', 'archived']; - - if (!slug || !title) { - return res.status(400).json({ - error: 'Missing required fields', - message: 'slug and title are required' - }); - } - - if (!validContentTypes.includes(content_type)) { - return res.status(400).json({ - error: 'Invalid content_type', - message: 'content_type must be: article or link' - }); - } - - if (!validStatuses.includes(status)) { - return res.status(400).json({ - error: 'Invalid status', - message: 'status must be: draft, published, or archived' - }); - } - - // Validate content_type requirements - if (content_type === 'link' && !external_url) { - return res.status(400).json({ - error: 'Missing external_url', - message: 'external_url is required for link type perspectives' - }); - } - - const pool = getPool(); - const result = await pool.query( - `INSERT INTO perspectives ( - slug, content_type, title, subtitle, category, excerpt, - content, external_url, external_site_name, - author_name, author_title, featured_image_url, - status, published_at, display_order, tags, metadata - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) - RETURNING *`, - [ - slug, content_type, title, subtitle, category, excerpt, - content, external_url, external_site_name, - author_name, author_title, featured_image_url, - status, published_at || null, display_order, tags, metadata - ] - ); - - const perspective = result.rows[0]; - - // Queue external links for Addie's knowledge base when published - if (perspective.content_type === 'link' && perspective.status === 'published' && perspective.external_url) { - queuePerspectiveLink({ - id: perspective.id, - title: perspective.title, - external_url: perspective.external_url, - category: perspective.category || 'perspective', - tags: perspective.tags, - }).catch(err => { - logger.warn({ err, perspectiveId: perspective.id }, 'Failed to queue perspective link for indexing'); - }); - } - - res.json(perspective); - } catch (error) { - logger.error({ err: error }, 'Create perspective error:'); - // Check for unique constraint violation - if (error instanceof Error && error.message.includes('duplicate key')) { - return res.status(400).json({ - error: 'Slug already exists', - message: 'A perspective with this slug already exists' - }); - } - res.status(500).json({ - error: 'Failed to create perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // PUT /api/admin/perspectives/:id - Update perspective - this.app.put('/api/admin/perspectives/:id', requireAuth, requireAdmin, async (req, res) => { - try { - const { id } = req.params; - const { - slug, - content_type, - title, - subtitle, - category, - excerpt, - content, - external_url, - external_site_name, - author_name, - author_title, - featured_image_url, - status, - published_at, - display_order, - tags, - metadata, - } = req.body; - - const validContentTypes = ['article', 'link']; - const validStatuses = ['draft', 'published', 'archived']; - - if (!slug || !title) { - return res.status(400).json({ - error: 'Missing required fields', - message: 'slug and title are required' - }); - } - - if (content_type && !validContentTypes.includes(content_type)) { - return res.status(400).json({ - error: 'Invalid content_type', - message: 'content_type must be: article or link' - }); - } - - if (status && !validStatuses.includes(status)) { - return res.status(400).json({ - error: 'Invalid status', - message: 'status must be: draft, published, or archived' - }); - } - - // Validate content_type requirements - if (content_type === 'link' && !external_url) { - return res.status(400).json({ - error: 'Missing external_url', - message: 'external_url is required for link type perspectives' - }); - } - - const pool = getPool(); - const result = await pool.query( - `UPDATE perspectives SET - slug = $1, - content_type = $2, - title = $3, - subtitle = $4, - category = $5, - excerpt = $6, - content = $7, - external_url = $8, - external_site_name = $9, - author_name = $10, - author_title = $11, - featured_image_url = $12, - status = $13, - published_at = $14, - display_order = $15, - tags = $16, - metadata = $17 - WHERE id = $18 - RETURNING *`, - [ - slug, content_type, title, subtitle, category, excerpt, - content, external_url, external_site_name, - author_name, author_title, featured_image_url, - status, published_at || null, display_order, tags, metadata, - id - ] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ - error: 'Perspective not found', - message: `No perspective found with id ${id}` - }); - } - - const perspective = result.rows[0]; - - // Queue external links for indexing when perspective is published - if (perspective.content_type === 'link' && perspective.status === 'published' && perspective.external_url) { - queuePerspectiveLink({ - id: perspective.id, - title: perspective.title, - external_url: perspective.external_url, - category: perspective.category || 'perspective', - tags: perspective.tags, - }).catch(err => { - logger.warn({ err, perspectiveId: perspective.id }, 'Failed to queue perspective link for indexing'); - }); - } - - res.json(perspective); - } catch (error) { - logger.error({ err: error }, 'Update perspective error:'); - // Check for unique constraint violation - if (error instanceof Error && error.message.includes('duplicate key')) { - return res.status(400).json({ - error: 'Slug already exists', - message: 'A perspective with this slug already exists' - }); - } - res.status(500).json({ - error: 'Failed to update perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // DELETE /api/admin/perspectives/:id - Delete perspective - this.app.delete('/api/admin/perspectives/:id', requireAuth, requireAdmin, async (req, res) => { - try { - const { id } = req.params; - const pool = getPool(); - - const result = await pool.query( - 'DELETE FROM perspectives WHERE id = $1 RETURNING id', - [id] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ - error: 'Perspective not found', - message: `No perspective found with id ${id}` - }); - } - - res.json({ success: true, deleted: id }); - } catch (error) { - logger.error({ err: error }, 'Delete perspective error:'); - res.status(500).json({ - error: 'Failed to delete perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - // ======================================== // Committee Routes (Working Groups, Councils, Chapters) // ======================================== @@ -3711,169 +3335,6 @@ Disallow: /api/admin/ res.send(robotsTxt); }); - // ======================================== - // Public Perspectives API Routes - // ======================================== - - // GET /api/perspectives - List published perspectives (excludes working group posts) - this.app.get('/api/perspectives', async (req, res) => { - try { - const pool = getPool(); - const result = await pool.query( - `SELECT - id, slug, content_type, title, subtitle, category, excerpt, - external_url, external_site_name, - author_name, author_title, featured_image_url, - published_at, display_order, tags, like_count - FROM perspectives - WHERE status = 'published' AND working_group_id IS NULL - ORDER BY published_at DESC NULLS LAST` - ); - - res.json(result.rows); - } catch (error) { - logger.error({ err: error }, 'Get published perspectives error:'); - res.status(500).json({ - error: 'Failed to get perspectives', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // GET /api/perspectives/:slug - Get single published perspective by slug - this.app.get('/api/perspectives/:slug', async (req, res) => { - try { - const { slug } = req.params; - const pool = getPool(); - const result = await pool.query( - `SELECT - id, slug, content_type, title, subtitle, category, excerpt, - content, external_url, external_site_name, - author_name, author_title, featured_image_url, - published_at, tags, metadata, like_count, updated_at - FROM perspectives - WHERE slug = $1 AND status = 'published'`, - [slug] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ - error: 'Perspective not found', - message: `No published perspective found with slug ${slug}` - }); - } - - res.json(result.rows[0]); - } catch (error) { - logger.error({ err: error }, 'Get perspective by slug error:'); - res.status(500).json({ - error: 'Failed to get perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // POST /api/perspectives/:id/like - Add a like to a perspective - this.app.post('/api/perspectives/:id/like', async (req, res) => { - try { - const { id } = req.params; - const { fingerprint } = req.body; - - if (!fingerprint) { - return res.status(400).json({ - error: 'Missing fingerprint', - message: 'A fingerprint is required to like a perspective' - }); - } - - const pool = getPool(); - - // Get IP hash for rate limiting - const ip = req.ip || req.socket.remoteAddress || ''; - const ipHash = crypto.createHash('sha256').update(ip).digest('hex').substring(0, 64); - - // Check rate limit (max 50 likes per IP per hour) - const rateLimitResult = await pool.query( - `SELECT COUNT(*) as count FROM perspective_likes - WHERE ip_hash = $1 AND created_at > NOW() - INTERVAL '1 hour'`, - [ipHash] - ); - - if (parseInt(rateLimitResult.rows[0].count) >= 50) { - return res.status(429).json({ - error: 'Rate limited', - message: 'Too many likes. Please try again later.' - }); - } - - // Insert the like (will fail if already exists due to unique constraint) - await pool.query( - `INSERT INTO perspective_likes (perspective_id, fingerprint, ip_hash) - VALUES ($1, $2, $3) - ON CONFLICT (perspective_id, fingerprint) DO NOTHING`, - [id, fingerprint, ipHash] - ); - - // Get updated like count - const countResult = await pool.query( - `SELECT like_count FROM perspectives WHERE id = $1`, - [id] - ); - - res.json({ - success: true, - like_count: countResult.rows[0]?.like_count || 0 - }); - } catch (error) { - logger.error({ err: error }, 'Add perspective like error:'); - res.status(500).json({ - error: 'Failed to add like', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // DELETE /api/perspectives/:id/like - Remove a like from a perspective - this.app.delete('/api/perspectives/:id/like', async (req, res) => { - try { - const { id } = req.params; - const { fingerprint } = req.body; - - if (!fingerprint) { - return res.status(400).json({ - error: 'Missing fingerprint', - message: 'A fingerprint is required to unlike a perspective' - }); - } - - const pool = getPool(); - - // Delete the like - await pool.query( - `DELETE FROM perspective_likes - WHERE perspective_id = $1 AND fingerprint = $2`, - [id, fingerprint] - ); - - // Get updated like count - const countResult = await pool.query( - `SELECT like_count FROM perspectives WHERE id = $1`, - [id] - ); - - res.json({ - success: true, - like_count: countResult.rows[0]?.like_count || 0 - }); - } catch (error) { - logger.error({ err: error }, 'Remove perspective like error:'); - res.status(500).json({ - error: 'Failed to remove like', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - // Serve admin pages // Note: /admin/prospects route is now in routes/admin.ts @@ -4885,139 +4346,6 @@ Disallow: /api/admin/ } }); - // ======================================== - // User Perspectives Routes (for Addie CMS tools) - // ======================================== - - // GET /api/me/perspectives - Get current user's perspectives - this.app.get('/api/me/perspectives', requireAuth, async (req, res) => { - try { - const user = req.user!; - const status = req.query.status as string | undefined; - const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); - - const pool = getPool(); - - let query = ` - SELECT id, slug, content_type, title, subtitle, category, excerpt, - external_url, external_site_name, author_name, author_title, - status, published_at, created_at, updated_at, tags - FROM perspectives - WHERE author_user_id = $1 AND working_group_id IS NULL - `; - const params: (string | number)[] = [user.id]; - - if (status && status !== 'all') { - query += ` AND status = $${params.length + 1}`; - params.push(status); - } - - query += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`; - params.push(limit); - - const result = await pool.query(query, params); - res.json(result.rows); - } catch (error) { - logger.error({ err: error }, 'GET /api/me/perspectives error'); - res.status(500).json({ - error: 'Failed to get perspectives', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // POST /api/me/perspectives - Create a new perspective (as draft) - this.app.post('/api/me/perspectives', requireAuth, async (req, res) => { - try { - const user = req.user!; - const { - title, - content, - content_type = 'article', - excerpt, - external_url, - external_site_name, - category, - tags = [], - author_name, - author_user_id, - } = req.body; - - if (!title) { - return res.status(400).json({ - error: 'Missing required fields', - message: 'title is required', - }); - } - - // Validate content_type - const validContentTypes = ['article', 'link']; - if (!validContentTypes.includes(content_type)) { - return res.status(400).json({ - error: 'Invalid content_type', - message: 'content_type must be: article or link', - }); - } - - // Validate content_type requirements - if (content_type === 'link' && !external_url) { - return res.status(400).json({ - error: 'Missing external_url', - message: 'external_url is required for link type perspectives', - }); - } - - if (content_type === 'article' && !content) { - return res.status(400).json({ - error: 'Missing content', - message: 'content is required for article type perspectives', - }); - } - - // Generate slug from title - const baseSlug = title - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .substring(0, 100); - - // Add timestamp suffix to make unique - const slug = `${baseSlug}-${Date.now().toString(36)}`; - - // Use provided author info or derive from user - const finalAuthorName = author_name || - (user.firstName && user.lastName - ? `${user.firstName} ${user.lastName}` - : user.email?.split('@')[0] || 'Anonymous'); - const finalAuthorUserId = author_user_id || user.id; - - const pool = getPool(); - const result = await pool.query( - `INSERT INTO perspectives ( - slug, content_type, title, content, excerpt, - external_url, external_site_name, category, tags, - author_name, author_user_id, status - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'draft') - RETURNING *`, - [ - slug, content_type, title, content, excerpt, - external_url, external_site_name, category, tags, - finalAuthorName, finalAuthorUserId, - ] - ); - - logger.info({ perspectiveId: result.rows[0].id, userId: user.id, title }, 'User created perspective draft'); - res.status(201).json(result.rows[0]); - } catch (error) { - logger.error({ err: error }, 'POST /api/me/perspectives error'); - res.status(500).json({ - error: 'Failed to create perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - // GET /api/me/agreements - Get user's agreement acceptance history this.app.get('/api/me/agreements', requireAuth, async (req, res) => { try { diff --git a/server/src/routes/admin/index.ts b/server/src/routes/admin/index.ts index 73a70150de..fe081a6f14 100644 --- a/server/src/routes/admin/index.ts +++ b/server/src/routes/admin/index.ts @@ -10,6 +10,7 @@ export { createAdminFeedsRouter } from './feeds.js'; export { createAdminNotificationChannelsRouter } from './notification-channels.js'; export { createAdminUsersRouter } from './users.js'; export { createAdminSettingsRouter } from './settings.js'; +export { createAdminPerspectivesRouter } from './perspectives.js'; // Core admin route setup functions export { setupProspectRoutes } from './prospects.js'; diff --git a/server/src/routes/admin/perspectives.ts b/server/src/routes/admin/perspectives.ts new file mode 100644 index 0000000000..6e82d6268c --- /dev/null +++ b/server/src/routes/admin/perspectives.ts @@ -0,0 +1,427 @@ +/** + * Admin Perspectives routes module + * + * Admin-only routes for managing perspectives: + * - List all perspectives + * - Get single perspective + * - Create/update/delete perspectives + * - Fetch URL metadata for auto-fill + */ + +import { Router } from 'express'; +import { createLogger } from '../../logger.js'; +import { requireAuth, requireAdmin } from '../../middleware/auth.js'; +import { getPool } from '../../db/client.js'; +import { decodeHtmlEntities } from '../../utils/html-entities.js'; +import { queuePerspectiveLink } from '../../addie/services/content-curator.js'; + +const logger = createLogger('admin-perspectives-routes'); + +/** + * Create admin perspectives routes + * Returns a router to be mounted at /api/admin/perspectives + */ +export function createAdminPerspectivesRouter(): Router { + const router = Router(); + + // GET /api/admin/perspectives - List all perspectives + router.get('/', requireAuth, requireAdmin, async (_req, res) => { + try { + const pool = getPool(); + const result = await pool.query( + `SELECT * FROM perspectives + ORDER BY display_order ASC, published_at DESC NULLS LAST, created_at DESC` + ); + + res.json(result.rows); + } catch (error) { + logger.error({ err: error }, 'Get all perspectives error'); + res.status(500).json({ + error: 'Failed to get perspectives', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // GET /api/admin/perspectives/:id - Get single perspective + router.get('/:id', requireAuth, requireAdmin, async (req, res) => { + try { + const { id } = req.params; + const pool = getPool(); + const result = await pool.query( + 'SELECT * FROM perspectives WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: 'Perspective not found', + message: `No perspective found with id ${id}` + }); + } + + res.json(result.rows[0]); + } catch (error) { + logger.error({ err: error }, 'Get perspective error'); + res.status(500).json({ + error: 'Failed to get perspective', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/admin/perspectives/fetch-url - Fetch URL metadata for auto-fill + router.post('/fetch-url', requireAuth, requireAdmin, async (req, res) => { + try { + const { url } = req.body; + + if (!url) { + return res.status(400).json({ + error: 'URL required', + message: 'Please provide a URL to fetch' + }); + } + + // Validate URL scheme to prevent SSRF + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return res.status(400).json({ + error: 'Invalid URL', + message: 'Please provide a valid URL' + }); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return res.status(400).json({ + error: 'Invalid URL', + message: 'Only HTTP and HTTPS URLs are allowed' + }); + } + + // Fetch the page with timeout + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; AgenticAdvertising/1.0)', + 'Accept': 'text/html,application/xhtml+xml' + }, + redirect: 'follow', + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + if (!response.ok) { + throw new Error(`Failed to fetch URL: ${response.status}`); + } + + const html = await response.text(); + + // Extract metadata from HTML + const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); + const ogTitleMatch = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i); + const ogDescMatch = html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i) + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i); + const descMatch = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i); + const ogSiteMatch = html.match(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i) + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i); + + // Determine title (prefer og:title, then <title>) + let title = ogTitleMatch?.[1] || titleMatch?.[1] || ''; + title = decodeHtmlEntities(title.trim()); + + // Determine description (prefer og:description, then meta description) + let excerpt = ogDescMatch?.[1] || descMatch?.[1] || ''; + excerpt = decodeHtmlEntities(excerpt.trim()); + + // Site name from og:site_name or parse from URL + let site_name = ogSiteMatch?.[1] || ''; + if (!site_name) { + try { + const parsedUrl = new URL(url); + site_name = parsedUrl.hostname.replace('www.', ''); + // Capitalize first letter + site_name = site_name.charAt(0).toUpperCase() + site_name.slice(1); + } catch { + // ignore URL parse errors + } + } + site_name = decodeHtmlEntities(site_name); + + res.json({ + title, + excerpt, + site_name + }); + + } catch (error) { + logger.error({ err: error }, 'Fetch URL metadata error'); + res.status(500).json({ + error: 'Failed to fetch URL', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + + // POST /api/admin/perspectives - Create new perspective + router.post('/', requireAuth, requireAdmin, async (req, res) => { + try { + const { + slug, + content_type = 'article', + title, + subtitle, + category, + excerpt, + content, + external_url, + external_site_name, + author_name, + author_title, + featured_image_url, + status = 'draft', + published_at, + display_order = 0, + tags = [], + metadata = {}, + } = req.body; + + const validContentTypes = ['article', 'link']; + const validStatuses = ['draft', 'published', 'archived']; + + if (!slug || !title) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'slug and title are required' + }); + } + + if (!validContentTypes.includes(content_type)) { + return res.status(400).json({ + error: 'Invalid content_type', + message: 'content_type must be: article or link' + }); + } + + if (!validStatuses.includes(status)) { + return res.status(400).json({ + error: 'Invalid status', + message: 'status must be: draft, published, or archived' + }); + } + + // Validate content_type requirements + if (content_type === 'link' && !external_url) { + return res.status(400).json({ + error: 'Missing external_url', + message: 'external_url is required for link type perspectives' + }); + } + + const pool = getPool(); + const result = await pool.query( + `INSERT INTO perspectives ( + slug, content_type, title, subtitle, category, excerpt, + content, external_url, external_site_name, + author_name, author_title, featured_image_url, + status, published_at, display_order, tags, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING *`, + [ + slug, content_type, title, subtitle, category, excerpt, + content, external_url, external_site_name, + author_name, author_title, featured_image_url, + status, published_at || null, display_order, tags, metadata + ] + ); + + const perspective = result.rows[0]; + + // Queue external links for Addie's knowledge base when published + if (perspective.content_type === 'link' && perspective.status === 'published' && perspective.external_url) { + queuePerspectiveLink({ + id: perspective.id, + title: perspective.title, + external_url: perspective.external_url, + category: perspective.category || 'perspective', + tags: perspective.tags, + }).catch(err => { + logger.warn({ err, perspectiveId: perspective.id }, 'Failed to queue perspective link for indexing'); + }); + } + + res.status(201).json(perspective); + } catch (error) { + logger.error({ err: error }, 'Create perspective error'); + // Check for unique constraint violation + if (error instanceof Error && error.message.includes('duplicate key')) { + return res.status(400).json({ + error: 'Slug already exists', + message: 'A perspective with this slug already exists' + }); + } + res.status(500).json({ + error: 'Failed to create perspective', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // PUT /api/admin/perspectives/:id - Update perspective + router.put('/:id', requireAuth, requireAdmin, async (req, res) => { + try { + const { id } = req.params; + const { + slug, + content_type, + title, + subtitle, + category, + excerpt, + content, + external_url, + external_site_name, + author_name, + author_title, + featured_image_url, + status, + published_at, + display_order, + tags, + metadata, + } = req.body; + + const validContentTypes = ['article', 'link']; + const validStatuses = ['draft', 'published', 'archived']; + + if (!slug || !title) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'slug and title are required' + }); + } + + if (content_type && !validContentTypes.includes(content_type)) { + return res.status(400).json({ + error: 'Invalid content_type', + message: 'content_type must be: article or link' + }); + } + + if (status && !validStatuses.includes(status)) { + return res.status(400).json({ + error: 'Invalid status', + message: 'status must be: draft, published, or archived' + }); + } + + // Validate content_type requirements + if (content_type === 'link' && !external_url) { + return res.status(400).json({ + error: 'Missing external_url', + message: 'external_url is required for link type perspectives' + }); + } + + const pool = getPool(); + const result = await pool.query( + `UPDATE perspectives SET + slug = $1, + content_type = $2, + title = $3, + subtitle = $4, + category = $5, + excerpt = $6, + content = $7, + external_url = $8, + external_site_name = $9, + author_name = $10, + author_title = $11, + featured_image_url = $12, + status = $13, + published_at = $14, + display_order = $15, + tags = $16, + metadata = $17 + WHERE id = $18 + RETURNING *`, + [ + slug, content_type, title, subtitle, category, excerpt, + content, external_url, external_site_name, + author_name, author_title, featured_image_url, + status, published_at || null, display_order, tags, metadata, + id + ] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: 'Perspective not found', + message: `No perspective found with id ${id}` + }); + } + + const perspective = result.rows[0]; + + // Queue external links for indexing when perspective is published + if (perspective.content_type === 'link' && perspective.status === 'published' && perspective.external_url) { + queuePerspectiveLink({ + id: perspective.id, + title: perspective.title, + external_url: perspective.external_url, + category: perspective.category || 'perspective', + tags: perspective.tags, + }).catch(err => { + logger.warn({ err, perspectiveId: perspective.id }, 'Failed to queue perspective link for indexing'); + }); + } + + res.json(perspective); + } catch (error) { + logger.error({ err: error }, 'Update perspective error'); + // Check for unique constraint violation + if (error instanceof Error && error.message.includes('duplicate key')) { + return res.status(400).json({ + error: 'Slug already exists', + message: 'A perspective with this slug already exists' + }); + } + res.status(500).json({ + error: 'Failed to update perspective', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // DELETE /api/admin/perspectives/:id - Delete perspective + router.delete('/:id', requireAuth, requireAdmin, async (req, res) => { + try { + const { id } = req.params; + const pool = getPool(); + + const result = await pool.query( + 'DELETE FROM perspectives WHERE id = $1 RETURNING id', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: 'Perspective not found', + message: `No perspective found with id ${id}` + }); + } + + res.json({ success: true, deleted: id }); + } catch (error) { + logger.error({ err: error }, 'Delete perspective error'); + res.status(500).json({ + error: 'Failed to delete perspective', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} diff --git a/server/src/routes/perspectives.ts b/server/src/routes/perspectives.ts new file mode 100644 index 0000000000..2e17bc9ec7 --- /dev/null +++ b/server/src/routes/perspectives.ts @@ -0,0 +1,332 @@ +/** + * Perspectives routes module + * + * Public and user routes for perspectives: + * - List published perspectives (public) + * - Get single perspective by slug (public) + * - Like/unlike perspectives (public) + * - User's own perspectives (authenticated) + */ + +import { Router } from 'express'; +import crypto from 'crypto'; +import { createLogger } from '../logger.js'; +import { requireAuth } from '../middleware/auth.js'; +import { getPool } from '../db/client.js'; + +const logger = createLogger('perspectives-routes'); + +/** + * Create public perspectives router + * Returns a router to be mounted at /api/perspectives + */ +export function createPerspectivesRouter(): Router { + const router = Router(); + + // GET /api/perspectives - List published perspectives (excludes working group posts) + router.get('/', async (_req, res) => { + try { + const pool = getPool(); + const result = await pool.query( + `SELECT + id, slug, content_type, title, subtitle, category, excerpt, + external_url, external_site_name, + author_name, author_title, featured_image_url, + published_at, display_order, tags, like_count + FROM perspectives + WHERE status = 'published' AND working_group_id IS NULL + ORDER BY published_at DESC NULLS LAST` + ); + + res.json(result.rows); + } catch (error) { + logger.error({ err: error }, 'Get published perspectives error'); + res.status(500).json({ + error: 'Failed to get perspectives', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // GET /api/perspectives/:slug - Get single published perspective by slug + router.get('/:slug', async (req, res) => { + try { + const { slug } = req.params; + const pool = getPool(); + const result = await pool.query( + `SELECT + id, slug, content_type, title, subtitle, category, excerpt, + content, external_url, external_site_name, + author_name, author_title, featured_image_url, + published_at, tags, metadata, like_count, updated_at + FROM perspectives + WHERE slug = $1 AND status = 'published'`, + [slug] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: 'Perspective not found', + message: `No published perspective found with slug ${slug}` + }); + } + + res.json(result.rows[0]); + } catch (error) { + logger.error({ err: error }, 'Get perspective by slug error'); + res.status(500).json({ + error: 'Failed to get perspective', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/perspectives/:id/like - Add a like to a perspective + router.post('/:id/like', async (req, res) => { + try { + const { id } = req.params; + const { fingerprint } = req.body; + + if (!fingerprint) { + return res.status(400).json({ + error: 'Missing fingerprint', + message: 'A fingerprint is required to like a perspective' + }); + } + + const pool = getPool(); + + // Get IP hash for rate limiting + const ip = req.ip || req.socket.remoteAddress || ''; + const ipHash = crypto.createHash('sha256').update(ip).digest('hex').substring(0, 64); + + // Check rate limit (max 50 likes per IP per hour) + const rateLimitResult = await pool.query( + `SELECT COUNT(*) as count FROM perspective_likes + WHERE ip_hash = $1 AND created_at > NOW() - INTERVAL '1 hour'`, + [ipHash] + ); + + if (parseInt(rateLimitResult.rows[0].count) >= 50) { + return res.status(429).json({ + error: 'Rate limited', + message: 'Too many likes. Please try again later.' + }); + } + + // Insert the like (will fail if already exists due to unique constraint) + await pool.query( + `INSERT INTO perspective_likes (perspective_id, fingerprint, ip_hash) + VALUES ($1, $2, $3) + ON CONFLICT (perspective_id, fingerprint) DO NOTHING`, + [id, fingerprint, ipHash] + ); + + // Get updated like count + const countResult = await pool.query( + `SELECT like_count FROM perspectives WHERE id = $1`, + [id] + ); + + res.json({ + success: true, + like_count: countResult.rows[0]?.like_count || 0 + }); + } catch (error) { + logger.error({ err: error }, 'Add perspective like error'); + res.status(500).json({ + error: 'Failed to add like', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // DELETE /api/perspectives/:id/like - Remove a like from a perspective + router.delete('/:id/like', async (req, res) => { + try { + const { id } = req.params; + const { fingerprint } = req.body; + + if (!fingerprint) { + return res.status(400).json({ + error: 'Missing fingerprint', + message: 'A fingerprint is required to unlike a perspective' + }); + } + + const pool = getPool(); + + // Delete the like + await pool.query( + `DELETE FROM perspective_likes + WHERE perspective_id = $1 AND fingerprint = $2`, + [id, fingerprint] + ); + + // Get updated like count + const countResult = await pool.query( + `SELECT like_count FROM perspectives WHERE id = $1`, + [id] + ); + + res.json({ + success: true, + like_count: countResult.rows[0]?.like_count || 0 + }); + } catch (error) { + logger.error({ err: error }, 'Remove perspective like error'); + res.status(500).json({ + error: 'Failed to remove like', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} + +/** + * Create user perspectives router + * Returns a router to be mounted at /api/me/perspectives + */ +export function createUserPerspectivesRouter(): Router { + const router = Router(); + + // GET /api/me/perspectives - Get current user's perspectives + router.get('/', requireAuth, async (req, res) => { + try { + const user = req.user!; + const status = req.query.status as string | undefined; + const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); + + // Validate status parameter + const validStatuses = ['draft', 'published', 'archived']; + if (status && status !== 'all' && !validStatuses.includes(status)) { + return res.status(400).json({ + error: 'Invalid status', + message: `status must be one of: ${validStatuses.join(', ')}, or 'all'`, + }); + } + + const pool = getPool(); + + let query = ` + SELECT id, slug, content_type, title, subtitle, category, excerpt, + external_url, external_site_name, author_name, author_title, + status, published_at, created_at, updated_at, tags + FROM perspectives + WHERE author_user_id = $1 AND working_group_id IS NULL + `; + const params: (string | number)[] = [user.id]; + + if (status && status !== 'all') { + query += ` AND status = $${params.length + 1}`; + params.push(status); + } + + query += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`; + params.push(limit); + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + logger.error({ err: error }, 'GET /api/me/perspectives error'); + res.status(500).json({ + error: 'Failed to get perspectives', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/me/perspectives - Create a new perspective (as draft) + router.post('/', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { + title, + content, + content_type = 'article', + excerpt, + external_url, + external_site_name, + category, + tags = [], + author_name, + } = req.body; + + if (!title) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'title is required', + }); + } + + // Validate content_type + const validContentTypes = ['article', 'link']; + if (!validContentTypes.includes(content_type)) { + return res.status(400).json({ + error: 'Invalid content_type', + message: 'content_type must be: article or link', + }); + } + + // Validate content_type requirements + if (content_type === 'link' && !external_url) { + return res.status(400).json({ + error: 'Missing external_url', + message: 'external_url is required for link type perspectives', + }); + } + + if (content_type === 'article' && !content) { + return res.status(400).json({ + error: 'Missing content', + message: 'content is required for article type perspectives', + }); + } + + // Generate slug from title + const baseSlug = title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .substring(0, 100); + + // Add timestamp suffix to make unique + const slug = `${baseSlug}-${Date.now().toString(36)}`; + + // Use provided author name or derive from user; always use authenticated user's ID + const finalAuthorName = author_name || + (user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.email?.split('@')[0] || 'Anonymous'); + + const pool = getPool(); + const result = await pool.query( + `INSERT INTO perspectives ( + slug, content_type, title, content, excerpt, + external_url, external_site_name, category, tags, + author_name, author_user_id, status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'draft') + RETURNING *`, + [ + slug, content_type, title, content, excerpt, + external_url, external_site_name, category, tags, + finalAuthorName, user.id, + ] + ); + + logger.info({ perspectiveId: result.rows[0].id, userId: user.id, title }, 'User created perspective draft'); + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error({ err: error }, 'POST /api/me/perspectives error'); + res.status(500).json({ + error: 'Failed to create perspective', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +}