diff --git a/.changeset/add-user-preferences-service.md b/.changeset/add-user-preferences-service.md new file mode 100644 index 000000000..bc95fbf4a --- /dev/null +++ b/.changeset/add-user-preferences-service.md @@ -0,0 +1,5 @@ +--- +"@objectstack/service-user-preferences": minor +--- + +Add User Preferences Service for managing user preferences and favorites with ObjectQL persistence diff --git a/apps/studio/package.json b/apps/studio/package.json index 25fbeb5a7..0e9691646 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -40,6 +40,7 @@ "@objectstack/service-analytics": "workspace:*", "@objectstack/service-automation": "workspace:*", "@objectstack/service-feed": "workspace:*", + "@objectstack/service-user-preferences": "workspace:*", "@objectstack/spec": "workspace:*", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 7d0c3000e..2a7ab0da8 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -35,6 +35,7 @@ import { MetadataPlugin } from '@objectstack/metadata'; import { AIServicePlugin } from '@objectstack/service-ai'; import { AutomationServicePlugin } from '@objectstack/service-automation'; import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; +import { UserPreferencesServicePlugin } from '@objectstack/service-user-preferences'; import { getRequestListener } from '@hono/node-server'; import type { Hono } from 'hono'; import { createBrokerShim } from '../src/lib/create-broker-shim.js'; @@ -129,6 +130,7 @@ async function ensureKernel(): Promise { await kernel.use(new SecurityPlugin()); await kernel.use(new AuditPlugin()); await kernel.use(new FeedServicePlugin()); + await kernel.use(new UserPreferencesServicePlugin()); await kernel.use(new MetadataPlugin({ watch: false })); await kernel.use(new AIServicePlugin()); await kernel.use(new AutomationServicePlugin()); diff --git a/apps/studio/src/mocks/createKernel.ts b/apps/studio/src/mocks/createKernel.ts index 0ef1b601c..c5f10b2ef 100644 --- a/apps/studio/src/mocks/createKernel.ts +++ b/apps/studio/src/mocks/createKernel.ts @@ -10,6 +10,7 @@ import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; import { MetadataPlugin } from '@objectstack/metadata'; import { AIServicePlugin } from '@objectstack/service-ai'; import { FeedServicePlugin } from '@objectstack/service-feed'; +import { UserPreferencesServicePlugin } from '@objectstack/service-user-preferences'; import { createBrokerShim } from '../lib/create-broker-shim'; // System object definitions — resolved via Vite aliases to plugin source (no runtime deps) @@ -86,6 +87,7 @@ export async function createKernel(options: KernelOptions) { // so that the setupNav service is available during their init() phase await kernel.use(new SetupPlugin()); await kernel.use(new FeedServicePlugin()); + await kernel.use(new UserPreferencesServicePlugin()); await kernel.use(new MetadataPlugin({ watch: false })); await kernel.use(new AIServicePlugin()); await kernel.use(new AutomationServicePlugin()); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 04a632ebd..ae2003af6 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -356,6 +356,22 @@ export default class Serve extends Command { } } + // 5. Auto-register UserPreferencesServicePlugin if not already loaded by config plugins. + const hasUserPrefsPlugin = plugins.some( + (p: any) => p.name === 'com.objectstack.service-user-preferences' + || p.constructor?.name === 'UserPreferencesServicePlugin' + ); + if (!hasUserPrefsPlugin) { + try { + const userPrefsPkg = '@objectstack/service-user-preferences'; + const { UserPreferencesServicePlugin } = await import(/* webpackIgnore: true */ userPrefsPkg); + await kernel.use(new UserPreferencesServicePlugin()); + trackPlugin('UserPreferences'); + } catch { + // @objectstack/service-user-preferences not installed — preferences features unavailable + } + } + // ── Studio UI ───────────────────────────────────────────────── // In dev mode, Studio UI is enabled by default (use --no-ui to disable). // Always serves the pre-built dist/ — no Vite dev server, no extra port. diff --git a/packages/services/service-user-preferences/CHANGELOG.md b/packages/services/service-user-preferences/CHANGELOG.md new file mode 100644 index 000000000..00eb1b9cc --- /dev/null +++ b/packages/services/service-user-preferences/CHANGELOG.md @@ -0,0 +1,29 @@ +# @objectstack/service-user-preferences + +## 1.0.0 (2026-04-09) + +### Features + +- **Initial Release** - User Preferences Service implementation +- **IUserPreferencesService** - Full service contract with get/set/setMany/delete/getAll/has/clear/listEntries methods +- **IUserFavoritesService** - Specialized favorites service with add/remove/has/toggle/list methods +- **ObjectQL Persistence** - Database-agnostic storage via IDataEngine +- **REST API** - Complete HTTP routes for preferences and favorites management + - `/api/v1/user/preferences` - CRUD operations for preferences + - `/api/v1/user/favorites` - Favorites management endpoints +- **Type Safety** - Full TypeScript support with Zod schemas +- **Prefix Filtering** - Query preferences by key prefix (e.g., `plugin.ai.*`) +- **Well-Known Keys** - Predefined system preference keys (theme, locale, timezone, etc.) +- **Auto-Registration** - Automatic plugin registration in CLI and Studio + +### Schema + +- **UserPreferenceEntry** - Core preference data model +- **FavoriteEntry** - Favorite item structure with type/target/metadata +- **WellKnownPreferenceKeys** - Enum of reserved system preference keys + +### Tests + +- Comprehensive unit tests for all service methods +- In-memory IDataEngine stub for fast testing +- Test coverage for scalar values, structured data, and edge cases diff --git a/packages/services/service-user-preferences/README.md b/packages/services/service-user-preferences/README.md new file mode 100644 index 000000000..b764e0f2d --- /dev/null +++ b/packages/services/service-user-preferences/README.md @@ -0,0 +1,208 @@ +# @objectstack/service-user-preferences + +User Preferences Service for ObjectStack — implements IUserPreferencesService and IUserFavoritesService with ObjectQL persistence and REST routes. + +## Features + +- **Scalar Preferences**: Simple key-value storage for user settings (theme, locale, etc.) +- **Structured Data**: Store complex data structures (favorites, recent items) +- **ObjectQL Persistence**: Leverages IDataEngine for database-agnostic storage +- **REST API**: Full HTTP routes for preferences and favorites management +- **Type Safety**: Complete TypeScript support with Zod schemas +- **Multi-tenant**: User-scoped preferences with isolation +- **Prefix Filtering**: Query preferences by key prefix (e.g., all `plugin.ai.*` settings) + +## Installation + +```bash +pnpm add @objectstack/service-user-preferences +``` + +## Usage + +### Basic Setup + +```typescript +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { DriverPlugin } from '@objectstack/runtime'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { UserPreferencesServicePlugin } from '@objectstack/service-user-preferences'; + +const kernel = new ObjectKernel(); + +await kernel.use(new ObjectQLPlugin()); +await kernel.use(new DriverPlugin(new InMemoryDriver())); +await kernel.use(new UserPreferencesServicePlugin()); + +await kernel.bootstrap(); + +const prefs = kernel.getService('user-preferences'); +``` + +### Scalar Preferences + +```typescript +// Set a preference +await prefs.set('user123', 'theme', 'dark'); + +// Get a preference +const theme = await prefs.get('user123', 'theme'); // => 'dark' + +// Set multiple preferences at once +await prefs.setMany('user123', { + theme: 'dark', + locale: 'en-US', + sidebar_collapsed: true, +}); + +// Get all preferences +const all = await prefs.getAll('user123'); +// => { theme: 'dark', locale: 'en-US', sidebar_collapsed: true } + +// Delete a preference +await prefs.delete('user123', 'theme'); + +// Check if a preference exists +const hasTheme = await prefs.has('user123', 'theme'); +``` + +### Structured Data (Favorites) + +```typescript +const favorites = kernel.getService('user-favorites'); + +// Add a favorite +const entry = await favorites.add('user123', { + type: 'view', + target: 'kanban_tasks', + label: 'My Tasks', + icon: 'kanban', +}); + +// List all favorites +const allFavorites = await favorites.list('user123'); + +// Remove a favorite +await favorites.remove('user123', entry.id); + +// Check if an item is favorited +const isFav = await favorites.has('user123', 'view', 'kanban_tasks'); + +// Toggle a favorite (add if not exists, remove if exists) +const added = await favorites.toggle('user123', { + type: 'view', + target: 'kanban_tasks', +}); +``` + +### Prefix Filtering + +```typescript +// Set plugin-specific preferences +await prefs.setMany('user123', { + 'plugin.ai.auto_save': true, + 'plugin.ai.model': 'gpt-4', + 'plugin.security.mfa_enabled': false, +}); + +// Get all AI plugin preferences +const aiPrefs = await prefs.getAll('user123', { prefix: 'plugin.ai.' }); +// => { 'plugin.ai.auto_save': true, 'plugin.ai.model': 'gpt-4' } + +// Clear all AI plugin preferences +await prefs.clear('user123', { prefix: 'plugin.ai.' }); +``` + +## REST API + +The plugin automatically registers HTTP routes when started: + +### Preferences Routes + +- **GET `/api/v1/user/preferences`** - Get all preferences (with optional `?prefix=` query param) +- **GET `/api/v1/user/preferences/:key`** - Get a single preference +- **POST `/api/v1/user/preferences`** - Batch set preferences (body: `{ preferences: { ... } }`) +- **PUT `/api/v1/user/preferences/:key`** - Set a single preference (body: `{ value: ... }`) +- **DELETE `/api/v1/user/preferences/:key`** - Delete a preference + +### Favorites Routes + +- **GET `/api/v1/user/favorites`** - List all favorites +- **POST `/api/v1/user/favorites`** - Add a favorite (body: `{ type, target, label?, icon?, metadata? }`) +- **DELETE `/api/v1/user/favorites/:id`** - Remove a favorite +- **POST `/api/v1/user/favorites/toggle`** - Toggle a favorite (body: `{ type, target, label?, icon?, metadata? }`) + +All routes require authentication and the user's ID is automatically extracted from the request context. + +## Well-Known Preference Keys + +The following preference keys are reserved for system-level settings: + +- `theme` - UI theme (`'light'` | `'dark'` | `'system'`) +- `locale` - User's preferred locale (`'en-US'`, `'zh-CN'`, etc.) +- `timezone` - User's timezone (`'America/New_York'`, etc.) +- `favorites` - User's favorite items (structured array) +- `recent_items` - Recently accessed items (structured array) +- `sidebar_collapsed` - UI: sidebar state (boolean) +- `page_size` - Default pagination size (number) + +Plugins can define custom keys using their own namespace, e.g., `plugin.ai.auto_save`. + +## Schema + +### UserPreferenceEntry + +```typescript +{ + id: string; // Auto-generated (e.g., 'pref_abc123') + userId: string; // User ID + key: string; // Preference key + value: unknown; // JSON-serializable value + valueType?: string; // Type hint: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null' + createdAt: string; // ISO timestamp + updatedAt: string; // ISO timestamp +} +``` + +### FavoriteEntry + +```typescript +{ + id: string; // Auto-generated (e.g., 'fav_xyz789') + type: 'object' | 'view' | 'app' | 'dashboard' | 'report' | 'record'; + target: string; // Target reference (object name, view name, etc.) + label?: string; // Display label override + icon?: string; // Icon override + metadata?: Record; // Custom metadata + createdAt: string; // ISO timestamp +} +``` + +## Database + +The plugin creates a `user_preferences` object in ObjectQL with the following schema: + +- `id` (text, primary) - Unique identifier +- `user_id` (text, indexed) - User who owns the preference +- `key` (text, indexed) - Preference key +- `value` (textarea) - JSON-serialized value +- `value_type` (select) - Type hint for client-side type safety +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp + +Unique composite index: `(user_id, key)` + +## Architecture + +The service follows ObjectStack's standard patterns: + +1. **Spec Layer** (`@objectstack/spec/identity`) - Zod schemas for preferences and favorites +2. **Contract Layer** (`@objectstack/spec/contracts`) - Service interfaces (IUserPreferencesService, IUserFavoritesService) +3. **Implementation Layer** - ObjectQL-based adapter for persistence +4. **Plugin Layer** - Kernel plugin with service registration and HTTP routes +5. **Client Layer** - Type-safe client SDK (future enhancement) + +## License + +Apache-2.0 © ObjectStack diff --git a/packages/services/service-user-preferences/package.json b/packages/services/service-user-preferences/package.json new file mode 100644 index 000000000..dd8b6849d --- /dev/null +++ b/packages/services/service-user-preferences/package.json @@ -0,0 +1,29 @@ +{ + "name": "@objectstack/service-user-preferences", + "version": "1.0.0", + "license": "Apache-2.0", + "description": "User Preferences Service for ObjectStack — implements IUserPreferencesService and IUserFavoritesService with ObjectQL persistence and REST routes", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^6.0.2", + "vitest": "^4.1.2" + } +} diff --git a/packages/services/service-user-preferences/src/__tests__/user-preferences-service.test.ts b/packages/services/service-user-preferences/src/__tests__/user-preferences-service.test.ts new file mode 100644 index 000000000..3668e0dc7 --- /dev/null +++ b/packages/services/service-user-preferences/src/__tests__/user-preferences-service.test.ts @@ -0,0 +1,468 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { ObjectQLUserPreferencesService } from '../adapters/objectql-preferences-adapter.js'; +import { UserFavoritesService } from '../adapters/favorites-adapter.js'; +import type { FavoriteEntry } from '@objectstack/spec/identity'; + +// ───────────────────────────────────────────────────────────────── +// In-memory IDataEngine stub (mimics driver-memory behavior) +// ───────────────────────────────────────────────────────────────── + +function createMemoryEngine(): IDataEngine { + const tables = new Map(); + + const getTable = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + + /** Evaluate a single filter condition against a row. */ + const matchesCondition = (row: any, where: Record): boolean => { + for (const [key, value] of Object.entries(where)) { + if (key === '$or') { + // At least one branch must match + if (!Array.isArray(value) || !value.some(branch => matchesCondition(row, branch))) { + return false; + } + } else if (typeof value === 'object' && value !== null && '$like' in value) { + // Simple LIKE pattern matching (prefix only for simplicity) + const pattern = value.$like as string; + const prefix = pattern.replace(/%$/, ''); + if (!row[key]?.startsWith(prefix)) return false; + } else if (typeof value === 'object' && value !== null && '$gt' in value) { + if (!(row[key] > value.$gt)) return false; + } else if (row[key] !== value) { + return false; + } + } + return true; + }; + + return { + find: async (objectName, query?) => { + let rows = [...getTable(objectName)]; + if (query?.where) { + rows = rows.filter(row => matchesCondition(row, query.where as Record)); + } + if (query?.orderBy && query.orderBy.length > 0) { + rows.sort((a, b) => { + for (const sort of query.orderBy!) { + const field = (sort as any).field; + const dir = (sort as any).order === 'desc' ? -1 : 1; + if (a[field] < b[field]) return -dir; + if (a[field] > b[field]) return dir; + } + return 0; + }); + } + if (query?.limit) { + rows = rows.slice(0, query.limit); + } + return rows; + }, + findOne: async (objectName, query?) => { + let rows = [...getTable(objectName)]; + if (query?.where) { + rows = rows.filter(row => matchesCondition(row, query.where as Record)); + } + return rows[0] ?? null; + }, + insert: async (objectName, data) => { + const table = getTable(objectName); + if (Array.isArray(data)) { + table.push(...data); + return data; + } + table.push({ ...data }); + return data; + }, + update: async (objectName, data, options?) => { + const table = getTable(objectName); + const where = options?.where as Record | undefined; + for (let i = 0; i < table.length; i++) { + if (where) { + let match = true; + for (const [key, value] of Object.entries(where)) { + if (table[i][key] !== value) { match = false; break; } + } + if (!match) continue; + } + Object.assign(table[i], data); + return table[i]; + } + return data; + }, + delete: async (objectName, options?) => { + const table = getTable(objectName); + const where = options?.where as Record | undefined; + let deleted = 0; + const multi = (options as any)?.multi ?? false; + for (let i = table.length - 1; i >= 0; i--) { + if (where && !matchesCondition(table[i], where)) { + continue; + } + table.splice(i, 1); + deleted++; + if (!multi) break; + } + return { deleted }; + }, + count: async (objectName, query?) => { + let rows = [...getTable(objectName)]; + if (query?.where) { + rows = rows.filter(row => matchesCondition(row, query.where as Record)); + } + return rows.length; + }, + aggregate: async () => [], + }; +} + +// ───────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────── + +describe('ObjectQLUserPreferencesService', () => { + let engine: IDataEngine; + let service: ObjectQLUserPreferencesService; + + beforeEach(() => { + engine = createMemoryEngine(); + service = new ObjectQLUserPreferencesService(engine); + }); + + // ── get() / set() ────────────────────────────────────────────── + + it('should set and get a scalar preference', async () => { + await service.set('user1', 'theme', 'dark'); + const value = await service.get('user1', 'theme'); + expect(value).toBe('dark'); + }); + + it('should return undefined for non-existent preference', async () => { + const value = await service.get('user1', 'nonexistent'); + expect(value).toBeUndefined(); + }); + + it('should update an existing preference', async () => { + await service.set('user1', 'theme', 'light'); + await service.set('user1', 'theme', 'dark'); + const value = await service.get('user1', 'theme'); + expect(value).toBe('dark'); + }); + + it('should handle different value types', async () => { + await service.set('user1', 'string', 'hello'); + await service.set('user1', 'number', 42); + await service.set('user1', 'boolean', true); + await service.set('user1', 'array', [1, 2, 3]); + await service.set('user1', 'object', { foo: 'bar' }); + await service.set('user1', 'null', null); + + expect(await service.get('user1', 'string')).toBe('hello'); + expect(await service.get('user1', 'number')).toBe(42); + expect(await service.get('user1', 'boolean')).toBe(true); + expect(await service.get('user1', 'array')).toEqual([1, 2, 3]); + expect(await service.get('user1', 'object')).toEqual({ foo: 'bar' }); + expect(await service.get('user1', 'null')).toBe(null); + }); + + it('should isolate preferences by user', async () => { + await service.set('user1', 'theme', 'dark'); + await service.set('user2', 'theme', 'light'); + + expect(await service.get('user1', 'theme')).toBe('dark'); + expect(await service.get('user2', 'theme')).toBe('light'); + }); + + // ── setMany() ────────────────────────────────────────────────── + + it('should set multiple preferences at once', async () => { + await service.setMany('user1', { + theme: 'dark', + locale: 'en-US', + sidebar_collapsed: true, + }); + + expect(await service.get('user1', 'theme')).toBe('dark'); + expect(await service.get('user1', 'locale')).toBe('en-US'); + expect(await service.get('user1', 'sidebar_collapsed')).toBe(true); + }); + + it('should update existing and create new preferences in batch', async () => { + await service.set('user1', 'theme', 'light'); + await service.setMany('user1', { + theme: 'dark', + locale: 'zh-CN', + }); + + expect(await service.get('user1', 'theme')).toBe('dark'); + expect(await service.get('user1', 'locale')).toBe('zh-CN'); + }); + + // ── delete() ─────────────────────────────────────────────────── + + it('should delete a preference', async () => { + await service.set('user1', 'theme', 'dark'); + const deleted = await service.delete('user1', 'theme'); + expect(deleted).toBe(true); + + const value = await service.get('user1', 'theme'); + expect(value).toBeUndefined(); + }); + + it('should return false when deleting non-existent preference', async () => { + const deleted = await service.delete('user1', 'nonexistent'); + expect(deleted).toBe(false); + }); + + // ── getAll() ─────────────────────────────────────────────────── + + it('should get all preferences for a user', async () => { + await service.setMany('user1', { + theme: 'dark', + locale: 'en-US', + sidebar_collapsed: true, + }); + + const all = await service.getAll('user1'); + expect(all).toEqual({ + theme: 'dark', + locale: 'en-US', + sidebar_collapsed: true, + }); + }); + + it('should return empty object when user has no preferences', async () => { + const all = await service.getAll('user1'); + expect(all).toEqual({}); + }); + + it('should filter by prefix', async () => { + await service.setMany('user1', { + 'plugin.ai.auto_save': true, + 'plugin.ai.model': 'gpt-4', + 'plugin.security.mfa': false, + 'theme': 'dark', + }); + + const aiPrefs = await service.getAll('user1', { prefix: 'plugin.ai.' }); + expect(aiPrefs).toEqual({ + 'plugin.ai.auto_save': true, + 'plugin.ai.model': 'gpt-4', + }); + }); + + // ── has() ────────────────────────────────────────────────────── + + it('should check if a preference exists', async () => { + await service.set('user1', 'theme', 'dark'); + + expect(await service.has('user1', 'theme')).toBe(true); + expect(await service.has('user1', 'nonexistent')).toBe(false); + }); + + // ── clear() ──────────────────────────────────────────────────── + + it('should clear all preferences for a user', async () => { + await service.setMany('user1', { + theme: 'dark', + locale: 'en-US', + }); + + await service.clear('user1'); + + const all = await service.getAll('user1'); + expect(all).toEqual({}); + }); + + it('should clear preferences by prefix', async () => { + await service.setMany('user1', { + 'plugin.ai.auto_save': true, + 'plugin.ai.model': 'gpt-4', + 'plugin.security.mfa': false, + 'theme': 'dark', + }); + + await service.clear('user1', { prefix: 'plugin.ai.' }); + + const all = await service.getAll('user1'); + expect(all).toEqual({ + 'plugin.security.mfa': false, + 'theme': 'dark', + }); + }); + + // ── listEntries() ────────────────────────────────────────────── + + it('should list all preference entries with metadata', async () => { + await service.setMany('user1', { + theme: 'dark', + locale: 'en-US', + }); + + const entries = await service.listEntries('user1'); + expect(entries).toHaveLength(2); + + const themeEntry = entries.find(e => e.key === 'theme'); + expect(themeEntry).toBeDefined(); + expect(themeEntry!.userId).toBe('user1'); + expect(themeEntry!.value).toBe('dark'); + expect(themeEntry!.valueType).toBe('string'); + expect(themeEntry!.createdAt).toBeDefined(); + expect(themeEntry!.updatedAt).toBeDefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────── +// Favorites Service Tests +// ───────────────────────────────────────────────────────────────── + +describe('UserFavoritesService', () => { + let engine: IDataEngine; + let preferencesService: ObjectQLUserPreferencesService; + let favoritesService: UserFavoritesService; + + beforeEach(() => { + engine = createMemoryEngine(); + preferencesService = new ObjectQLUserPreferencesService(engine); + favoritesService = new UserFavoritesService(preferencesService); + }); + + // ── list() ───────────────────────────────────────────────────── + + it('should return empty array when no favorites exist', async () => { + const favorites = await favoritesService.list('user1'); + expect(favorites).toEqual([]); + }); + + // ── add() ────────────────────────────────────────────────────── + + it('should add a favorite', async () => { + const entry = await favoritesService.add('user1', { + type: 'view', + target: 'kanban_tasks', + label: 'My Tasks', + icon: 'kanban', + }); + + expect(entry.id).toMatch(/^fav_/); + expect(entry.type).toBe('view'); + expect(entry.target).toBe('kanban_tasks'); + expect(entry.label).toBe('My Tasks'); + expect(entry.icon).toBe('kanban'); + expect(entry.createdAt).toBeDefined(); + }); + + it('should not create duplicate favorites (same type + target)', async () => { + const entry1 = await favoritesService.add('user1', { + type: 'view', + target: 'kanban_tasks', + }); + + const entry2 = await favoritesService.add('user1', { + type: 'view', + target: 'kanban_tasks', + }); + + // Should return the existing favorite + expect(entry1.id).toBe(entry2.id); + + // Verify only one favorite exists + const favorites = await favoritesService.list('user1'); + expect(favorites).toHaveLength(1); + }); + + // ── remove() ─────────────────────────────────────────────────── + + it('should remove a favorite by ID', async () => { + const entry = await favoritesService.add('user1', { + type: 'view', + target: 'kanban_tasks', + }); + + const removed = await favoritesService.remove('user1', entry.id); + expect(removed).toBe(true); + + const favorites = await favoritesService.list('user1'); + expect(favorites).toHaveLength(0); + }); + + it('should return false when removing non-existent favorite', async () => { + const removed = await favoritesService.remove('user1', 'fav_nonexistent'); + expect(removed).toBe(false); + }); + + // ── has() ────────────────────────────────────────────────────── + + it('should check if an item is favorited', async () => { + await favoritesService.add('user1', { + type: 'view', + target: 'kanban_tasks', + }); + + expect(await favoritesService.has('user1', 'view', 'kanban_tasks')).toBe(true); + expect(await favoritesService.has('user1', 'view', 'other_view')).toBe(false); + expect(await favoritesService.has('user1', 'object', 'kanban_tasks')).toBe(false); + }); + + // ── toggle() ─────────────────────────────────────────────────── + + it('should toggle favorites (add when not exists)', async () => { + const added = await favoritesService.toggle('user1', { + type: 'view', + target: 'kanban_tasks', + }); + + expect(added).toBe(true); + + const favorites = await favoritesService.list('user1'); + expect(favorites).toHaveLength(1); + }); + + it('should toggle favorites (remove when exists)', async () => { + await favoritesService.add('user1', { + type: 'view', + target: 'kanban_tasks', + }); + + const added = await favoritesService.toggle('user1', { + type: 'view', + target: 'kanban_tasks', + }); + + expect(added).toBe(false); + + const favorites = await favoritesService.list('user1'); + expect(favorites).toHaveLength(0); + }); + + // ── Multiple favorites ───────────────────────────────────────── + + it('should handle multiple favorites', async () => { + await favoritesService.add('user1', { + type: 'view', + target: 'kanban_tasks', + }); + + await favoritesService.add('user1', { + type: 'object', + target: 'contacts', + }); + + await favoritesService.add('user1', { + type: 'app', + target: 'crm', + }); + + const favorites = await favoritesService.list('user1'); + expect(favorites).toHaveLength(3); + + const types = favorites.map(f => f.type); + expect(types).toContain('view'); + expect(types).toContain('object'); + expect(types).toContain('app'); + }); +}); diff --git a/packages/services/service-user-preferences/src/adapters/favorites-adapter.ts b/packages/services/service-user-preferences/src/adapters/favorites-adapter.ts new file mode 100644 index 000000000..755b47d74 --- /dev/null +++ b/packages/services/service-user-preferences/src/adapters/favorites-adapter.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { randomUUID } from 'node:crypto'; +import type { IUserFavoritesService, IUserPreferencesService } from '@objectstack/spec/contracts'; +import type { FavoriteEntry, FavoritesValue } from '@objectstack/spec/identity'; + +/** + * UserFavoritesService — Favorites management service. + * + * Implements IUserFavoritesService on top of IUserPreferencesService. + * Favorites are stored as a structured preference with key 'favorites'. + */ +export class UserFavoritesService implements IUserFavoritesService { + private readonly preferences: IUserPreferencesService; + private readonly FAVORITES_KEY = 'favorites'; + + constructor(preferences: IUserPreferencesService) { + this.preferences = preferences; + } + + async list(userId: string): Promise { + const favorites = await this.preferences.get(userId, this.FAVORITES_KEY); + return favorites ?? []; + } + + async add(userId: string, entry: Omit): Promise { + const favorites = await this.list(userId); + + // Check for duplicates (same type + target) + const existing = favorites.find(f => f.type === entry.type && f.target === entry.target); + if (existing) { + return existing; + } + + const newEntry: FavoriteEntry = { + id: `fav_${randomUUID()}`, + ...entry, + createdAt: new Date().toISOString(), + }; + + favorites.push(newEntry); + await this.preferences.set(userId, this.FAVORITES_KEY, favorites); + + return newEntry; + } + + async remove(userId: string, favoriteId: string): Promise { + const favorites = await this.list(userId); + const index = favorites.findIndex(f => f.id === favoriteId); + + if (index === -1) return false; + + favorites.splice(index, 1); + await this.preferences.set(userId, this.FAVORITES_KEY, favorites); + + return true; + } + + async has(userId: string, type: string, target: string): Promise { + const favorites = await this.list(userId); + return favorites.some(f => f.type === type && f.target === target); + } + + async toggle(userId: string, entry: Omit): Promise { + const favorites = await this.list(userId); + const existingIndex = favorites.findIndex(f => f.type === entry.type && f.target === entry.target); + + if (existingIndex !== -1) { + // Remove + favorites.splice(existingIndex, 1); + await this.preferences.set(userId, this.FAVORITES_KEY, favorites); + return false; + } else { + // Add + await this.add(userId, entry); + return true; + } + } +} diff --git a/packages/services/service-user-preferences/src/adapters/index.ts b/packages/services/service-user-preferences/src/adapters/index.ts new file mode 100644 index 000000000..662b14e0c --- /dev/null +++ b/packages/services/service-user-preferences/src/adapters/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export * from './objectql-preferences-adapter.js'; +export * from './favorites-adapter.js'; diff --git a/packages/services/service-user-preferences/src/adapters/objectql-preferences-adapter.ts b/packages/services/service-user-preferences/src/adapters/objectql-preferences-adapter.ts new file mode 100644 index 000000000..ac453d581 --- /dev/null +++ b/packages/services/service-user-preferences/src/adapters/objectql-preferences-adapter.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { randomUUID } from 'node:crypto'; +import type { + IUserPreferencesService, + IDataEngine, +} from '@objectstack/spec/contracts'; +import type { UserPreferenceEntry } from '@objectstack/spec/identity'; + +/** Object name used for persistence. */ +const USER_PREFERENCES_OBJECT = 'user_preferences'; + +/** Database row shape for user_preferences. */ +interface DbUserPreferenceRow { + id: string; + user_id: string; + key: string; + value: string | null; + value_type: string | null; + created_at: string; + updated_at: string; +} + +/** + * ObjectQLUserPreferencesService — Persistent implementation of IUserPreferencesService. + * + * Delegates all storage to an {@link IDataEngine} instance, using the + * `user_preferences` object. This decouples the service from any specific + * database driver (Turso, Postgres, SQLite, etc.). + * + * Production environments should use this implementation to ensure + * preferences persist across service restarts. + */ +export class ObjectQLUserPreferencesService implements IUserPreferencesService { + private readonly engine: IDataEngine; + + constructor(engine: IDataEngine) { + this.engine = engine; + } + + async get(userId: string, key: string): Promise { + const row: DbUserPreferenceRow | null = await this.engine.findOne(USER_PREFERENCES_OBJECT, { + where: { user_id: userId, key }, + }); + + if (!row || row.value === null) return undefined; + + return this.deserializeValue(row.value); + } + + async set(userId: string, key: string, value: unknown): Promise { + const now = new Date().toISOString(); + + // Check if preference already exists + const existing: DbUserPreferenceRow | null = await this.engine.findOne(USER_PREFERENCES_OBJECT, { + where: { user_id: userId, key }, + fields: ['id'], + }); + + const serializedValue = this.serializeValue(value); + const valueType = this.detectValueType(value); + + if (existing) { + // Update existing preference + await this.engine.update(USER_PREFERENCES_OBJECT, { + id: existing.id, + value: serializedValue, + value_type: valueType, + updated_at: now, + }, { + where: { id: existing.id }, + }); + } else { + // Insert new preference + const id = `pref_${randomUUID()}`; + await this.engine.insert(USER_PREFERENCES_OBJECT, { + id, + user_id: userId, + key, + value: serializedValue, + value_type: valueType, + created_at: now, + updated_at: now, + }); + } + } + + async setMany(userId: string, preferences: Record): Promise { + const now = new Date().toISOString(); + + // Get all existing preferences for this user in a single query + const existingRows: DbUserPreferenceRow[] = await this.engine.find(USER_PREFERENCES_OBJECT, { + where: { user_id: userId }, + fields: ['id', 'key'], + }); + + const existingMap = new Map(existingRows.map(row => [row.key, row.id])); + + // Prepare batch updates and inserts + const updates: Array<{ id: string; value: string; value_type: string; updated_at: string }> = []; + const inserts: DbUserPreferenceRow[] = []; + + for (const [key, value] of Object.entries(preferences)) { + const serializedValue = this.serializeValue(value); + const valueType = this.detectValueType(value); + + const existingId = existingMap.get(key); + if (existingId) { + // Update + updates.push({ + id: existingId, + value: serializedValue, + value_type: valueType, + updated_at: now, + }); + } else { + // Insert + inserts.push({ + id: `pref_${randomUUID()}`, + user_id: userId, + key, + value: serializedValue, + value_type: valueType, + created_at: now, + updated_at: now, + }); + } + } + + // Execute batch operations + if (updates.length > 0) { + // Update each one (ObjectQL doesn't guarantee batch update support) + await Promise.all( + updates.map(update => + this.engine.update(USER_PREFERENCES_OBJECT, update, { + where: { id: update.id }, + }) + ) + ); + } + + if (inserts.length > 0) { + await this.engine.insert(USER_PREFERENCES_OBJECT, inserts); + } + } + + async delete(userId: string, key: string): Promise { + const existing: DbUserPreferenceRow | null = await this.engine.findOne(USER_PREFERENCES_OBJECT, { + where: { user_id: userId, key }, + fields: ['id'], + }); + + if (!existing) return false; + + await this.engine.delete(USER_PREFERENCES_OBJECT, { + where: { id: existing.id }, + }); + + return true; + } + + async getAll(userId: string, options?: { prefix?: string }): Promise> { + const where: Record = { user_id: userId }; + + // Apply prefix filter if specified + if (options?.prefix) { + // Use SQL LIKE pattern for prefix matching + where.key = { $like: `${options.prefix}%` }; + } + + const rows: DbUserPreferenceRow[] = await this.engine.find(USER_PREFERENCES_OBJECT, { + where, + orderBy: [{ field: 'key', order: 'asc' }], + }); + + const result: Record = {}; + for (const row of rows) { + if (row.value !== null) { + result[row.key] = this.deserializeValue(row.value); + } + } + + return result; + } + + async has(userId: string, key: string): Promise { + const count = await this.engine.count(USER_PREFERENCES_OBJECT, { + where: { user_id: userId, key }, + }); + + return count > 0; + } + + async clear(userId: string, options?: { prefix?: string }): Promise { + const where: Record = { user_id: userId }; + + // Apply prefix filter if specified + if (options?.prefix) { + where.key = { $like: `${options.prefix}%` }; + } + + await this.engine.delete(USER_PREFERENCES_OBJECT, { + where, + multi: true, + }); + } + + async listEntries(userId: string, options?: { prefix?: string }): Promise { + const where: Record = { user_id: userId }; + + // Apply prefix filter if specified + if (options?.prefix) { + where.key = { $like: `${options.prefix}%` }; + } + + const rows: DbUserPreferenceRow[] = await this.engine.find(USER_PREFERENCES_OBJECT, { + where, + orderBy: [{ field: 'key', order: 'asc' }], + }); + + return rows.map(row => ({ + id: row.id, + userId: row.user_id, + key: row.key, + value: row.value !== null ? this.deserializeValue(row.value) : null, + valueType: (row.value_type as 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null') ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + } + + // ── Private helpers ────────────────────────────────────────────── + + /** + * Serialize a value to JSON string for storage. + */ + private serializeValue(value: unknown): string { + return JSON.stringify(value); + } + + /** + * Deserialize a JSON string to its original value. + */ + private deserializeValue(value: string): T { + try { + return JSON.parse(value) as T; + } catch { + // Fallback to raw string if JSON parsing fails + return value as T; + } + } + + /** + * Detect the type of a value for the value_type hint. + */ + private detectValueType(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value === 'object') return 'object'; + return typeof value; // 'string', 'number', 'boolean' + } +} diff --git a/packages/services/service-user-preferences/src/index.ts b/packages/services/service-user-preferences/src/index.ts new file mode 100644 index 000000000..ecfa4151b --- /dev/null +++ b/packages/services/service-user-preferences/src/index.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/service-user-preferences + * + * User Preferences Service for ObjectStack. + * + * Provides unified user preferences and favorites management with: + * - Scalar preferences (theme, locale, etc.) + * - Structured data (favorites, recent items) + * - HTTP REST API + * - ObjectQL-based persistence + * + * @example + * ```ts + * import { UserPreferencesServicePlugin } from '@objectstack/service-user-preferences'; + * import { ObjectKernel } from '@objectstack/core'; + * + * const kernel = new ObjectKernel(); + * kernel.use(new UserPreferencesServicePlugin()); + * await kernel.bootstrap(); + * + * const prefs = kernel.getService('user-preferences'); + * await prefs.set('user123', 'theme', 'dark'); + * ``` + */ + +export * from './plugin.js'; +export * from './adapters/index.js'; +export * from './objects/index.js'; +export * from './routes/index.js'; diff --git a/packages/services/service-user-preferences/src/objects/index.ts b/packages/services/service-user-preferences/src/objects/index.ts new file mode 100644 index 000000000..6afa708d4 --- /dev/null +++ b/packages/services/service-user-preferences/src/objects/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export * from './user-preference.object.js'; diff --git a/packages/services/service-user-preferences/src/objects/user-preference.object.ts b/packages/services/service-user-preferences/src/objects/user-preference.object.ts new file mode 100644 index 000000000..f06eb8b5d --- /dev/null +++ b/packages/services/service-user-preferences/src/objects/user-preference.object.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * user_preferences — User Preferences Object + * + * Stores user-specific preferences and configuration. + * Supports both scalar values (theme, locale) and structured data (favorites, recent_items). + * + * @namespace identity + */ +export const UserPreferenceObject = ObjectSchema.create({ + namespace: 'identity', + name: 'user_preferences', + label: 'User Preference', + pluralLabel: 'User Preferences', + icon: 'settings', + isSystem: true, + description: 'User-specific preferences and configuration', + + fields: { + id: Field.text({ + label: 'Preference ID', + required: true, + readonly: true, + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + maxLength: 255, + description: 'User who owns this preference', + }), + + key: Field.text({ + label: 'Key', + required: true, + maxLength: 255, + description: 'Preference key (well-known or custom, e.g., theme, locale, plugin.ai.auto_save)', + }), + + value: Field.textarea({ + label: 'Value', + required: false, + description: 'JSON-serialized preference value', + }), + + value_type: Field.select({ + label: 'Value Type', + required: false, + options: [ + { label: 'String', value: 'string' }, + { label: 'Number', value: 'number' }, + { label: 'Boolean', value: 'boolean' }, + { label: 'Object', value: 'object' }, + { label: 'Array', value: 'array' }, + { label: 'Null', value: 'null' }, + ], + description: 'Type hint for client-side type safety', + }), + + created_at: Field.datetime({ + label: 'Created At', + required: true, + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + required: true, + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + // Primary lookup: user_id + key (unique composite) + { fields: ['user_id', 'key'], unique: true }, + // Secondary lookup: user_id alone (for getAll) + { fields: ['user_id'] }, + // Timestamp ordering + { fields: ['created_at'] }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/services/service-user-preferences/src/plugin.ts b/packages/services/service-user-preferences/src/plugin.ts new file mode 100644 index 000000000..a56de2f4e --- /dev/null +++ b/packages/services/service-user-preferences/src/plugin.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IDataEngine, IUserPreferencesService, IUserFavoritesService } from '@objectstack/spec/contracts'; +import { ObjectQLUserPreferencesService } from './adapters/objectql-preferences-adapter.js'; +import { UserFavoritesService } from './adapters/favorites-adapter.js'; +import { UserPreferenceObject } from './objects/index.js'; +import { buildUserPreferencesRoutes } from './routes/index.js'; + +/** + * Configuration options for the UserPreferencesServicePlugin. + */ +export interface UserPreferencesServicePluginOptions { + /** Enable debug logging. */ + debug?: boolean; +} + +/** + * UserPreferencesServicePlugin — Kernel plugin for User Preferences Service. + * + * Lifecycle: + * 1. **init** — Creates {@link ObjectQLUserPreferencesService} and {@link UserFavoritesService}, + * registers them as `'user-preferences'` and `'user-favorites'` services. + * 2. **start** — Registers REST routes for preferences and favorites APIs. + * 3. **destroy** — Cleans up references. + * + * @example + * ```ts + * import { LiteKernel } from '@objectstack/core'; + * import { UserPreferencesServicePlugin } from '@objectstack/service-user-preferences'; + * + * const kernel = new LiteKernel(); + * kernel.use(new UserPreferencesServicePlugin()); + * await kernel.bootstrap(); + * + * const prefs = kernel.getService('user-preferences'); + * await prefs.set('user123', 'theme', 'dark'); + * const theme = await prefs.get('user123', 'theme'); + * ``` + */ +export class UserPreferencesServicePlugin implements Plugin { + name = 'com.objectstack.service-user-preferences'; + version = '1.0.0'; + type = 'standard' as const; + dependencies: string[] = ['com.objectstack.engine.objectql']; + + private preferencesService?: IUserPreferencesService; + private favoritesService?: IUserFavoritesService; + + constructor(_options: UserPreferencesServicePluginOptions = {}) { + // Reserved for future use + } + + async init(ctx: PluginContext): Promise { + // Get the data engine from ObjectQL plugin + const dataEngine = ctx.getService('data'); + if (!dataEngine) { + throw new Error('[UserPreferences] IDataEngine not found. Ensure ObjectQLPlugin is loaded first.'); + } + + // Create service instances + this.preferencesService = new ObjectQLUserPreferencesService(dataEngine); + this.favoritesService = new UserFavoritesService(this.preferencesService); + + // Register services + ctx.registerService('user-preferences', this.preferencesService); + ctx.registerService('user-favorites', this.favoritesService); + + // Register user_preferences object via manifest service + ctx.getService<{ register(m: any): void }>('manifest').register({ + id: 'com.objectstack.service-user-preferences', + name: 'User Preferences Service', + version: '1.0.0', + type: 'plugin', + namespace: 'identity', + objects: [UserPreferenceObject], + }); + + // Contribute navigation items to the Setup App (if SetupPlugin is loaded). + try { + const setupNav = ctx.getService<{ contribute(c: any): void }>('setupNav'); + if (setupNav) { + setupNav.contribute({ + areaId: 'area_identity', + items: [ + { + id: 'nav_user_preferences', + type: 'object', + label: { key: 'setup.nav.user_preferences', defaultValue: 'User Preferences' }, + objectName: 'user_preferences', + icon: 'settings', + order: 30, + }, + ], + }); + ctx.logger.info('[UserPreferences] Navigation items contributed to Setup App'); + } + } catch { + // SetupPlugin not loaded — skip silently + } + + ctx.logger.info('[UserPreferences] Service initialized'); + } + + async start(ctx: PluginContext): Promise { + if (!this.preferencesService || !this.favoritesService) return; + + // Build and expose route definitions + const routes = buildUserPreferencesRoutes( + this.preferencesService, + this.favoritesService, + ctx.logger + ); + + // Trigger hook so HTTP server plugins can mount these routes + await ctx.trigger('user-preferences:routes', routes); + + // Cache routes on the kernel so HttpDispatcher can find them + const kernel = ctx.getKernel(); + if (kernel) { + (kernel as any).__userPreferencesRoutes = routes; + } + + ctx.logger.info(`[UserPreferences] Service started — routes=${routes.length}`); + } + + async destroy(): Promise { + this.preferencesService = undefined; + this.favoritesService = undefined; + } +} diff --git a/packages/services/service-user-preferences/src/routes/index.ts b/packages/services/service-user-preferences/src/routes/index.ts new file mode 100644 index 000000000..4b89746ae --- /dev/null +++ b/packages/services/service-user-preferences/src/routes/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export * from './preferences-routes.js'; diff --git a/packages/services/service-user-preferences/src/routes/preferences-routes.ts b/packages/services/service-user-preferences/src/routes/preferences-routes.ts new file mode 100644 index 000000000..9a9606be5 --- /dev/null +++ b/packages/services/service-user-preferences/src/routes/preferences-routes.ts @@ -0,0 +1,353 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IUserPreferencesService, IUserFavoritesService, Logger } from '@objectstack/spec/contracts'; + +/** + * Minimal HTTP handler abstraction so routes stay framework-agnostic. + * + * Consumers wire these handlers to their HTTP server of choice + * (Hono, Express, Fastify, etc.) via the kernel's HTTP server service. + */ +export interface RouteDefinition { + /** HTTP method */ + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + /** Path pattern (e.g. '/api/v1/user/preferences') */ + path: string; + /** Human-readable description */ + description: string; + /** Whether this route requires authentication (default: true). */ + auth?: boolean; + /** Required permissions for accessing this route. */ + permissions?: string[]; + /** + * Handler receives a plain request-like object and returns a response-like object. + */ + handler: (req: RouteRequest) => Promise; +} + +/** + * Authenticated user context attached to a route request. + */ +export interface RouteUserContext { + /** Unique user identifier. */ + userId: string; + /** User display name (optional). */ + displayName?: string; + /** Roles assigned to the user (e.g. `['admin', 'user']`). */ + roles?: string[]; + /** Fine-grained permissions (e.g. `['preferences:read', 'preferences:write']`). */ + permissions?: string[]; +} + +export interface RouteRequest { + /** Parsed JSON body (for POST/PUT requests) */ + body?: unknown; + /** Route/query parameters */ + params?: Record; + /** Query string parameters */ + query?: Record; + /** Authenticated user context (populated by auth middleware). */ + user?: RouteUserContext; +} + +export interface RouteResponse { + /** HTTP status code */ + status: number; + /** JSON-serializable body */ + body?: unknown; +} + +/** + * Build HTTP routes for the User Preferences Service. + * + * Routes: + * - GET /api/v1/user/preferences - Get all preferences (optionally filtered by prefix) + * - GET /api/v1/user/preferences/:key - Get a single preference by key + * - POST /api/v1/user/preferences - Set multiple preferences (batch) + * - PUT /api/v1/user/preferences/:key - Set a single preference + * - DELETE /api/v1/user/preferences/:key - Delete a preference + * - GET /api/v1/user/favorites - List all favorites + * - POST /api/v1/user/favorites - Add a favorite + * - DELETE /api/v1/user/favorites/:id - Remove a favorite + * - POST /api/v1/user/favorites/toggle - Toggle a favorite + * + * @param preferencesService - User preferences service instance + * @param favoritesService - User favorites service instance + * @param logger - Logger instance + * @returns Array of route definitions + */ +export function buildUserPreferencesRoutes( + preferencesService: IUserPreferencesService, + favoritesService: IUserFavoritesService, + logger: Logger +): RouteDefinition[] { + return [ + // ── Preferences Routes ────────────────────────────────────────── + + { + method: 'GET', + path: '/api/v1/user/preferences', + description: 'Get all user preferences (optionally filtered by prefix)', + auth: true, + permissions: ['preferences:read'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const prefix = req.query?.prefix; + const preferences = await preferencesService.getAll(userId, { prefix }); + + return { status: 200, body: { preferences } }; + }, + }, + + { + method: 'GET', + path: '/api/v1/user/preferences/:key', + description: 'Get a single preference by key', + auth: true, + permissions: ['preferences:read'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const key = req.params?.key; + if (!key) { + return { status: 400, body: { error: 'Missing preference key' } }; + } + + const value = await preferencesService.get(userId, key); + + if (value === undefined) { + return { status: 404, body: { error: 'Preference not found' } }; + } + + return { status: 200, body: { key, value } }; + }, + }, + + { + method: 'POST', + path: '/api/v1/user/preferences', + description: 'Set multiple preferences (batch)', + auth: true, + permissions: ['preferences:write'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const body = req.body as Record | undefined; + if (!body || typeof body !== 'object') { + return { status: 400, body: { error: 'Invalid request body' } }; + } + + const preferences = body.preferences as Record | undefined; + if (!preferences || typeof preferences !== 'object') { + return { status: 400, body: { error: 'Missing preferences field' } }; + } + + await preferencesService.setMany(userId, preferences); + + logger.info(`[UserPreferences] Batch set ${Object.keys(preferences).length} preferences for user ${userId}`); + + return { status: 200, body: { success: true } }; + }, + }, + + { + method: 'PUT', + path: '/api/v1/user/preferences/:key', + description: 'Set a single preference', + auth: true, + permissions: ['preferences:write'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const key = req.params?.key; + if (!key) { + return { status: 400, body: { error: 'Missing preference key' } }; + } + + const body = req.body as { value?: unknown } | undefined; + if (!body || !('value' in body)) { + return { status: 400, body: { error: 'Missing value field' } }; + } + + await preferencesService.set(userId, key, body.value); + + logger.debug(`[UserPreferences] Set preference ${key} for user ${userId}`); + + return { status: 200, body: { success: true } }; + }, + }, + + { + method: 'DELETE', + path: '/api/v1/user/preferences/:key', + description: 'Delete a preference', + auth: true, + permissions: ['preferences:write'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const key = req.params?.key; + if (!key) { + return { status: 400, body: { error: 'Missing preference key' } }; + } + + const deleted = await preferencesService.delete(userId, key); + + if (!deleted) { + return { status: 404, body: { error: 'Preference not found' } }; + } + + logger.debug(`[UserPreferences] Deleted preference ${key} for user ${userId}`); + + return { status: 200, body: { success: true } }; + }, + }, + + // ── Favorites Routes ──────────────────────────────────────────── + + { + method: 'GET', + path: '/api/v1/user/favorites', + description: 'List all favorites for the current user', + auth: true, + permissions: ['preferences:read'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const favorites = await favoritesService.list(userId); + + return { status: 200, body: { favorites } }; + }, + }, + + { + method: 'POST', + path: '/api/v1/user/favorites', + description: 'Add a new favorite', + auth: true, + permissions: ['preferences:write'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const body = req.body as Record | undefined; + if (!body || typeof body !== 'object') { + return { status: 400, body: { error: 'Invalid request body' } }; + } + + const { type, target, label, icon, metadata } = body; + + if (!type || typeof type !== 'string') { + return { status: 400, body: { error: 'Missing or invalid type field' } }; + } + + if (!target || typeof target !== 'string') { + return { status: 400, body: { error: 'Missing or invalid target field' } }; + } + + const favorite = await favoritesService.add(userId, { + type: type as 'object' | 'view' | 'app' | 'dashboard' | 'report' | 'record', + target, + label: typeof label === 'string' ? label : undefined, + icon: typeof icon === 'string' ? icon : undefined, + metadata: typeof metadata === 'object' ? metadata as Record : undefined, + }); + + logger.info(`[UserPreferences] Added favorite ${type}:${target} for user ${userId}`); + + return { status: 201, body: { favorite } }; + }, + }, + + { + method: 'DELETE', + path: '/api/v1/user/favorites/:id', + description: 'Remove a favorite by ID', + auth: true, + permissions: ['preferences:write'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const id = req.params?.id; + if (!id) { + return { status: 400, body: { error: 'Missing favorite ID' } }; + } + + const removed = await favoritesService.remove(userId, id); + + if (!removed) { + return { status: 404, body: { error: 'Favorite not found' } }; + } + + logger.info(`[UserPreferences] Removed favorite ${id} for user ${userId}`); + + return { status: 200, body: { success: true } }; + }, + }, + + { + method: 'POST', + path: '/api/v1/user/favorites/toggle', + description: 'Toggle a favorite (add if not exists, remove if exists)', + auth: true, + permissions: ['preferences:write'], + handler: async (req) => { + const userId = req.user?.userId; + if (!userId) { + return { status: 401, body: { error: 'Unauthorized' } }; + } + + const body = req.body as Record | undefined; + if (!body || typeof body !== 'object') { + return { status: 400, body: { error: 'Invalid request body' } }; + } + + const { type, target, label, icon, metadata } = body; + + if (!type || typeof type !== 'string') { + return { status: 400, body: { error: 'Missing or invalid type field' } }; + } + + if (!target || typeof target !== 'string') { + return { status: 400, body: { error: 'Missing or invalid target field' } }; + } + + const added = await favoritesService.toggle(userId, { + type: type as 'object' | 'view' | 'app' | 'dashboard' | 'report' | 'record', + target, + label: typeof label === 'string' ? label : undefined, + icon: typeof icon === 'string' ? icon : undefined, + metadata: typeof metadata === 'object' ? metadata as Record : undefined, + }); + + logger.info(`[UserPreferences] Toggled favorite ${type}:${target} for user ${userId} (${added ? 'added' : 'removed'})`); + + return { status: 200, body: { added } }; + }, + }, + ]; +} diff --git a/packages/services/service-user-preferences/tsconfig.json b/packages/services/service-user-preferences/tsconfig.json new file mode 100644 index 000000000..0b8b99d88 --- /dev/null +++ b/packages/services/service-user-preferences/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 845ecebad..f6cfac64f 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -36,6 +36,7 @@ export * from './workflow-service.js'; export * from './feed-service.js'; export * from './export-service.js'; export * from './package-service.js'; +export * from './user-preferences-service.js'; // Provisioning & Deployment export * from './turso-platform.js'; diff --git a/packages/spec/src/contracts/user-preferences-service.ts b/packages/spec/src/contracts/user-preferences-service.ts new file mode 100644 index 000000000..cd1c87a08 --- /dev/null +++ b/packages/spec/src/contracts/user-preferences-service.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { UserPreferenceEntry, FavoriteEntry } from '../identity/user-preference.zod'; + +/** + * IUserPreferencesService — User Preferences Service Contract + * + * Defines the interface for managing user preferences in ObjectStack. + * Supports both scalar preferences (theme, locale) and structured data (favorites, recent_items). + * + * Follows Dependency Inversion Principle — plugins depend on this interface, + * not on concrete implementations. + * + * Aligns with CoreServiceName 'user-preferences' in core-services.zod.ts. + * + * @example + * ```ts + * const prefs = ctx.getService('user-preferences'); + * + * // Get a scalar preference + * const theme = await prefs.get('user123', 'theme'); // => 'dark' + * + * // Set a scalar preference + * await prefs.set('user123', 'theme', 'light'); + * + * // Get structured data (favorites) + * const favorites = await prefs.get('user123', 'favorites'); + * + * // Set multiple preferences at once + * await prefs.setMany('user123', { + * theme: 'dark', + * locale: 'en-US', + * sidebar_collapsed: true, + * }); + * + * // Get all preferences for a user + * const allPrefs = await prefs.getAll('user123'); + * + * // Delete a preference + * await prefs.delete('user123', 'theme'); + * + * // Query preferences by prefix + * const aiPrefs = await prefs.getAll('user123', { prefix: 'plugin.ai.' }); + * ``` + */ +export interface IUserPreferencesService { + /** + * Get a user preference value by key + * + * @param userId - User ID + * @param key - Preference key (well-known or custom) + * @returns The preference value, or undefined if not set + */ + get(userId: string, key: string): Promise; + + /** + * Set a user preference value + * + * @param userId - User ID + * @param key - Preference key + * @param value - Preference value (JSON-serializable) + */ + set(userId: string, key: string, value: unknown): Promise; + + /** + * Set multiple user preferences at once (batch operation) + * + * @param userId - User ID + * @param preferences - Key-value pairs to set + */ + setMany(userId: string, preferences: Record): Promise; + + /** + * Delete a user preference by key + * + * @param userId - User ID + * @param key - Preference key to delete + * @returns True if the preference was deleted, false if it didn't exist + */ + delete(userId: string, key: string): Promise; + + /** + * Get all user preferences (optionally filtered by key prefix) + * + * @param userId - User ID + * @param options - Query options (prefix filter, etc.) + * @returns Key-value pairs of all matching preferences + */ + getAll(userId: string, options?: { prefix?: string }): Promise>; + + /** + * Check if a preference key exists for a user + * + * @param userId - User ID + * @param key - Preference key + * @returns True if the preference exists + */ + has(userId: string, key: string): Promise; + + /** + * Clear all preferences for a user (or matching a prefix) + * + * @param userId - User ID + * @param options - Query options (prefix filter, etc.) + */ + clear(userId: string, options?: { prefix?: string }): Promise; + + /** + * Get all preference entries for a user (full schema, including metadata) + * + * @param userId - User ID + * @param options - Query options (prefix filter, etc.) + * @returns Array of UserPreferenceEntry objects + */ + listEntries(userId: string, options?: { prefix?: string }): Promise; +} + +/** + * IUserFavoritesService — User Favorites Service Contract + * + * Specialized service for managing user favorites. + * This is a convenience layer on top of IUserPreferencesService. + * + * Favorites are stored as a structured preference with key 'favorites'. + * + * @example + * ```ts + * const favorites = ctx.getService('user-favorites'); + * + * // Add a favorite + * await favorites.add('user123', { + * type: 'view', + * target: 'kanban_tasks', + * label: 'My Tasks', + * icon: 'kanban', + * }); + * + * // List all favorites + * const items = await favorites.list('user123'); + * + * // Remove a favorite + * await favorites.remove('user123', 'favorite_id_123'); + * + * // Check if an item is favorited + * const isFav = await favorites.has('user123', 'view', 'kanban_tasks'); + * ``` + */ +export interface IUserFavoritesService { + /** + * List all favorites for a user + * + * @param userId - User ID + * @returns Array of favorite entries + */ + list(userId: string): Promise; + + /** + * Add a new favorite + * + * @param userId - User ID + * @param entry - Favorite entry (without id and createdAt, which are auto-generated) + * @returns The created favorite entry with id and createdAt + */ + add(userId: string, entry: Omit): Promise; + + /** + * Remove a favorite by ID + * + * @param userId - User ID + * @param favoriteId - Favorite entry ID + * @returns True if the favorite was removed, false if it didn't exist + */ + remove(userId: string, favoriteId: string): Promise; + + /** + * Check if an item is in the user's favorites + * + * @param userId - User ID + * @param type - Item type + * @param target - Item target reference + * @returns True if the item is favorited + */ + has(userId: string, type: string, target: string): Promise; + + /** + * Toggle a favorite (add if not exists, remove if exists) + * + * @param userId - User ID + * @param entry - Favorite entry to toggle + * @returns True if added, false if removed + */ + toggle(userId: string, entry: Omit): Promise; +} diff --git a/packages/spec/src/identity/index.ts b/packages/spec/src/identity/index.ts index 0f770b092..b1c54dc91 100644 --- a/packages/spec/src/identity/index.ts +++ b/packages/spec/src/identity/index.ts @@ -5,3 +5,4 @@ export * from './protocol'; export * from './role.zod'; export * from './organization.zod'; export * from './scim.zod'; +export * from './user-preference.zod'; diff --git a/packages/spec/src/identity/user-preference.zod.ts b/packages/spec/src/identity/user-preference.zod.ts new file mode 100644 index 000000000..8815f6844 --- /dev/null +++ b/packages/spec/src/identity/user-preference.zod.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * User Preference Schema + * + * Defines the standard user preferences data model for ObjectStack. + * Supports both scalar values (theme, locale) and structured data (favorites, recent_items). + * + * This is the Zod schema layer. TypeScript types are derived via z.infer<>. + * Service layer contracts are defined in packages/spec/src/contracts/user-preferences-service.ts. + */ + +/** + * Well-known preference keys + * + * These keys are reserved for system-level preferences. + * Plugins can define custom keys with their own namespace (e.g., 'plugin.ai.auto_save'). + */ +export const WellKnownPreferenceKeys = z.enum([ + 'theme', // UI theme: 'light' | 'dark' | 'system' + 'locale', // User's preferred locale: 'en-US' | 'zh-CN' | etc. + 'timezone', // User's timezone: 'America/New_York' | 'Asia/Shanghai' | etc. + 'favorites', // User's favorite items (structured) + 'recent_items', // User's recently accessed items (structured) + 'sidebar_collapsed', // UI: whether sidebar is collapsed + 'page_size', // Default pagination size +]); + +export type WellKnownPreferenceKey = z.infer; + +/** + * Favorite Entry Schema + * + * Represents a single favorite item (object, view, app, etc.) + */ +export const FavoriteEntrySchema = z.object({ + /** + * Unique identifier for the favorite entry + */ + id: z.string().describe('Unique identifier (auto-generated)'), + + /** + * Type of the favorite item + */ + type: z.enum(['object', 'view', 'app', 'dashboard', 'report', 'record']).describe('Item type'), + + /** + * Target reference (object name, view name, app ID, record ID, etc.) + */ + target: z.string().describe('Target reference (e.g., object name, view name, record ID)'), + + /** + * Display label (optional override) + */ + label: z.string().optional().describe('Display label override'), + + /** + * Icon (optional override) + */ + icon: z.string().optional().describe('Icon override'), + + /** + * Custom metadata + */ + metadata: z.record(z.string(), z.unknown()).optional().describe('Custom metadata'), + + /** + * Creation timestamp + */ + createdAt: z.string().datetime().describe('Creation timestamp'), +}); + +export type FavoriteEntry = z.infer; + +/** + * Favorites Value Schema + * + * The structured value for the 'favorites' preference key. + * Array of favorite entries. + */ +export const FavoritesValueSchema = z.array(FavoriteEntrySchema); + +export type FavoritesValue = z.infer; + +/** + * Recent Item Entry Schema + * + * Represents a recently accessed item + */ +export const RecentItemEntrySchema = z.object({ + /** + * Type of the recent item + */ + type: z.enum(['object', 'view', 'app', 'dashboard', 'report', 'record']).describe('Item type'), + + /** + * Target reference (object name, view name, app ID, record ID, etc.) + */ + target: z.string().describe('Target reference'), + + /** + * Display label + */ + label: z.string().optional().describe('Display label'), + + /** + * Last accessed timestamp + */ + accessedAt: z.string().datetime().describe('Last accessed timestamp'), + + /** + * Access count + */ + accessCount: z.number().int().min(1).default(1).describe('Number of times accessed'), +}); + +export type RecentItemEntry = z.infer; + +/** + * Recent Items Value Schema + * + * The structured value for the 'recent_items' preference key. + * Array of recently accessed items, sorted by accessedAt (descending). + */ +export const RecentItemsValueSchema = z.array(RecentItemEntrySchema); + +export type RecentItemsValue = z.infer; + +/** + * User Preference Entry Schema + * + * Represents a single user preference key-value pair. + * This is the core schema used for storage in the database. + */ +export const UserPreferenceEntrySchema = z.object({ + /** + * Unique identifier (auto-generated) + */ + id: z.string().describe('Unique identifier'), + + /** + * User ID who owns this preference + */ + userId: z.string().describe('User ID'), + + /** + * Preference key (well-known or custom) + * + * Well-known keys: 'theme', 'locale', 'favorites', 'recent_items', etc. + * Custom keys: 'plugin.ai.auto_save', 'plugin.security.mfa_enabled', etc. + */ + key: z.string().min(1).describe('Preference key'), + + /** + * Preference value (JSON-serializable) + * + * Scalar types: string, number, boolean, null + * Structured types: object, array + */ + value: z.unknown().describe('Preference value (JSON-serializable)'), + + /** + * Value type hint (optional, for client-side type safety) + */ + valueType: z.enum(['string', 'number', 'boolean', 'object', 'array', 'null']).optional().describe('Value type hint'), + + /** + * Creation timestamp + */ + createdAt: z.string().datetime().describe('Creation timestamp'), + + /** + * Last update timestamp + */ + updatedAt: z.string().datetime().describe('Last update timestamp'), +}); + +export type UserPreferenceEntry = z.infer; + +/** + * User Preference Batch Set Schema + * + * Used for batch set operations (setMany). + */ +export const UserPreferenceBatchSetSchema = z.record( + z.string(), // key + z.unknown() // value +); + +export type UserPreferenceBatchSet = z.infer; + +/** + * User Preference Query Options Schema + * + * Used for filtering preferences by key prefix. + */ +export const UserPreferenceQueryOptionsSchema = z.object({ + /** + * Key prefix filter (e.g., 'plugin.ai.' to get all AI plugin preferences) + */ + prefix: z.string().optional().describe('Key prefix filter'), + + /** + * User ID filter (for admin queries) + */ + userId: z.string().optional().describe('User ID filter'), +}); + +export type UserPreferenceQueryOptions = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bca28112..0e9f81c05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,9 @@ importers: '@objectstack/service-feed': specifier: workspace:* version: link:../../packages/services/service-feed + '@objectstack/service-user-preferences': + specifier: workspace:* + version: link:../../packages/services/service-user-preferences '@objectstack/spec': specifier: workspace:* version: link:../../packages/spec @@ -1281,6 +1284,25 @@ importers: specifier: ^4.1.2 version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(happy-dom@20.8.9)(msw@2.12.14(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.5(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/services/service-user-preferences: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(happy-dom@20.8.9)(msw@2.12.14(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.5(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/spec: dependencies: ai: @@ -10205,7 +10227,7 @@ snapshots: dependencies: chokidar: 4.0.3 confbox: 0.2.4 - defu: 6.1.4 + defu: 6.1.6 dotenv: 16.6.1 exsolve: 1.0.8 giget: 2.0.0 @@ -11098,7 +11120,7 @@ snapshots: dependencies: citty: 0.1.6 consola: 3.4.2 - defu: 6.1.4 + defu: 6.1.6 node-fetch-native: 1.6.7 nypm: 0.6.5 pathe: 2.0.3 @@ -12759,7 +12781,7 @@ snapshots: rc9@2.1.2: dependencies: - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 optional: true