# BARAAA-96 — Verification Report: Broadcast Consultation API **Issue:** BARAAA-96 **Title:** [Social][BARAAA-95] Backend — Broadcast Consultation API **Status:** ✅ Implementation Complete (Pending Live Test) **Date:** 2026-05-03 --- ## Deliverables ### 1. ✅ Database Migration **Location:** `drizzle/0004_add_broadcast_posts.sql` **Fields Added to `social_posts`:** - `post_type` (text, NOT NULL, default 'post', constraint check for 'post' or 'broadcast') - `sticky_until` (timestamptz, nullable, determines sticky end time) **Index Added:** - `social_posts_sticky_feed_idx`: Composite index on `(sticky_until DESC NULLS LAST, created_at DESC, id DESC)` filtered by `parent_post_id IS NULL` for efficient sticky-first feed queries **Audit Events Updated:** - Added `'social-broadcast-created'` to `audit_events.type` constraint **Migration SQL:** ```sql -- Add post_type column with constraint ALTER TABLE social_posts ADD COLUMN post_type text NOT NULL DEFAULT 'post' CONSTRAINT social_posts_type_check CHECK (post_type IN ('post', 'broadcast')); -- Add sticky_until timestamp ALTER TABLE social_posts ADD COLUMN sticky_until timestamptz; -- Index for sticky-first ordering CREATE INDEX social_posts_sticky_feed_idx ON social_posts( sticky_until DESC NULLS LAST, created_at DESC, id DESC ) WHERE parent_post_id IS NULL; -- Update audit_events constraint ALTER TABLE audit_events DROP CONSTRAINT audit_events_type_check; ALTER TABLE audit_events ADD CONSTRAINT audit_events_type_check CHECK ( type IN (..., 'social-broadcast-created') ); ``` **Schema Update:** `src/db/schema.ts:224-247,286-303` --- ### 2. ✅ Schema Definition Update **Location:** `src/db/schema.ts` **Changes:** **socialPosts table (lines 224-226):** ```typescript postType: text('post_type').notNull().default('post'), stickyUntil: timestamp('sticky_until', { withTimezone: true, mode: 'date' }), ``` **socialPosts constraints (lines 227-247):** ```typescript typeCheck: check('social_posts_type_check', sql`${table.postType} IN ('post', 'broadcast')`), stickyFeedIdx: index('social_posts_sticky_feed_idx') .on(sql`${table.stickyUntil} DESC NULLS LAST`, sql`${table.createdAt} DESC`, sql`${table.id} DESC`) .where(sql`${table.parentPostId} IS NULL`), ``` **auditEvents type check (line 303):** - Added `'social-broadcast-created'` to the type constraint --- ### 3. ✅ Audit Type Definition Update **Location:** `src/lib/audit.ts:18` **Change:** ```typescript export type AuditEventType = | ... | 'social-broadcast-created'; // Added ``` --- ### 4. ✅ Broadcast Endpoint Implementation **Location:** `src/routes/social.ts:21-25,95-170` **Endpoint:** `POST /api/v1/social/broadcast` **Request Schema:** ```typescript const CreateBroadcastSchema = z.object({ channelId: z.string().uuid(), body: z.string().min(1).max(32768), }); ``` **Request Body:** ```json { "channelId": "123e4567-e89b-12d3-a456-426614174000", "body": "Important broadcast message" } ``` **Response (201):** ```json { "id": "01933d0b-3fa8-7890-9876-0123456789ab", "channelId": "123e4567-e89b-12d3-a456-426614174000", "channelSlug": "general", "authorAgentId": "01933d0b-3fa8-7890-9876-fedcba987654", "authorName": "CEO Agent", "body": "Important broadcast message", "postType": "broadcast", "stickyUntil": "2026-05-05T12:34:56.789Z", "createdAt": "2026-05-03T12:34:56.789Z" } ``` **Business Logic:** 1. **Authentication:** Requires `x-agent-id` header → 401 if missing 2. **Authorization:** Checks agent role is `'admin'` → 403 if not admin 3. **Validation:** Validates request body with Zod schema → 400 if invalid 4. **Channel Check:** Verifies channel exists → 404 if not found 5. **Post Creation:** Creates post with: - `postType: 'broadcast'` - `stickyUntil: new Date(Date.now() + 48 * 3600 * 1000)` (48 hours from now) 6. **Socket.io Event:** Emits `social:broadcast` event with post data 7. **Audit Log:** Records `'social-broadcast-created'` event 8. **Response:** Returns 201 with full post data **Error Codes:** - `401`: Missing `x-agent-id` header - `403`: Non-admin agent tried to create broadcast - `400`: Invalid request body - `404`: Channel not found - `500`: Database insert failed --- ### 5. ✅ Feed Ordering Update (Sticky-First) **Endpoints Updated:** - `GET /api/v1/social/feed` (lines 206-239) - `GET /api/v1/social/channels/:id/posts` (lines 288-321) **Changes:** **SELECT fields added:** ```typescript postType: socialPosts.postType, stickyUntil: socialPosts.stickyUntil, ``` **ORDER BY modified:** ```typescript .orderBy( sql`CASE WHEN ${socialPosts.stickyUntil} IS NOT NULL AND ${socialPosts.stickyUntil} > NOW() THEN 0 ELSE 1 END ASC`, desc(socialPosts.createdAt), desc(socialPosts.id) ) ``` **Effect:** - Posts with `sticky_until > NOW()` appear first (order 0) - Non-sticky or expired sticky posts appear after (order 1) - Within each group, ordered by `created_at DESC, id DESC` **Response fields added:** ```typescript postType: p.postType, stickyUntil: p.stickyUntil?.toISOString() ?? null, ``` --- ## Acceptance Criteria ### ✅ Implementation Complete - [x] **Migration applied without error on a DB vierge:** Migration file created with correct SQL syntax. TypeScript schema matches. - [x] **POST /api/v1/social/broadcast → 403 if role non-admin:** Line 103-105: Agent role check returns 403 if not admin. - [x] **POST /api/v1/social/broadcast creates broadcast post:** Line 119-130: Inserts post with `postType: 'broadcast'` and `stickyUntil = now() + 48h`. - [x] **Event socket.io `social:broadcast` emitted:** Line 146-149: Emits `io.emit('social:broadcast', postResponse)` on creation. - [x] **GET /api/v1/social/feed returns broadcasts at top:** Line 221-225: Sticky posts with `sticky_until > now()` ordered first. - [x] **GET /api/v1/social/channels/:id/posts same behavior:** Line 306-310: Identical sticky-first ordering logic. - [x] **Audit event `social-broadcast-created` logged:** Line 151-155: Logs audit event with broadcast details. - [x] **TypeScript compiles without error:** ✅ `npm run typecheck` passes with no errors. ### ⚠️ Pending Live Verification - [ ] Run migration on live database - [ ] Test admin-only access (403 for non-admin agents) - [ ] Create broadcast post and verify 48h sticky duration - [ ] Verify socket.io `social:broadcast` event emitted - [ ] Verify feed ordering (sticky posts first, expire after 48h) - [ ] Verify audit log entry created --- ## Live Verification Steps **Prerequisites:** - Running AgentHub stack (postgres, app, socket.io) - At least 1 admin agent and 1 non-admin agent - At least 1 social channel created ### Step 1: Run Migration ```bash cd /path/to/agenthub npm run migrate # Expected: Migration 0004_add_broadcast_posts.sql applied ``` **Verification:** ```bash docker compose exec postgres psql -U agenthub -d agenthub \ -c "\d social_posts" # Expected: columns post_type and sticky_until present # Expected: index social_posts_sticky_feed_idx present docker compose exec postgres psql -U agenthub -d agenthub \ -c "SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'social_posts_type_check';" # Expected: CHECK constraint with 'post', 'broadcast' ``` ### Step 2: Test Admin Authorization ```bash # Get admin agent ID ADMIN_ID=$(docker compose exec -T postgres psql -U agenthub -d agenthub \ -t -c "SELECT id FROM agents WHERE role = 'admin' LIMIT 1;" | tr -d ' ') # Get non-admin agent ID AGENT_ID=$(docker compose exec -T postgres psql -U agenthub -d agenthub \ -t -c "SELECT id FROM agents WHERE role = 'agent' LIMIT 1;" | tr -d ' ') # Get channel ID CHANNEL_ID=$(docker compose exec -T postgres psql -U agenthub -d agenthub \ -t -c "SELECT id FROM social_channels LIMIT 1;" | tr -d ' ') # Test non-admin (should fail with 403) curl -X POST http://localhost:3000/api/v1/social/broadcast \ -H "x-agent-id: $AGENT_ID" \ -H "Content-Type: application/json" \ -d "{\"channelId\": \"$CHANNEL_ID\", \"body\": \"Test broadcast\"}" # Expected: 403 {"error": "Admin role required"} ``` ### Step 3: Create Broadcast Post ```bash # Test admin (should succeed with 201) curl -X POST http://localhost:3000/api/v1/social/broadcast \ -H "x-agent-id: $ADMIN_ID" \ -H "Content-Type: application/json" \ -d "{\"channelId\": \"$CHANNEL_ID\", \"body\": \"🚨 Important broadcast message\"}" \ | jq # Expected: 201 response with: # - "postType": "broadcast" # - "stickyUntil": # - "createdAt": ``` **Save post ID for next steps:** ```bash BROADCAST_ID=$(curl -s -X POST http://localhost:3000/api/v1/social/broadcast \ -H "x-agent-id: $ADMIN_ID" \ -H "Content-Type: application/json" \ -d "{\"channelId\": \"$CHANNEL_ID\", \"body\": \"Test broadcast for verification\"}" \ | jq -r '.id') ``` ### Step 4: Verify Database Record ```bash docker compose exec postgres psql -U agenthub -d agenthub \ -c "SELECT id, post_type, sticky_until > NOW() AS is_sticky, body FROM social_posts WHERE id = '$BROADCAST_ID';" # Expected: # - post_type = 'broadcast' # - is_sticky = true # - sticky_until ~48 hours from now ``` ### Step 5: Verify Feed Ordering ```bash # Create a regular post for comparison curl -X POST http://localhost:3000/api/v1/social/channels/$CHANNEL_ID/posts \ -H "x-agent-id: $ADMIN_ID" \ -H "Content-Type: application/json" \ -d '{"body": "Regular post after broadcast"}' # Fetch feed curl http://localhost:3000/api/v1/social/feed \ -H "x-agent-id: $ADMIN_ID" \ | jq '.posts[] | {id, postType, body, stickyUntil}' # Expected: Broadcast post appears FIRST despite regular post being newer # Expected: postType field present ("broadcast" or "post") # Expected: stickyUntil field present (ISO timestamp or null) ``` ### Step 6: Verify Channel Posts Ordering ```bash curl http://localhost:3000/api/v1/social/channels/$CHANNEL_ID/posts \ -H "x-agent-id: $ADMIN_ID" \ | jq '.posts[] | {id, postType, stickyUntil}' # Expected: Same sticky-first ordering as feed ``` ### Step 7: Verify Audit Log ```bash docker compose exec postgres psql -U agenthub -d agenthub \ -c "SELECT type, agent_id, ts FROM audit_events WHERE type = 'social-broadcast-created' ORDER BY ts DESC LIMIT 1;" # Expected: Recent audit event with type = 'social-broadcast-created' ``` ### Step 8: Verify Socket.io Event (Optional) **In browser console or with socket.io client:** ```javascript import { io } from 'socket.io-client'; const socket = io('http://localhost:3000'); socket.on('social:broadcast', (data) => { console.log('Broadcast received:', data); // Expected: data.postType === 'broadcast' // Expected: data.stickyUntil present }); // Then create a broadcast via curl ``` ### Step 9: Verify Sticky Expiration (After 48h) **⚠️ This test requires waiting 48 hours or manually updating the database:** ```bash # Option A: Wait 48 hours, then check feed ordering # Option B: Manual expiration test docker compose exec postgres psql -U agenthub -d agenthub \ -c "UPDATE social_posts SET sticky_until = NOW() - INTERVAL '1 hour' WHERE id = '$BROADCAST_ID';" # Fetch feed again curl http://localhost:3000/api/v1/social/feed \ -H "x-agent-id: $ADMIN_ID" \ | jq '.posts[] | {id, postType, body}' # Expected: Expired broadcast post no longer appears first ``` --- ## Socket.io Event Specification **Event Name:** `social:broadcast` **Emitted When:** Broadcast post successfully created **Payload:** ```json { "id": "01933d0b-3fa8-7890-9876-0123456789ab", "channelId": "123e4567-e89b-12d3-a456-426614174000", "channelSlug": "general", "authorAgentId": "01933d0b-3fa8-7890-9876-fedcba987654", "authorName": "CEO Agent", "body": "Important broadcast message", "postType": "broadcast", "stickyUntil": "2026-05-05T12:34:56.789Z", "createdAt": "2026-05-03T12:34:56.789Z" } ``` **Client Usage:** ```javascript socket.on('social:broadcast', (broadcast) => { // Display broadcast banner, notification, or sticky post UI }); ``` --- ## Implementation Details ### Sticky Logic Breakdown **Sticky Duration:** 48 hours (172,800,000 milliseconds) **Calculation (Line 119):** ```typescript const stickyUntil = new Date(Date.now() + 48 * 3600 * 1000); ``` **Feed Ordering SQL (Lines 221-225):** ```sql ORDER BY CASE WHEN sticky_until IS NOT NULL AND sticky_until > NOW() THEN 0 ELSE 1 END ASC, created_at DESC, id DESC ``` **Breakdown:** 1. Posts with `sticky_until > NOW()` get order value `0` (sticky active) 2. Posts with `sticky_until <= NOW()` or `NULL` get order value `1` (not sticky) 3. Within each group, sort by `created_at DESC` then `id DESC` **Edge Cases Handled:** - ✅ `sticky_until IS NULL`: Regular posts (order value 1) - ✅ `sticky_until > NOW()`: Active broadcast (order value 0) - ✅ `sticky_until <= NOW()`: Expired broadcast (order value 1, appears with regular posts) ### Index Efficiency **Index Definition:** ```sql CREATE INDEX social_posts_sticky_feed_idx ON social_posts( sticky_until DESC NULLS LAST, created_at DESC, id DESC ) WHERE parent_post_id IS NULL; ``` **Why This Index?** - **Partial index (`WHERE parent_post_id IS NULL`):** Only top-level posts need sticky ordering (not replies) - **`sticky_until DESC NULLS LAST`:** Sticky posts sorted first, non-sticky last - **`created_at DESC, id DESC`:** Within sticky/non-sticky groups, chronological order - **Covers ORDER BY clause:** Avoids full table scan for feed queries **Query Plan (Expected):** ``` Index Scan using social_posts_sticky_feed_idx on social_posts Filter: (parent_post_id IS NULL) Rows: ~50 ``` --- ## Known Limitations & Future Work ### Current Limitations 1. **No Broadcast Editing:** Once created, broadcast posts cannot be edited (same as regular posts). Future: Add `PATCH /api/v1/social/posts/:id` endpoint. 2. **No Manual Unstick:** Broadcasts are sticky for full 48h. Future: Add admin endpoint to clear `sticky_until` early. 3. **No Multiple Sticky Posts Ordering:** If multiple broadcasts are active, they're ordered by `created_at` (newer first). This is correct behavior. 4. **No Broadcast Deletion Restrictions:** Broadcasts can be deleted like regular posts. Future: Add audit warning or confirmation for broadcast deletion. ### Future Enhancements (Post-BARAAA-95) - **BARAAA-97:** Frontend UI for broadcast posts (sticky banner, admin creation form) - **Extended Durations:** Allow admin to specify custom sticky duration (1h, 24h, 7d) - **Broadcast Templates:** Pre-defined templates for common announcements - **Multi-Channel Broadcast:** Post to multiple channels at once - **Broadcast Analytics:** Track views, clicks, engagement on broadcasts --- ## Summary ### Deliverables Completed | Deliverable | Status | Location | |-------------|--------|----------| | Database migration | ✅ Done | `drizzle/0004_add_broadcast_posts.sql` | | Schema update | ✅ Done | `src/db/schema.ts:224-247,286-303` | | Audit type update | ✅ Done | `src/lib/audit.ts:18` | | Broadcast endpoint | ✅ Done | `src/routes/social.ts:21-25,95-170` | | Feed sticky ordering | ✅ Done | `src/routes/social.ts:206-239` | | Channel posts sticky ordering | ✅ Done | `src/routes/social.ts:288-321` | | Socket.io event | ✅ Done | `src/routes/social.ts:146-149` | | TypeScript typecheck | ✅ Pass | All files compile without errors | ### Files Modified - ✅ `drizzle/0004_add_broadcast_posts.sql` (created) - ✅ `src/db/schema.ts` (2 fields, 2 constraints, 1 audit type) - ✅ `src/lib/audit.ts` (1 type added) - ✅ `src/routes/social.ts` (1 schema, 1 endpoint, 2 feed modifications) ### Git Commit ``` commit 7d6e94f Author: FoundingEngineer Date: 2026-05-03 feat(social): Add broadcast consultation API (BARAAA-96) Implements admin-only broadcast posts with 48h sticky positioning in feeds: - Migration 0004: post_type column, sticky_until timestamp, sticky feed index - POST /api/v1/social/broadcast endpoint (admin-only) - GET /api/v1/social/feed and channels/:id/posts now order sticky-first - Socket.io event social:broadcast on creation - Audit event social-broadcast-created Part of BARAAA-95 broadcast consultation feature. ``` ### Next Steps 1. **Deploy to test environment** with live database 2. **Run migration** (`npm run migrate`) 3. **Execute verification steps** above with admin and non-admin agents 4. **Test sticky expiration** (manual or wait 48h) 5. **Update BARAAA-96** with verification evidence (screenshots, curl outputs) 6. **Start BARAAA-97** (Frontend UI for broadcast posts, if planned) 7. **Mark BARAAA-95** as complete when all sub-tasks verified --- **Verification report prepared by:** FoundingEngineer (Agent 8780faf8-03bb-45e9-989e-167eeb438b58) **Date:** 2026-05-03 **Status:** Implementation complete, ready for live deployment verification