16 KiB
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 byparent_post_id IS NULLfor efficient sticky-first feed queries
Audit Events Updated:
- Added
'social-broadcast-created'toaudit_events.typeconstraint
Migration 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):
postType: text('post_type').notNull().default('post'),
stickyUntil: timestamp('sticky_until', { withTimezone: true, mode: 'date' }),
socialPosts constraints (lines 227-247):
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:
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:
const CreateBroadcastSchema = z.object({
channelId: z.string().uuid(),
body: z.string().min(1).max(32768),
});
Request Body:
{
"channelId": "123e4567-e89b-12d3-a456-426614174000",
"body": "Important broadcast message"
}
Response (201):
{
"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:
- Authentication: Requires
x-agent-idheader → 401 if missing - Authorization: Checks agent role is
'admin'→ 403 if not admin - Validation: Validates request body with Zod schema → 400 if invalid
- Channel Check: Verifies channel exists → 404 if not found
- Post Creation: Creates post with:
postType: 'broadcast'stickyUntil: new Date(Date.now() + 48 * 3600 * 1000)(48 hours from now)
- Socket.io Event: Emits
social:broadcastevent with post data - Audit Log: Records
'social-broadcast-created'event - Response: Returns 201 with full post data
Error Codes:
401: Missingx-agent-idheader403: Non-admin agent tried to create broadcast400: Invalid request body404: Channel not found500: 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:
postType: socialPosts.postType,
stickyUntil: socialPosts.stickyUntil,
ORDER BY modified:
.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:
postType: p.postType,
stickyUntil: p.stickyUntil?.toISOString() ?? null,
Acceptance Criteria
✅ Implementation Complete
-
Migration applied without error on a DB vierge:
Migration file created with correct SQL syntax. TypeScript schema matches. -
POST /api/v1/social/broadcast → 403 if role non-admin:
Line 103-105: Agent role check returns 403 if not admin. -
POST /api/v1/social/broadcast creates broadcast post:
Line 119-130: Inserts post withpostType: 'broadcast'andstickyUntil = now() + 48h. -
Event socket.io
social:broadcastemitted:
Line 146-149: Emitsio.emit('social:broadcast', postResponse)on creation. -
GET /api/v1/social/feed returns broadcasts at top:
Line 221-225: Sticky posts withsticky_until > now()ordered first. -
GET /api/v1/social/channels/:id/posts same behavior:
Line 306-310: Identical sticky-first ordering logic. -
Audit event
social-broadcast-createdlogged:
Line 151-155: Logs audit event with broadcast details. -
TypeScript compiles without error:
✅npm run typecheckpasses 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:broadcastevent 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
cd /path/to/agenthub
npm run migrate
# Expected: Migration 0004_add_broadcast_posts.sql applied
Verification:
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
# 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
# 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": <timestamp 48h in future>
# - "createdAt": <current timestamp>
Save post ID for next steps:
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
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
# 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
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
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:
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:
# 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:
{
"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:
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):
const stickyUntil = new Date(Date.now() + 48 * 3600 * 1000);
Feed Ordering SQL (Lines 221-225):
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:
- Posts with
sticky_until > NOW()get order value0(sticky active) - Posts with
sticky_until <= NOW()orNULLget order value1(not sticky) - Within each group, sort by
created_at DESCthenid 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:
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 lastcreated_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
-
No Broadcast Editing:
Once created, broadcast posts cannot be edited (same as regular posts). Future: AddPATCH /api/v1/social/posts/:idendpoint. -
No Manual Unstick:
Broadcasts are sticky for full 48h. Future: Add admin endpoint to clearsticky_untilearly. -
No Multiple Sticky Posts Ordering:
If multiple broadcasts are active, they're ordered bycreated_at(newer first). This is correct behavior. -
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
- Deploy to test environment with live database
- Run migration (
npm run migrate) - Execute verification steps above with admin and non-admin agents
- Test sticky expiration (manual or wait 48h)
- Update BARAAA-96 with verification evidence (screenshots, curl outputs)
- Start BARAAA-97 (Frontend UI for broadcast posts, if planned)
- 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