diff --git a/ROADMAP.md b/ROADMAP.md index 8b1f560b9..9081fb394 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -152,7 +152,7 @@ The following renames are planned for packages that implement core service contr - [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation, Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services, SystemObjectName/SystemFieldName Constants, StorageNameMapping Utilities - [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook - [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development -- [x] **API Protocol** — Protocol (104 schemas), Endpoint, Contract, Router, Dispatcher, REST Server, GraphQL, OData, WebSocket, Realtime, Batch, Versioning, HTTP Cache, Documentation, Discovery, Registry, Errors, Auth, Auth Endpoints, Metadata, Analytics, Query Adapter, Storage, Plugin REST API +- [x] **API Protocol** — Protocol (104 schemas), Endpoint, Contract, Router, Dispatcher, REST Server, GraphQL, OData, WebSocket, Realtime, Batch, Versioning, HTTP Cache, Documentation, Discovery, Registry, Errors, Auth, Auth Endpoints, Metadata, Analytics, Query Adapter, Storage, Plugin REST API, Feed API (Feed CRUD, Reactions, Subscription) - [x] **Security Protocol** — Permission, Policy, RLS, Sharing, Territory - [x] **Identity Protocol** — Identity, Organization, Role, SCIM - [x] **Kernel Protocol** — Plugin, Plugin Lifecycle, Plugin Loading, Plugin Registry, Plugin Security, Plugin Validator, Plugin Versioning, Service Registry, Startup Orchestrator, Feature Flags, Context, Events, Metadata Plugin, Metadata Loader, Metadata Customization, CLI Extension, Dev Plugin, Package Registry, Package Upgrade, Execution Context diff --git a/packages/spec/src/api/feed-api.test.ts b/packages/spec/src/api/feed-api.test.ts new file mode 100644 index 000000000..013104eaf --- /dev/null +++ b/packages/spec/src/api/feed-api.test.ts @@ -0,0 +1,563 @@ +import { describe, it, expect } from 'vitest'; +import { + FeedPathParamsSchema, + FeedItemPathParamsSchema, + GetFeedRequestSchema, + GetFeedResponseSchema, + CreateFeedItemRequestSchema, + CreateFeedItemResponseSchema, + UpdateFeedItemRequestSchema, + UpdateFeedItemResponseSchema, + DeleteFeedItemRequestSchema, + DeleteFeedItemResponseSchema, + AddReactionRequestSchema, + AddReactionResponseSchema, + RemoveReactionRequestSchema, + RemoveReactionResponseSchema, + SubscribeRequestSchema, + SubscribeResponseSchema, + UnsubscribeRequestSchema, + UnsubscribeResponseSchema, + FeedApiErrorCode, + FeedApiContracts, +} from './feed-api.zod'; + +// ========================================== +// Path Parameters +// ========================================== + +describe('FeedPathParamsSchema', () => { + it('should accept valid path params', () => { + const params = FeedPathParamsSchema.parse({ + object: 'account', + recordId: 'rec_123', + }); + expect(params.object).toBe('account'); + expect(params.recordId).toBe('rec_123'); + }); + + it('should reject missing object', () => { + expect(() => FeedPathParamsSchema.parse({ recordId: 'rec_123' })).toThrow(); + }); + + it('should reject missing recordId', () => { + expect(() => FeedPathParamsSchema.parse({ object: 'account' })).toThrow(); + }); +}); + +describe('FeedItemPathParamsSchema', () => { + it('should accept valid item path params', () => { + const params = FeedItemPathParamsSchema.parse({ + object: 'account', + recordId: 'rec_123', + feedId: 'feed_001', + }); + expect(params.feedId).toBe('feed_001'); + }); + + it('should reject missing feedId', () => { + expect(() => + FeedItemPathParamsSchema.parse({ object: 'account', recordId: 'rec_123' }) + ).toThrow(); + }); +}); + +// ========================================== +// Feed List (GET) +// ========================================== + +describe('GetFeedRequestSchema', () => { + it('should accept request with defaults', () => { + const req = GetFeedRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + }); + expect(req.type).toBe('all'); + expect(req.limit).toBe(20); + expect(req.cursor).toBeUndefined(); + }); + + it('should accept request with all fields', () => { + const req = GetFeedRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + type: 'comments_only', + limit: 50, + cursor: 'cursor_abc', + }); + expect(req.type).toBe('comments_only'); + expect(req.limit).toBe(50); + expect(req.cursor).toBe('cursor_abc'); + }); + + it('should reject limit exceeding max', () => { + expect(() => + GetFeedRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + limit: 200, + }) + ).toThrow(); + }); + + it('should reject limit below min', () => { + expect(() => + GetFeedRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + limit: 0, + }) + ).toThrow(); + }); + + it('should reject invalid filter type', () => { + expect(() => + GetFeedRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + type: 'invalid_filter', + }) + ).toThrow(); + }); +}); + +describe('GetFeedResponseSchema', () => { + it('should accept valid response with items', () => { + const resp = GetFeedResponseSchema.parse({ + success: true, + data: { + items: [ + { + id: 'feed_001', + type: 'comment', + object: 'account', + recordId: 'rec_123', + actor: { type: 'user', id: 'user_456', name: 'John Smith' }, + body: 'Great progress!', + createdAt: '2026-01-15T10:30:00Z', + }, + ], + hasMore: false, + }, + }); + expect(resp.data.items).toHaveLength(1); + expect(resp.data.items[0].type).toBe('comment'); + expect(resp.data.hasMore).toBe(false); + }); + + it('should accept response with pagination', () => { + const resp = GetFeedResponseSchema.parse({ + success: true, + data: { + items: [], + total: 42, + nextCursor: 'cursor_next', + hasMore: true, + }, + }); + expect(resp.data.total).toBe(42); + expect(resp.data.nextCursor).toBe('cursor_next'); + expect(resp.data.hasMore).toBe(true); + }); + + it('should reject missing hasMore', () => { + expect(() => + GetFeedResponseSchema.parse({ + success: true, + data: { items: [] }, + }) + ).toThrow(); + }); +}); + +// ========================================== +// Feed Create (POST) +// ========================================== + +describe('CreateFeedItemRequestSchema', () => { + it('should accept minimal comment request', () => { + const req = CreateFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + type: 'comment', + body: 'Hello!', + }); + expect(req.type).toBe('comment'); + expect(req.body).toBe('Hello!'); + expect(req.visibility).toBe('public'); + }); + + it('should accept comment with mentions and visibility', () => { + const req = CreateFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + type: 'comment', + body: 'Hey @jane', + mentions: [ + { type: 'user', id: 'user_789', name: 'Jane Doe', offset: 4, length: 5 }, + ], + visibility: 'internal', + }); + expect(req.mentions).toHaveLength(1); + expect(req.visibility).toBe('internal'); + }); + + it('should accept threaded reply', () => { + const req = CreateFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + type: 'comment', + body: 'Reply text', + parentId: 'feed_001', + }); + expect(req.parentId).toBe('feed_001'); + }); + + it('should reject missing type', () => { + expect(() => + CreateFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + body: 'Hello', + }) + ).toThrow(); + }); + + it('should reject invalid feed item type', () => { + expect(() => + CreateFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + type: 'unknown_type', + }) + ).toThrow(); + }); +}); + +describe('CreateFeedItemResponseSchema', () => { + it('should accept valid creation response', () => { + const resp = CreateFeedItemResponseSchema.parse({ + success: true, + data: { + id: 'feed_002', + type: 'comment', + object: 'account', + recordId: 'rec_123', + actor: { type: 'user', id: 'user_456', name: 'John' }, + body: 'New comment', + createdAt: '2026-01-15T11:00:00Z', + }, + }); + expect(resp.data.id).toBe('feed_002'); + }); +}); + +// ========================================== +// Feed Update (PUT) +// ========================================== + +describe('UpdateFeedItemRequestSchema', () => { + it('should accept body update', () => { + const req = UpdateFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + feedId: 'feed_001', + body: 'Updated comment', + }); + expect(req.body).toBe('Updated comment'); + expect(req.feedId).toBe('feed_001'); + }); + + it('should accept visibility update', () => { + const req = UpdateFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + feedId: 'feed_001', + visibility: 'private', + }); + expect(req.visibility).toBe('private'); + }); + + it('should reject missing feedId', () => { + expect(() => + UpdateFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + body: 'Updated', + }) + ).toThrow(); + }); +}); + +describe('UpdateFeedItemResponseSchema', () => { + it('should accept valid update response', () => { + const resp = UpdateFeedItemResponseSchema.parse({ + success: true, + data: { + id: 'feed_001', + type: 'comment', + object: 'account', + recordId: 'rec_123', + actor: { type: 'user', id: 'user_456' }, + body: 'Updated comment', + createdAt: '2026-01-15T10:30:00Z', + editedAt: '2026-01-15T11:00:00Z', + isEdited: true, + }, + }); + expect(resp.data.isEdited).toBe(true); + expect(resp.data.editedAt).toBeDefined(); + }); +}); + +// ========================================== +// Feed Delete (DELETE) +// ========================================== + +describe('DeleteFeedItemRequestSchema', () => { + it('should accept valid delete params', () => { + const req = DeleteFeedItemRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + feedId: 'feed_001', + }); + expect(req.feedId).toBe('feed_001'); + }); +}); + +describe('DeleteFeedItemResponseSchema', () => { + it('should accept valid delete response', () => { + const resp = DeleteFeedItemResponseSchema.parse({ + success: true, + data: { feedId: 'feed_001' }, + }); + expect(resp.data.feedId).toBe('feed_001'); + }); + + it('should reject missing feedId in response data', () => { + expect(() => + DeleteFeedItemResponseSchema.parse({ + success: true, + data: {}, + }) + ).toThrow(); + }); +}); + +// ========================================== +// Reactions +// ========================================== + +describe('AddReactionRequestSchema', () => { + it('should accept valid reaction', () => { + const req = AddReactionRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + feedId: 'feed_001', + emoji: '👍', + }); + expect(req.emoji).toBe('👍'); + }); + + it('should accept shortcode emoji', () => { + const req = AddReactionRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + feedId: 'feed_001', + emoji: ':thumbsup:', + }); + expect(req.emoji).toBe(':thumbsup:'); + }); + + it('should reject missing emoji', () => { + expect(() => + AddReactionRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + feedId: 'feed_001', + }) + ).toThrow(); + }); +}); + +describe('AddReactionResponseSchema', () => { + it('should accept valid reaction response', () => { + const resp = AddReactionResponseSchema.parse({ + success: true, + data: { + reactions: [ + { emoji: '👍', userIds: ['user_456'], count: 1 }, + ], + }, + }); + expect(resp.data.reactions).toHaveLength(1); + expect(resp.data.reactions[0].count).toBe(1); + }); +}); + +describe('RemoveReactionRequestSchema', () => { + it('should accept valid removal', () => { + const req = RemoveReactionRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + feedId: 'feed_001', + emoji: '👍', + }); + expect(req.emoji).toBe('👍'); + }); +}); + +describe('RemoveReactionResponseSchema', () => { + it('should accept valid removal response with empty reactions', () => { + const resp = RemoveReactionResponseSchema.parse({ + success: true, + data: { reactions: [] }, + }); + expect(resp.data.reactions).toHaveLength(0); + }); +}); + +// ========================================== +// Subscription +// ========================================== + +describe('SubscribeRequestSchema', () => { + it('should accept request with defaults', () => { + const req = SubscribeRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + }); + expect(req.events).toEqual(['all']); + expect(req.channels).toEqual(['in_app']); + }); + + it('should accept request with specific events and channels', () => { + const req = SubscribeRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + events: ['comment', 'field_change'], + channels: ['in_app', 'email'], + }); + expect(req.events).toEqual(['comment', 'field_change']); + expect(req.channels).toEqual(['in_app', 'email']); + }); + + it('should reject invalid event type', () => { + expect(() => + SubscribeRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + events: ['unknown_event'], + }) + ).toThrow(); + }); + + it('should reject invalid channel', () => { + expect(() => + SubscribeRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + channels: ['sms'], + }) + ).toThrow(); + }); +}); + +describe('SubscribeResponseSchema', () => { + it('should accept valid subscription response', () => { + const resp = SubscribeResponseSchema.parse({ + success: true, + data: { + object: 'account', + recordId: 'rec_123', + userId: 'user_456', + events: ['comment', 'field_change'], + channels: ['in_app', 'email'], + active: true, + createdAt: '2026-01-15T10:00:00Z', + }, + }); + expect(resp.data.userId).toBe('user_456'); + expect(resp.data.active).toBe(true); + }); +}); + +describe('UnsubscribeRequestSchema', () => { + it('should accept valid unsubscribe params', () => { + const req = UnsubscribeRequestSchema.parse({ + object: 'account', + recordId: 'rec_123', + }); + expect(req.object).toBe('account'); + expect(req.recordId).toBe('rec_123'); + }); +}); + +describe('UnsubscribeResponseSchema', () => { + it('should accept valid unsubscribe response', () => { + const resp = UnsubscribeResponseSchema.parse({ + success: true, + data: { + object: 'account', + recordId: 'rec_123', + unsubscribed: true, + }, + }); + expect(resp.data.unsubscribed).toBe(true); + }); + + it('should reject missing unsubscribed flag', () => { + expect(() => + UnsubscribeResponseSchema.parse({ + success: true, + data: { + object: 'account', + recordId: 'rec_123', + }, + }) + ).toThrow(); + }); +}); + +// ========================================== +// Error Codes +// ========================================== + +describe('FeedApiErrorCode', () => { + it('should accept valid error codes', () => { + expect(FeedApiErrorCode.parse('feed_item_not_found')).toBe('feed_item_not_found'); + expect(FeedApiErrorCode.parse('feed_permission_denied')).toBe('feed_permission_denied'); + expect(FeedApiErrorCode.parse('reaction_already_exists')).toBe('reaction_already_exists'); + }); + + it('should reject invalid error code', () => { + expect(() => FeedApiErrorCode.parse('unknown_error')).toThrow(); + }); +}); + +// ========================================== +// Contract Registry +// ========================================== + +describe('FeedApiContracts', () => { + it('should define all 8 endpoints', () => { + expect(Object.keys(FeedApiContracts)).toHaveLength(8); + }); + + it('should have correct HTTP methods', () => { + expect(FeedApiContracts.listFeed.method).toBe('GET'); + expect(FeedApiContracts.createFeedItem.method).toBe('POST'); + expect(FeedApiContracts.updateFeedItem.method).toBe('PUT'); + expect(FeedApiContracts.deleteFeedItem.method).toBe('DELETE'); + expect(FeedApiContracts.addReaction.method).toBe('POST'); + expect(FeedApiContracts.removeReaction.method).toBe('DELETE'); + expect(FeedApiContracts.subscribe.method).toBe('POST'); + expect(FeedApiContracts.unsubscribe.method).toBe('DELETE'); + }); + + it('should have valid paths', () => { + expect(FeedApiContracts.listFeed.path).toContain('/feed'); + expect(FeedApiContracts.addReaction.path).toContain('/reactions'); + expect(FeedApiContracts.subscribe.path).toContain('/subscribe'); + }); +}); diff --git a/packages/spec/src/api/feed-api.zod.ts b/packages/spec/src/api/feed-api.zod.ts new file mode 100644 index 000000000..9d114299e --- /dev/null +++ b/packages/spec/src/api/feed-api.zod.ts @@ -0,0 +1,350 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { BaseResponseSchema } from './contract.zod'; +import { + FeedItemType, + FeedItemSchema, + FeedVisibility, + MentionSchema, + ReactionSchema, +} from '../data/feed.zod'; +import { + SubscriptionEventType, + NotificationChannel, + RecordSubscriptionSchema, +} from '../data/subscription.zod'; + +/** + * Feed / Chatter API Protocol + * + * Defines the HTTP interface for the unified activity timeline (Feed). + * Covers Feed CRUD, Emoji Reactions, and Record Subscription endpoints. + * + * Base path: /api/data/{object}/{recordId}/feed + * + * @example Endpoints + * GET /api/data/{object}/{recordId}/feed — List feed items + * POST /api/data/{object}/{recordId}/feed — Create feed item + * PUT /api/data/{object}/{recordId}/feed/{feedId} — Update feed item + * DELETE /api/data/{object}/{recordId}/feed/{feedId} — Delete feed item + * POST /api/data/{object}/{recordId}/feed/{feedId}/reactions — Add reaction + * DELETE /api/data/{object}/{recordId}/feed/{feedId}/reactions/{emoji} — Remove reaction + * POST /api/data/{object}/{recordId}/subscribe — Subscribe + * DELETE /api/data/{object}/{recordId}/subscribe — Unsubscribe + */ + +// ========================================== +// 1. Path Parameters +// ========================================== + +/** + * Common path parameters shared across all feed endpoints. + */ +export const FeedPathParamsSchema = z.object({ + object: z.string().describe('Object name (e.g., "account")'), + recordId: z.string().describe('Record ID'), +}); +export type FeedPathParams = z.infer; + +/** + * Path parameters for single-feed-item operations (update, delete). + */ +export const FeedItemPathParamsSchema = FeedPathParamsSchema.extend({ + feedId: z.string().describe('Feed item ID'), +}); +export type FeedItemPathParams = z.infer; + +// ========================================== +// 2. Feed List (GET) +// ========================================== + +/** + * Feed filter type for the list query. + * Maps to FeedFilterMode: all | comments_only | changes_only | tasks_only + */ +export const FeedListFilterType = z.enum([ + 'all', + 'comments_only', + 'changes_only', + 'tasks_only', +]); + +/** + * Query parameters for listing feed items. + * + * @example GET /api/data/account/rec_123/feed?type=all&limit=20&cursor=xxx + */ +export const GetFeedRequestSchema = FeedPathParamsSchema.extend({ + type: FeedListFilterType.default('all') + .describe('Filter by feed item category'), + limit: z.number().int().min(1).max(100).default(20) + .describe('Maximum number of items to return'), + cursor: z.string().optional() + .describe('Cursor for pagination (opaque string from previous response)'), +}); +export type GetFeedRequest = z.infer; + +/** + * Response for the feed list endpoint. + */ +export const GetFeedResponseSchema = BaseResponseSchema.extend({ + data: z.object({ + items: z.array(FeedItemSchema).describe('Feed items in reverse chronological order'), + total: z.number().int().optional().describe('Total feed items matching filter'), + nextCursor: z.string().optional().describe('Cursor for the next page'), + hasMore: z.boolean().describe('Whether more items are available'), + }), +}); +export type GetFeedResponse = z.infer; + +// ========================================== +// 3. Feed Create (POST) +// ========================================== + +/** + * Request body for creating a new feed item (comment, note, task, etc.). + * + * @example POST /api/data/account/rec_123/feed + * { type: 'comment', body: 'Great progress! @jane can you follow up?', mentions: [...] } + */ +export const CreateFeedItemRequestSchema = FeedPathParamsSchema.extend({ + type: FeedItemType.describe('Type of feed item to create'), + body: z.string().optional() + .describe('Rich text body (Markdown supported)'), + mentions: z.array(MentionSchema).optional() + .describe('Mentioned users, teams, or records'), + parentId: z.string().optional() + .describe('Parent feed item ID for threaded replies'), + visibility: FeedVisibility.default('public') + .describe('Visibility: public, internal, or private'), +}); +export type CreateFeedItemRequest = z.infer; + +/** + * Response after creating a feed item. + */ +export const CreateFeedItemResponseSchema = BaseResponseSchema.extend({ + data: FeedItemSchema.describe('The created feed item'), +}); +export type CreateFeedItemResponse = z.infer; + +// ========================================== +// 4. Feed Update (PUT) +// ========================================== + +/** + * Request body for updating an existing feed item (e.g., editing a comment). + * + * @example PUT /api/data/account/rec_123/feed/feed_001 + * { body: 'Updated comment text', mentions: [...] } + */ +export const UpdateFeedItemRequestSchema = FeedItemPathParamsSchema.extend({ + body: z.string().optional() + .describe('Updated rich text body'), + mentions: z.array(MentionSchema).optional() + .describe('Updated mentions'), + visibility: FeedVisibility.optional() + .describe('Updated visibility'), +}); +export type UpdateFeedItemRequest = z.infer; + +/** + * Response after updating a feed item. + */ +export const UpdateFeedItemResponseSchema = BaseResponseSchema.extend({ + data: FeedItemSchema.describe('The updated feed item'), +}); +export type UpdateFeedItemResponse = z.infer; + +// ========================================== +// 5. Feed Delete (DELETE) +// ========================================== + +/** + * Request parameters for deleting a feed item. + * + * @example DELETE /api/data/account/rec_123/feed/feed_001 + */ +export const DeleteFeedItemRequestSchema = FeedItemPathParamsSchema; +export type DeleteFeedItemRequest = z.infer; + +/** + * Response after deleting a feed item. + */ +export const DeleteFeedItemResponseSchema = BaseResponseSchema.extend({ + data: z.object({ + feedId: z.string().describe('ID of the deleted feed item'), + }), +}); +export type DeleteFeedItemResponse = z.infer; + +// ========================================== +// 6. Reactions (POST / DELETE) +// ========================================== + +/** + * Request for adding an emoji reaction to a feed item. + * + * @example POST /api/data/account/rec_123/feed/feed_001/reactions + * { emoji: '👍' } + */ +export const AddReactionRequestSchema = FeedItemPathParamsSchema.extend({ + emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'), +}); +export type AddReactionRequest = z.infer; + +/** + * Response after adding a reaction. + */ +export const AddReactionResponseSchema = BaseResponseSchema.extend({ + data: z.object({ + reactions: z.array(ReactionSchema).describe('Updated reaction list for the feed item'), + }), +}); +export type AddReactionResponse = z.infer; + +/** + * Request for removing an emoji reaction from a feed item. + * + * @example DELETE /api/data/account/rec_123/feed/feed_001/reactions/👍 + */ +export const RemoveReactionRequestSchema = FeedItemPathParamsSchema.extend({ + emoji: z.string().describe('Emoji character or shortcode to remove'), +}); +export type RemoveReactionRequest = z.infer; + +/** + * Response after removing a reaction. + */ +export const RemoveReactionResponseSchema = BaseResponseSchema.extend({ + data: z.object({ + reactions: z.array(ReactionSchema).describe('Updated reaction list for the feed item'), + }), +}); +export type RemoveReactionResponse = z.infer; + +// ========================================== +// 7. Record Subscription (POST / DELETE) +// ========================================== + +/** + * Request for subscribing to record notifications. + * + * @example POST /api/data/account/rec_123/subscribe + * { events: ['comment', 'field_change'], channels: ['in_app', 'email'] } + */ +export const SubscribeRequestSchema = FeedPathParamsSchema.extend({ + events: z.array(SubscriptionEventType).default(['all']) + .describe('Event types to subscribe to'), + channels: z.array(NotificationChannel).default(['in_app']) + .describe('Notification delivery channels'), +}); +export type SubscribeRequest = z.infer; + +/** + * Response after subscribing. + */ +export const SubscribeResponseSchema = BaseResponseSchema.extend({ + data: RecordSubscriptionSchema.describe('The created or updated subscription'), +}); +export type SubscribeResponse = z.infer; + +/** + * Request for unsubscribing from record notifications. + * + * @example DELETE /api/data/account/rec_123/subscribe + */ +export const UnsubscribeRequestSchema = FeedPathParamsSchema; +export type UnsubscribeRequest = z.infer; + +/** + * Response after unsubscribing. + */ +export const UnsubscribeResponseSchema = BaseResponseSchema.extend({ + data: z.object({ + object: z.string().describe('Object name'), + recordId: z.string().describe('Record ID'), + unsubscribed: z.boolean().describe('Whether the user was unsubscribed'), + }), +}); +export type UnsubscribeResponse = z.infer; + +// ========================================== +// 8. Feed API Error Codes +// ========================================== + +/** + * Error codes specific to Feed/Chatter operations. + */ +export const FeedApiErrorCode = z.enum([ + 'feed_item_not_found', + 'feed_permission_denied', + 'feed_item_not_editable', + 'feed_invalid_parent', + 'reaction_already_exists', + 'reaction_not_found', + 'subscription_already_exists', + 'subscription_not_found', + 'invalid_feed_type', +]); +export type FeedApiErrorCode = z.infer; + +// ========================================== +// 9. Feed API Contract Registry +// ========================================== + +/** + * Standard Feed API contracts map. + * Used for generating SDKs, documentation, and route registration. + */ +export const FeedApiContracts = { + listFeed: { + method: 'GET' as const, + path: '/api/data/:object/:recordId/feed', + input: GetFeedRequestSchema, + output: GetFeedResponseSchema, + }, + createFeedItem: { + method: 'POST' as const, + path: '/api/data/:object/:recordId/feed', + input: CreateFeedItemRequestSchema, + output: CreateFeedItemResponseSchema, + }, + updateFeedItem: { + method: 'PUT' as const, + path: '/api/data/:object/:recordId/feed/:feedId', + input: UpdateFeedItemRequestSchema, + output: UpdateFeedItemResponseSchema, + }, + deleteFeedItem: { + method: 'DELETE' as const, + path: '/api/data/:object/:recordId/feed/:feedId', + input: DeleteFeedItemRequestSchema, + output: DeleteFeedItemResponseSchema, + }, + addReaction: { + method: 'POST' as const, + path: '/api/data/:object/:recordId/feed/:feedId/reactions', + input: AddReactionRequestSchema, + output: AddReactionResponseSchema, + }, + removeReaction: { + method: 'DELETE' as const, + path: '/api/data/:object/:recordId/feed/:feedId/reactions/:emoji', + input: RemoveReactionRequestSchema, + output: RemoveReactionResponseSchema, + }, + subscribe: { + method: 'POST' as const, + path: '/api/data/:object/:recordId/subscribe', + input: SubscribeRequestSchema, + output: SubscribeResponseSchema, + }, + unsubscribe: { + method: 'DELETE' as const, + path: '/api/data/:object/:recordId/subscribe', + input: UnsubscribeRequestSchema, + output: UnsubscribeResponseSchema, + }, +}; diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index c4206bf4d..eccd63f0e 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -42,3 +42,4 @@ export * from './metadata.zod'; export * from './dispatcher.zod'; export * from './plugin-rest-api.zod'; export * from './query-adapter.zod'; +export * from './feed-api.zod';