From 195943f65d8533c46d4cc6774c02475dc16b92f9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 8 Jan 2026 07:39:36 -0800 Subject: [PATCH 1/3] feat: unified content management with authorship separation (#670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements unified content management system with separation of authorship from ownership, supporting co-authored content and proposal workflows: **Database Migration (150):** - Create content_authors table for co-authoring support - Add proposer tracking fields to perspectives table - Add review tracking (reviewed_by_user_id, reviewed_at, rejection_reason) - Update status constraint to include pending_review and rejected - Create helper functions: is_committee_lead(), is_content_owner() **Content Routes (/api/content):** - POST /propose - Submit content to personal or committee collections - GET /pending - List pending content for review (leads/admins) - POST /:id/approve - Approve pending content - POST /:id/reject - Reject with reason **My Content Routes (/api/me/content):** - GET / - Unified view of user's content (author/proposer/owner) - PUT /:id - Update content user owns - POST /:id/authors - Add co-author - DELETE /:id/authors/:authorId - Remove co-author **Addie Tools:** - propose_content - Create content targeting personal or committee - get_my_content - View all user's content with relationships - list_pending_content - Review queue for leads/admins - approve_content - Approve and publish pending content - reject_content - Reject with feedback **Proactive Notifications:** - Added pending_content to MemberContext interface - Committee leads and admins see pending counts in context - Addie proactively mentions pending items needing review šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/src/addie/mcp/member-tools.ts | 468 +++++++++ server/src/addie/member-context.ts | 108 +++ .../150_unified_content_management.sql | 165 ++++ server/src/http.ts | 8 + server/src/routes/content.ts | 915 ++++++++++++++++++ specs/unified-content-management.md | 469 +++++++++ 6 files changed, 2133 insertions(+) create mode 100644 server/src/db/migrations/150_unified_content_management.sql create mode 100644 server/src/routes/content.ts create mode 100644 specs/unified-content-management.md diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 1603c986f9..977cde1bfe 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -354,6 +354,150 @@ export const MEMBER_TOOLS: AddieTool[] = [ }, }, + // ============================================ + // UNIFIED CONTENT MANAGEMENT + // ============================================ + { + name: 'propose_content', + description: + 'Create content for the website (perspectives, committee posts). Content can be for personal perspectives or committee collections. Committee leads and admins can publish directly; others submit for review. Supports co-authors.', + usage_hints: 'use for "write a perspective", "post to the sustainability group", "create an article", "share my thoughts on X"', + input_schema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Content title', + }, + content: { + type: 'string', + description: 'Article content in markdown format (required for article type)', + }, + content_type: { + type: 'string', + enum: ['article', 'link'], + description: 'Type of content. article=original content, link=external link with commentary (default: article)', + }, + external_url: { + type: 'string', + description: 'URL for link type content', + }, + excerpt: { + type: 'string', + description: 'Short excerpt/summary (auto-generated from content if not provided)', + }, + category: { + type: 'string', + description: 'Category for the content', + }, + collection: { + type: 'object', + description: 'Where to publish: personal (perspectives page) or committee (committee page)', + properties: { + type: { + type: 'string', + enum: ['personal', 'committee'], + description: 'Collection type', + }, + committee_slug: { + type: 'string', + description: 'Committee slug (required if type is committee)', + }, + }, + required: ['type'], + }, + co_author_emails: { + type: 'array', + items: { type: 'string' }, + description: 'Email addresses of co-authors to add', + }, + }, + required: ['title', 'collection'], + }, + }, + { + name: 'get_my_content', + description: + 'Get all content where the user is an author, proposer, or owner (committee lead). Shows content across all collections with status and relationship info.', + usage_hints: 'use for "show my content", "my perspectives", "what have I written?", "my pending posts"', + input_schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['draft', 'pending_review', 'published', 'archived', 'rejected', 'all'], + description: 'Filter by status (default: all)', + }, + collection: { + type: 'string', + description: 'Filter by collection: "personal" or a committee slug', + }, + relationship: { + type: 'string', + enum: ['author', 'proposer', 'owner'], + description: 'Filter by relationship type', + }, + }, + required: [], + }, + }, + { + name: 'list_pending_content', + description: + 'List content pending review that the user can approve/reject. Only committee leads see their committee content; admins see all pending content.', + usage_hints: 'use for "what content needs approval?", "pending posts", "review queue"', + input_schema: { + type: 'object', + properties: { + committee_slug: { + type: 'string', + description: 'Filter to a specific committee', + }, + }, + required: [], + }, + }, + { + name: 'approve_content', + description: + 'Approve pending content for publication. Only committee leads (for their committees) and admins can approve content.', + usage_hints: 'use for "approve this post", "publish this content"', + input_schema: { + type: 'object', + properties: { + content_id: { + type: 'string', + description: 'The ID of the content to approve', + }, + publish_immediately: { + type: 'boolean', + description: 'Whether to publish immediately (default: true) or save as draft', + }, + }, + required: ['content_id'], + }, + }, + { + name: 'reject_content', + description: + 'Reject pending content with a reason. Only committee leads (for their committees) and admins can reject content. The proposer will see the rejection reason.', + usage_hints: 'use for "reject this post", "decline this content"', + input_schema: { + type: 'object', + properties: { + content_id: { + type: 'string', + description: 'The ID of the content to reject', + }, + reason: { + type: 'string', + description: 'Reason for rejection (required - helps the author understand and improve)', + }, + }, + required: ['content_id', 'reason'], + }, + }, + // ============================================ // ACCOUNT LINKING // ============================================ @@ -1183,6 +1327,330 @@ 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.`; }); + // ============================================ + // UNIFIED CONTENT MANAGEMENT + // ============================================ + handlers.set('propose_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to create content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const title = input.title as string; + const contentBody = input.content as string | undefined; + const contentType = (input.content_type as string) || 'article'; + const externalUrl = input.external_url as string | undefined; + const excerpt = input.excerpt as string | undefined; + const category = input.category as string | undefined; + const collection = input.collection as { type: string; committee_slug?: string }; + const coAuthorEmails = input.co_author_emails as string[] | undefined; + + // Validate requirements + if (contentType === 'article' && !contentBody) { + return 'Content is required for article type. Please provide the content in markdown format.'; + } + if (contentType === 'link' && !externalUrl) { + return 'A URL is required for link type content. Please provide the external_url.'; + } + if (collection.type === 'committee' && !collection.committee_slug) { + return 'committee_slug is required when targeting a committee collection.'; + } + + // Build request body + const body: Record = { + title, + content: contentBody, + content_type: contentType, + external_url: externalUrl, + excerpt, + category, + collection, + }; + + // Handle co-authors by looking up user IDs from emails + if (coAuthorEmails && coAuthorEmails.length > 0) { + // For now, co-authors are added via a separate call after content creation + // We'll note them in the response + } + + const result = await callApi('POST', '/api/content/propose', memberContext, body); + + if (!result.ok) { + if (result.status === 404) { + return `Committee "${collection.committee_slug}" not found. Use list_working_groups to see available committees.`; + } + return `Failed to create content: ${result.error}`; + } + + const data = result.data as { id: string; slug: string; status: string; message: string }; + + let response = `## Content ${data.status === 'published' ? 'Published' : 'Submitted'}\n\n`; + response += `**Title:** ${title}\n`; + response += `**Status:** ${data.status === 'published' ? 'āœ… Published' : 'ā³ Pending Review'}\n`; + + if (collection.type === 'committee') { + response += `**Collection:** ${collection.committee_slug}\n`; + } else { + response += `**Collection:** Personal (perspectives)\n`; + } + + if (data.status === 'published') { + if (collection.type === 'committee') { + response += `\n**View:** https://agenticadvertising.org/committees/${collection.committee_slug}\n`; + } else { + response += `\n**View:** https://agenticadvertising.org/perspectives/${data.slug}\n`; + } + } else { + response += `\n_A committee lead or admin will review your submission. You'll be notified when it's approved._\n`; + } + + if (coAuthorEmails && coAuthorEmails.length > 0) { + response += `\nšŸ’” **Note:** To add co-authors, you can edit this content at: https://agenticadvertising.org/admin/content/${data.id}`; + } + + return response; + }); + + handlers.set('get_my_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to see your content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const status = input.status as string | undefined; + const collection = input.collection as string | undefined; + const relationship = input.relationship as string | undefined; + + // Build query string + const params = new URLSearchParams(); + if (status && status !== 'all') params.set('status', status); + if (collection) params.set('collection', collection); + if (relationship) params.set('relationship', relationship); + + const queryString = params.toString() ? `?${params.toString()}` : ''; + const result = await callApi('GET', `/api/me/content${queryString}`, memberContext); + + if (!result.ok) { + return `Failed to fetch your content: ${result.error}`; + } + + const data = result.data as { + items: Array<{ + id: string; + slug: string; + title: string; + status: string; + content_type: string; + collection: { type: string; committee_name?: string; committee_slug?: string }; + relationships: string[]; + authors: Array<{ display_name: string }>; + published_at?: string; + created_at: string; + }>; + }; + + if (data.items.length === 0) { + let response = "You don't have any content yet.\n\n"; + response += 'Use `propose_content` to create your first article or perspective!'; + return response; + } + + let response = `## Your Content\n\n`; + + // Group by status + const byStatus: Record = {}; + for (const item of data.items) { + if (!byStatus[item.status]) byStatus[item.status] = []; + byStatus[item.status].push(item); + } + + // Display order: pending_review first, then published, then others + const statusOrder = ['pending_review', 'published', 'draft', 'rejected', 'archived']; + const statusEmoji: Record = { + pending_review: 'ā³', + published: 'āœ…', + draft: 'šŸ“', + rejected: 'āŒ', + archived: 'šŸ“¦', + }; + const statusLabel: Record = { + pending_review: 'Pending Review', + published: 'Published', + draft: 'Drafts', + rejected: 'Rejected', + archived: 'Archived', + }; + + for (const statusKey of statusOrder) { + const items = byStatus[statusKey]; + if (!items || items.length === 0) continue; + + response += `### ${statusEmoji[statusKey] || ''} ${statusLabel[statusKey] || statusKey} (${items.length})\n\n`; + + for (const item of items) { + const collectionLabel = item.collection.type === 'committee' + ? `šŸ“ ${item.collection.committee_name || item.collection.committee_slug}` + : 'šŸ“ Personal'; + const roleLabels = item.relationships.map(r => { + if (r === 'author') return 'āœļø Author'; + if (r === 'proposer') return 'šŸ“¤ Proposer'; + if (r === 'owner') return 'šŸ‘‘ Owner'; + return r; + }).join(' | '); + + response += `**${item.title}**\n`; + response += `${collectionLabel} | ${roleLabels}\n`; + if (item.authors.length > 1) { + response += `_Co-authors: ${item.authors.map(a => a.display_name).join(', ')}_\n`; + } + if (item.published_at) { + response += `_Published: ${new Date(item.published_at).toLocaleDateString()}_\n`; + } + response += `\n`; + } + } + + return response; + }); + + handlers.set('list_pending_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to see pending content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const committeeSlug = input.committee_slug as string | undefined; + const queryString = committeeSlug ? `?committee_slug=${encodeURIComponent(committeeSlug)}` : ''; + + const result = await callApi('GET', `/api/content/pending${queryString}`, memberContext); + + if (!result.ok) { + return `Failed to fetch pending content: ${result.error}`; + } + + const data = result.data as { + items: Array<{ + id: string; + title: string; + slug: string; + excerpt?: string; + content_type: string; + proposer: { id: string; name: string }; + proposed_at: string; + collection: { type: string; committee_name?: string; committee_slug?: string }; + authors: Array<{ display_name: string }>; + }>; + summary: { + total: number; + by_collection: Record; + }; + }; + + if (data.items.length === 0) { + return 'āœ… No pending content to review! All caught up.'; + } + + let response = `## Pending Content for Review\n\n`; + response += `**Total:** ${data.summary.total} item(s)\n\n`; + + // Show breakdown by collection + if (Object.keys(data.summary.by_collection).length > 1) { + response += `**By collection:**\n`; + for (const [col, count] of Object.entries(data.summary.by_collection)) { + const label = col === 'personal' ? 'Personal perspectives' : col; + response += `- ${label}: ${count}\n`; + } + response += `\n`; + } + + for (const item of data.items) { + const collectionLabel = item.collection.type === 'committee' + ? `šŸ“ ${item.collection.committee_name || item.collection.committee_slug}` + : 'šŸ“ Personal'; + const proposedDate = new Date(item.proposed_at).toLocaleDateString(); + + response += `---\n\n`; + response += `### ${item.title}\n`; + response += `**ID:** \`${item.id}\`\n`; + response += `${collectionLabel} | Proposed by ${item.proposer.name} on ${proposedDate}\n`; + if (item.excerpt) { + response += `\n_${item.excerpt}_\n`; + } + response += `\n**Actions:** \`approve_content\` or \`reject_content\` with content_id: \`${item.id}\`\n\n`; + } + + return response; + }); + + handlers.set('approve_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to approve content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const contentId = input.content_id as string; + const publishImmediately = input.publish_immediately !== false; // default true + + const result = await callApi( + 'POST', + `/api/content/${contentId}/approve`, + memberContext, + { publish_immediately: publishImmediately } + ); + + if (!result.ok) { + if (result.status === 403) { + return 'Permission denied. Only committee leads and admins can approve content.'; + } + if (result.status === 404) { + return `Content not found with ID: ${contentId}`; + } + if (result.status === 400) { + return `This content is not pending review. It may have already been processed.`; + } + return `Failed to approve content: ${result.error}`; + } + + const data = result.data as { status: string; message: string }; + + if (publishImmediately) { + return `āœ… Content approved and published! The author will be notified.`; + } else { + return `āœ… Content approved and saved as draft. The author can publish when ready.`; + } + }); + + handlers.set('reject_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to reject content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const contentId = input.content_id as string; + const reason = input.reason as string; + + if (!reason) { + return 'A reason is required when rejecting content. This helps the author understand and improve.'; + } + + const result = await callApi( + 'POST', + `/api/content/${contentId}/reject`, + memberContext, + { reason } + ); + + if (!result.ok) { + if (result.status === 403) { + return 'Permission denied. Only committee leads and admins can reject content.'; + } + if (result.status === 404) { + return `Content not found with ID: ${contentId}`; + } + if (result.status === 400) { + return `This content is not pending review. It may have already been processed.`; + } + return `Failed to reject content: ${result.error}`; + } + + return `āŒ Content rejected. The author will see the following reason:\n\n> ${reason}\n\nThey can revise and resubmit if appropriate.`; + }); + // ============================================ // ACCOUNT LINKING // ============================================ diff --git a/server/src/addie/member-context.ts b/server/src/addie/member-context.ts index 40259b3752..49cd49a006 100644 --- a/server/src/addie/member-context.ts +++ b/server/src/addie/member-context.ts @@ -14,6 +14,7 @@ import { AddieDatabase } from '../db/addie-db.js'; import { getThreadService } from './thread-service.js'; import { workos } from '../auth/workos-client.js'; import { logger } from '../logger.js'; +import { getPool } from '../db/client.js'; const slackDb = new SlackDatabase(); const memberDb = new MemberDatabase(); @@ -22,6 +23,62 @@ const workingGroupDb = new WorkingGroupDatabase(); const emailPrefsDb = new EmailPreferencesDatabase(); const addieDb = new AddieDatabase(); +/** + * Get pending content count for a user + * Returns counts for committee leads (their committees) and admins (all) + */ +async function getPendingContentForUser( + workosUserId: string, + isAdmin: boolean +): Promise<{ total: number; by_committee: Record }> { + const pool = getPool(); + + // Get committees user leads + const leaderResult = await pool.query( + `SELECT wg.id, wg.name, wg.slug + FROM working_group_leaders wgl + JOIN working_groups wg ON wg.id = wgl.working_group_id + WHERE wgl.user_id = $1`, + [workosUserId] + ); + const ledCommitteeIds = leaderResult.rows.map(c => c.id); + + if (!isAdmin && ledCommitteeIds.length === 0) { + return { total: 0, by_committee: {} }; + } + + // Build query for pending content + let query = ` + SELECT wg.slug as committee_slug, COUNT(*) as count + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.status = 'pending_review' + `; + const params: (string | string[])[] = []; + + if (!isAdmin) { + // Non-admins only see pending for committees they lead + params.push(ledCommitteeIds); + query += ` AND p.working_group_id = ANY($${params.length})`; + } + + query += ` GROUP BY wg.slug`; + + const result = await pool.query<{ committee_slug: string | null; count: string }>(query, params); + + const byCommittee: Record = {}; + let total = 0; + + for (const row of result.rows) { + const key = row.committee_slug || 'personal'; + const count = parseInt(row.count, 10); + byCommittee[key] = count; + total += count; + } + + return { total, by_committee: byCommittee }; +} + // Cache for member context to avoid repeated lookups for the same user // TTL of 30 minutes - user profile data rarely changes, and we invalidate on specific events const MEMBER_CONTEXT_CACHE_TTL_MS = 30 * 60 * 1000; @@ -163,6 +220,12 @@ export interface MemberContext { last_interaction_at: Date | null; recent_topics: string[]; }; + + /** Pending content the user can review (committee leads and admins) */ + pending_content?: { + total: number; + by_committee: Record; + }; } /** @@ -397,6 +460,23 @@ export async function getMemberContext(slackUserId: string): Promise wg.is_leader) + .map(wg => wg.name) || []; + const userIsAdmin = context.org_membership?.role === 'admin'; + + if (ledCommitteeIds.length > 0 || userIsAdmin) { + try { + const pendingContent = await getPendingContentForUser(workosUserId, userIsAdmin); + if (pendingContent.total > 0) { + context.pending_content = pendingContent; + } + } catch (error) { + logger.warn({ error, workosUserId }, 'Addie: Failed to get pending content'); + } + } + logger.debug( { slackUserId, @@ -648,6 +728,21 @@ export async function getWebMemberContext(workosUserId: string): Promise wg.is_leader) || []; + const webUserIsAdmin = context.org_membership?.role === 'admin'; + + if (leadsCommittees.length > 0 || webUserIsAdmin) { + try { + const pendingContent = await getPendingContentForUser(workosUserId, webUserIsAdmin); + if (pendingContent.total > 0) { + context.pending_content = pendingContent; + } + } catch (error) { + logger.warn({ error, workosUserId }, 'Addie Web: Failed to get pending content'); + } + } + logger.debug( { workosUserId, @@ -825,6 +920,19 @@ export function formatMemberContextForPrompt(context: MemberContext, channel: 'w lines.push('Note: This user\'s Slack account is not yet linked to their AgenticAdvertising.org account.'); } + // Pending content notifications (for committee leads and admins) + if (context.pending_content && context.pending_content.total > 0) { + lines.push(''); + lines.push('### Action Required: Pending Content'); + lines.push(`There are ${context.pending_content.total} content item(s) awaiting your review.`); + for (const [committee, count] of Object.entries(context.pending_content.by_committee)) { + const label = committee === 'personal' ? 'Personal perspectives' : committee; + lines.push(`- ${label}: ${count}`); + } + lines.push(''); + lines.push('IMPORTANT: Proactively mention this pending content to the user. Use `list_pending_content` to show details when relevant, or if the user asks about pending items.'); + } + // Note: Previous Addie interactions removed - Slack Assistant threads handle conversation context automatically lines.push(''); diff --git a/server/src/db/migrations/150_unified_content_management.sql b/server/src/db/migrations/150_unified_content_management.sql new file mode 100644 index 0000000000..da4712b8ba --- /dev/null +++ b/server/src/db/migrations/150_unified_content_management.sql @@ -0,0 +1,165 @@ +-- Migration: 150_unified_content_management.sql +-- Unified content management system with co-authoring, ownership, and proposal workflow +-- See specs/unified-content-management.md for full spec + +-- ============================================================================= +-- 1. Create content_authors table for co-authoring support +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS content_authors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + perspective_id UUID NOT NULL REFERENCES perspectives(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, -- WorkOS user ID + display_name VARCHAR(255) NOT NULL, + display_title VARCHAR(255), + display_order INTEGER DEFAULT 0, -- For author ordering (0 = primary) + created_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(perspective_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_content_authors_perspective ON content_authors(perspective_id); +CREATE INDEX IF NOT EXISTS idx_content_authors_user ON content_authors(user_id); + +COMMENT ON TABLE content_authors IS 'Authors of content items - supports multiple co-authors per perspective'; +COMMENT ON COLUMN content_authors.display_order IS 'Lower numbers appear first; 0 = primary author'; + +-- ============================================================================= +-- 2. Add proposer tracking and review workflow columns to perspectives +-- ============================================================================= + +ALTER TABLE perspectives + ADD COLUMN IF NOT EXISTS proposer_user_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS proposed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS reviewed_by_user_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS rejection_reason TEXT; + +-- Create indexes for new columns +CREATE INDEX IF NOT EXISTS idx_perspectives_proposer ON perspectives(proposer_user_id); +CREATE INDEX IF NOT EXISTS idx_perspectives_proposed_at ON perspectives(proposed_at); + +COMMENT ON COLUMN perspectives.proposer_user_id IS 'User who originally proposed/submitted this content'; +COMMENT ON COLUMN perspectives.proposed_at IS 'When content was submitted for review'; +COMMENT ON COLUMN perspectives.reviewed_by_user_id IS 'User who approved/rejected this content'; +COMMENT ON COLUMN perspectives.reviewed_at IS 'When content was reviewed'; +COMMENT ON COLUMN perspectives.rejection_reason IS 'Reason given when content was rejected'; + +-- ============================================================================= +-- 3. Update status constraint to include new states +-- ============================================================================= + +-- Drop existing constraint and add new one with pending_review and rejected +ALTER TABLE perspectives + DROP CONSTRAINT IF EXISTS perspectives_status_check; + +ALTER TABLE perspectives + ADD CONSTRAINT perspectives_status_check + CHECK (status IN ('draft', 'pending_review', 'published', 'archived', 'rejected')); + +-- ============================================================================= +-- 4. Backfill existing data +-- ============================================================================= + +-- Set proposer_user_id from author_user_id where not already set +UPDATE perspectives +SET proposer_user_id = author_user_id +WHERE proposer_user_id IS NULL AND author_user_id IS NOT NULL; + +-- Create content_authors records from existing author data +-- This preserves backward compatibility while enabling co-authoring +INSERT INTO content_authors (perspective_id, user_id, display_name, display_title, display_order) +SELECT + p.id, + p.author_user_id, + COALESCE(p.author_name, 'Unknown'), + p.author_title, + 0 -- Primary author +FROM perspectives p +WHERE p.author_user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM content_authors ca + WHERE ca.perspective_id = p.id AND ca.user_id = p.author_user_id + ); + +-- ============================================================================= +-- 5. Create helper view for content with authors +-- ============================================================================= + +CREATE OR REPLACE VIEW content_with_authors AS +SELECT + p.*, + COALESCE( + (SELECT json_agg( + json_build_object( + 'user_id', ca.user_id, + 'display_name', ca.display_name, + 'display_title', ca.display_title, + 'display_order', ca.display_order + ) ORDER BY ca.display_order + ) + FROM content_authors ca + WHERE ca.perspective_id = p.id), + '[]'::json + ) AS authors_json +FROM perspectives p; + +COMMENT ON VIEW content_with_authors IS 'Perspectives with aggregated authors as JSON'; + +-- ============================================================================= +-- 6. Create function to check if user is committee lead +-- ============================================================================= + +CREATE OR REPLACE FUNCTION is_committee_lead(committee_id UUID, user_id VARCHAR) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM working_group_leaders wgl + WHERE wgl.working_group_id = committee_id + AND wgl.user_id = is_committee_lead.user_id + ); +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION is_committee_lead IS 'Check if a user is a leader of a given committee/working group'; + +-- ============================================================================= +-- 7. Create function to check content ownership +-- ============================================================================= + +CREATE OR REPLACE FUNCTION is_content_owner(perspective_id UUID, user_id VARCHAR) +RETURNS BOOLEAN AS $$ +DECLARE + v_working_group_id UUID; + v_proposer_user_id VARCHAR; +BEGIN + -- Get the perspective details + SELECT p.working_group_id, p.proposer_user_id + INTO v_working_group_id, v_proposer_user_id + FROM perspectives p + WHERE p.id = perspective_id; + + -- Not found + IF v_working_group_id IS NULL AND v_proposer_user_id IS NULL THEN + RETURN FALSE; + END IF; + + -- Committee content: check if user is a lead + IF v_working_group_id IS NOT NULL THEN + RETURN is_committee_lead(v_working_group_id, user_id); + END IF; + + -- Personal content: check if user is the proposer or an author + RETURN v_proposer_user_id = user_id OR EXISTS ( + SELECT 1 FROM content_authors ca + WHERE ca.perspective_id = is_content_owner.perspective_id + AND ca.user_id = is_content_owner.user_id + ); +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION is_content_owner IS 'Check if a user owns/controls a piece of content'; + +-- ============================================================================= +-- Done +-- ============================================================================= diff --git a/server/src/http.ts b/server/src/http.ts index ec84dd3365..7660e57036 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -60,6 +60,7 @@ 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 { createContentRouter, createMyContentRouter } from "./routes/content.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"; @@ -3629,6 +3630,13 @@ export class HTTPServer { this.app.use('/api/working-groups', publicApiRouter); this.app.use('/api/me/working-groups', userApiRouter); + // ======================================== + // Unified Content Management Routes + // ======================================== + + this.app.use('/api/content', createContentRouter()); + this.app.use('/api/me/content', createMyContentRouter()); + // ======================================== // SEO Routes (sitemap.xml, robots.txt) // ======================================== diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts new file mode 100644 index 0000000000..986b46dfde --- /dev/null +++ b/server/src/routes/content.ts @@ -0,0 +1,915 @@ +/** + * Content routes module + * + * Unified content management routes: + * - Propose content to any collection + * - View pending content for review + * - Approve/reject pending content + * - Get user's content (My Content view) + */ + +import { Router } from 'express'; +import { createLogger } from '../logger.js'; +import { requireAuth } from '../middleware/auth.js'; +import { getPool } from '../db/client.js'; + +const logger = createLogger('content-routes'); + +interface ContentAuthor { + user_id: string; + display_name: string; + display_title?: string; + display_order?: number; +} + +interface ProposeContentRequest { + title: string; + content?: string; + content_type?: 'article' | 'link'; + external_url?: string; + external_site_name?: string; + excerpt?: string; + category?: string; + tags?: string[]; + collection: { + type: 'personal' | 'committee'; + committee_slug?: string; + }; + authors?: ContentAuthor[]; +} + +/** + * Check if user is a committee lead + */ +async function isCommitteeLead(committeeId: string, userId: string): Promise { + const pool = getPool(); + const result = await pool.query( + `SELECT 1 FROM working_group_leaders + WHERE working_group_id = $1 AND user_id = $2`, + [committeeId, userId] + ); + return result.rows.length > 0; +} + +/** + * Check if user is a site admin + */ +async function isAdmin(userId: string): Promise { + const pool = getPool(); + const result = await pool.query( + `SELECT 1 FROM users + WHERE id = $1 AND is_aao_admin = true`, + [userId] + ); + return result.rows.length > 0; +} + +/** + * Get user info for author display + */ +async function getUserInfo(userId: string): Promise<{ name: string; title?: string } | null> { + const pool = getPool(); + const result = await pool.query( + `SELECT first_name, last_name, title, email FROM users WHERE id = $1`, + [userId] + ); + if (result.rows.length === 0) return null; + const user = result.rows[0]; + const name = user.first_name && user.last_name + ? `${user.first_name} ${user.last_name}` + : user.email?.split('@')[0] || 'Unknown'; + return { name, title: user.title }; +} + +/** + * Create content routes + * Returns a router to be mounted at /api/content + */ +export function createContentRouter(): Router { + const router = Router(); + + // POST /api/content/propose - Submit content to any collection + router.post('/propose', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { + title, + content, + content_type = 'article', + external_url, + external_site_name, + excerpt, + category, + tags = [], + collection, + authors, + } = req.body as ProposeContentRequest; + + // Validate required fields + if (!title) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'title is required', + }); + } + + if (!collection || !collection.type) { + return res.status(400).json({ + error: 'Missing collection', + message: 'collection.type is required (personal or committee)', + }); + } + + // 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 content', + }); + } + + if (content_type === 'article' && !content) { + return res.status(400).json({ + error: 'Missing content', + message: 'content is required for article type content', + }); + } + + const pool = getPool(); + let committeeId: string | null = null; + let canPublishDirectly = false; + + // Resolve committee if specified + if (collection.type === 'committee') { + if (!collection.committee_slug) { + return res.status(400).json({ + error: 'Missing committee_slug', + message: 'committee_slug is required for committee content', + }); + } + + const committeeResult = await pool.query( + `SELECT id FROM working_groups WHERE slug = $1`, + [collection.committee_slug] + ); + + if (committeeResult.rows.length === 0) { + return res.status(404).json({ + error: 'Committee not found', + message: `No committee found with slug: ${collection.committee_slug}`, + }); + } + + committeeId = committeeResult.rows[0].id as string; + + // Check if user is a lead - can publish directly + canPublishDirectly = await isCommitteeLead(committeeId, user.id); + } + + // Site admins can always publish directly + if (await isAdmin(user.id)) { + canPublishDirectly = true; + } + + // Generate slug from title + const baseSlug = title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .substring(0, 100); + const slug = `${baseSlug}-${Date.now().toString(36)}`; + + // Determine initial status + const status = canPublishDirectly ? 'published' : 'pending_review'; + const publishedAt = canPublishDirectly ? new Date().toISOString() : null; + const proposedAt = new Date().toISOString(); + + // Get author info for display + const userInfo = await getUserInfo(user.id); + const authorName = userInfo?.name || user.email?.split('@')[0] || 'Unknown'; + const authorTitle = userInfo?.title; + + // Insert the content + const result = await pool.query( + `INSERT INTO perspectives ( + slug, content_type, title, content, excerpt, + external_url, external_site_name, category, tags, + author_name, author_title, author_user_id, + proposer_user_id, proposed_at, + working_group_id, status, published_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING *`, + [ + slug, content_type, title, content, excerpt, + external_url, external_site_name, category, tags, + authorName, authorTitle, user.id, + user.id, proposedAt, + committeeId, status, publishedAt, + ] + ); + + const perspective = result.rows[0]; + + // Create content_authors records + const authorsToCreate = authors && authors.length > 0 + ? authors + : [{ user_id: user.id, display_name: authorName, display_title: authorTitle, display_order: 0 }]; + + for (const author of authorsToCreate) { + await pool.query( + `INSERT INTO content_authors (perspective_id, user_id, display_name, display_title, display_order) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (perspective_id, user_id) DO NOTHING`, + [perspective.id, author.user_id, author.display_name, author.display_title, author.display_order || 0] + ); + } + + logger.info({ + perspectiveId: perspective.id, + userId: user.id, + title, + status, + collection: collection.type, + committeeSlug: collection.committee_slug, + }, 'Content proposed'); + + const message = canPublishDirectly + ? 'Content published successfully' + : 'Content submitted for review. A committee lead or admin will review it soon.'; + + res.status(201).json({ + id: perspective.id, + slug: perspective.slug, + status: perspective.status, + message, + }); + } catch (error) { + logger.error({ err: error }, 'POST /api/content/propose error'); + res.status(500).json({ + error: 'Failed to propose content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // GET /api/content/pending - List pending content user can review + router.get('/pending', requireAuth, async (req, res) => { + try { + const user = req.user!; + const committeeSlug = req.query.committee_slug as string | undefined; + const pool = getPool(); + + // Get committees user leads + const leaderResult = await pool.query( + `SELECT wg.id, wg.name, wg.slug + FROM working_group_leaders wgl + JOIN working_groups wg ON wg.id = wgl.working_group_id + WHERE wgl.user_id = $1`, + [user.id] + ); + const ledCommittees = leaderResult.rows; + const ledCommitteeIds = ledCommittees.map(c => c.id); + + // Check if admin + const userIsAdmin = await isAdmin(user.id); + + if (!userIsAdmin && ledCommitteeIds.length === 0) { + return res.json({ + items: [], + summary: { total: 0, by_collection: {} }, + }); + } + + // Build query for pending content + let query = ` + SELECT + p.id, p.title, p.excerpt, p.slug, p.content_type, + p.proposer_user_id, p.proposed_at, p.working_group_id, + wg.name as committee_name, wg.slug as committee_slug, + u.first_name, u.last_name, u.email as proposer_email, + (SELECT json_agg(json_build_object( + 'user_id', ca.user_id, + 'display_name', ca.display_name + ) ORDER BY ca.display_order) + FROM content_authors ca WHERE ca.perspective_id = p.id) as authors + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + LEFT JOIN users u ON u.id = p.proposer_user_id + WHERE p.status = 'pending_review' + `; + const params: (string | string[])[] = []; + + if (!userIsAdmin) { + // Non-admins only see pending for committees they lead + params.push(ledCommitteeIds); + query += ` AND p.working_group_id = ANY($${params.length})`; + } + + if (committeeSlug) { + params.push(committeeSlug); + query += ` AND wg.slug = $${params.length}`; + } + + query += ` ORDER BY p.proposed_at ASC`; + + const result = await pool.query(query, params); + + // Format response + const items = result.rows.map(row => ({ + id: row.id, + title: row.title, + slug: row.slug, + excerpt: row.excerpt, + content_type: row.content_type, + proposer: { + id: row.proposer_user_id, + name: row.first_name && row.last_name + ? `${row.first_name} ${row.last_name}` + : row.proposer_email?.split('@')[0] || 'Unknown', + }, + proposed_at: row.proposed_at, + collection: { + type: row.working_group_id ? 'committee' : 'personal', + committee_name: row.committee_name, + committee_slug: row.committee_slug, + }, + authors: row.authors || [], + })); + + // Calculate summary + const byCollection: Record = {}; + for (const item of items) { + const key = item.collection.committee_slug || 'personal'; + byCollection[key] = (byCollection[key] || 0) + 1; + } + + res.json({ + items, + summary: { + total: items.length, + by_collection: byCollection, + }, + }); + } catch (error) { + logger.error({ err: error }, 'GET /api/content/pending error'); + res.status(500).json({ + error: 'Failed to get pending content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/content/:id/approve - Approve pending content + router.post('/:id/approve', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id } = req.params; + const { publish_immediately = true } = req.body; + const pool = getPool(); + + // Get the content + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const content = contentResult.rows[0]; + + if (content.status !== 'pending_review') { + return res.status(400).json({ + error: 'Invalid status', + message: `Content is not pending review (current status: ${content.status})`, + }); + } + + // Check permission + const userIsAdmin = await isAdmin(user.id); + const userIsLead = content.working_group_id + ? await isCommitteeLead(content.working_group_id, user.id) + : false; + + if (!userIsAdmin && !userIsLead) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to approve this content', + }); + } + + // Update status + const newStatus = publish_immediately ? 'published' : 'draft'; + const publishedAt = publish_immediately ? new Date().toISOString() : null; + + await pool.query( + `UPDATE perspectives + SET status = $1, published_at = $2, + reviewed_by_user_id = $3, reviewed_at = NOW() + WHERE id = $4`, + [newStatus, publishedAt, user.id, id] + ); + + logger.info({ + contentId: id, + reviewerId: user.id, + newStatus, + committeeSlug: content.committee_slug, + }, 'Content approved'); + + res.json({ + success: true, + status: newStatus, + message: publish_immediately + ? 'Content approved and published' + : 'Content approved and saved as draft', + }); + } catch (error) { + logger.error({ err: error }, 'POST /api/content/:id/approve error'); + res.status(500).json({ + error: 'Failed to approve content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/content/:id/reject - Reject pending content + router.post('/:id/reject', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id } = req.params; + const { reason } = req.body; + const pool = getPool(); + + if (!reason) { + return res.status(400).json({ + error: 'Missing reason', + message: 'A reason is required when rejecting content', + }); + } + + // Get the content + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const content = contentResult.rows[0]; + + if (content.status !== 'pending_review') { + return res.status(400).json({ + error: 'Invalid status', + message: `Content is not pending review (current status: ${content.status})`, + }); + } + + // Check permission + const userIsAdmin = await isAdmin(user.id); + const userIsLead = content.working_group_id + ? await isCommitteeLead(content.working_group_id, user.id) + : false; + + if (!userIsAdmin && !userIsLead) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to reject this content', + }); + } + + // Update status + await pool.query( + `UPDATE perspectives + SET status = 'rejected', rejection_reason = $1, + reviewed_by_user_id = $2, reviewed_at = NOW() + WHERE id = $3`, + [reason, user.id, id] + ); + + logger.info({ + contentId: id, + reviewerId: user.id, + reason, + committeeSlug: content.committee_slug, + }, 'Content rejected'); + + res.json({ + success: true, + status: 'rejected', + message: 'Content rejected', + }); + } catch (error) { + logger.error({ err: error }, 'POST /api/content/:id/reject error'); + res.status(500).json({ + error: 'Failed to reject content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} + +/** + * Create user content routes (My Content) + * Returns a router to be mounted at /api/me/content + */ +export function createMyContentRouter(): Router { + const router = Router(); + + // GET /api/me/content - Get all content where user has a relationship + router.get('/', requireAuth, async (req, res) => { + try { + const user = req.user!; + const status = req.query.status as string | undefined; + const collection = req.query.collection as string | undefined; + const relationship = req.query.relationship as string | undefined; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const pool = getPool(); + + // Get committees user leads (for "owner" relationship) + const leaderResult = await pool.query( + `SELECT working_group_id FROM working_group_leaders WHERE user_id = $1`, + [user.id] + ); + const ledCommitteeIds = leaderResult.rows.map(r => r.working_group_id); + + // Build the query + let query = ` + SELECT DISTINCT ON (p.id) + p.id, p.slug, p.content_type, p.title, p.subtitle, p.category, p.excerpt, + p.external_url, p.external_site_name, p.status, p.published_at, + p.created_at, p.updated_at, p.working_group_id, p.proposer_user_id, + wg.name as committee_name, wg.slug as committee_slug, + -- Determine relationships + CASE WHEN p.proposer_user_id = $1 THEN true ELSE false END as is_proposer, + CASE WHEN EXISTS (SELECT 1 FROM content_authors ca WHERE ca.perspective_id = p.id AND ca.user_id = $1) THEN true ELSE false END as is_author, + CASE WHEN p.working_group_id = ANY($2) THEN true ELSE false END as is_lead, + -- Get authors + (SELECT json_agg(json_build_object( + 'user_id', ca.user_id, + 'display_name', ca.display_name, + 'display_title', ca.display_title + ) ORDER BY ca.display_order) + FROM content_authors ca WHERE ca.perspective_id = p.id) as authors + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE ( + p.proposer_user_id = $1 + OR EXISTS (SELECT 1 FROM content_authors ca WHERE ca.perspective_id = p.id AND ca.user_id = $1) + OR p.working_group_id = ANY($2) + ) + `; + const params: (string | string[] | number)[] = [user.id, ledCommitteeIds]; + + // Apply filters + if (status && status !== 'all') { + const validStatuses = ['draft', 'pending_review', 'published', 'archived', 'rejected']; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + error: 'Invalid status', + message: `status must be one of: ${validStatuses.join(', ')}, or 'all'`, + }); + } + params.push(status); + query += ` AND p.status = $${params.length}`; + } + + if (collection) { + if (collection === 'personal') { + query += ` AND p.working_group_id IS NULL`; + } else { + // Assume it's a committee slug + params.push(collection); + query += ` AND wg.slug = $${params.length}`; + } + } + + query += ` ORDER BY p.id, p.created_at DESC`; + params.push(limit); + query += ` LIMIT $${params.length}`; + + const result = await pool.query(query, params); + + // Format response with relationships + const items = result.rows.map(row => { + const relationships: string[] = []; + if (row.is_author) relationships.push('author'); + if (row.is_proposer) relationships.push('proposer'); + if (row.is_lead) relationships.push('owner'); + + return { + id: row.id, + slug: row.slug, + title: row.title, + subtitle: row.subtitle, + content_type: row.content_type, + category: row.category, + excerpt: row.excerpt, + status: row.status, + collection: { + type: row.working_group_id ? 'committee' : 'personal', + committee_name: row.committee_name, + committee_slug: row.committee_slug, + }, + relationships, + authors: row.authors || [], + published_at: row.published_at, + created_at: row.created_at, + updated_at: row.updated_at, + }; + }).filter(item => { + // Apply relationship filter if specified + if (!relationship) return true; + return item.relationships.includes(relationship); + }); + + res.json({ items }); + } catch (error) { + logger.error({ err: error }, 'GET /api/me/content error'); + res.status(500).json({ + error: 'Failed to get content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // PUT /api/me/content/:id - Update content user owns + router.put('/:id', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id } = req.params; + const { + title, + content, + content_type, + excerpt, + external_url, + external_site_name, + category, + tags, + } = req.body; + const pool = getPool(); + + // Get the content and check ownership + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const contentItem = contentResult.rows[0]; + + // Check permission: proposer, author, committee lead, or admin + const isProposer = contentItem.proposer_user_id === user.id; + const isAuthor = await pool.query( + `SELECT 1 FROM content_authors WHERE perspective_id = $1 AND user_id = $2`, + [id, user.id] + ).then(r => r.rows.length > 0); + const userIsLead = contentItem.working_group_id + ? await isCommitteeLead(contentItem.working_group_id, user.id) + : false; + const userIsAdmin = await isAdmin(user.id); + + if (!isProposer && !isAuthor && !userIsLead && !userIsAdmin) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to edit this content', + }); + } + + // Build update query + const updates: string[] = []; + const values: (string | string[] | null)[] = []; + let paramIndex = 1; + + if (title !== undefined) { + updates.push(`title = $${paramIndex++}`); + values.push(title); + } + if (content !== undefined) { + updates.push(`content = $${paramIndex++}`); + values.push(content); + } + if (content_type !== undefined) { + updates.push(`content_type = $${paramIndex++}`); + values.push(content_type); + } + if (excerpt !== undefined) { + updates.push(`excerpt = $${paramIndex++}`); + values.push(excerpt); + } + if (external_url !== undefined) { + updates.push(`external_url = $${paramIndex++}`); + values.push(external_url); + } + if (external_site_name !== undefined) { + updates.push(`external_site_name = $${paramIndex++}`); + values.push(external_site_name); + } + if (category !== undefined) { + updates.push(`category = $${paramIndex++}`); + values.push(category); + } + if (tags !== undefined) { + updates.push(`tags = $${paramIndex++}`); + values.push(tags); + } + + if (updates.length === 0) { + return res.status(400).json({ + error: 'No updates provided', + message: 'Please provide at least one field to update', + }); + } + + values.push(id); + const result = await pool.query( + `UPDATE perspectives SET ${updates.join(', ')}, updated_at = NOW() + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + logger.info({ contentId: id, userId: user.id }, 'Content updated'); + + res.json(result.rows[0]); + } catch (error) { + logger.error({ err: error }, 'PUT /api/me/content/:id error'); + res.status(500).json({ + error: 'Failed to update content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/me/content/:id/authors - Add co-author to content + router.post('/:id/authors', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id } = req.params; + const { user_id, display_name, display_title } = req.body; + const pool = getPool(); + + if (!user_id || !display_name) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'user_id and display_name are required', + }); + } + + // Check ownership + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const contentItem = contentResult.rows[0]; + + // Check permission + const isProposer = contentItem.proposer_user_id === user.id; + const userIsLead = contentItem.working_group_id + ? await isCommitteeLead(contentItem.working_group_id, user.id) + : false; + const userIsAdmin = await isAdmin(user.id); + + if (!isProposer && !userIsLead && !userIsAdmin) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to add authors to this content', + }); + } + + // Get current max display_order + const orderResult = await pool.query( + `SELECT COALESCE(MAX(display_order), -1) + 1 as next_order + FROM content_authors WHERE perspective_id = $1`, + [id] + ); + const nextOrder = orderResult.rows[0].next_order; + + // Add the author + const result = await pool.query( + `INSERT INTO content_authors (perspective_id, user_id, display_name, display_title, display_order) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (perspective_id, user_id) DO UPDATE SET + display_name = EXCLUDED.display_name, + display_title = EXCLUDED.display_title + RETURNING *`, + [id, user_id, display_name, display_title, nextOrder] + ); + + logger.info({ contentId: id, authorUserId: user_id }, 'Author added to content'); + + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error({ err: error }, 'POST /api/me/content/:id/authors error'); + res.status(500).json({ + error: 'Failed to add author', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // DELETE /api/me/content/:id/authors/:authorId - Remove co-author from content + router.delete('/:id/authors/:authorId', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id, authorId } = req.params; + const pool = getPool(); + + // Check ownership + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const contentItem = contentResult.rows[0]; + + // Check permission + const isProposer = contentItem.proposer_user_id === user.id; + const userIsLead = contentItem.working_group_id + ? await isCommitteeLead(contentItem.working_group_id, user.id) + : false; + const userIsAdmin = await isAdmin(user.id); + + if (!isProposer && !userIsLead && !userIsAdmin) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to remove authors from this content', + }); + } + + // Remove the author + const result = await pool.query( + `DELETE FROM content_authors WHERE perspective_id = $1 AND user_id = $2 RETURNING *`, + [id, authorId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: 'Author not found', + message: `No author found with id: ${authorId}`, + }); + } + + logger.info({ contentId: id, authorUserId: authorId }, 'Author removed from content'); + + res.json({ success: true, deleted: authorId }); + } catch (error) { + logger.error({ err: error }, 'DELETE /api/me/content/:id/authors/:authorId error'); + res.status(500).json({ + error: 'Failed to remove author', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} diff --git a/specs/unified-content-management.md b/specs/unified-content-management.md new file mode 100644 index 0000000000..d7ebe64a57 --- /dev/null +++ b/specs/unified-content-management.md @@ -0,0 +1,469 @@ +# Unified Content Management System + +## What We're Building + +A content management system that separates authorship (who gets credit) from ownership (who controls), supports co-authoring, and enables content proposals with review workflows. + +--- + +## Core Concepts + +### Authorship vs Ownership vs Proposer + +| Role | What it means | Example | +|------|--------------|---------| +| **Authors** | Who gets display credit | "By Alice Chen and Bob Smith" | +| **Owner** | Who can publish/edit/delete | Committee leads control committee content | +| **Proposer** | Who originally submitted | Tracked for pending items; may differ from authors | + +### Ownership Rules + +``` +Content Type → Owner +───────────────────────────────────────────────────── +Personal perspective → The individual author +Committee content → Committee leads (via working_group_leaders) +Site-wide content → Site admins +``` + +### Content Status Flow + +``` +[draft] ──user creates──→ [pending_review] ──owner approves──→ [published] + │ + └──owner rejects──→ [rejected] +``` + +- Members with ownership rights can skip `pending_review` and publish directly +- Content always starts as `draft` when created, moves to `pending_review` on submission + +--- + +## Data Model Changes + +### 1. New Table: `content_authors` + +Replaces single `author_user_id` with support for multiple co-authors. + +```sql +CREATE TABLE content_authors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + perspective_id UUID NOT NULL REFERENCES perspectives(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, -- WorkOS user ID + display_name VARCHAR(255) NOT NULL, + display_title VARCHAR(255), + display_order INTEGER DEFAULT 0, -- For author ordering + created_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(perspective_id, user_id) +); + +CREATE INDEX idx_content_authors_perspective ON content_authors(perspective_id); +CREATE INDEX idx_content_authors_user ON content_authors(user_id); +``` + +### 2. Modify `perspectives` Table + +```sql +-- Add proposer tracking and new status +ALTER TABLE perspectives + ADD COLUMN proposer_user_id VARCHAR(255), + ADD COLUMN proposed_at TIMESTAMPTZ, + ADD COLUMN reviewed_by_user_id VARCHAR(255), + ADD COLUMN reviewed_at TIMESTAMPTZ, + ADD COLUMN rejection_reason TEXT; + +-- Update status constraint to include new states +ALTER TABLE perspectives + DROP CONSTRAINT IF EXISTS perspectives_status_check, + ADD CONSTRAINT perspectives_status_check + CHECK (status IN ('draft', 'pending_review', 'published', 'archived', 'rejected')); + +-- Backfill: existing author_user_id becomes both proposer and first author +-- (handled in migration) +``` + +### 3. Keep Existing Columns (Backward Compatible) + +- `author_user_id` - deprecated but kept for backward compatibility; will be the "primary" author +- `author_name` / `author_title` - kept for display fallback; computed from first author going forward + +--- + +## Permission Model + +### Who Can Do What + +```typescript +interface ContentPermissions { + // Anyone can propose content to any collection + canPropose: () => boolean; // Always true for authenticated users + + // Ownership determines control + canPublish: (content: Perspective, user: User) => boolean; + canEdit: (content: Perspective, user: User) => boolean; + canDelete: (content: Perspective, user: User) => boolean; +} + +function isOwner(content: Perspective, userId: string): boolean { + // Personal content: user is proposer/author + if (!content.working_group_id) { + return content.proposer_user_id === userId || + content.authors?.some(a => a.user_id === userId); + } + + // Committee content: user is committee lead + return isCommitteeLead(content.working_group_id, userId); +} + +function canPublishDirectly(content: Perspective, userId: string): boolean { + // Site admins can always publish + if (isAdmin(userId)) return true; + + // Committee leads can publish to their committees + if (content.working_group_id) { + return isCommitteeLead(content.working_group_id, userId); + } + + // Personal content: only if no committee (goes to /perspectives page) + // These require admin approval since they're site-wide + return false; +} +``` + +### Permission Matrix + +| Action | Personal Content | Committee Content | Site-wide | +|--------|-----------------|-------------------|-----------| +| Create draft | Author | Any member | Admin | +| Submit for review | Author | Any member | Admin | +| Publish directly | Never (needs admin) | Committee lead | Admin | +| Edit published | Admin | Committee lead | Admin | +| Delete | Admin | Committee lead | Admin | +| View pending | Proposer + Admin | Proposer + Lead | Admin | + +--- + +## API Changes + +### New Endpoints + +``` +POST /api/content/propose + Submit content to any collection (creates in pending_review) + +GET /api/content/pending + List pending content user can review (as owner/lead/admin) + +POST /api/content/:id/approve + Approve pending content (owner/lead/admin only) + +POST /api/content/:id/reject + Reject pending content with reason (owner/lead/admin only) + +GET /api/me/content + "My Content" view - content where user is author, proposer, or owner +``` + +### Propose Content + +```typescript +// POST /api/content/propose +interface ProposeContentRequest { + title: string; + content?: string; // Markdown for articles + content_type: 'article' | 'link'; + external_url?: string; + external_site_name?: string; + excerpt?: string; + category?: string; + + // Where it should appear + collection: { + type: 'personal' | 'committee'; + committee_slug?: string; // Required if type === 'committee' + }; + + // Authors (defaults to current user) + authors?: Array<{ + user_id: string; + display_name: string; + display_title?: string; + }>; +} + +interface ProposeContentResponse { + id: string; + status: 'draft' | 'pending_review' | 'published'; + // If user is owner, status will be 'published' (direct publish) + // Otherwise, status will be 'pending_review' + message: string; +} +``` + +### Get Pending Content + +```typescript +// GET /api/content/pending +interface GetPendingResponse { + items: Array<{ + id: string; + title: string; + excerpt?: string; + proposer: { + id: string; + name: string; + }; + proposed_at: string; + collection: { + type: 'personal' | 'committee'; + committee_name?: string; + committee_slug?: string; + }; + authors: Array<{ + user_id: string; + display_name: string; + }>; + }>; + summary: { + total: number; + by_collection: Record; + }; +} +``` + +### My Content View + +```typescript +// GET /api/me/content +interface MyContentResponse { + items: Array<{ + id: string; + title: string; + status: string; + collection: { + type: 'personal' | 'committee'; + committee_name?: string; + }; + // User's relationship to this content + relationships: Array<'author' | 'proposer' | 'owner'>; + published_at?: string; + created_at: string; + }>; +} +``` + +--- + +## Addie Tool Changes + +### New Tools + +```typescript +// For committee leads and admins +{ + name: 'list_pending_content', + description: 'List content pending review that you can approve or reject.', + input_schema: { + type: 'object', + properties: { + committee_slug: { + type: 'string', + description: 'Filter to specific committee (optional)' + } + } + } +} + +{ + name: 'approve_content', + description: 'Approve pending content for publication.', + input_schema: { + type: 'object', + properties: { + content_id: { type: 'string' }, + publish_immediately: { type: 'boolean', default: true } + }, + required: ['content_id'] + } +} + +{ + name: 'reject_content', + description: 'Reject pending content with feedback.', + input_schema: { + type: 'object', + properties: { + content_id: { type: 'string' }, + reason: { type: 'string' } + }, + required: ['content_id', 'reason'] + } +} +``` + +### Updated Tools + +```typescript +// Update create_perspective to support proposals +{ + name: 'create_perspective', + description: 'Create a perspective article or link. If targeting a committee you lead, publishes directly. Otherwise, submits for review.', + input_schema: { + type: 'object', + properties: { + title: { type: 'string' }, + content: { type: 'string' }, + content_type: { enum: ['article', 'link'] }, + // New: where to publish + committee_slug: { + type: 'string', + description: 'Committee to publish to. Omit for personal perspective.' + }, + // New: co-authors + co_author_emails: { + type: 'array', + items: { type: 'string' }, + description: 'Email addresses of co-authors to add' + } + }, + required: ['title'] + } +} +``` + +### Proactive Notifications + +Addie surfaces pending content to relevant users: + +```typescript +// In Addie's context building +interface PendingNotification { + type: 'pending_content'; + count: number; + summary: string; // "3 articles pending in Media Buying Protocol" + action: string; // "Use list_pending_content to review" +} + +// Triggers: +// 1. When committee lead opens Addie: check for pending in their committees +// 2. When admin opens Addie: check for pending site-wide content +// 3. Daily digest option (future) +``` + +Example Addie prompts: + +``` +"You have 3 pending posts in Media Buying Protocol committee awaiting review." + +"2 perspective submissions need your approval before they can be published." + +"Alice Chen submitted an article 'The Future of Agentic Ads' to your committee yesterday." +``` + +--- + +## UI Requirements + +### "My Content" Dashboard Section + +Location: `/dashboard` or `/me/content` + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ My Content │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ [Draft] My thoughts on AI advertising āœļø šŸ—‘ļø │ +│ Personal Ā· You are the author │ +│ │ +│ [Pending] Q4 Media Buying Trends šŸ‘ļø │ +│ Media Buying Protocol Ā· You proposed this │ +│ │ +│ [Published] Welcome to NYC Chapter! āœļø │ +│ NYC Chapter Ā· You are a lead │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +Badges: +- `author` - "You are an author" +- `proposer` - "You proposed this" +- `owner` - "You are a lead" / "You can manage" + +### Pending Review Queue (for leads/admins) + +Location: Committee manage page or admin dashboard + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Pending Review (3) │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ Q4 Media Buying Trends │ +│ By: Alice Chen Ā· Proposed: 2 days ago │ +│ [Preview] [Approve] [Reject] │ +│ │ +│ New Member Welcome Guide │ +│ By: Bob Smith Ā· Proposed: 5 days ago │ +│ [Preview] [Approve] [Reject] │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +### Content Creation Flow + +When member creates content: + +1. **Select collection**: "Where should this appear?" + - My personal perspectives + - [List of committees they're members of] + +2. **Add co-authors** (optional): Search by name/email + +3. **Submit**: + - If they're an owner (committee lead): "Publish now" or "Save as draft" + - If they're not an owner: "Submit for review" (goes to pending_review) + +--- + +## Migration Plan + +### Phase 1: Database Changes + +1. Create `content_authors` table +2. Add new columns to `perspectives` +3. Backfill: copy `author_user_id` to `proposer_user_id` and create `content_authors` records + +### Phase 2: API Updates + +1. Add new endpoints (`/api/content/*`) +2. Update existing perspective endpoints to use new permission model +3. Ensure backward compatibility with existing clients + +### Phase 3: Addie Tools + +1. Add `list_pending_content`, `approve_content`, `reject_content` +2. Update `create_perspective` with committee targeting +3. Add proactive pending notifications + +### Phase 4: UI + +1. Add "My Content" section to dashboard +2. Add pending review queue to committee manage pages +3. Update content creation flow + +--- + +## Success Criteria + +- [ ] Multiple authors can be credited on a single piece of content +- [ ] Committee leads control committee content (not individual authors) +- [ ] Non-leads can propose content that goes to pending review +- [ ] Addie proactively notifies leads/admins of pending items +- [ ] "My Content" view shows all content where user has a relationship +- [ ] Existing content continues to work (backward compatible) + +--- + +## Terminology + +- Use **"committee"** in user-facing text (covers working groups, councils, chapters) +- Keep `working_group_id` as column name (no DB rename needed) +- API uses `committee_slug` not `working_group_slug` for new endpoints From 75881dd7307a023d474f87ff91b2466c56278112 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 8 Jan 2026 08:04:43 -0800 Subject: [PATCH 2/3] feat: add unified CMS page at /my-content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create my-content.html with unified content management UI - Shows user's content (authored, proposed, owned) - Pending review tab for committee leads and admins - Create/edit content with committee targeting - Approve/reject workflow for reviewers - Filter by status and collection - Update /admin/perspectives to redirect to /my-content - Update admin APIs to accept pending_review and rejected statuses šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/public/my-content.html | 1247 +++++++++++++++++++++++++++++++++ server/src/http.ts | 21 +- 2 files changed, 1262 insertions(+), 6 deletions(-) create mode 100644 server/public/my-content.html diff --git a/server/public/my-content.html b/server/public/my-content.html new file mode 100644 index 0000000000..6ff22daf8f --- /dev/null +++ b/server/public/my-content.html @@ -0,0 +1,1247 @@ + + + + + + + My Content - AgenticAdvertising.org + + + + + + + +
+ +
+ Loading... +
+ + + + + + + + + + + + diff --git a/server/src/http.ts b/server/src/http.ts index 7660e57036..a7f513a7b2 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -1288,6 +1288,14 @@ export class HTTPServer { }); this.app.get('/dashboard/emails', (req, res) => serveDashboardPage(req, res, 'dashboard-emails.html')); + // My Content - unified CMS for all authenticated users + this.app.get('/my-content', async (req, res) => { + if (this.isAdcpDomain(req)) { + return res.redirect('https://agenticadvertising.org/my-content'); + } + await this.serveHtmlWithConfig(req, res, 'my-content.html'); + }); + // API endpoints // Public config endpoint - returns feature flags and auth state for nav @@ -3386,7 +3394,7 @@ export class HTTPServer { } = req.body; const validContentTypes = ['article', 'link']; - const validStatuses = ['draft', 'published', 'archived']; + const validStatuses = ['draft', 'published', 'archived', 'pending_review', 'rejected']; if (!slug || !title) { return res.status(400).json({ @@ -3405,7 +3413,7 @@ export class HTTPServer { if (!validStatuses.includes(status)) { return res.status(400).json({ error: 'Invalid status', - message: 'status must be: draft, published, or archived' + message: 'status must be: draft, published, archived, pending_review, or rejected' }); } @@ -3492,7 +3500,7 @@ export class HTTPServer { } = req.body; const validContentTypes = ['article', 'link']; - const validStatuses = ['draft', 'published', 'archived']; + const validStatuses = ['draft', 'published', 'archived', 'pending_review', 'rejected']; if (!slug || !title) { return res.status(400).json({ @@ -3511,7 +3519,7 @@ export class HTTPServer { if (status && !validStatuses.includes(status)) { return res.status(400).json({ error: 'Invalid status', - message: 'status must be: draft, published, or archived' + message: 'status must be: draft, published, archived, pending_review, or rejected' }); } @@ -3903,8 +3911,9 @@ Disallow: /api/admin/ // Note: /admin/billing is now served from billing.ts router - this.app.get('/admin/perspectives', requireAuth, requireAdmin, async (req, res) => { - await this.serveHtmlWithConfig(req, res, 'admin-perspectives.html'); + // Redirect old admin perspectives to unified CMS + this.app.get('/admin/perspectives', requireAuth, requireAdmin, (req, res) => { + res.redirect(301, '/my-content'); }); this.app.get('/admin/working-groups', requireAuth, requireAdmin, async (req, res) => { From f5d3af53a6d8f87016077cf7f06eb3b186f0cd5c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 9 Jan 2026 10:05:50 -0500 Subject: [PATCH 3/3] fix: collections API and my-content improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix SQL queries to use correct column names (workos_user_id) - Add GET /api/content/collections endpoint for public and user committees - Add migration 151 for public content collections (Editorial, Training) - Update my-content.html status hint logic to use collection data - Remove deprecated admin-perspectives.html šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/new-coins-think.md | 2 + server/public/admin-perspectives.html | 1091 ----------------- server/public/my-content.html | 149 +-- .../151_public_content_collections.sql | 58 + server/src/http.ts | 387 +----- server/src/routes/content.ts | 206 +++- 6 files changed, 312 insertions(+), 1581 deletions(-) create mode 100644 .changeset/new-coins-think.md delete mode 100644 server/public/admin-perspectives.html create mode 100644 server/src/db/migrations/151_public_content_collections.sql diff --git a/.changeset/new-coins-think.md b/.changeset/new-coins-think.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/new-coins-think.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-perspectives.html b/server/public/admin-perspectives.html deleted file mode 100644 index f2d88c1dc0..0000000000 --- a/server/public/admin-perspectives.html +++ /dev/null @@ -1,1091 +0,0 @@ - - - - - - - Admin - Perspectives - AdCP Registry - - - - - - - - - -
- -
- Loading... -
- - - - - - - - - - - - diff --git a/server/public/my-content.html b/server/public/my-content.html index 6ff22daf8f..c09c458a24 100644 --- a/server/public/my-content.html +++ b/server/public/my-content.html @@ -485,8 +485,7 @@

Your Content

@@ -540,8 +539,7 @@

Create Content

Where this content will appear on the site
@@ -665,8 +663,8 @@

currentUser = await meResponse.json(); isAdmin = currentUser.is_admin; - // Load committees user can post to - await loadCommittees(); + // Load collections user can post to + await loadCollections(); // Load user's content await loadMyContent(); @@ -683,24 +681,24 @@

} } - // Load committees for dropdowns - async function loadCommittees() { + // Load available collections for content submission + async function loadCollections() { try { - // Get committees user is a member of - const response = await fetch('/api/me/working-groups'); + const response = await fetch('/api/content/collections'); if (response.ok) { const data = await response.json(); - committees = data.map(m => ({ - slug: m.working_group.slug, - name: m.working_group.name, - isLeader: m.role === 'leader' + committees = data.collections.map(c => ({ + slug: c.slug, + name: c.name, + type: c.type, + canPublishDirectly: c.can_publish_directly })); // Populate dropdowns populateCommitteeDropdowns(); } } catch (error) { - console.error('Failed to load committees:', error); + console.error('Failed to load collections:', error); } } @@ -709,15 +707,41 @@

const collectionFilter = document.getElementById('collectionFilter'); const reviewFilter = document.getElementById('reviewCollectionFilter'); - // Add committee options to create modal - committees.forEach(c => { - const option = document.createElement('option'); - option.value = `committee:${c.slug}`; - option.textContent = c.name; - collectionSelect.appendChild(option); - }); + // Clear existing options + collectionSelect.innerHTML = ''; + collectionFilter.innerHTML = ''; + + // Group collections by type + const publicCollections = committees.filter(c => c.type === 'public'); + const committeeCollections = committees.filter(c => c.type === 'committee'); + + // Add public collections first (site-wide) + if (publicCollections.length > 0) { + const publicGroup = document.createElement('optgroup'); + publicGroup.label = 'Site-wide'; + publicCollections.forEach(c => { + const option = document.createElement('option'); + option.value = c.slug; + option.textContent = c.name; + publicGroup.appendChild(option); + }); + collectionSelect.appendChild(publicGroup); + } - // Add to filter + // Add committee collections + if (committeeCollections.length > 0) { + const committeeGroup = document.createElement('optgroup'); + committeeGroup.label = 'Your Committees'; + committeeCollections.forEach(c => { + const option = document.createElement('option'); + option.value = c.slug; + option.textContent = c.name; + committeeGroup.appendChild(option); + }); + collectionSelect.appendChild(committeeGroup); + } + + // Add all to filter dropdown committees.forEach(c => { const option = document.createElement('option'); option.value = c.slug; @@ -725,9 +749,9 @@

collectionFilter.appendChild(option); }); - // Add led committees to review filter - const ledCommittees = committees.filter(c => c.isLeader); - ledCommittees.forEach(c => { + // Add collections user can review to review filter + const reviewableCollections = committees.filter(c => c.canPublishDirectly); + reviewableCollections.forEach(c => { const option = document.createElement('option'); option.value = c.slug; option.textContent = c.name; @@ -754,8 +778,8 @@

// Check if user can review and load pending content async function checkReviewPermissions() { - const ledCommittees = committees.filter(c => c.isLeader); - canReview = isAdmin || ledCommittees.length > 0; + const reviewableCollections = committees.filter(c => c.canPublishDirectly); + canReview = isAdmin || reviewableCollections.length > 0; if (canReview) { document.getElementById('reviewTab').classList.remove('hidden'); @@ -798,11 +822,9 @@

filtered = filtered.filter(c => c.status === statusFilter); } if (collectionFilter) { - if (collectionFilter === 'personal') { - filtered = filtered.filter(c => c.collection?.type === 'personal'); - } else { - filtered = filtered.filter(c => c.collection?.committee_slug === collectionFilter); - } + filtered = filtered.filter(c => + c.collection?.committee_slug === collectionFilter || + c.collection?.slug === collectionFilter); } if (filtered.length === 0) { @@ -847,9 +869,8 @@

All caught up!

function renderContentItem(item, isPendingView) { const statusBadge = `${formatStatus(item.status)}`; const typeBadge = `${item.content_type}`; - const collectionBadge = item.collection?.type === 'committee' - ? `${item.collection.committee_name || item.collection.committee_slug}` - : `Personal`; + const collectionName = item.collection?.committee_name || item.collection?.name || item.collection?.committee_slug || 'Unknown'; + const collectionBadge = `${collectionName}`; const relationships = (item.relationships || []).map(r => `${r}` @@ -939,10 +960,9 @@

${escapeHtml(item.title)}

document.getElementById('statusSelect').value = item.status === 'published' ? 'published' : 'draft'; // Set collection - if (item.collection?.type === 'committee') { - document.getElementById('collection').value = `committee:${item.collection.committee_slug}`; - } else { - document.getElementById('collection').value = 'personal'; + const collectionSlug = item.collection?.committee_slug || item.collection?.slug; + if (collectionSlug) { + document.getElementById('collection').value = collectionSlug; } setContentType(item.content_type || 'article'); @@ -957,12 +977,8 @@

${escapeHtml(item.title)}

const item = myContent.find(c => c.id === id); if (!item) return; - let url; - if (item.collection?.type === 'committee') { - url = `/committees/${item.collection.committee_slug}`; - } else { - url = `/perspectives/${item.slug}`; - } + // All content now lives under perspectives + const url = `/perspectives/${item.slug}`; window.open(url, '_blank'); } @@ -1009,25 +1025,21 @@

${escapeHtml(item.title)}

// Update status hint based on collection function updateStatusHint() { - const collection = document.getElementById('collection').value; + const collectionSlug = document.getElementById('collection').value; const hint = document.getElementById('statusHint'); const statusSelect = document.getElementById('statusSelect'); - if (collection.startsWith('committee:')) { - const slug = collection.replace('committee:', ''); - const committee = committees.find(c => c.slug === slug); + const collection = committees.find(c => c.slug === collectionSlug); - if (committee?.isLeader || isAdmin) { - hint.textContent = 'As a lead, you can publish directly.'; - statusSelect.disabled = false; - } else { - hint.textContent = 'Will be submitted for review by committee leads.'; - statusSelect.value = 'draft'; - statusSelect.disabled = true; - } - } else { - hint.textContent = 'Personal perspectives can be published directly.'; + if (collection?.canPublishDirectly || isAdmin) { + hint.textContent = collection?.type === 'committee' + ? 'As a lead, you can publish directly.' + : 'You can publish directly.'; statusSelect.disabled = false; + } else { + hint.textContent = 'Will be submitted for review.'; + statusSelect.value = 'draft'; + statusSelect.disabled = true; } } @@ -1041,7 +1053,7 @@

${escapeHtml(item.title)}

btn.textContent = 'Fetching...'; try { - const response = await fetch('/api/admin/perspectives/fetch-url', { + const response = await fetch('/api/content/fetch-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) @@ -1066,20 +1078,9 @@

${escapeHtml(item.title)}

event.preventDefault(); const id = document.getElementById('contentId').value; - const collectionValue = document.getElementById('collection').value; + const collectionSlug = document.getElementById('collection').value; const statusValue = document.getElementById('statusSelect').value; - // Parse collection - let collection; - if (collectionValue.startsWith('committee:')) { - collection = { - type: 'committee', - committee_slug: collectionValue.replace('committee:', '') - }; - } else { - collection = { type: 'personal' }; - } - const data = { title: document.getElementById('title').value.trim(), content: document.getElementById('articleContent').value || null, @@ -1087,7 +1088,7 @@

${escapeHtml(item.title)}

external_url: document.getElementById('externalUrl').value.trim() || null, excerpt: document.getElementById('excerpt').value.trim() || null, category: document.getElementById('category').value || null, - collection, + collection: { slug: collectionSlug }, }; const btn = document.getElementById('saveBtn'); diff --git a/server/src/db/migrations/151_public_content_collections.sql b/server/src/db/migrations/151_public_content_collections.sql new file mode 100644 index 0000000000..ee6066782c --- /dev/null +++ b/server/src/db/migrations/151_public_content_collections.sql @@ -0,0 +1,58 @@ +-- Migration: 151_public_content_collections.sql +-- Add support for site-wide content collections that anyone can submit to +-- Examples: Perspectives, Learn Agentic (Training & Education) + +-- ============================================================================= +-- 1. Add accepts_public_submissions flag to working_groups +-- ============================================================================= + +ALTER TABLE working_groups + ADD COLUMN IF NOT EXISTS accepts_public_submissions BOOLEAN DEFAULT FALSE; + +COMMENT ON COLUMN working_groups.accepts_public_submissions IS + 'If true, any authenticated user can submit content to this group (requires lead approval)'; + +-- ============================================================================= +-- 2. Create Editorial working group for site-wide Perspectives +-- ============================================================================= + +INSERT INTO working_groups (slug, name, description, accepts_public_submissions) +VALUES ( + 'editorial', + 'Editorial', + 'Site-wide perspectives and thought leadership content. Anyone can submit, editorial team approves.', + TRUE +) +ON CONFLICT (slug) DO UPDATE SET + accepts_public_submissions = TRUE, + description = EXCLUDED.description; + +-- ============================================================================= +-- 3. Mark Training & Education as accepting public submissions +-- ============================================================================= + +UPDATE working_groups +SET accepts_public_submissions = TRUE, + description = COALESCE(description, '') || ' Anyone can submit educational content for review.' +WHERE slug = 'training-education-wg'; + +-- ============================================================================= +-- 4. Create index for efficient lookup of public collections +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS idx_working_groups_public_submissions + ON working_groups(accepts_public_submissions) + WHERE accepts_public_submissions = TRUE; + +-- ============================================================================= +-- 5. Migrate existing personal perspectives to Editorial +-- ============================================================================= + +-- Move any existing perspectives without a working_group_id to Editorial +UPDATE perspectives +SET working_group_id = (SELECT id FROM working_groups WHERE slug = 'editorial') +WHERE working_group_id IS NULL; + +-- ============================================================================= +-- Done +-- ============================================================================= diff --git a/server/src/http.ts b/server/src/http.ts index a7f513a7b2..be054d6bd6 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -58,12 +58,11 @@ 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 { createContentRouter, createMyContentRouter } from "./routes/content.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"; @@ -3245,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', 'pending_review', 'rejected']; - - 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, archived, pending_review, or rejected' - }); - } - - // 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', 'pending_review', 'rejected']; - - 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, archived, pending_review, or rejected' - }); - } - - // 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) // ======================================== diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts index 986b46dfde..79ba41bca4 100644 --- a/server/src/routes/content.ts +++ b/server/src/routes/content.ts @@ -32,8 +32,9 @@ interface ProposeContentRequest { category?: string; tags?: string[]; collection: { - type: 'personal' | 'committee'; + type?: 'personal' | 'committee'; // Deprecated - kept for backwards compatibility committee_slug?: string; + slug?: string; // New format - collection slug directly }; authors?: ContentAuthor[]; } @@ -88,6 +89,59 @@ async function getUserInfo(userId: string): Promise<{ name: string; title?: stri export function createContentRouter(): Router { const router = Router(); + // GET /api/content/collections - Get available collections for content submission + router.get('/collections', requireAuth, async (req, res) => { + try { + const user = req.user!; + const pool = getPool(); + + // Get public collections (anyone can submit) + const publicResult = await pool.query( + `SELECT id, slug, name, description + FROM working_groups + WHERE accepts_public_submissions = TRUE + ORDER BY name` + ); + + // Get committees user is a member of (non-public ones) + const memberResult = await pool.query( + `SELECT wg.id, wg.slug, wg.name, wg.description, + EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) as is_leader + FROM working_group_memberships wgm + JOIN working_groups wg ON wg.id = wgm.working_group_id + WHERE wgm.workos_user_id = $1 + AND wg.accepts_public_submissions = FALSE + ORDER BY wg.name`, + [user.id] + ); + + const collections = [ + ...publicResult.rows.map(row => ({ + slug: row.slug, + name: row.name, + description: row.description, + type: 'public' as const, + can_publish_directly: false, // Public collections always require approval + })), + ...memberResult.rows.map(row => ({ + slug: row.slug, + name: row.name, + description: row.description, + type: 'committee' as const, + can_publish_directly: row.is_leader, + })), + ]; + + res.json({ collections }); + } catch (error) { + logger.error({ err: error }, 'GET /api/content/collections error'); + res.status(500).json({ + error: 'Failed to get collections', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + // POST /api/content/propose - Submit content to any collection router.post('/propose', requireAuth, async (req, res) => { try { @@ -113,10 +167,12 @@ export function createContentRouter(): Router { }); } - if (!collection || !collection.type) { + // Support both old format (collection.type + committee_slug) and new format (just committee_slug) + const committeeSlug = collection?.committee_slug || collection?.slug; + if (!committeeSlug) { return res.status(400).json({ error: 'Missing collection', - message: 'collection.type is required (personal or committee)', + message: 'collection.committee_slug or collection.slug is required', }); } @@ -136,40 +192,43 @@ export function createContentRouter(): Router { } const pool = getPool(); - let committeeId: string | null = null; - let canPublishDirectly = false; - // Resolve committee if specified - if (collection.type === 'committee') { - if (!collection.committee_slug) { - return res.status(400).json({ - error: 'Missing committee_slug', - message: 'committee_slug is required for committee content', - }); - } + // Resolve the collection (working group) + const committeeResult = await pool.query( + `SELECT id, accepts_public_submissions FROM working_groups WHERE slug = $1`, + [committeeSlug] + ); - const committeeResult = await pool.query( - `SELECT id FROM working_groups WHERE slug = $1`, - [collection.committee_slug] - ); + if (committeeResult.rows.length === 0) { + return res.status(404).json({ + error: 'Collection not found', + message: `No collection found with slug: ${committeeSlug}`, + }); + } - if (committeeResult.rows.length === 0) { - return res.status(404).json({ - error: 'Committee not found', - message: `No committee found with slug: ${collection.committee_slug}`, - }); - } + const committeeId = committeeResult.rows[0].id as string; + const acceptsPublicSubmissions = committeeResult.rows[0].accepts_public_submissions; - committeeId = committeeResult.rows[0].id as string; + // Check if user can submit to this collection + const userIsLead = await isCommitteeLead(committeeId, user.id); + const userIsAdmin = await isAdmin(user.id); - // Check if user is a lead - can publish directly - canPublishDirectly = await isCommitteeLead(committeeId, user.id); + // For non-public collections, user must be a member + if (!acceptsPublicSubmissions && !userIsLead && !userIsAdmin) { + const membershipResult = await pool.query( + `SELECT 1 FROM working_group_memberships WHERE working_group_id = $1 AND workos_user_id = $2`, + [committeeId, user.id] + ); + if (membershipResult.rows.length === 0) { + return res.status(403).json({ + error: 'Not a member', + message: 'You must be a member of this committee to submit content', + }); + } } - // Site admins can always publish directly - if (await isAdmin(user.id)) { - canPublishDirectly = true; - } + // Determine if user can publish directly (leads and admins only) + const canPublishDirectly = userIsLead || userIsAdmin; // Generate slug from title const baseSlug = title @@ -441,6 +500,93 @@ export function createContentRouter(): Router { } }); + // POST /api/content/fetch-url - Fetch URL metadata for auto-fill + router.post('/fetch-url', requireAuth, 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[^>]*>([^<]+)<\/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); + + // Decode HTML entities helper + const decodeHtmlEntities = (text: string): string => { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec)) + .replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + }; + + // 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 }, 'POST /api/content/fetch-url error'); + res.status(500).json({ + error: 'Failed to fetch URL', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + // POST /api/content/:id/reject - Reject pending content router.post('/:id/reject', requireAuth, async (req, res) => { try {