( user: ContentUser, request: ProposeContentRequest )
| 363 | * Use this from internal services like Addie to bypass HTTP authentication. |
| 364 | */ |
| 365 | export async function proposeContentForUser( |
| 366 | user: ContentUser, |
| 367 | request: ProposeContentRequest |
| 368 | ): Promise<ProposeContentResult> { |
| 369 | const { |
| 370 | title, |
| 371 | subtitle, |
| 372 | content, |
| 373 | content_type = 'article', |
| 374 | external_url, |
| 375 | external_site_name, |
| 376 | excerpt, |
| 377 | category, |
| 378 | tags = [], |
| 379 | author_title: requestAuthorTitle, |
| 380 | featured_image_url, |
| 381 | content_origin = 'member', |
| 382 | collection, |
| 383 | authors, |
| 384 | status: requestedStatus, |
| 385 | } = request; |
| 386 | |
| 387 | // Per-user rate check — bounds every entry path to proposeContentForUser, |
| 388 | // including Addie's MCP tool handler that bypasses HTTP middleware. |
| 389 | const rate = checkProposeRateLimit(user.id); |
| 390 | if (!rate.ok) { |
| 391 | const retrySeconds = Math.max(1, Math.ceil(rate.retryAfterMs / 1000)); |
| 392 | logger.warn({ userId: user.id, retrySeconds }, 'proposeContentForUser rate-limited'); |
| 393 | return { |
| 394 | success: false, |
| 395 | error: `Submission rate limit exceeded (${PROPOSE_MAX_PER_WINDOW} per ${PROPOSE_WINDOW_MS / 60000} minutes). Try again in ${retrySeconds} seconds.`, |
| 396 | }; |
| 397 | } |
| 398 | |
| 399 | // Validate required fields |
| 400 | if (!title) { |
| 401 | return { success: false, error: 'title is required' }; |
| 402 | } |
| 403 | |
| 404 | // Field length validation — mirror DB column limits so callers get a |
| 405 | // friendly 400 instead of the Postgres "value too long" → HTTP 500 |
| 406 | // path we were hitting (see #2734). The DB schema is authoritative; |
| 407 | // these constants just surface the limit before we hit the insert. |
| 408 | if (title.length > 500) { |
| 409 | return { success: false, error: `title is too long (max 500 characters; got ${title.length})` }; |
| 410 | } |
| 411 | if (subtitle && subtitle.length > 1000) { |
| 412 | return { success: false, error: `subtitle is too long (max 1000 characters; got ${subtitle.length})` }; |
| 413 | } |
| 414 | if (requestAuthorTitle && requestAuthorTitle.length > 255) { |
| 415 | return { success: false, error: `author_title is too long (max 255 characters; got ${requestAuthorTitle.length})` }; |
| 416 | } |
| 417 | if (external_site_name && external_site_name.length > 255) { |
| 418 | return { success: false, error: `external_site_name is too long (max 255 characters; got ${external_site_name.length})` }; |
| 419 | } |
| 420 | |
| 421 | // Support both old format (collection.type + committee_slug) and new format (just committee_slug) |
| 422 | const committeeSlug = collection?.committee_slug || collection?.slug; |
no test coverage detected