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:
parent
5555c04d10
commit
7d6e94f076
4 changed files with 138 additions and 4 deletions
33
drizzle/0004_add_broadcast_posts.sql
Normal file
33
drizzle/0004_add_broadcast_posts.sql
Normal 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'
|
||||
)
|
||||
);
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue