UNPKG

officernd-mcp-server

Version:

MCP server for OfficeRnD workspace management - create, search, update and cancel bookings

831 lines 35.3 kB
import fetch from 'node-fetch'; import { ResourceCache } from './resource-cache.js'; import { ResourceRateCache } from './resource-rate-cache.js'; export class OfficeRnDClient { clientId; clientSecret; orgSlug; accessToken = null; tokenExpiry = null; baseUrl = 'https://app.officernd.com/api/v2'; identityUrl = 'https://identity.officernd.com/oauth/token'; resourceCache; resourceRateCache; API_LIMIT_MAX = 50; constructor(clientId, clientSecret, orgSlug, cacheMinutes = 60) { this.clientId = clientId; this.clientSecret = clientSecret; this.orgSlug = orgSlug; this.resourceCache = new ResourceCache(cacheMinutes); this.resourceRateCache = new ResourceRateCache(cacheMinutes); } /** * Validates and normalizes the limit parameter to ensure it doesn't exceed OfficeRnD's API limit */ validateLimit(limit) { if (limit === undefined) { return undefined; } if (limit < 1) { throw new Error(`Limit must be at least 1, got ${limit}`); } if (limit > this.API_LIMIT_MAX) { throw new Error(`Limit cannot exceed ${this.API_LIMIT_MAX} (OfficeRnD API hard cap), got ${limit}`); } return limit; } async ensureAuthenticated() { // Check if we have a valid token if (this.accessToken && this.tokenExpiry && this.tokenExpiry > new Date()) { return; } // Get new token await this.authenticate(); } async authenticate() { const params = new URLSearchParams(); params.set('grant_type', 'client_credentials'); params.set('client_id', this.clientId); params.set('client_secret', this.clientSecret); params.set('scope', 'flex.billing.resourceRates.read flex.community.members.read flex.community.companies.read flex.space.locations.read flex.space.resources.read flex.space.resourceTypes.read flex.space.bookings.read flex.space.bookings.validate flex.space.bookings.create flex.space.bookings.update flex.space.bookings.delete flex.space.bookings.cancel'); try { const response = await fetch(this.identityUrl, { method: 'POST', headers: { 'accept': 'application/json', 'content-type': 'application/x-www-form-urlencoded', }, body: params, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Authentication failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); this.accessToken = data.access_token; // Set expiry to 5 minutes before actual expiry to be safe this.tokenExpiry = new Date(Date.now() + (data.expires_in - 300) * 1000); } catch (error) { console.error('Authentication error:', error); throw new Error(`Failed to authenticate with OfficeRnD: ${error instanceof Error ? error.message : String(error)}`); } } async searchBookings(params) { await this.ensureAuthenticated(); const url = new URL(`${this.baseUrl}/organizations/${this.orgSlug}/bookings`); // Add query parameters if (params.bookingId) url.searchParams.append('_id', params.bookingId); if (params.companyId) url.searchParams.append('company', params.companyId); if (params.memberId) url.searchParams.append('member', params.memberId); if (params.locationId) url.searchParams.append('location', params.locationId); if (params.resourceId) url.searchParams.append('resource', params.resourceId); // Handle date filters if (params.seriesStartFrom) { url.searchParams.append('seriesStart[$gte]', params.seriesStartFrom); } if (params.seriesStartTo) { url.searchParams.append('seriesStart[$lte]', params.seriesStartTo); } if (params.seriesEndFrom) { url.searchParams.append('seriesEnd[$gte]', params.seriesEndFrom); } if (params.seriesEndTo) { url.searchParams.append('seriesEnd[$lte]', params.seriesEndTo); } // Pagination const validatedLimit = this.validateLimit(params.limit); if (validatedLimit) url.searchParams.append('$limit', validatedLimit.toString()); if (params.cursorNext) url.searchParams.append('$cursorNext', params.cursorNext); if (params.cursorPrev) url.searchParams.append('$cursorPrev', params.cursorPrev); // Field selection if (params.fields && params.fields.length > 0) { url.searchParams.append('$select', params.fields.join(',')); } try { const response = await fetch(url.toString(), { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Search bookings error:', error); throw new Error(`Failed to search bookings: ${error instanceof Error ? error.message : String(error)}`); } } async getBookingDetails(bookingId) { await this.ensureAuthenticated(); const url = `${this.baseUrl}/organizations/${this.orgSlug}/bookings/${bookingId}`; try { const response = await fetch(url, { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { if (response.status === 404) { throw new Error(`Booking with ID ${bookingId} not found`); } const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Get booking details error:', error); throw new Error(`Failed to get booking details: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get booking details with enriched resource information * This method fetches the full booking details and includes the resource information if available */ async getBookingDetailsWithResource(bookingId) { const booking = await this.getBookingDetails(bookingId); if (!booking.resource) { return { booking }; } // Try to get resource from cache first let resource = this.resourceCache.get(booking.resource); if (!resource) { // Not in cache, fetch from API try { resource = await this.getResourceDetails(booking.resource); // Store in cache for future use this.resourceCache.set(resource); } catch (error) { console.error(`Failed to fetch resource ${booking.resource}:`, error); // Return booking without resource if fetch fails return { booking }; } } return { booking, resource }; } /** * Search bookings and enrich them with resource information * This method performs additional API calls to get full booking details including resources */ async searchBookingsWithResources(params) { // First, get the basic booking search results const searchResults = await this.searchBookings(params); // For each booking, fetch full details to get resource information const enrichedResults = await Promise.all(searchResults.results.map(async (basicBooking) => { try { return await this.getBookingDetailsWithResource(basicBooking._id); } catch (error) { console.error(`Failed to enrich booking ${basicBooking._id}:`, error); // Return basic booking if enrichment fails return { booking: basicBooking }; } })); return { rangeStart: searchResults.rangeStart, rangeEnd: searchResults.rangeEnd, cursorNext: searchResults.cursorNext, cursorPrev: searchResults.cursorPrev, results: enrichedResults, }; } async searchMembers(params) { await this.ensureAuthenticated(); const url = new URL(`${this.baseUrl}/organizations/${this.orgSlug}/members`); // Add query parameters if (params.memberId) url.searchParams.append('_id', params.memberId); if (params.name) url.searchParams.append('name', params.name); if (params.email) url.searchParams.append('email', params.email); if (params.locationId) url.searchParams.append('location', params.locationId); if (params.companyId) url.searchParams.append('company', params.companyId); if (params.status) url.searchParams.append('status', params.status); // Handle date filters if (params.createdAtFrom) { url.searchParams.append('createdAt[$gte]', params.createdAtFrom); } if (params.createdAtTo) { url.searchParams.append('createdAt[$lte]', params.createdAtTo); } if (params.modifiedAtFrom) { url.searchParams.append('modifiedAt[$gte]', params.modifiedAtFrom); } if (params.modifiedAtTo) { url.searchParams.append('modifiedAt[$lte]', params.modifiedAtTo); } // Pagination and sorting const validatedLimit = this.validateLimit(params.limit); if (validatedLimit) url.searchParams.append('$limit', validatedLimit.toString()); if (params.cursorNext) url.searchParams.append('$cursorNext', params.cursorNext); if (params.cursorPrev) url.searchParams.append('$cursorPrev', params.cursorPrev); if (params.sort) url.searchParams.append('$sort', params.sort); // Field selection if (params.fields && params.fields.length > 0) { url.searchParams.append('$select', params.fields.join(',')); } try { const response = await fetch(url.toString(), { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Search members error:', error); throw new Error(`Failed to search members: ${error instanceof Error ? error.message : String(error)}`); } } async getMemberDetails(memberId) { await this.ensureAuthenticated(); const url = `${this.baseUrl}/organizations/${this.orgSlug}/members/${memberId}`; try { const response = await fetch(url, { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { if (response.status === 404) { throw new Error(`Member with ID ${memberId} not found`); } const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Get member details error:', error); throw new Error(`Failed to get member details: ${error instanceof Error ? error.message : String(error)}`); } } async searchResources(params) { await this.ensureAuthenticated(); const url = new URL(`${this.baseUrl}/organizations/${this.orgSlug}/resources`); // Add query parameters if (params.resourceId) url.searchParams.append('_id', params.resourceId); if (params.name) url.searchParams.append('name', params.name); if (params.locationId) url.searchParams.append('location', params.locationId); if (params.resourceTypeId) url.searchParams.append('type', params.resourceTypeId); // Pagination and sorting const validatedLimit = this.validateLimit(params.limit); if (validatedLimit) url.searchParams.append('$limit', validatedLimit.toString()); if (params.cursorNext) url.searchParams.append('$cursorNext', params.cursorNext); if (params.cursorPrev) url.searchParams.append('$cursorPrev', params.cursorPrev); if (params.sort) url.searchParams.append('$sort', params.sort); // Field selection if (params.fields && params.fields.length > 0) { url.searchParams.append('$select', params.fields.join(',')); } try { const response = await fetch(url.toString(), { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); // Cache all resources for future use if (data.results && data.results.length > 0) { this.resourceCache.setMany(data.results); } return data; } catch (error) { console.error('Search resources error:', error); throw new Error(`Failed to search resources: ${error instanceof Error ? error.message : String(error)}`); } } async getResourceDetails(resourceId) { // Check cache first const cached = this.resourceCache.get(resourceId); if (cached) { return cached; } await this.ensureAuthenticated(); const url = `${this.baseUrl}/organizations/${this.orgSlug}/resources/${resourceId}`; try { const response = await fetch(url, { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { if (response.status === 404) { throw new Error(`Resource with ID ${resourceId} not found`); } const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); // Store in cache this.resourceCache.set(data); return data; } catch (error) { console.error('Get resource details error:', error); throw new Error(`Failed to get resource details: ${error instanceof Error ? error.message : String(error)}`); } } async getResourceTypes() { await this.ensureAuthenticated(); const url = `${this.baseUrl}/organizations/${this.orgSlug}/resource-types`; try { const response = await fetch(url, { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Get resource types error:', error); throw new Error(`Failed to get resource types: ${error instanceof Error ? error.message : String(error)}`); } } /** * Preload all resources into cache * Useful for warming up the cache with all available resources */ async preloadResourceCache() { try { const allResources = []; let cursor; // Fetch all resources page by page do { const result = await this.searchResources({ limit: this.API_LIMIT_MAX, cursorNext: cursor }); if (result.results && result.results.length > 0) { allResources.push(...result.results); } cursor = result.cursorNext; } while (cursor); // Store all resources in cache this.resourceCache.setMany(allResources); return allResources.length; } catch (error) { console.error('Failed to preload resource cache:', error); throw new Error(`Failed to preload resources: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get cache statistics */ getResourceCacheStats() { return this.resourceCache.getStats(); } /** * Clear the resource cache */ clearResourceCache() { this.resourceCache.clear(); } async searchCompanies(params) { await this.ensureAuthenticated(); const url = new URL(`${this.baseUrl}/organizations/${this.orgSlug}/companies`); // Add query parameters if (params.companyId) url.searchParams.append('_id', params.companyId); if (params.name) url.searchParams.append('name', params.name); if (params.email) url.searchParams.append('email', params.email); if (params.locationId) url.searchParams.append('location', params.locationId); if (params.status) url.searchParams.append('status', params.status); // Handle date filters if (params.createdAtFrom) { url.searchParams.append('createdAt[$gte]', params.createdAtFrom); } if (params.createdAtTo) { url.searchParams.append('createdAt[$lte]', params.createdAtTo); } if (params.modifiedAtFrom) { url.searchParams.append('modifiedAt[$gte]', params.modifiedAtFrom); } if (params.modifiedAtTo) { url.searchParams.append('modifiedAt[$lte]', params.modifiedAtTo); } // Pagination and sorting const validatedLimit = this.validateLimit(params.limit); if (validatedLimit) url.searchParams.append('$limit', validatedLimit.toString()); if (params.cursorNext) url.searchParams.append('$cursorNext', params.cursorNext); if (params.cursorPrev) url.searchParams.append('$cursorPrev', params.cursorPrev); if (params.sort) url.searchParams.append('$sort', params.sort); // Field selection if (params.fields && params.fields.length > 0) { url.searchParams.append('$select', params.fields.join(',')); } try { const response = await fetch(url.toString(), { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Search companies error:', error); throw new Error(`Failed to search companies: ${error instanceof Error ? error.message : String(error)}`); } } async getCompanyDetails(companyId) { await this.ensureAuthenticated(); const url = `${this.baseUrl}/organizations/${this.orgSlug}/companies/${companyId}`; try { const response = await fetch(url, { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { if (response.status === 404) { throw new Error(`Company with ID ${companyId} not found`); } const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Get company details error:', error); throw new Error(`Failed to get company details: ${error instanceof Error ? error.message : String(error)}`); } } async createBooking(params, options) { await this.ensureAuthenticated(); const url = new URL(`${this.baseUrl}/organizations/${this.orgSlug}/bookings`); // Add isSilent query parameter if specified if (options?.isSilent) { url.searchParams.append('isSilent', 'true'); } // Validate that either member or company is provided if (!params.member && !params.company) { throw new Error('Either member or company must be provided when creating a booking'); } try { const response = await fetch(url, { method: 'POST', headers: { 'accept': 'application/json', 'content-type': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, body: JSON.stringify(params), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Create booking error:', error); throw new Error(`Failed to create booking: ${error instanceof Error ? error.message : String(error)}`); } } async updateBooking(bookingId, params) { await this.ensureAuthenticated(); // First, fetch the existing booking to get current values for required fields let existingBooking; try { existingBooking = await this.getBookingDetails(bookingId); } catch (error) { if (error instanceof Error && error.message.includes('not found')) { throw new Error(`Booking with ID ${bookingId} not found`); } throw error; } // Merge existing values with update params // The API requires start, end, and resource fields to be present const updatePayload = { start: params.start || existingBooking.start, end: params.end || existingBooking.end, resource: params.resource || existingBooking.resource, // Include other fields only if they are provided in params ...(params.member !== undefined && { member: params.member }), ...(params.company !== undefined && { company: params.company }), ...(params.title !== undefined && { title: params.title }), ...(params.description !== undefined && { description: params.description }), ...(params.isTentative !== undefined && { isTentative: params.isTentative }), ...(params.isPrivate !== undefined && { isPrivate: params.isPrivate }), ...(params.isFree !== undefined && { isFree: params.isFree }), }; const url = new URL(`${this.baseUrl}/organizations/${this.orgSlug}/bookings/${bookingId}`); // Add isSilent parameter to avoid validation issues url.searchParams.append('isSilent', 'true'); try { const response = await fetch(url.toString(), { method: 'PUT', headers: { 'accept': 'application/json', 'content-type': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, body: JSON.stringify(updatePayload), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return data; } catch (error) { console.error('Update booking error:', error); throw new Error(`Failed to update booking: ${error instanceof Error ? error.message : String(error)}`); } } async cancelBooking(bookingId) { await this.ensureAuthenticated(); const url = `${this.baseUrl}/organizations/${this.orgSlug}/bookings/${bookingId}/cancel`; try { const response = await fetch(url, { method: 'POST', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { if (response.status === 404) { throw new Error(`Booking with ID ${bookingId} not found`); } const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } // The API returns the updated booking with isCancelled: true const booking = await response.json(); return { success: true, booking, message: `Booking ${bookingId} cancelled successfully`, }; } catch (error) { console.error('Cancel booking error:', error); throw new Error(`Failed to cancel booking: ${error instanceof Error ? error.message : String(error)}`); } } async searchResourceRates(params) { await this.ensureAuthenticated(); const url = new URL(`${this.baseUrl}/organizations/${this.orgSlug}/resource-rates`); // Add query parameters if (params.resourceRateId) url.searchParams.append('_id', params.resourceRateId); if (params.resourceId) url.searchParams.append('resource', params.resourceId); if (params.name) url.searchParams.append('name', params.name); // Pagination and sorting const validatedLimit = this.validateLimit(params.limit); if (validatedLimit) url.searchParams.append('$limit', validatedLimit.toString()); if (params.cursorNext) url.searchParams.append('$cursorNext', params.cursorNext); if (params.cursorPrev) url.searchParams.append('$cursorPrev', params.cursorPrev); if (params.sort) url.searchParams.append('$sort', params.sort); // Field selection if (params.fields && params.fields.length > 0) { url.searchParams.append('$select', params.fields.join(',')); } try { const response = await fetch(url.toString(), { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); // Cache all rates for future use if (data.results && data.results.length > 0) { this.resourceRateCache.setRates(data.results); } return data; } catch (error) { console.error('Search resource rates error:', error); throw new Error(`Failed to search resource rates: ${error instanceof Error ? error.message : String(error)}`); } } async getResourceRateDetails(resourceRateId) { // Check cache first const cached = this.resourceRateCache.getRate(resourceRateId); if (cached) { return cached; } await this.ensureAuthenticated(); const url = `${this.baseUrl}/organizations/${this.orgSlug}/resource-rates/${resourceRateId}`; try { const response = await fetch(url, { method: 'GET', headers: { 'accept': 'application/json', 'authorization': `Bearer ${this.accessToken}`, }, }); if (!response.ok) { if (response.status === 404) { throw new Error(`Resource rate with ID ${resourceRateId} not found`); } const errorText = await response.text(); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); // Store in cache this.resourceRateCache.setRate(data); return data; } catch (error) { console.error('Get resource rate details error:', error); throw new Error(`Failed to get resource rate details: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get all rates for a specific resource */ async getRatesForResource(resourceId) { // Check cache first const cached = this.resourceRateCache.getRatesForResource(resourceId); if (cached) { return cached; } // Fetch from API const result = await this.searchResourceRates({ resourceId, limit: this.API_LIMIT_MAX }); return result.results || []; } /** * Preload all resource rates into cache */ async preloadResourceRateCache() { try { const allRates = []; let cursor; // Fetch all rates page by page do { const result = await this.searchResourceRates({ limit: this.API_LIMIT_MAX, cursorNext: cursor }); if (result.results && result.results.length > 0) { allRates.push(...result.results); } cursor = result.cursorNext; } while (cursor); // Store all rates in cache this.resourceRateCache.setRates(allRates); return allRates.length; } catch (error) { console.error('Failed to preload resource rate cache:', error); throw new Error(`Failed to preload resource rates: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get resource rate cache statistics */ getResourceRateCacheStats() { return this.resourceRateCache.getStats(); } /** * Clear the resource rate cache */ clearResourceRateCache() { this.resourceRateCache.clear(); } /** * Get bookable resources with their rates * This method combines resource information with pricing data */ async getBookableResourcesWithRates(params) { try { // Validate limit if provided const validatedLimit = params?.limit ? this.validateLimit(params.limit) : this.API_LIMIT_MAX; // First, get the resources const resourceParams = { locationId: params?.locationId, resourceTypeId: params?.resourceTypeId, limit: validatedLimit, }; const resourcesResponse = await this.searchResources(resourceParams); const resources = resourcesResponse.results || []; // Filter only bookable resources const bookableResources = resources.filter(r => r.bookable !== false); // For each bookable resource, get its rates const resourcesWithRates = await Promise.all(bookableResources.map(async (resource) => { try { const rates = await this.getRatesForResource(resource._id); // Filter only active rates const activeRates = rates.filter(rate => rate.isActive !== false); return { resource, rates: activeRates, }; } catch (error) { console.error(`Failed to get rates for resource ${resource._id}:`, error); // Return resource with empty rates if fetch fails return { resource, rates: [], }; } })); // Sort by resource name resourcesWithRates.sort((a, b) => a.resource.name.localeCompare(b.resource.name)); return resourcesWithRates; } catch (error) { console.error('Failed to get bookable resources with rates:', error); throw new Error(`Failed to get bookable resources with rates: ${error instanceof Error ? error.message : String(error)}`); } } } //# sourceMappingURL=officernd-client.js.map