diff --git a/drizzle/0001_red_leo.sql b/drizzle/0001_red_leo.sql new file mode 100644 index 0000000..d543975 --- /dev/null +++ b/drizzle/0001_red_leo.sql @@ -0,0 +1,43 @@ +CREATE TABLE "social_channels" ( + "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text, + "created_by" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "social_channels_slug_unique" UNIQUE("slug"), + CONSTRAINT "social_channels_slug_check" CHECK ("social_channels"."slug" ~ '^[a-z0-9][a-z0-9-]{0,63}$'), + CONSTRAINT "social_channels_name_check" CHECK (length("social_channels"."name") BETWEEN 1 AND 128) +); +--> statement-breakpoint +CREATE TABLE "social_posts" ( + "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL, + "channel_id" uuid NOT NULL, + "author_agent_id" uuid NOT NULL, + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "social_posts_body_check" CHECK (length("social_posts"."body") BETWEEN 1 AND 32768) +); +--> statement-breakpoint +ALTER TABLE "audit_events" DROP CONSTRAINT "audit_events_type_check";--> statement-breakpoint +ALTER TABLE "social_channels" ADD CONSTRAINT "social_channels_created_by_agents_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."agents"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_channel_id_social_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."social_channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "social_posts_channel_created_at_idx" ON "social_posts" USING btree ("channel_id","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX "social_posts_author_idx" ON "social_posts" USING btree ("author_agent_id");--> statement-breakpoint +CREATE INDEX "social_posts_feed_idx" ON "social_posts" USING btree ("created_at" DESC,"id" DESC);--> statement-breakpoint +ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_type_check" CHECK ("audit_events"."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' + )); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..3903209 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,871 @@ +{ + "id": "c29dadd7-efac-40c2-8b17-4261afe9f918", + "prevId": "30b9b909-c7b6-419a-8142-bd93865d77e0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_role_idx": { + "name": "agents_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_name_unique": { + "name": "agents_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": { + "agents_name_check": { + "name": "agents_name_check", + "value": "\"agents\".\"name\" ~ '^[a-z0-9][a-z0-9-]{0,63}$'" + }, + "agents_display_name_check": { + "name": "agents_display_name_check", + "value": "length(\"agents\".\"display_name\") BETWEEN 1 AND 128" + }, + "agents_role_check": { + "name": "agents_role_check", + "value": "\"agents\".\"role\" IN ('admin', 'agent')" + } + }, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hash_argon2id": { + "name": "hash_argon2id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_tokens_agent_id_idx": { + "name": "api_tokens_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_tokens_active_prefix_idx": { + "name": "api_tokens_active_prefix_idx", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"api_tokens\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_agent_id_agents_id_fk": { + "name": "api_tokens_agent_id_agents_id_fk", + "tableFrom": "api_tokens", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_prefix_unique": { + "name": "api_tokens_prefix_unique", + "nullsNotDistinct": false, + "columns": [ + "prefix" + ] + } + }, + "policies": {}, + "checkConstraints": { + "api_tokens_prefix_check": { + "name": "api_tokens_prefix_check", + "value": "\"api_tokens\".\"prefix\" ~ '^ah_live_[a-zA-Z0-9]{4}$'" + }, + "api_tokens_status_check": { + "name": "api_tokens_status_check", + "value": "\"api_tokens\".\"status\" IN ('active', 'rotating', 'revoked')" + }, + "api_tokens_revoked_at_check": { + "name": "api_tokens_revoked_at_check", + "value": "\"api_tokens\".\"revoked_at\" IS NULL OR \"api_tokens\".\"status\" = 'revoked'" + }, + "api_tokens_expires_at_check": { + "name": "api_tokens_expires_at_check", + "value": "\"api_tokens\".\"expires_at\" IS NULL OR \"api_tokens\".\"expires_at\" > \"api_tokens\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload_hash": { + "name": "payload_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_ts_idx": { + "name": "audit_events_ts_idx", + "columns": [ + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_ts_idx": { + "name": "audit_events_type_ts_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_agent_ts_idx": { + "name": "audit_events_agent_ts_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_events\".\"agent_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_agent_id_agents_id_fk": { + "name": "audit_events_agent_id_agents_id_fk", + "tableFrom": "audit_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "audit_events_type_check": { + "name": "audit_events_type_check", + "value": "\"audit_events\".\"type\" IN (\n 'login',\n 'token-issued',\n 'token-rotated',\n 'token-revoked',\n 'jwt-issued',\n 'agent-created',\n 'agent-deleted',\n 'room-created',\n 'room-deleted',\n 'message-sent',\n 'social-channel-created',\n 'social-post-created'\n )" + }, + "audit_events_payload_hash_check": { + "name": "audit_events_payload_hash_check", + "value": "length(\"audit_events\".\"payload_hash\") = 32" + } + }, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_room_created_at_idx": { + "name": "messages_room_created_at_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_room_id_rooms_id_fk": { + "name": "messages_room_id_rooms_id_fk", + "tableFrom": "messages", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_author_agent_id_agents_id_fk": { + "name": "messages_author_agent_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "messages_body_check": { + "name": "messages_body_check", + "value": "length(\"messages\".\"body\") BETWEEN 1 AND 16384" + } + }, + "isRLSEnabled": false + }, + "public.room_members": { + "name": "room_members", + "schema": "", + "columns": { + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_members_agent_id_idx": { + "name": "room_members_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "room_members_room_id_rooms_id_fk": { + "name": "room_members_room_id_rooms_id_fk", + "tableFrom": "room_members", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "room_members_agent_id_agents_id_fk": { + "name": "room_members_agent_id_agents_id_fk", + "tableFrom": "room_members", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "room_members_room_id_agent_id_pk": { + "name": "room_members_room_id_agent_id_pk", + "columns": [ + "room_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rooms": { + "name": "rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_created_by_agents_id_fk": { + "name": "rooms_created_by_agents_id_fk", + "tableFrom": "rooms", + "tableTo": "agents", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rooms_slug_unique": { + "name": "rooms_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "rooms_slug_check": { + "name": "rooms_slug_check", + "value": "\"rooms\".\"slug\" ~ '^[a-z0-9][a-z0-9-]{0,63}$'" + }, + "rooms_name_check": { + "name": "rooms_name_check", + "value": "length(\"rooms\".\"name\") BETWEEN 1 AND 128" + } + }, + "isRLSEnabled": false + }, + "public.social_channels": { + "name": "social_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_channels_created_by_agents_id_fk": { + "name": "social_channels_created_by_agents_id_fk", + "tableFrom": "social_channels", + "tableTo": "agents", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "social_channels_slug_unique": { + "name": "social_channels_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "social_channels_slug_check": { + "name": "social_channels_slug_check", + "value": "\"social_channels\".\"slug\" ~ '^[a-z0-9][a-z0-9-]{0,63}$'" + }, + "social_channels_name_check": { + "name": "social_channels_name_check", + "value": "length(\"social_channels\".\"name\") BETWEEN 1 AND 128" + } + }, + "isRLSEnabled": false + }, + "public.social_posts": { + "name": "social_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_posts_channel_created_at_idx": { + "name": "social_posts_channel_created_at_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "social_posts_author_idx": { + "name": "social_posts_author_idx", + "columns": [ + { + "expression": "author_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "social_posts_feed_idx": { + "name": "social_posts_feed_idx", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_posts_channel_id_social_channels_id_fk": { + "name": "social_posts_channel_id_social_channels_id_fk", + "tableFrom": "social_posts", + "tableTo": "social_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "social_posts_author_agent_id_agents_id_fk": { + "name": "social_posts_author_agent_id_agents_id_fk", + "tableFrom": "social_posts", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "social_posts_body_check": { + "name": "social_posts_body_check", + "value": "length(\"social_posts\".\"body\") BETWEEN 1 AND 32768" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2721e15..28593bb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1777580928805, "tag": "0000_cold_naoko", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777731893232, + "tag": "0001_red_leo", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 7f53ebd..2cc7b1f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { registerAgentRoutes } from './routes/agents.js'; import { registerTokenRoutes } from './routes/tokens.js'; import { registerSessionRoutes } from './routes/sessions.js'; import { registerRoomRoutes } from './routes/rooms.js'; +import { registerSocialRoutes } from './routes/social.js'; import { setupSocketIO } from './socket/index.js'; import { register as metricsRegister } from './lib/metrics.js'; import { startMetricsCollector } from './services/metrics-collector.js'; @@ -59,6 +60,7 @@ export async function buildApp({ config }: BuildAppOptions): Promise agents.id, { onDelete: 'restrict' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), + }, + (table) => ({ + slugCheck: check( + 'social_channels_slug_check', + sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,63}$'`, + ), + nameCheck: check( + 'social_channels_name_check', + sql`length(${table.name}) BETWEEN 1 AND 128`, + ), + }), +); + +// social_posts +export const socialPosts = pgTable( + 'social_posts', + { + id: uuid('id') + .primaryKey() + .default(sql`uuidv7()`), + channelId: uuid('channel_id') + .notNull() + .references(() => socialChannels.id, { onDelete: 'cascade' }), + authorAgentId: uuid('author_agent_id') + .notNull() + .references(() => agents.id, { onDelete: 'restrict' }), + body: text('body').notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), + }, + (table) => ({ + bodyCheck: check('social_posts_body_check', sql`length(${table.body}) BETWEEN 1 AND 32768`), + channelCreatedAtIdx: index('social_posts_channel_created_at_idx').on( + table.channelId, + sql`${table.createdAt} DESC`, + sql`${table.id} DESC`, + ), + authorIdx: index('social_posts_author_idx').on(table.authorAgentId), + feedIdx: index('social_posts_feed_idx').on( + sql`${table.createdAt} DESC`, + sql`${table.id} DESC`, + ), + }), +); + // audit_events export const auditEvents = pgTable( 'audit_events', @@ -189,7 +248,9 @@ export const auditEvents = pgTable( 'agent-deleted', 'room-created', 'room-deleted', - 'message-sent' + 'message-sent', + 'social-channel-created', + 'social-post-created' )`, ), payloadHashCheck: check( diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 42a00fa..6cf2fdb 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -13,7 +13,9 @@ export type AuditEventType = | 'agent-deleted' | 'room-created' | 'room-deleted' - | 'message-sent'; + | 'message-sent' + | 'social-channel-created' + | 'social-post-created'; export interface AuditPayload { [key: string]: unknown; diff --git a/src/routes/social.ts b/src/routes/social.ts new file mode 100644 index 0000000..af09eb5 --- /dev/null +++ b/src/routes/social.ts @@ -0,0 +1,351 @@ +import type { FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { socialChannels, socialPosts, agents } from '../db/schema.js'; +import { eq, and, sql, desc } from 'drizzle-orm'; +import { z } from 'zod'; +import { auditLog } from '../lib/audit.js'; + +const CreateChannelSchema = z.object({ + slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/), + name: z.string().min(1).max(128), + description: z.string().max(1024).optional(), +}); + +const CreatePostSchema = z.object({ + body: z.string().min(1).max(32768), +}); + +export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) { + const db = drizzle(pool); + + // GET /api/v1/social/channels — list all channels + app.get('/api/v1/social/channels', 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 result = await db + .select({ + id: socialChannels.id, + slug: socialChannels.slug, + name: socialChannels.name, + description: socialChannels.description, + createdBy: socialChannels.createdBy, + createdAt: socialChannels.createdAt, + }) + .from(socialChannels) + .orderBy(socialChannels.name); + + return reply.send({ + channels: result.map((c) => ({ + ...c, + createdAt: c.createdAt.toISOString(), + })), + }); + }); + + // POST /api/v1/social/channels — create channel (admin only) + app.post('/api/v1/social/channels', 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 = CreateChannelSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'Invalid request', details: parsed.error }); + } + + try { + const [channel] = await db + .insert(socialChannels) + .values({ ...parsed.data, createdBy: agentId }) + .returning(); + + if (!channel) { + return reply.code(500).send({ error: 'Failed to create channel' }); + } + + await auditLog(db, { + type: 'social-channel-created', + agentId, + payload: { channelId: channel.id, slug: channel.slug }, + }); + + return reply.code(201).send({ + ...channel, + createdAt: channel.createdAt.toISOString(), + }); + } catch (err: unknown) { + if (err && typeof err === 'object' && 'code' in err && err.code === '23505') { + return reply.code(409).send({ error: 'Channel slug already exists' }); + } + throw err; + } + }); + + // 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; + if (!agentId) { + return reply.code(401).send({ error: 'Missing x-agent-id header' }); + } + + const { id } = request.params as { id: string }; + + const [channel] = await db + .select() + .from(socialChannels) + .where(eq(socialChannels.id, id)); + + if (!channel) { + return reply.code(404).send({ error: 'Channel not found' }); + } + + const [countResult] = await db + .select({ count: sql`count(*)::int` }) + .from(socialPosts) + .where(eq(socialPosts.channelId, id)); + + return reply.send({ + ...channel, + createdAt: channel.createdAt.toISOString(), + postCount: countResult?.count ?? 0, + }); + }); + + // GET /api/v1/social/feed — global feed across all channels, paginated + app.get('/api/v1/social/feed', 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 { before, limit } = request.query as { before?: string; limit?: string }; + const limitNum = Math.min(parseInt(limit || '50', 10), 100); + + const conditions = []; + if (before) { + conditions.push(sql`${socialPosts.id} < ${before}`); + } + + const result = await db + .select({ + id: socialPosts.id, + channelId: socialPosts.channelId, + channelSlug: socialChannels.slug, + channelName: socialChannels.name, + authorAgentId: socialPosts.authorAgentId, + authorName: agents.displayName, + body: socialPosts.body, + createdAt: socialPosts.createdAt, + }) + .from(socialPosts) + .innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id)) + .innerJoin(agents, eq(socialPosts.authorAgentId, agents.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(socialPosts.createdAt), desc(socialPosts.id)) + .limit(limitNum); + + return reply.send({ + posts: result.map((p) => ({ + ...p, + createdAt: p.createdAt.toISOString(), + })), + hasMore: result.length === limitNum, + cursor: result.length > 0 ? result[result.length - 1]!.id : null, + }); + }); + + // GET /api/v1/social/channels/:id/posts — posts in a channel, paginated + app.get('/api/v1/social/channels/:id/posts', 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 { id: channelId } = request.params as { id: string }; + const { before, limit } = request.query as { before?: string; limit?: string }; + const limitNum = Math.min(parseInt(limit || '50', 10), 100); + + const [channel] = await db + .select() + .from(socialChannels) + .where(eq(socialChannels.id, channelId)); + + if (!channel) { + return reply.code(404).send({ error: 'Channel not found' }); + } + + const conditions = [eq(socialPosts.channelId, channelId)]; + if (before) { + conditions.push(sql`${socialPosts.id} < ${before}`); + } + + const result = await db + .select({ + id: socialPosts.id, + channelId: socialPosts.channelId, + authorAgentId: socialPosts.authorAgentId, + authorName: agents.displayName, + body: socialPosts.body, + createdAt: socialPosts.createdAt, + }) + .from(socialPosts) + .innerJoin(agents, eq(socialPosts.authorAgentId, agents.id)) + .where(and(...conditions)) + .orderBy(desc(socialPosts.createdAt), desc(socialPosts.id)) + .limit(limitNum); + + return reply.send({ + channel: { + id: channel.id, + slug: channel.slug, + name: channel.name, + }, + posts: result.map((p) => ({ + ...p, + createdAt: p.createdAt.toISOString(), + })), + hasMore: result.length === limitNum, + cursor: result.length > 0 ? result[result.length - 1]!.id : null, + }); + }); + + // POST /api/v1/social/channels/:id/posts — create a post + app.post('/api/v1/social/channels/:id/posts', 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 { id: channelId } = request.params as { id: string }; + + const [channel] = await db + .select() + .from(socialChannels) + .where(eq(socialChannels.id, channelId)); + + if (!channel) { + return reply.code(404).send({ error: 'Channel not found' }); + } + + const parsed = CreatePostSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'Invalid request', details: parsed.error }); + } + + const [post] = await db + .insert(socialPosts) + .values({ + channelId, + authorAgentId: agentId, + body: parsed.data.body, + }) + .returning(); + + if (!post) { + return reply.code(500).send({ error: 'Failed to create post' }); + } + + await auditLog(db, { + type: 'social-post-created', + agentId, + payload: { postId: post.id, channelId, channelSlug: channel.slug }, + }); + + 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, + createdAt: post.createdAt.toISOString(), + }; + + // Emit real-time event via socket.io if available + const io = (app.server as any).__socketio; + if (io) { + io.emit('social:post', postResponse); + } + + return reply.code(201).send(postResponse); + }); + + // GET /api/v1/social/posts/:id — get a single post + app.get('/api/v1/social/posts/:id', 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 { id } = request.params as { id: string }; + + const result = await db + .select({ + id: socialPosts.id, + channelId: socialPosts.channelId, + channelSlug: socialChannels.slug, + channelName: socialChannels.name, + authorAgentId: socialPosts.authorAgentId, + authorName: agents.displayName, + body: socialPosts.body, + createdAt: socialPosts.createdAt, + updatedAt: socialPosts.updatedAt, + }) + .from(socialPosts) + .innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id)) + .innerJoin(agents, eq(socialPosts.authorAgentId, agents.id)) + .where(eq(socialPosts.id, id)); + + if (result.length === 0) { + return reply.code(404).send({ error: 'Post not found' }); + } + + const post = result[0]!; + return reply.send({ + ...post, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + }); + }); + + // DELETE /api/v1/social/posts/:id — delete a post (author or admin) + app.delete('/api/v1/social/posts/:id', 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 { id } = request.params as { id: string }; + + const [post] = await db + .select() + .from(socialPosts) + .where(eq(socialPosts.id, id)); + + if (!post) { + return reply.code(404).send({ error: 'Post not found' }); + } + + if (post.authorAgentId !== agentId) { + const [agent] = await db.select().from(agents).where(eq(agents.id, agentId)); + if (!agent || agent.role !== 'admin') { + return reply.code(403).send({ error: 'Only the author or an admin can delete this post' }); + } + } + + await db.delete(socialPosts).where(eq(socialPosts.id, id)); + return reply.code(204).send(); + }); +} diff --git a/src/socket/index.ts b/src/socket/index.ts index aaeba00..20dddc8 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -462,5 +462,8 @@ export function setupSocketIO( } }, 10_000); + // Attach to httpServer for access from REST routes (e.g. social real-time events) + (httpServer as any).__socketio = io; + return io; } diff --git a/test/social.test.ts b/test/social.test.ts new file mode 100644 index 0000000..a82fc32 --- /dev/null +++ b/test/social.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/app.js'; +import { loadConfig } from '../src/config.js'; +import { pool, closePool } from '../src/db/pool.js'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { + agents, + apiTokens, + auditEvents, + socialChannels, + socialPosts, + messages, + roomMembers, + rooms, +} from '../src/db/schema.js'; + +describe('Social API', () => { + let app: FastifyInstance; + let adminId: string; + let agentId: string; + + beforeAll(async () => { + const config = loadConfig({ + ...process.env, + NODE_ENV: 'test', + JWT_SECRET: 'test-secret-with-at-least-32-chars-for-jwt-security', + }); + app = await buildApp({ config }); + await app.ready(); + + const db = drizzle(pool); + await db.delete(socialPosts); + await db.delete(socialChannels); + await db.delete(auditEvents); + await db.delete(messages); + await db.delete(roomMembers); + await db.delete(rooms); + await db.delete(apiTokens); + await db.delete(agents); + + // Create admin agent + const adminRes = await request(app.server) + .post('/api/v1/agents') + .send({ name: 'social-admin', displayName: 'Social Admin', role: 'admin' }); + adminId = adminRes.body.id; + + // Create regular agent + const agentRes = await request(app.server) + .post('/api/v1/agents') + .send({ name: 'social-agent', displayName: 'Social Agent', role: 'agent' }); + agentId = agentRes.body.id; + }); + + afterAll(async () => { + await app.close(); + await closePool(); + }); + + describe('Channels', () => { + let channelId: string; + + it('should create a channel (admin)', async () => { + const res = await request(app.server) + .post('/api/v1/social/channels') + .set('x-agent-id', adminId) + .send({ slug: 'general', name: 'General', description: 'Main channel' }) + .expect(201); + + expect(res.body).toHaveProperty('id'); + expect(res.body.slug).toBe('general'); + expect(res.body.name).toBe('General'); + expect(res.body.description).toBe('Main channel'); + channelId = res.body.id; + }); + + it('should reject channel creation by non-admin', async () => { + await request(app.server) + .post('/api/v1/social/channels') + .set('x-agent-id', agentId) + .send({ slug: 'hacker', name: 'Hacker' }) + .expect(403); + }); + + it('should reject duplicate slug', async () => { + await request(app.server) + .post('/api/v1/social/channels') + .set('x-agent-id', adminId) + .send({ slug: 'general', name: 'General 2' }) + .expect(409); + }); + + it('should list channels', async () => { + const res = await request(app.server) + .get('/api/v1/social/channels') + .set('x-agent-id', adminId) + .expect(200); + + expect(res.body.channels).toHaveLength(1); + expect(res.body.channels[0].slug).toBe('general'); + }); + + it('should get channel by id with post count', async () => { + const res = await request(app.server) + .get(`/api/v1/social/channels/${channelId}`) + .set('x-agent-id', adminId) + .expect(200); + + expect(res.body.slug).toBe('general'); + expect(res.body.postCount).toBe(0); + }); + + it('should 404 for unknown channel', async () => { + await request(app.server) + .get('/api/v1/social/channels/00000000-0000-0000-0000-000000000000') + .set('x-agent-id', adminId) + .expect(404); + }); + }); + + describe('Posts', () => { + let channelId: string; + let postId: string; + + beforeAll(async () => { + // Create a channel for post tests + const res = await request(app.server) + .post('/api/v1/social/channels') + .set('x-agent-id', adminId) + .send({ slug: 'philosophy', name: 'Philosophy' }); + channelId = res.body.id; + }); + + it('should create a post (any agent)', async () => { + const res = await request(app.server) + .post(`/api/v1/social/channels/${channelId}/posts`) + .set('x-agent-id', agentId) + .send({ body: 'I think, therefore I am.' }) + .expect(201); + + expect(res.body).toHaveProperty('id'); + expect(res.body.body).toBe('I think, therefore I am.'); + expect(res.body.authorAgentId).toBe(agentId); + expect(res.body.authorName).toBe('Social Agent'); + expect(res.body.channelSlug).toBe('philosophy'); + postId = res.body.id; + }); + + it('should 404 when posting to unknown channel', async () => { + await request(app.server) + .post('/api/v1/social/channels/00000000-0000-0000-0000-000000000000/posts') + .set('x-agent-id', agentId) + .send({ body: 'Hello void' }) + .expect(404); + }); + + it('should reject empty body', async () => { + await request(app.server) + .post(`/api/v1/social/channels/${channelId}/posts`) + .set('x-agent-id', agentId) + .send({ body: '' }) + .expect(400); + }); + + it('should get a post by id', async () => { + const res = await request(app.server) + .get(`/api/v1/social/posts/${postId}`) + .set('x-agent-id', agentId) + .expect(200); + + expect(res.body.id).toBe(postId); + expect(res.body.body).toBe('I think, therefore I am.'); + expect(res.body.channelSlug).toBe('philosophy'); + }); + + it('should list posts in channel', async () => { + // Add another post + await request(app.server) + .post(`/api/v1/social/channels/${channelId}/posts`) + .set('x-agent-id', adminId) + .send({ body: 'To be or not to be.' }); + + const res = await request(app.server) + .get(`/api/v1/social/channels/${channelId}/posts`) + .set('x-agent-id', agentId) + .expect(200); + + expect(res.body.posts.length).toBe(2); + expect(res.body.channel.slug).toBe('philosophy'); + // Newest first + expect(res.body.posts[0].body).toBe('To be or not to be.'); + }); + + it('should show post count on channel', async () => { + const res = await request(app.server) + .get(`/api/v1/social/channels/${channelId}`) + .set('x-agent-id', agentId) + .expect(200); + + expect(res.body.postCount).toBe(2); + }); + + it('should delete own post', async () => { + await request(app.server) + .delete(`/api/v1/social/posts/${postId}`) + .set('x-agent-id', agentId) + .expect(204); + + await request(app.server) + .get(`/api/v1/social/posts/${postId}`) + .set('x-agent-id', agentId) + .expect(404); + }); + + it('should not delete another agents post (non-admin)', async () => { + // Create post as admin + const res = await request(app.server) + .post(`/api/v1/social/channels/${channelId}/posts`) + .set('x-agent-id', adminId) + .send({ body: 'Admin thought.' }); + + await request(app.server) + .delete(`/api/v1/social/posts/${res.body.id}`) + .set('x-agent-id', agentId) + .expect(403); + }); + }); + + describe('Feed', () => { + it('should return global feed across channels', async () => { + const res = await request(app.server) + .get('/api/v1/social/feed') + .set('x-agent-id', agentId) + .expect(200); + + expect(res.body.posts.length).toBeGreaterThan(0); + expect(res.body.posts[0]).toHaveProperty('channelSlug'); + expect(res.body.posts[0]).toHaveProperty('authorName'); + }); + + it('should paginate feed with cursor', async () => { + const res = await request(app.server) + .get('/api/v1/social/feed?limit=1') + .set('x-agent-id', agentId) + .expect(200); + + expect(res.body.posts).toHaveLength(1); + expect(res.body.hasMore).toBe(true); + expect(res.body.cursor).toBeTruthy(); + + // Fetch next page + const res2 = await request(app.server) + .get(`/api/v1/social/feed?limit=1&before=${res.body.cursor}`) + .set('x-agent-id', agentId) + .expect(200); + + expect(res2.body.posts).toHaveLength(1); + expect(res2.body.posts[0].id).not.toBe(res.body.posts[0].id); + }); + }); +});