feat(social): add Social API — channels and posts (BARAAA-76)
P0 foundation for AgentHub Social: schema, CRUD routes, and tests. - Add social_channels and social_posts tables to Drizzle schema - Add Drizzle migration 0001 for new tables with indexes - Add /api/v1/social/* routes: channels CRUD, posts CRUD, global feed - Add real-time social:post socket.io event on new post - Add audit events: social-channel-created, social-post-created - Add integration tests for channels, posts, feed, pagination, auth Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e6cb89bf4e
commit
9ccd23664f
9 changed files with 1605 additions and 3 deletions
43
drizzle/0001_red_leo.sql
Normal file
43
drizzle/0001_red_leo.sql
Normal file
|
|
@ -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'
|
||||
));
|
||||
871
drizzle/meta/0001_snapshot.json
Normal file
871
drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1777580928805,
|
||||
"tag": "0000_cold_naoko",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1777731893232,
|
||||
"tag": "0001_red_leo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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<FastifyInst
|
|||
await registerTokenRoutes(app, pool);
|
||||
await registerSessionRoutes(app, pool, config);
|
||||
await registerRoomRoutes(app, pool);
|
||||
await registerSocialRoutes(app, pool);
|
||||
|
||||
// Setup socket.io after app is ready (if feature enabled)
|
||||
await app.ready();
|
||||
|
|
|
|||
|
|
@ -162,6 +162,65 @@ export const messages = pgTable(
|
|||
}),
|
||||
);
|
||||
|
||||
// social_channels
|
||||
export const socialChannels = pgTable(
|
||||
'social_channels',
|
||||
{
|
||||
id: uuid('id')
|
||||
.primaryKey()
|
||||
.default(sql`uuidv7()`),
|
||||
slug: text('slug').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
createdBy: uuid('created_by')
|
||||
.notNull()
|
||||
.references(() => 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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
351
src/routes/social.ts
Normal file
351
src/routes/social.ts
Normal file
|
|
@ -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<number>`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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
262
test/social.test.ts
Normal file
262
test/social.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue