diff --git a/ROADMAP.md b/ROADMAP.md index e8d4911a..942bacf9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -304,11 +304,13 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow |:---|:---:|:---|:---| | `II18nService` | **P1** | `@objectstack/service-i18n` | Map-backed translation with locale resolution | | `IRealtimeService` | **P1** | `@objectstack/service-realtime` | WebSocket/SSE push (replaces Studio setTimeout hack) | +| `IFeedService` | **P1** | `@objectstack/service-feed` | ✅ Feed/Chatter with comments, reactions, subscriptions | | `ISearchService` | **P1** | `@objectstack/service-search` | In-memory search first, then Meilisearch driver | | `INotificationService` | **P2** | `@objectstack/service-notification` | Email adapter (console logger in dev mode) | - [x] `service-i18n` — Implement `II18nService` with file-based locale loading - [x] `service-realtime` — Implement `IRealtimeService` with WebSocket + in-memory pub/sub +- [x] `service-feed` — Implement `IFeedService` with in-memory adapter (Feed CRUD, Reactions, Subscriptions, Threading) - [ ] `service-search` — Implement `ISearchService` with in-memory search + Meilisearch adapter - [ ] `service-notification` — Implement `INotificationService` with email adapter @@ -571,19 +573,20 @@ Final polish and advanced features. | 12 | Job Service | `IJobService` | ✅ | `@objectstack/service-job` | Interval + cron skeleton | | 13 | Storage Service | `IStorageService` | ✅ | `@objectstack/service-storage` | Local FS + S3 skeleton | | 14 | Realtime Service | `IRealtimeService` | ✅ | `@objectstack/service-realtime` | In-memory pub/sub | -| 15 | Search Service | `ISearchService` | ❌ | `@objectstack/service-search` (planned) | Spec only | -| 16 | Notification Service | `INotificationService` | ❌ | `@objectstack/service-notification` (planned) | Spec only | -| 17 | AI Service | `IAIService` | ❌ | `@objectstack/service-ai` (planned) | Spec only | -| 18 | Automation Service | `IAutomationService` | ✅ | `@objectstack/service-automation` | Plugin-based DAG engine (MVP) | -| 19 | Workflow Service | `IWorkflowService` | ❌ | `@objectstack/service-workflow` (planned) | Spec only | -| 20 | GraphQL Service | `IGraphQLService` | ❌ | `@objectstack/service-graphql` (planned) | Spec only | -| 21 | i18n Service | `II18nService` | ✅ | `@objectstack/service-i18n` | File-based locale loading | -| 22 | UI Service | `IUIService` | ⚠️ | — | **Deprecated** — merged into `IMetadataService` | -| 23 | Schema Driver | `ISchemaDriver` | ❌ | — | Spec only | -| 24 | Startup Orchestrator | `IStartupOrchestrator` | ❌ | — | Kernel handles basics | -| 25 | Plugin Validator | `IPluginValidator` | ❌ | — | Spec only | - -**Summary:** 13 fully implemented · 2 partially implemented · 10 specification only +| 15 | Feed Service | `IFeedService` | ✅ | `@objectstack/service-feed` | In-memory feed/chatter (comments, reactions, subscriptions) | +| 16 | Search Service | `ISearchService` | ❌ | `@objectstack/service-search` (planned) | Spec only | +| 17 | Notification Service | `INotificationService` | ❌ | `@objectstack/service-notification` (planned) | Spec only | +| 18 | AI Service | `IAIService` | ❌ | `@objectstack/service-ai` (planned) | Spec only | +| 19 | Automation Service | `IAutomationService` | ✅ | `@objectstack/service-automation` | Plugin-based DAG engine (MVP) | +| 20 | Workflow Service | `IWorkflowService` | ❌ | `@objectstack/service-workflow` (planned) | Spec only | +| 21 | GraphQL Service | `IGraphQLService` | ❌ | `@objectstack/service-graphql` (planned) | Spec only | +| 22 | i18n Service | `II18nService` | ✅ | `@objectstack/service-i18n` | File-based locale loading | +| 23 | UI Service | `IUIService` | ⚠️ | — | **Deprecated** — merged into `IMetadataService` | +| 24 | Schema Driver | `ISchemaDriver` | ❌ | — | Spec only | +| 25 | Startup Orchestrator | `IStartupOrchestrator` | ❌ | — | Kernel handles basics | +| 26 | Plugin Validator | `IPluginValidator` | ❌ | — | Spec only | + +**Summary:** 14 fully implemented · 2 partially implemented · 10 specification only --- @@ -612,6 +615,7 @@ Final polish and advanced features. | `@objectstack/service-storage` | 3.0.6 | 8 | ✅ Stable | 7/10 | | `@objectstack/service-i18n` | 3.0.7 | 20 | ✅ Stable | 7/10 | | `@objectstack/service-realtime` | 3.0.7 | 14 | ✅ Stable | 7/10 | +| `@objectstack/service-feed` | 3.0.7 | 40 | ✅ Stable | 7/10 | | `@objectstack/nextjs` | 3.0.2 | ✅ | ✅ Stable | 10/10 | | `@objectstack/nestjs` | 3.0.2 | ✅ | ✅ Stable | 10/10 | | `@objectstack/hono` | 3.0.2 | ✅ | ✅ Stable | 10/10 | diff --git a/packages/services/service-feed/package.json b/packages/services/service-feed/package.json new file mode 100644 index 00000000..c63e30ac --- /dev/null +++ b/packages/services/service-feed/package.json @@ -0,0 +1,29 @@ +{ + "name": "@objectstack/service-feed", + "version": "3.0.7", + "license": "Apache-2.0", + "description": "Feed/Chatter Service for ObjectStack — implements IFeedService with in-memory adapter for comments, reactions, field changes, and record subscriptions", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^4.0.18", + "@types/node": "^25.2.3" + } +} diff --git a/packages/services/service-feed/src/feed-service-plugin.ts b/packages/services/service-feed/src/feed-service-plugin.ts new file mode 100644 index 00000000..9eb9fa96 --- /dev/null +++ b/packages/services/service-feed/src/feed-service-plugin.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { InMemoryFeedAdapter } from './in-memory-feed-adapter.js'; +import type { InMemoryFeedAdapterOptions } from './in-memory-feed-adapter.js'; + +/** + * Configuration options for the FeedServicePlugin. + */ +export interface FeedServicePluginOptions { + /** Feed adapter type (default: 'memory') */ + adapter?: 'memory'; + /** Options for the in-memory adapter */ + memory?: InMemoryFeedAdapterOptions; +} + +/** + * FeedServicePlugin — Production IFeedService implementation. + * + * Registers a Feed/Chatter service with the kernel during the init phase. + * Currently supports in-memory storage for single-process environments. + * + * @example + * ```ts + * import { ObjectKernel } from '@objectstack/core'; + * import { FeedServicePlugin } from '@objectstack/service-feed'; + * + * const kernel = new ObjectKernel(); + * kernel.use(new FeedServicePlugin()); + * await kernel.bootstrap(); + * + * const feed = kernel.getService('feed'); + * const item = await feed.createFeedItem({ + * object: 'account', + * recordId: 'rec_123', + * type: 'comment', + * actor: { type: 'user', id: 'user_1', name: 'Alice' }, + * body: 'Great progress!', + * }); + * ``` + */ +export class FeedServicePlugin implements Plugin { + name = 'com.objectstack.service.feed'; + version = '1.0.0'; + type = 'standard'; + + private readonly options: FeedServicePluginOptions; + + constructor(options: FeedServicePluginOptions = {}) { + this.options = { adapter: 'memory', ...options }; + } + + async init(ctx: PluginContext): Promise { + const feed = new InMemoryFeedAdapter(this.options.memory); + ctx.registerService('feed', feed); + ctx.logger.info('FeedServicePlugin: registered in-memory feed adapter'); + } +} diff --git a/packages/services/service-feed/src/in-memory-feed-adapter.test.ts b/packages/services/service-feed/src/in-memory-feed-adapter.test.ts new file mode 100644 index 00000000..ccc0710e --- /dev/null +++ b/packages/services/service-feed/src/in-memory-feed-adapter.test.ts @@ -0,0 +1,507 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { InMemoryFeedAdapter } from './in-memory-feed-adapter'; +import type { IFeedService, CreateFeedItemInput } from '@objectstack/spec/contracts'; + +/** Helper to create a standard comment input. */ +function commentInput(overrides: Partial = {}): CreateFeedItemInput { + return { + object: 'account', + recordId: 'rec_123', + type: 'comment', + actor: { type: 'user', id: 'user_1', name: 'Alice' }, + body: 'Hello world', + ...overrides, + }; +} + +describe('InMemoryFeedAdapter', () => { + // ========================================== + // Contract compliance + // ========================================== + + it('should implement IFeedService contract', () => { + const feed: IFeedService = new InMemoryFeedAdapter(); + expect(typeof feed.listFeed).toBe('function'); + expect(typeof feed.createFeedItem).toBe('function'); + expect(typeof feed.updateFeedItem).toBe('function'); + expect(typeof feed.deleteFeedItem).toBe('function'); + expect(typeof feed.getFeedItem).toBe('function'); + expect(typeof feed.addReaction).toBe('function'); + expect(typeof feed.removeReaction).toBe('function'); + expect(typeof feed.subscribe).toBe('function'); + expect(typeof feed.unsubscribe).toBe('function'); + expect(typeof feed.getSubscription).toBe('function'); + }); + + // ========================================== + // Feed CRUD + // ========================================== + + it('should start with zero items', () => { + const feed = new InMemoryFeedAdapter(); + expect(feed.getItemCount()).toBe(0); + }); + + it('should create a feed item and return it', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + expect(item.id).toBeDefined(); + expect(item.type).toBe('comment'); + expect(item.object).toBe('account'); + expect(item.recordId).toBe('rec_123'); + expect(item.actor.id).toBe('user_1'); + expect(item.body).toBe('Hello world'); + expect(item.visibility).toBe('public'); + expect(item.replyCount).toBe(0); + expect(item.isEdited).toBe(false); + expect(item.createdAt).toBeDefined(); + expect(feed.getItemCount()).toBe(1); + }); + + it('should get a feed item by ID', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + const fetched = await feed.getFeedItem(item.id); + expect(fetched).toEqual(item); + }); + + it('should return null for unknown feed item ID', async () => { + const feed = new InMemoryFeedAdapter(); + const result = await feed.getFeedItem('nonexistent'); + expect(result).toBeNull(); + }); + + it('should update a feed item body and mark as edited', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + const updated = await feed.updateFeedItem(item.id, { body: 'Updated text' }); + expect(updated.body).toBe('Updated text'); + expect(updated.isEdited).toBe(true); + expect(updated.editedAt).toBeDefined(); + expect(updated.updatedAt).toBeDefined(); + }); + + it('should update feed item visibility', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + const updated = await feed.updateFeedItem(item.id, { visibility: 'internal' }); + expect(updated.visibility).toBe('internal'); + }); + + it('should throw when updating a non-existent feed item', async () => { + const feed = new InMemoryFeedAdapter(); + await expect(feed.updateFeedItem('nonexistent', { body: 'x' })) + .rejects.toThrow(/Feed item not found/); + }); + + it('should delete a feed item', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + await feed.deleteFeedItem(item.id); + expect(feed.getItemCount()).toBe(0); + expect(await feed.getFeedItem(item.id)).toBeNull(); + }); + + it('should throw when deleting a non-existent feed item', async () => { + const feed = new InMemoryFeedAdapter(); + await expect(feed.deleteFeedItem('nonexistent')) + .rejects.toThrow(/Feed item not found/); + }); + + it('should enforce maxItems limit', async () => { + const feed = new InMemoryFeedAdapter({ maxItems: 2 }); + + await feed.createFeedItem(commentInput({ body: 'first' })); + await feed.createFeedItem(commentInput({ body: 'second' })); + + await expect(feed.createFeedItem(commentInput({ body: 'third' }))) + .rejects.toThrow(/Maximum feed item limit reached/); + }); + + // ========================================== + // Feed Listing & Filtering + // ========================================== + + it('should list feed items for a record in reverse chronological order', async () => { + const feed = new InMemoryFeedAdapter(); + await feed.createFeedItem(commentInput({ body: 'first' })); + await feed.createFeedItem(commentInput({ body: 'second' })); + await feed.createFeedItem(commentInput({ body: 'third' })); + + const result = await feed.listFeed({ object: 'account', recordId: 'rec_123' }); + expect(result.items).toHaveLength(3); + expect(result.total).toBe(3); + expect(result.hasMore).toBe(false); + // Reverse chronological: third, second, first + expect(result.items[0].body).toBe('third'); + expect(result.items[2].body).toBe('first'); + }); + + it('should not return items from other records', async () => { + const feed = new InMemoryFeedAdapter(); + await feed.createFeedItem(commentInput({ recordId: 'rec_A' })); + await feed.createFeedItem(commentInput({ recordId: 'rec_B' })); + + const result = await feed.listFeed({ object: 'account', recordId: 'rec_A' }); + expect(result.items).toHaveLength(1); + expect(result.items[0].recordId).toBe('rec_A'); + }); + + it('should filter comments only', async () => { + const feed = new InMemoryFeedAdapter(); + await feed.createFeedItem(commentInput({ type: 'comment', body: 'comment' })); + await feed.createFeedItem(commentInput({ type: 'field_change' })); + + const result = await feed.listFeed({ + object: 'account', + recordId: 'rec_123', + filter: 'comments_only', + }); + expect(result.items).toHaveLength(1); + expect(result.items[0].type).toBe('comment'); + }); + + it('should filter changes only', async () => { + const feed = new InMemoryFeedAdapter(); + await feed.createFeedItem(commentInput({ type: 'comment' })); + await feed.createFeedItem(commentInput({ type: 'field_change' })); + + const result = await feed.listFeed({ + object: 'account', + recordId: 'rec_123', + filter: 'changes_only', + }); + expect(result.items).toHaveLength(1); + expect(result.items[0].type).toBe('field_change'); + }); + + it('should filter tasks only', async () => { + const feed = new InMemoryFeedAdapter(); + await feed.createFeedItem(commentInput({ type: 'comment' })); + await feed.createFeedItem(commentInput({ type: 'task' })); + + const result = await feed.listFeed({ + object: 'account', + recordId: 'rec_123', + filter: 'tasks_only', + }); + expect(result.items).toHaveLength(1); + expect(result.items[0].type).toBe('task'); + }); + + it('should paginate with limit and cursor', async () => { + const feed = new InMemoryFeedAdapter(); + await feed.createFeedItem(commentInput({ body: 'A' })); + await feed.createFeedItem(commentInput({ body: 'B' })); + await feed.createFeedItem(commentInput({ body: 'C' })); + + // First page + const page1 = await feed.listFeed({ + object: 'account', + recordId: 'rec_123', + limit: 2, + }); + expect(page1.items).toHaveLength(2); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBeDefined(); + + // Second page + const page2 = await feed.listFeed({ + object: 'account', + recordId: 'rec_123', + limit: 2, + cursor: page1.nextCursor, + }); + expect(page2.items).toHaveLength(1); + expect(page2.hasMore).toBe(false); + }); + + // ========================================== + // Threading + // ========================================== + + it('should support threaded replies and track reply count', async () => { + const feed = new InMemoryFeedAdapter(); + const parent = await feed.createFeedItem(commentInput({ body: 'parent' })); + + await feed.createFeedItem(commentInput({ + body: 'reply 1', + parentId: parent.id, + })); + + const updatedParent = await feed.getFeedItem(parent.id); + expect(updatedParent!.replyCount).toBe(1); + }); + + it('should decrement reply count on reply deletion', async () => { + const feed = new InMemoryFeedAdapter(); + const parent = await feed.createFeedItem(commentInput({ body: 'parent' })); + const reply = await feed.createFeedItem(commentInput({ + body: 'reply', + parentId: parent.id, + })); + + await feed.deleteFeedItem(reply.id); + + const updatedParent = await feed.getFeedItem(parent.id); + expect(updatedParent!.replyCount).toBe(0); + }); + + it('should throw when creating a reply with invalid parent', async () => { + const feed = new InMemoryFeedAdapter(); + await expect( + feed.createFeedItem(commentInput({ parentId: 'nonexistent' })), + ).rejects.toThrow(/Parent feed item not found/); + }); + + // ========================================== + // Reactions + // ========================================== + + it('should add a reaction to a feed item', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + const reactions = await feed.addReaction(item.id, '👍', 'user_1'); + expect(reactions).toHaveLength(1); + expect(reactions[0].emoji).toBe('👍'); + expect(reactions[0].userIds).toEqual(['user_1']); + expect(reactions[0].count).toBe(1); + }); + + it('should add multiple users to the same reaction', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + await feed.addReaction(item.id, '👍', 'user_1'); + const reactions = await feed.addReaction(item.id, '👍', 'user_2'); + + expect(reactions[0].userIds).toEqual(['user_1', 'user_2']); + expect(reactions[0].count).toBe(2); + }); + + it('should support multiple emoji types on the same item', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + await feed.addReaction(item.id, '👍', 'user_1'); + const reactions = await feed.addReaction(item.id, '❤️', 'user_1'); + + expect(reactions).toHaveLength(2); + }); + + it('should throw when adding duplicate reaction', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + await feed.addReaction(item.id, '👍', 'user_1'); + await expect(feed.addReaction(item.id, '👍', 'user_1')) + .rejects.toThrow(/Reaction already exists/); + }); + + it('should remove a reaction', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + await feed.addReaction(item.id, '👍', 'user_1'); + await feed.addReaction(item.id, '👍', 'user_2'); + + const reactions = await feed.removeReaction(item.id, '👍', 'user_1'); + expect(reactions[0].userIds).toEqual(['user_2']); + expect(reactions[0].count).toBe(1); + }); + + it('should remove reaction entry when last user removes', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + await feed.addReaction(item.id, '👍', 'user_1'); + const reactions = await feed.removeReaction(item.id, '👍', 'user_1'); + + expect(reactions).toHaveLength(0); + }); + + it('should throw when removing a non-existent reaction', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + await expect(feed.removeReaction(item.id, '👍', 'user_1')) + .rejects.toThrow(/Reaction not found/); + }); + + it('should throw when adding/removing reaction on non-existent item', async () => { + const feed = new InMemoryFeedAdapter(); + + await expect(feed.addReaction('nonexistent', '👍', 'user_1')) + .rejects.toThrow(/Feed item not found/); + await expect(feed.removeReaction('nonexistent', '👍', 'user_1')) + .rejects.toThrow(/Feed item not found/); + }); + + // ========================================== + // Subscriptions + // ========================================== + + it('should start with zero subscriptions', () => { + const feed = new InMemoryFeedAdapter(); + expect(feed.getSubscriptionCount()).toBe(0); + }); + + it('should subscribe to record notifications', async () => { + const feed = new InMemoryFeedAdapter(); + + const sub = await feed.subscribe({ + object: 'account', + recordId: 'rec_123', + userId: 'user_1', + events: ['comment', 'field_change'], + channels: ['in_app', 'email'], + }); + + expect(sub.object).toBe('account'); + expect(sub.recordId).toBe('rec_123'); + expect(sub.userId).toBe('user_1'); + expect(sub.events).toEqual(['comment', 'field_change']); + expect(sub.channels).toEqual(['in_app', 'email']); + expect(sub.active).toBe(true); + expect(sub.createdAt).toBeDefined(); + expect(feed.getSubscriptionCount()).toBe(1); + }); + + it('should use default events and channels', async () => { + const feed = new InMemoryFeedAdapter(); + + const sub = await feed.subscribe({ + object: 'account', + recordId: 'rec_123', + userId: 'user_1', + }); + + expect(sub.events).toEqual(['all']); + expect(sub.channels).toEqual(['in_app']); + }); + + it('should update existing subscription instead of creating duplicate', async () => { + const feed = new InMemoryFeedAdapter(); + + await feed.subscribe({ + object: 'account', + recordId: 'rec_123', + userId: 'user_1', + events: ['comment'], + }); + + const updated = await feed.subscribe({ + object: 'account', + recordId: 'rec_123', + userId: 'user_1', + events: ['comment', 'field_change'], + }); + + expect(feed.getSubscriptionCount()).toBe(1); + expect(updated.events).toEqual(['comment', 'field_change']); + }); + + it('should get a subscription by record and user', async () => { + const feed = new InMemoryFeedAdapter(); + + await feed.subscribe({ + object: 'account', + recordId: 'rec_123', + userId: 'user_1', + }); + + const sub = await feed.getSubscription('account', 'rec_123', 'user_1'); + expect(sub).not.toBeNull(); + expect(sub!.userId).toBe('user_1'); + }); + + it('should return null for non-existent subscription', async () => { + const feed = new InMemoryFeedAdapter(); + const sub = await feed.getSubscription('account', 'rec_123', 'user_1'); + expect(sub).toBeNull(); + }); + + it('should unsubscribe from record notifications', async () => { + const feed = new InMemoryFeedAdapter(); + + await feed.subscribe({ + object: 'account', + recordId: 'rec_123', + userId: 'user_1', + }); + + const result = await feed.unsubscribe('account', 'rec_123', 'user_1'); + expect(result).toBe(true); + expect(feed.getSubscriptionCount()).toBe(0); + }); + + it('should return false when unsubscribing without existing subscription', async () => { + const feed = new InMemoryFeedAdapter(); + const result = await feed.unsubscribe('account', 'rec_123', 'user_1'); + expect(result).toBe(false); + }); + + // ========================================== + // Edge cases + // ========================================== + + it('should create feed items with mentions', async () => { + const feed = new InMemoryFeedAdapter(); + + const item = await feed.createFeedItem(commentInput({ + body: 'Hello @jane', + mentions: [{ type: 'user', id: 'user_2', name: 'Jane', offset: 6, length: 5 }], + })); + + expect(item.mentions).toHaveLength(1); + expect(item.mentions![0].name).toBe('Jane'); + }); + + it('should create feed items with field changes', async () => { + const feed = new InMemoryFeedAdapter(); + + const item = await feed.createFeedItem({ + object: 'account', + recordId: 'rec_123', + type: 'field_change', + actor: { type: 'user', id: 'user_1' }, + changes: [ + { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' }, + ], + }); + + expect(item.type).toBe('field_change'); + expect(item.changes).toHaveLength(1); + expect(item.changes![0].field).toBe('status'); + }); + + it('should return unique feed item IDs', async () => { + const feed = new InMemoryFeedAdapter(); + + const item1 = await feed.createFeedItem(commentInput({ body: 'A' })); + const item2 = await feed.createFeedItem(commentInput({ body: 'B' })); + const item3 = await feed.createFeedItem(commentInput({ body: 'C' })); + + expect(item1.id).not.toBe(item2.id); + expect(item2.id).not.toBe(item3.id); + }); + + it('should persist reaction state in the feed item', async () => { + const feed = new InMemoryFeedAdapter(); + const item = await feed.createFeedItem(commentInput()); + + await feed.addReaction(item.id, '👍', 'user_1'); + + const fetched = await feed.getFeedItem(item.id); + expect(fetched!.reactions).toHaveLength(1); + expect(fetched!.reactions![0].emoji).toBe('👍'); + }); +}); diff --git a/packages/services/service-feed/src/in-memory-feed-adapter.ts b/packages/services/service-feed/src/in-memory-feed-adapter.ts new file mode 100644 index 00000000..f1da64eb --- /dev/null +++ b/packages/services/service-feed/src/in-memory-feed-adapter.ts @@ -0,0 +1,321 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { + IFeedService, + CreateFeedItemInput, + UpdateFeedItemInput, + ListFeedOptions, + FeedListResult, + SubscribeInput, +} from '@objectstack/spec/contracts'; +import type { FeedItem, Reaction } from '@objectstack/spec/data'; +import type { RecordSubscription } from '@objectstack/spec/data'; + +/** + * Configuration options for InMemoryFeedAdapter. + */ +export interface InMemoryFeedAdapterOptions { + /** Maximum number of feed items to store (0 = unlimited) */ + maxItems?: number; +} + +/** + * In-memory Feed/Chatter adapter implementing IFeedService. + * + * Uses Map-backed stores for feed items, reactions, and subscriptions. + * Supports feed CRUD, emoji reactions, threaded replies, and record subscriptions. + * + * Suitable for single-process environments, development, and testing. + * For production deployments, use a database-backed adapter. + * + * @example + * ```ts + * const feed = new InMemoryFeedAdapter(); + * + * const item = await feed.createFeedItem({ + * object: 'account', + * recordId: 'rec_123', + * type: 'comment', + * actor: { type: 'user', id: 'user_1', name: 'Alice' }, + * body: 'Great progress!', + * }); + * + * const list = await feed.listFeed({ object: 'account', recordId: 'rec_123' }); + * ``` + */ +export class InMemoryFeedAdapter implements IFeedService { + private readonly items = new Map(); + private counter = 0; + private readonly subscriptions = new Map(); + private readonly maxItems: number; + + constructor(options: InMemoryFeedAdapterOptions = {}) { + this.maxItems = options.maxItems ?? 0; + } + + async listFeed(options: ListFeedOptions): Promise { + let items = Array.from(this.items.values()).filter( + (item) => item.object === options.object && item.recordId === options.recordId, + ); + + // Apply filter + if (options.filter && options.filter !== 'all') { + items = items.filter((item) => { + switch (options.filter) { + case 'comments_only': + return item.type === 'comment'; + case 'changes_only': + return item.type === 'field_change'; + case 'tasks_only': + return item.type === 'task'; + default: + return true; + } + }); + } + + // Sort reverse chronological (stable: break ties by ID descending) + items.sort((a, b) => { + const timeDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (timeDiff !== 0) return timeDiff; + return b.id < a.id ? -1 : b.id > a.id ? 1 : 0; + }); + + const total = items.length; + const limit = options.limit ?? 20; + + // Cursor-based pagination + let startIndex = 0; + if (options.cursor) { + const cursorIndex = items.findIndex((item) => item.id === options.cursor); + if (cursorIndex >= 0) { + startIndex = cursorIndex + 1; + } + } + + const page = items.slice(startIndex, startIndex + limit); + const hasMore = startIndex + limit < total; + + return { + items: page, + total, + nextCursor: hasMore && page.length > 0 ? page[page.length - 1].id : undefined, + hasMore, + }; + } + + async createFeedItem(input: CreateFeedItemInput): Promise { + if (this.maxItems > 0 && this.items.size >= this.maxItems) { + throw new Error( + `Maximum feed item limit reached (${this.maxItems}). ` + + 'Delete existing items before adding new ones.', + ); + } + + const id = `feed_${++this.counter}`; + const now = new Date().toISOString(); + + // Increment parent reply count if threading + if (input.parentId) { + const parent = this.items.get(input.parentId); + if (!parent) { + throw new Error(`Parent feed item not found: ${input.parentId}`); + } + const updatedParent: FeedItem = { + ...parent, + replyCount: (parent.replyCount ?? 0) + 1, + updatedAt: now, + }; + this.items.set(parent.id, updatedParent); + } + + const item: FeedItem = { + id, + type: input.type as FeedItem['type'], + object: input.object, + recordId: input.recordId, + actor: { + type: input.actor.type, + id: input.actor.id, + ...(input.actor.name ? { name: input.actor.name } : {}), + ...(input.actor.avatarUrl ? { avatarUrl: input.actor.avatarUrl } : {}), + }, + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.mentions ? { mentions: input.mentions } : {}), + ...(input.changes ? { changes: input.changes } : {}), + ...(input.parentId ? { parentId: input.parentId } : {}), + visibility: input.visibility ?? 'public', + replyCount: 0, + isEdited: false, + createdAt: now, + }; + + this.items.set(id, item); + return item; + } + + async updateFeedItem(feedId: string, input: UpdateFeedItemInput): Promise { + const existing = this.items.get(feedId); + if (!existing) { + throw new Error(`Feed item not found: ${feedId}`); + } + + const now = new Date().toISOString(); + const updated: FeedItem = { + ...existing, + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.mentions !== undefined ? { mentions: input.mentions } : {}), + ...(input.visibility !== undefined ? { visibility: input.visibility } : {}), + updatedAt: now, + editedAt: now, + isEdited: true, + }; + + this.items.set(feedId, updated); + return updated; + } + + async deleteFeedItem(feedId: string): Promise { + const item = this.items.get(feedId); + if (!item) { + throw new Error(`Feed item not found: ${feedId}`); + } + + // Decrement parent reply count if threaded + if (item.parentId) { + const parent = this.items.get(item.parentId); + if (parent) { + const updatedParent: FeedItem = { + ...parent, + replyCount: Math.max(0, (parent.replyCount ?? 0) - 1), + }; + this.items.set(parent.id, updatedParent); + } + } + + this.items.delete(feedId); + } + + async getFeedItem(feedId: string): Promise { + return this.items.get(feedId) ?? null; + } + + async addReaction(feedId: string, emoji: string, userId: string): Promise { + const item = this.items.get(feedId); + if (!item) { + throw new Error(`Feed item not found: ${feedId}`); + } + + const reactions = [...(item.reactions ?? [])]; + const existing = reactions.find((r) => r.emoji === emoji); + + if (existing) { + if (existing.userIds.includes(userId)) { + throw new Error(`Reaction already exists: ${emoji} by ${userId}`); + } + existing.userIds = [...existing.userIds, userId]; + existing.count = existing.userIds.length; + } else { + reactions.push({ emoji, userIds: [userId], count: 1 }); + } + + const updated: FeedItem = { ...item, reactions }; + this.items.set(feedId, updated); + return reactions; + } + + async removeReaction(feedId: string, emoji: string, userId: string): Promise { + const item = this.items.get(feedId); + if (!item) { + throw new Error(`Feed item not found: ${feedId}`); + } + + let reactions = [...(item.reactions ?? [])]; + const existing = reactions.find((r) => r.emoji === emoji); + + if (!existing || !existing.userIds.includes(userId)) { + throw new Error(`Reaction not found: ${emoji} by ${userId}`); + } + + existing.userIds = existing.userIds.filter((id) => id !== userId); + existing.count = existing.userIds.length; + + // Remove reaction entry if no users left + reactions = reactions.filter((r) => r.count > 0); + + const updated: FeedItem = { ...item, reactions }; + this.items.set(feedId, updated); + return reactions; + } + + async subscribe(input: SubscribeInput): Promise { + const key = this.subscriptionKey(input.object, input.recordId, input.userId); + const existing = this.findSubscription(input.object, input.recordId, input.userId); + + if (existing) { + // Update existing subscription + const updated: RecordSubscription = { + ...existing, + events: input.events ?? existing.events, + channels: input.channels ?? existing.channels, + active: true, + }; + this.subscriptions.set(key, updated); + return updated; + } + + const now = new Date().toISOString(); + const subscription: RecordSubscription = { + object: input.object, + recordId: input.recordId, + userId: input.userId, + events: input.events ?? ['all'], + channels: input.channels ?? ['in_app'], + active: true, + createdAt: now, + }; + + this.subscriptions.set(key, subscription); + return subscription; + } + + async unsubscribe(object: string, recordId: string, userId: string): Promise { + const key = this.subscriptionKey(object, recordId, userId); + return this.subscriptions.delete(key); + } + + async getSubscription( + object: string, + recordId: string, + userId: string, + ): Promise { + return this.findSubscription(object, recordId, userId); + } + + /** + * Get the total number of feed items stored. + */ + getItemCount(): number { + return this.items.size; + } + + /** + * Get the total number of subscriptions stored. + */ + getSubscriptionCount(): number { + return this.subscriptions.size; + } + + private subscriptionKey(object: string, recordId: string, userId: string): string { + return `${object}:${recordId}:${userId}`; + } + + private findSubscription( + object: string, + recordId: string, + userId: string, + ): RecordSubscription | null { + const key = this.subscriptionKey(object, recordId, userId); + return this.subscriptions.get(key) ?? null; + } +} diff --git a/packages/services/service-feed/src/index.ts b/packages/services/service-feed/src/index.ts new file mode 100644 index 00000000..c3e7b632 --- /dev/null +++ b/packages/services/service-feed/src/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { FeedServicePlugin } from './feed-service-plugin.js'; +export type { FeedServicePluginOptions } from './feed-service-plugin.js'; +export { InMemoryFeedAdapter } from './in-memory-feed-adapter.js'; +export type { InMemoryFeedAdapterOptions } from './in-memory-feed-adapter.js'; + +// Feed Service Objects (metadata definitions) +export { FeedItem, FeedReaction, RecordSubscription } from './objects/index.js'; diff --git a/packages/services/service-feed/src/objects/feed-item.object.ts b/packages/services/service-feed/src/objects/feed-item.object.ts new file mode 100644 index 00000000..86ce2b24 --- /dev/null +++ b/packages/services/service-feed/src/objects/feed-item.object.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Feed Item Object + * + * System object for storing feed/chatter items including comments, + * field changes, tasks, events, and system activities. + * + * Belongs to `service-feed` package per "protocol + service ownership" pattern. + */ +export const FeedItem = ObjectSchema.create({ + name: 'sys_feed_item', + label: 'Feed Item', + pluralLabel: 'Feed Items', + icon: 'message-square', + description: 'Unified activity timeline entries (comments, field changes, tasks, events)', + titleFormat: '{type}: {body}', + compactLayout: ['type', 'object', 'record_id', 'created_at'], + + fields: { + id: Field.text({ + label: 'Feed Item ID', + required: true, + readonly: true, + }), + + type: Field.select({ + label: 'Type', + required: true, + options: [ + { label: 'Comment', value: 'comment' }, + { label: 'Field Change', value: 'field_change' }, + { label: 'Task', value: 'task' }, + { label: 'Event', value: 'event' }, + { label: 'Email', value: 'email' }, + { label: 'Call', value: 'call' }, + { label: 'Note', value: 'note' }, + { label: 'File', value: 'file' }, + { label: 'Record Create', value: 'record_create' }, + { label: 'Record Delete', value: 'record_delete' }, + { label: 'Approval', value: 'approval' }, + { label: 'Sharing', value: 'sharing' }, + { label: 'System', value: 'system' }, + ], + }), + + object: Field.text({ + label: 'Object Name', + required: true, + searchable: true, + }), + + record_id: Field.text({ + label: 'Record ID', + required: true, + searchable: true, + }), + + actor_type: Field.select({ + label: 'Actor Type', + required: true, + options: [ + { label: 'User', value: 'user' }, + { label: 'System', value: 'system' }, + { label: 'Service', value: 'service' }, + { label: 'Automation', value: 'automation' }, + ], + }), + + actor_id: Field.text({ + label: 'Actor ID', + required: true, + }), + + actor_name: Field.text({ + label: 'Actor Name', + }), + + actor_avatar_url: Field.url({ + label: 'Actor Avatar URL', + }), + + body: Field.textarea({ + label: 'Body', + description: 'Rich text body (Markdown supported)', + }), + + mentions: Field.textarea({ + label: 'Mentions', + description: 'Array of @mention objects (JSON)', + }), + + changes: Field.textarea({ + label: 'Field Changes', + description: 'Array of field change entries (JSON)', + }), + + reactions: Field.textarea({ + label: 'Reactions', + description: 'Array of emoji reaction objects (JSON)', + }), + + parent_id: Field.text({ + label: 'Parent Feed Item ID', + description: 'For threaded replies', + }), + + reply_count: Field.number({ + label: 'Reply Count', + defaultValue: 0, + }), + + visibility: Field.select({ + label: 'Visibility', + defaultValue: 'public', + options: [ + { label: 'Public', value: 'public' }, + { label: 'Internal', value: 'internal' }, + { label: 'Private', value: 'private' }, + ], + }), + + is_edited: Field.boolean({ + label: 'Is Edited', + defaultValue: false, + }), + + edited_at: Field.datetime({ + label: 'Edited At', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['object', 'record_id'], unique: false }, + { fields: ['actor_id'], unique: false }, + { fields: ['parent_id'], unique: false }, + { fields: ['created_at'], unique: false }, + ], + + enable: { + trackHistory: false, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/services/service-feed/src/objects/feed-reaction.object.ts b/packages/services/service-feed/src/objects/feed-reaction.object.ts new file mode 100644 index 00000000..fc0a4d29 --- /dev/null +++ b/packages/services/service-feed/src/objects/feed-reaction.object.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Feed Reaction Object + * + * System object for storing individual emoji reactions on feed items. + * Each row represents one user's reaction on one feed item. + * + * Belongs to `service-feed` package per "protocol + service ownership" pattern. + */ +export const FeedReaction = ObjectSchema.create({ + name: 'sys_feed_reaction', + label: 'Feed Reaction', + pluralLabel: 'Feed Reactions', + icon: 'smile', + description: 'Emoji reactions on feed items', + titleFormat: '{emoji} by {user_id}', + compactLayout: ['feed_item_id', 'emoji', 'user_id'], + + fields: { + id: Field.text({ + label: 'Reaction ID', + required: true, + readonly: true, + }), + + feed_item_id: Field.text({ + label: 'Feed Item ID', + required: true, + }), + + emoji: Field.text({ + label: 'Emoji', + required: true, + description: 'Emoji character or shortcode (e.g., "👍", ":thumbsup:")', + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['feed_item_id', 'emoji', 'user_id'], unique: true }, + { fields: ['feed_item_id'], unique: false }, + { fields: ['user_id'], unique: false }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/services/service-feed/src/objects/index.ts b/packages/services/service-feed/src/objects/index.ts new file mode 100644 index 00000000..ea5090ac --- /dev/null +++ b/packages/services/service-feed/src/objects/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Feed Service Objects + * + * ObjectQL-based object definitions for Feed/Chatter database schema. + * These objects define the persistent storage model for feed items, + * reactions, and record subscriptions. + */ + +export { FeedItem } from './feed-item.object.js'; +export { FeedReaction } from './feed-reaction.object.js'; +export { RecordSubscription } from './record-subscription.object.js'; diff --git a/packages/services/service-feed/src/objects/record-subscription.object.ts b/packages/services/service-feed/src/objects/record-subscription.object.ts new file mode 100644 index 00000000..fed8b086 --- /dev/null +++ b/packages/services/service-feed/src/objects/record-subscription.object.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Record Subscription Object + * + * System object for storing record-level notification subscriptions. + * Enables Airtable-style bell icon for record change notifications. + * + * Belongs to `service-feed` package per "protocol + service ownership" pattern. + */ +export const RecordSubscription = ObjectSchema.create({ + name: 'sys_record_subscription', + label: 'Record Subscription', + pluralLabel: 'Record Subscriptions', + icon: 'bell', + description: 'Record-level notification subscriptions for feed events', + titleFormat: '{object}/{record_id} — {user_id}', + compactLayout: ['object', 'record_id', 'user_id', 'active'], + + fields: { + id: Field.text({ + label: 'Subscription ID', + required: true, + readonly: true, + }), + + object: Field.text({ + label: 'Object Name', + required: true, + }), + + record_id: Field.text({ + label: 'Record ID', + required: true, + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + }), + + events: Field.textarea({ + label: 'Subscribed Events', + description: 'Array of event types: comment, mention, field_change, task, approval, all (JSON)', + }), + + channels: Field.textarea({ + label: 'Notification Channels', + description: 'Array of channels: in_app, email, push, slack (JSON)', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['object', 'record_id', 'user_id'], unique: true }, + { fields: ['user_id'], unique: false }, + { fields: ['object', 'record_id'], unique: false }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/services/service-feed/tsconfig.json b/packages/services/service-feed/tsconfig.json new file mode 100644 index 00000000..583257f9 --- /dev/null +++ b/packages/services/service-feed/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/spec/src/contracts/feed-service.test.ts b/packages/spec/src/contracts/feed-service.test.ts new file mode 100644 index 00000000..68a203ee --- /dev/null +++ b/packages/spec/src/contracts/feed-service.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import type { IFeedService } from './feed-service'; + +describe('Feed Service Contract', () => { + it('should allow a minimal IFeedService implementation with all required methods', () => { + const service: IFeedService = { + listFeed: async () => ({ items: [], hasMore: false }), + createFeedItem: async () => ({ + id: 'feed_1', + type: 'comment', + object: 'account', + recordId: 'rec_1', + actor: { type: 'user', id: 'user_1' }, + visibility: 'public', + replyCount: 0, + isEdited: false, + createdAt: new Date().toISOString(), + }), + updateFeedItem: async () => ({ + id: 'feed_1', + type: 'comment', + object: 'account', + recordId: 'rec_1', + actor: { type: 'user', id: 'user_1' }, + visibility: 'public', + replyCount: 0, + isEdited: true, + createdAt: new Date().toISOString(), + }), + deleteFeedItem: async () => {}, + getFeedItem: async () => null, + addReaction: async () => [], + removeReaction: async () => [], + subscribe: async () => ({ + object: 'account', + recordId: 'rec_1', + userId: 'user_1', + events: ['all'], + channels: ['in_app'], + active: true, + createdAt: new Date().toISOString(), + }), + unsubscribe: async () => true, + getSubscription: async () => null, + }; + + expect(typeof service.listFeed).toBe('function'); + expect(typeof service.createFeedItem).toBe('function'); + expect(typeof service.updateFeedItem).toBe('function'); + expect(typeof service.deleteFeedItem).toBe('function'); + expect(typeof service.getFeedItem).toBe('function'); + expect(typeof service.addReaction).toBe('function'); + expect(typeof service.removeReaction).toBe('function'); + expect(typeof service.subscribe).toBe('function'); + expect(typeof service.unsubscribe).toBe('function'); + expect(typeof service.getSubscription).toBe('function'); + }); + + it('should create and retrieve a feed item', async () => { + const items = new Map(); + let counter = 0; + + const service: IFeedService = { + listFeed: async () => ({ items: Array.from(items.values()), hasMore: false }), + createFeedItem: async (input) => { + const id = `feed_${++counter}`; + const item = { + id, + type: input.type as any, + object: input.object, + recordId: input.recordId, + actor: input.actor, + body: input.body, + visibility: input.visibility ?? 'public', + replyCount: 0, + isEdited: false, + createdAt: new Date().toISOString(), + }; + items.set(id, item); + return item; + }, + updateFeedItem: async () => ({} as any), + deleteFeedItem: async () => {}, + getFeedItem: async (feedId) => items.get(feedId) ?? null, + addReaction: async () => [], + removeReaction: async () => [], + subscribe: async () => ({} as any), + unsubscribe: async () => true, + getSubscription: async () => null, + }; + + const item = await service.createFeedItem({ + object: 'account', + recordId: 'rec_123', + type: 'comment', + actor: { type: 'user', id: 'user_1', name: 'Alice' }, + body: 'Hello world', + }); + + expect(item.id).toBeDefined(); + expect(item.body).toBe('Hello world'); + + const fetched = await service.getFeedItem(item.id); + expect(fetched).toEqual(item); + }); + + it('should list feed items', async () => { + const service: IFeedService = { + listFeed: async (options) => ({ + items: [ + { + id: 'feed_1', + type: 'comment', + object: options.object, + recordId: options.recordId, + actor: { type: 'user', id: 'user_1' }, + visibility: 'public', + replyCount: 0, + isEdited: false, + createdAt: new Date().toISOString(), + }, + ], + total: 1, + hasMore: false, + }), + createFeedItem: async () => ({} as any), + updateFeedItem: async () => ({} as any), + deleteFeedItem: async () => {}, + getFeedItem: async () => null, + addReaction: async () => [], + removeReaction: async () => [], + subscribe: async () => ({} as any), + unsubscribe: async () => true, + getSubscription: async () => null, + }; + + const result = await service.listFeed({ object: 'account', recordId: 'rec_123' }); + expect(result.items).toHaveLength(1); + expect(result.hasMore).toBe(false); + }); + + it('should handle subscribe and unsubscribe', async () => { + const subs = new Map(); + + const service: IFeedService = { + listFeed: async () => ({ items: [], hasMore: false }), + createFeedItem: async () => ({} as any), + updateFeedItem: async () => ({} as any), + deleteFeedItem: async () => {}, + getFeedItem: async () => null, + addReaction: async () => [], + removeReaction: async () => [], + subscribe: async (input) => { + const sub = { + object: input.object, + recordId: input.recordId, + userId: input.userId, + events: input.events ?? ['all'], + channels: input.channels ?? ['in_app'], + active: true, + createdAt: new Date().toISOString(), + }; + subs.set(`${input.object}:${input.recordId}:${input.userId}`, sub); + return sub; + }, + unsubscribe: async (object, recordId, userId) => { + return subs.delete(`${object}:${recordId}:${userId}`); + }, + getSubscription: async (object, recordId, userId) => { + return subs.get(`${object}:${recordId}:${userId}`) ?? null; + }, + }; + + const sub = await service.subscribe({ + object: 'account', + recordId: 'rec_123', + userId: 'user_1', + events: ['comment'], + }); + expect(sub.active).toBe(true); + expect(sub.events).toEqual(['comment']); + + const fetched = await service.getSubscription('account', 'rec_123', 'user_1'); + expect(fetched).not.toBeNull(); + + const result = await service.unsubscribe('account', 'rec_123', 'user_1'); + expect(result).toBe(true); + + const gone = await service.getSubscription('account', 'rec_123', 'user_1'); + expect(gone).toBeNull(); + }); +}); diff --git a/packages/spec/src/contracts/feed-service.ts b/packages/spec/src/contracts/feed-service.ts new file mode 100644 index 00000000..0d1e84a1 --- /dev/null +++ b/packages/spec/src/contracts/feed-service.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IFeedService - Feed/Chatter Service Contract + * + * Defines the interface for Feed/Chatter operations in ObjectStack. + * Covers feed CRUD, emoji reactions, and record subscriptions. + * Concrete implementations (in-memory, database-backed, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete feed service implementations. + * + * Aligned with CoreServiceName 'feed' in core-services.zod.ts. + */ + +import type { FeedItem, Reaction } from '../data/feed.zod'; +import type { RecordSubscription } from '../data/subscription.zod'; + +// ========================================== +// Feed Item Types +// ========================================== + +/** + * Input for creating a new feed item. + */ +export interface CreateFeedItemInput { + /** Object name (e.g., "account") */ + object: string; + /** Record ID */ + recordId: string; + /** Feed item type */ + type: string; + /** Actor information */ + actor: { + type: 'user' | 'system' | 'service' | 'automation'; + id: string; + name?: string; + avatarUrl?: string; + }; + /** Rich text body (Markdown) */ + body?: string; + /** @mentions */ + mentions?: Array<{ + type: 'user' | 'team' | 'record'; + id: string; + name: string; + offset: number; + length: number; + }>; + /** Field changes (for field_change type) */ + changes?: Array<{ + field: string; + fieldLabel?: string; + oldValue?: unknown; + newValue?: unknown; + oldDisplayValue?: string; + newDisplayValue?: string; + }>; + /** Parent feed item ID for threaded replies */ + parentId?: string; + /** Visibility level */ + visibility?: 'public' | 'internal' | 'private'; +} + +/** + * Input for updating an existing feed item. + */ +export interface UpdateFeedItemInput { + /** Updated body text */ + body?: string; + /** Updated mentions */ + mentions?: Array<{ + type: 'user' | 'team' | 'record'; + id: string; + name: string; + offset: number; + length: number; + }>; + /** Updated visibility */ + visibility?: 'public' | 'internal' | 'private'; +} + +/** + * Options for listing feed items. + */ +export interface ListFeedOptions { + /** Object name */ + object: string; + /** Record ID */ + recordId: string; + /** Filter mode */ + filter?: 'all' | 'comments_only' | 'changes_only' | 'tasks_only'; + /** Maximum items to return */ + limit?: number; + /** Cursor for pagination */ + cursor?: string; +} + +/** + * Paginated feed list result. + */ +export interface FeedListResult { + /** Feed items in reverse chronological order */ + items: FeedItem[]; + /** Total feed items matching filter */ + total?: number; + /** Cursor for next page */ + nextCursor?: string; + /** Whether more items are available */ + hasMore: boolean; +} + +// ========================================== +// Subscription Types +// ========================================== + +/** + * Input for subscribing to record notifications. + */ +export interface SubscribeInput { + /** Object name */ + object: string; + /** Record ID */ + recordId: string; + /** Subscribing user ID */ + userId: string; + /** Event types to subscribe to */ + events?: Array<'comment' | 'mention' | 'field_change' | 'task' | 'approval' | 'all'>; + /** Notification channels */ + channels?: Array<'in_app' | 'email' | 'push' | 'slack'>; +} + +// ========================================== +// Service Interface +// ========================================== + +export interface IFeedService { + // ---- Feed CRUD ---- + + /** + * List feed items for a record. + * @param options - Filter and pagination options + * @returns Paginated list of feed items + */ + listFeed(options: ListFeedOptions): Promise; + + /** + * Create a new feed item. + * @param input - Feed item data + * @returns The created feed item + */ + createFeedItem(input: CreateFeedItemInput): Promise; + + /** + * Update an existing feed item (e.g., edit a comment). + * @param feedId - Feed item ID + * @param input - Updated fields + * @returns The updated feed item + */ + updateFeedItem(feedId: string, input: UpdateFeedItemInput): Promise; + + /** + * Delete a feed item. + * @param feedId - Feed item ID + */ + deleteFeedItem(feedId: string): Promise; + + /** + * Get a single feed item by ID. + * @param feedId - Feed item ID + * @returns The feed item, or null if not found + */ + getFeedItem(feedId: string): Promise; + + // ---- Reactions ---- + + /** + * Add an emoji reaction to a feed item. + * @param feedId - Feed item ID + * @param emoji - Emoji character or shortcode + * @param userId - User adding the reaction + * @returns Updated reactions list + */ + addReaction(feedId: string, emoji: string, userId: string): Promise; + + /** + * Remove an emoji reaction from a feed item. + * @param feedId - Feed item ID + * @param emoji - Emoji character or shortcode + * @param userId - User removing the reaction + * @returns Updated reactions list + */ + removeReaction(feedId: string, emoji: string, userId: string): Promise; + + // ---- Subscriptions ---- + + /** + * Subscribe to record-level notifications. + * @param input - Subscription details + * @returns The created or updated subscription + */ + subscribe(input: SubscribeInput): Promise; + + /** + * Unsubscribe from record notifications. + * @param object - Object name + * @param recordId - Record ID + * @param userId - User ID + * @returns Whether the user was unsubscribed + */ + unsubscribe(object: string, recordId: string, userId: string): Promise; + + /** + * Get a user's subscription for a record. + * @param object - Object name + * @param recordId - Record ID + * @param userId - User ID + * @returns The subscription, or null if not subscribed + */ + getSubscription(object: string, recordId: string, userId: string): Promise; +} diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 11b83457..24f18040 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -32,3 +32,4 @@ export * from './ai-service.js'; export * from './i18n-service.js'; export * from './ui-service.js'; export * from './workflow-service.js'; +export * from './feed-service.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abceb63b..786f98cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -918,6 +918,25 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.3)(happy-dom@20.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0) + packages/services/service-feed: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.3)(happy-dom@20.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0) + packages/services/service-i18n: dependencies: '@objectstack/core':