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(),
|
body: text('body').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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) => ({
|
(table) => ({
|
||||||
bodyCheck: check('social_posts_body_check', sql`length(${table.body}) BETWEEN 1 AND 32768`),
|
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(
|
channelCreatedAtIdx: index('social_posts_channel_created_at_idx').on(
|
||||||
table.channelId,
|
table.channelId,
|
||||||
sql`${table.createdAt} DESC`,
|
sql`${table.createdAt} DESC`,
|
||||||
|
|
@ -242,6 +245,9 @@ export const socialPosts = pgTable(
|
||||||
sql`${table.createdAt} DESC`,
|
sql`${table.createdAt} DESC`,
|
||||||
sql`${table.id} 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',
|
'room-deleted',
|
||||||
'message-sent',
|
'message-sent',
|
||||||
'social-channel-created',
|
'social-channel-created',
|
||||||
'social-post-created'
|
'social-post-created',
|
||||||
|
'social-broadcast-created'
|
||||||
)`,
|
)`,
|
||||||
),
|
),
|
||||||
payloadHashCheck: check(
|
payloadHashCheck: check(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ export type AuditEventType =
|
||||||
| 'room-deleted'
|
| 'room-deleted'
|
||||||
| 'message-sent'
|
| 'message-sent'
|
||||||
| 'social-channel-created'
|
| 'social-channel-created'
|
||||||
| 'social-post-created';
|
| 'social-post-created'
|
||||||
|
| 'social-broadcast-created';
|
||||||
|
|
||||||
export interface AuditPayload {
|
export interface AuditPayload {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ const CreatePostSchema = z.object({
|
||||||
body: z.string().min(1).max(32768),
|
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) {
|
export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
const db = drizzle(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
|
// GET /api/v1/social/channels/:id — get single channel with post count
|
||||||
app.get('/api/v1/social/channels/:id', async (request, reply) => {
|
app.get('/api/v1/social/channels/:id', async (request, reply) => {
|
||||||
const agentId = request.headers['x-agent-id'] as string | undefined;
|
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,
|
authorUrlKey: agents.urlKey,
|
||||||
body: socialPosts.body,
|
body: socialPosts.body,
|
||||||
parentPostId: socialPosts.parentPostId,
|
parentPostId: socialPosts.parentPostId,
|
||||||
|
postType: socialPosts.postType,
|
||||||
|
stickyUntil: socialPosts.stickyUntil,
|
||||||
createdAt: socialPosts.createdAt,
|
createdAt: socialPosts.createdAt,
|
||||||
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
|
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(socialChannels, eq(socialPosts.channelId, socialChannels.id))
|
||||||
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
|
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
|
||||||
.where(and(...conditions))
|
.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);
|
.limit(limitNum);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
posts: result.map((p) => ({
|
posts: result.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
|
postType: p.postType,
|
||||||
|
stickyUntil: p.stickyUntil?.toISOString() ?? null,
|
||||||
createdAt: p.createdAt.toISOString(),
|
createdAt: p.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
hasMore: result.length === limitNum,
|
hasMore: result.length === limitNum,
|
||||||
|
|
@ -201,13 +286,19 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
authorUrlKey: agents.urlKey,
|
authorUrlKey: agents.urlKey,
|
||||||
body: socialPosts.body,
|
body: socialPosts.body,
|
||||||
parentPostId: socialPosts.parentPostId,
|
parentPostId: socialPosts.parentPostId,
|
||||||
|
postType: socialPosts.postType,
|
||||||
|
stickyUntil: socialPosts.stickyUntil,
|
||||||
createdAt: socialPosts.createdAt,
|
createdAt: socialPosts.createdAt,
|
||||||
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
|
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
|
||||||
})
|
})
|
||||||
.from(socialPosts)
|
.from(socialPosts)
|
||||||
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
|
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
|
||||||
.where(and(...conditions))
|
.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);
|
.limit(limitNum);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
|
|
@ -218,6 +309,8 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
},
|
},
|
||||||
posts: result.map((p) => ({
|
posts: result.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
|
postType: p.postType,
|
||||||
|
stickyUntil: p.stickyUntil?.toISOString() ?? null,
|
||||||
createdAt: p.createdAt.toISOString(),
|
createdAt: p.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
hasMore: result.length === limitNum,
|
hasMore: result.length === limitNum,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue