From 7d6e94f07660cd534d4377a1991e54ed1cfc9448 Mon Sep 17 00:00:00 2001 From: Paperclip FoundingEngineer Date: Sun, 3 May 2026 00:31:58 +0000 Subject: [PATCH] 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 --- drizzle/0004_add_broadcast_posts.sql | 33 ++++++++++ src/db/schema.ts | 9 ++- src/lib/audit.ts | 3 +- src/routes/social.ts | 97 +++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 drizzle/0004_add_broadcast_posts.sql diff --git a/drizzle/0004_add_broadcast_posts.sql b/drizzle/0004_add_broadcast_posts.sql new file mode 100644 index 0000000..e91350e --- /dev/null +++ b/drizzle/0004_add_broadcast_posts.sql @@ -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' + ) +); diff --git a/src/db/schema.ts b/src/db/schema.ts index 2b29b1d..0e93785 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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( diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 6cf2fdb..376a024 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -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; diff --git a/src/routes/social.ts b/src/routes/social.ts index cf82bdd..6173028 100644 --- a/src/routes/social.ts +++ b/src/routes/social.ts @@ -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`(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`(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,