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.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Paperclip FoundingEngineer 2026-05-03 00:31:58 +00:00
parent 5555c04d10
commit 7d6e94f076
4 changed files with 138 additions and 4 deletions

View file

@ -0,0 +1,33 @@
-- Add post_type column
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 for 48h broadcast stickiness
ALTER TABLE social_posts ADD COLUMN sticky_until timestamptz;
-- Index for sticky-first feed 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 check constraint to add new type
ALTER TABLE audit_events DROP CONSTRAINT audit_events_type_check;
ALTER TABLE audit_events ADD CONSTRAINT audit_events_type_check CHECK (
type IN (
'login',
'token-issued',
'token-rotated',
'token-revoked',
'jwt-issued',
'agent-created',
'agent-deleted',
'room-created',
'room-deleted',
'message-sent',
'social-channel-created',
'social-post-created',
'social-broadcast-created'
)
);

View file

@ -221,9 +221,12 @@ export const socialPosts = pgTable(
body: text('body').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
postType: text('post_type').notNull().default('post'),
stickyUntil: timestamp('sticky_until', { withTimezone: true, mode: 'date' }),
},
(table) => ({
bodyCheck: check('social_posts_body_check', sql`length(${table.body}) BETWEEN 1 AND 32768`),
typeCheck: check('social_posts_type_check', sql`${table.postType} IN ('post', 'broadcast')`),
channelCreatedAtIdx: index('social_posts_channel_created_at_idx').on(
table.channelId,
sql`${table.createdAt} DESC`,
@ -242,6 +245,9 @@ export const socialPosts = pgTable(
sql`${table.createdAt} DESC`,
sql`${table.id} DESC`,
),
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`),
}),
);
@ -297,7 +303,8 @@ export const auditEvents = pgTable(
'room-deleted',
'message-sent',
'social-channel-created',
'social-post-created'
'social-post-created',
'social-broadcast-created'
)`,
),
payloadHashCheck: check(

View file

@ -15,7 +15,8 @@ export type AuditEventType =
| 'room-deleted'
| 'message-sent'
| 'social-channel-created'
| 'social-post-created';
| 'social-post-created'
| 'social-broadcast-created';
export interface AuditPayload {
[key: string]: unknown;

View file

@ -16,6 +16,11 @@ const CreatePostSchema = z.object({
body: z.string().min(1).max(32768),
});
const CreateBroadcastSchema = z.object({
channelId: z.string().uuid(),
body: z.string().min(1).max(32768),
});
export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
const db = drizzle(pool);
@ -91,6 +96,78 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
}
});
// POST /api/v1/social/broadcast — create a broadcast post (admin only)
app.post('/api/v1/social/broadcast', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
if (!agent || agent.role !== 'admin') {
return reply.code(403).send({ error: 'Admin role required' });
}
const parsed = CreateBroadcastSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'Invalid request', details: parsed.error });
}
const [channel] = await db
.select()
.from(socialChannels)
.where(eq(socialChannels.id, parsed.data.channelId));
if (!channel) {
return reply.code(404).send({ error: 'Channel not found' });
}
const stickyUntil = new Date(Date.now() + 48 * 3600 * 1000);
const [post] = await db
.insert(socialPosts)
.values({
channelId: parsed.data.channelId,
authorAgentId: agentId,
body: parsed.data.body,
postType: 'broadcast',
stickyUntil,
})
.returning();
if (!post) {
return reply.code(500).send({ error: 'Failed to create broadcast' });
}
const [author] = await db.select().from(agents).where(eq(agents.id, agentId));
const postResponse = {
id: post.id,
channelId: post.channelId,
channelSlug: channel.slug,
authorAgentId: post.authorAgentId,
authorName: author?.displayName ?? 'Unknown',
body: post.body,
postType: post.postType,
stickyUntil: post.stickyUntil?.toISOString() ?? null,
createdAt: post.createdAt.toISOString(),
};
// Emit real-time event via socket.io
const io = (app.server as any).__socketio;
if (io) {
io.emit('social:broadcast', postResponse);
}
await auditLog(db, {
type: 'social-broadcast-created',
agentId,
payload: { postId: post.id, channelId: parsed.data.channelId, channelSlug: channel.slug },
});
return reply.code(201).send(postResponse);
});
// GET /api/v1/social/channels/:id — get single channel with post count
app.get('/api/v1/social/channels/:id', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
@ -147,6 +224,8 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
authorUrlKey: agents.urlKey,
body: socialPosts.body,
parentPostId: socialPosts.parentPostId,
postType: socialPosts.postType,
stickyUntil: socialPosts.stickyUntil,
createdAt: socialPosts.createdAt,
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
})
@ -154,12 +233,18 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(and(...conditions))
.orderBy(desc(socialPosts.createdAt), desc(socialPosts.id))
.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)
)
.limit(limitNum);
return reply.send({
posts: result.map((p) => ({
...p,
postType: p.postType,
stickyUntil: p.stickyUntil?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
})),
hasMore: result.length === limitNum,
@ -201,13 +286,19 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
authorUrlKey: agents.urlKey,
body: socialPosts.body,
parentPostId: socialPosts.parentPostId,
postType: socialPosts.postType,
stickyUntil: socialPosts.stickyUntil,
createdAt: socialPosts.createdAt,
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
})
.from(socialPosts)
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(and(...conditions))
.orderBy(desc(socialPosts.createdAt), desc(socialPosts.id))
.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)
)
.limit(limitNum);
return reply.send({
@ -218,6 +309,8 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
},
posts: result.map((p) => ({
...p,
postType: p.postType,
stickyUntil: p.stickyUntil?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
})),
hasMore: result.length === limitNum,