diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 3e71416c3..7bd7e7bd7 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -112,6 +112,7 @@ export const BaseProviders = [ 'teams', 'telegram', 'todoist', + 'toggl', 'trello', 'twilio', 'twitter', @@ -230,6 +231,7 @@ export const ProviderDisplayNames = { teams: 'Teams', telegram: 'Telegram', todoist: 'Todoist', + toggl: 'Toggl', trello: 'Trello', twilio: 'Twilio', twitter: 'Twitter', @@ -355,6 +357,7 @@ export type AllProviders = | 'teams' | 'telegram' | 'todoist' + | 'toggl' | 'trello' | 'twilio' | 'twitter' diff --git a/packages/toggl/client.test.ts b/packages/toggl/client.test.ts new file mode 100644 index 000000000..d1ce826d6 --- /dev/null +++ b/packages/toggl/client.test.ts @@ -0,0 +1,186 @@ +import { ApiError } from 'corsair/http'; +import { makeTogglRequest } from './client'; +import { errorHandlers } from './error-handlers'; + +// Deliberately not credential-shaped: a real Toggl token is 32 hex characters, +// so this cannot be mistaken for one or used against the API. +const TOKEN = 'fake-toggl-token-for-tests-only'; + +type ErrorContext = Parameters[1]; + +// The handlers only read `operation`; a narrow cast keeps the fixture readable +// without restating the whole plugin context. +const context = { operation: 'me.get' } as ErrorContext; + +/** + * Builds an ApiError carrying a given status and body, for asserting which + * handler a response routes to. + */ +function apiError(status: number, message: string): ApiError { + return new ApiError( + { method: 'GET', url: 'me' }, + { url: 'me', ok: false, status, statusText: message, body: message }, + message, + ); +} + +describe('makeTogglRequest', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + function mockFetch(body: unknown, status = 200) { + const spy = jest.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + url: 'https://api.track.toggl.com/api/v9/me', + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), + })); + global.fetch = spy as unknown as typeof global.fetch; + return spy; + } + + // The shared request layer normalises headers into a Headers instance. + function authHeaderOf(init: RequestInit): string { + const headers = init.headers; + if (headers instanceof Headers) { + return headers.get('Authorization') ?? ''; + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers).Authorization ?? ''; + } + return (headers as Record | undefined)?.Authorization ?? ''; + } + + it('authenticates with HTTP Basic using api_token as the password', async () => { + const spy = mockFetch({ id: 1 }); + await makeTogglRequest('me', TOKEN); + + const [, init] = spy.mock.calls[0] as unknown as [string, RequestInit]; + const expected = `Basic ${Buffer.from(`${TOKEN}:api_token`).toString('base64')}`; + expect(authHeaderOf(init)).toBe(expected); + }); + + it('does not send the raw token as a bearer credential', async () => { + const spy = mockFetch({ id: 1 }); + await makeTogglRequest('me', TOKEN); + + const auth = authHeaderOf(init0(spy)); + expect(auth).toMatch(/^Basic /); + expect(auth).not.toContain('Bearer'); + // The token must be base64-encoded, never sent in the clear. + expect(auth).not.toContain(TOKEN); + }); + + function init0(spy: ReturnType): RequestInit { + const [, init] = spy.mock.calls[0] as unknown as [string, RequestInit]; + return init; + } + + it('targets the Track API v9 base url', async () => { + const spy = mockFetch({ id: 1 }); + await makeTogglRequest('workspaces/3000001/clients', TOKEN); + + const [url] = spy.mock.calls[0] as unknown as [string]; + expect(url).toBe( + 'https://api.track.toggl.com/api/v9/workspaces/3000001/clients', + ); + }); + + it('returns the parsed response body', async () => { + mockFetch({ id: 4000001, name: 'Acme Corp' }); + const result = await makeTogglRequest<{ id: number; name: string }>( + 'workspaces/3000001/clients/4000001', + TOKEN, + ); + expect(result).toEqual({ id: 4000001, name: 'Acme Corp' }); + }); +}); + +describe('error handlers', () => { + it('matches a 429 as a rate limit error and retries', async () => { + const error = apiError(429, 'Too Many Requests'); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error, context)).toBe(true); + + const result = await errorHandlers.RATE_LIMIT_ERROR.handler(error, context); + expect(result.maxRetries).toBe(5); + }); + + it('treats a 402 as the sliding-window quota, not a payment failure', async () => { + // Toggl uses 402 for its per-organization request quota, which clears + // with time, so it is retryable in the same way as a 429. + const error = apiError(402, 'Payment Required'); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error, context)).toBe(true); + + const result = await errorHandlers.RATE_LIMIT_ERROR.handler(error, context); + expect(result.maxRetries).toBe(5); + }); + + it('treats a plain 403 as a permission error', async () => { + const error = apiError(403, 'Forbidden'); + expect(errorHandlers.PERMISSION_ERROR.match(error, context)).toBe(true); + + const result = await errorHandlers.PERMISSION_ERROR.handler(error, context); + expect(result.maxRetries).toBe(0); + }); + + it('classifies a 403 with an invalid-token body as an auth error, not a permission error', () => { + // Toggl answers a bad or revoked token with 403 rather than 401, so the + // body is the only thing separating the two cases. They must not both + // match, or a dead credential gets reported as a missing permission. + const error = apiError(403, 'Incorrect username and/or password'); + + expect(errorHandlers.AUTH_ERROR.match(error, context)).toBe(true); + expect(errorHandlers.PERMISSION_ERROR.match(error, context)).toBe(false); + }); + + it('matches an incorrect-credentials body as an auth error', () => { + const error = new Error('Incorrect username and/or password'); + expect(errorHandlers.AUTH_ERROR.match(error, context)).toBe(true); + }); + + it('does not retry authentication failures', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const error = apiError(401, 'Unauthorized'); + const result = await errorHandlers.AUTH_ERROR.handler(error, context); + expect(result.maxRetries).toBe(0); + }); + + it('retries transient network failures', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const error = new Error('fetch failed'); + expect(errorHandlers.NETWORK_ERROR.match(error, context)).toBe(true); + + const result = await errorHandlers.NETWORK_ERROR.handler(error, context); + expect(result.maxRetries).toBe(3); + }); + + it('matches a 404 as not found without retrying', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const error = apiError(404, 'Not Found'); + expect(errorHandlers.NOT_FOUND_ERROR.match(error, context)).toBe(true); + + const result = await errorHandlers.NOT_FOUND_ERROR.handler(error, context); + expect(result.maxRetries).toBe(0); + }); + + it('matches a 400 as a validation error', () => { + const error = apiError(400, 'Bad Request'); + expect(errorHandlers.VALIDATION_ERROR.match(error, context)).toBe(true); + }); + + it('falls back to the default handler for unknown failures', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('something unexpected'); + expect(errorHandlers.DEFAULT.match(error, context)).toBe(true); + + const result = await errorHandlers.DEFAULT.handler(error, context); + expect(result.maxRetries).toBe(0); + }); +}); diff --git a/packages/toggl/client.ts b/packages/toggl/client.ts new file mode 100644 index 000000000..074c77639 --- /dev/null +++ b/packages/toggl/client.ts @@ -0,0 +1,90 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { request } from 'corsair/http'; + +const TOGGL_API_BASE = 'https://api.track.toggl.com/api/v9'; + +/** + * Webhook subscriptions live on a separate service with its own version, not + * under the Track v9 path. + */ +const TOGGL_WEBHOOKS_BASE = 'https://api.track.toggl.com/webhooks/api/v1'; + +/** + * Toggl enforces roughly one request per second per API token per IP using a + * leaky bucket, and answers with 429 once the bucket is full. It does not + * document a Retry-After header, so the exponential backoff below is what + * normally paces retries; the header name is declared so that a Retry-After is + * still honoured if one is present. + */ +const TOGGL_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 5, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +/** + * Toggl authenticates with HTTP Basic, using the API token as the username and + * the literal string `api_token` as the password. + * + * @see https://engineering.toggl.com/docs/authentication + */ +function buildAuthHeader(apiToken: string): string { + const encoded = Buffer.from(`${apiToken}:api_token`).toString('base64'); + return `Basic ${encoded}`; +} + +/** + * Issues a Toggl request with Basic auth, rate-limit retries and this plugin's + * error handlers. + * + * `options.base` selects the host: the Track v9 API by default, or Toggl's + * separate webhooks service for subscription management. + */ +export async function makeTogglRequest( + endpoint: string, + apiToken: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record | unknown[]; + query?: Record; + /** Target the webhooks service instead of the Track v9 API. */ + base?: 'track' | 'webhooks'; + } = {}, +): Promise { + const { method = 'GET', body, query, base = 'track' } = options; + + const config: OpenAPIConfig = { + BASE: base === 'webhooks' ? TOGGL_WEBHOOKS_BASE : TOGGL_API_BASE, + VERSION: '9', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + 'Content-Type': 'application/json', + Authorization: buildAuthHeader(apiToken), + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query, + }; + + return await request(config, requestOptions, { + rateLimitConfig: TOGGL_RATE_LIMIT_CONFIG, + }); +} diff --git a/packages/toggl/endpoints-extended.test.ts b/packages/toggl/endpoints-extended.test.ts new file mode 100644 index 000000000..bb5840cab --- /dev/null +++ b/packages/toggl/endpoints-extended.test.ts @@ -0,0 +1,455 @@ +/** + * Covers the operations added to match the OSS catalog surface: the /me + * collections, reference data, organization groups and users, workspace logo + * and preferences, project membership, bulk time-entry edits, webhook + * subscription management and the transactional mail endpoints. + * + * Network access is mocked, so this runs in CI. The mail endpoints in + * particular are only ever exercised here — firing them for real would send + * email from the test account. + */ +import { + Me, + Organizations, + Projects, + Reference, + Smail, + Tasks, + TimeEntries, + Webhooks, + Workspaces, +} from './endpoints'; + +const WS = 3000001; +const ORG = 2000001; +const BASE = 'https://api.track.toggl.com/api/v9'; +const WEBHOOKS = 'https://api.track.toggl.com/webhooks/api/v1'; + +type Ctx = Parameters[0]; + +function makeStore() { + return { + upsertByEntityId: jest.fn(async () => undefined), + deleteByEntityId: jest.fn(async () => true), + }; +} + +/** Builds an endpoint context and hands back the cache mocks for assertions. */ +function makeCtxWithDb() { + const db = { + workspaces: makeStore(), + clients: makeStore(), + projects: makeStore(), + tags: makeStore(), + }; + // Only `key`, `db` and the logging members are touched by the endpoints. + const ctx = { + key: 'fake-toggl-token-for-tests-only', + db, + database: undefined, + $getAccountId: async () => 'test-account', + } as unknown as Ctx; + return { ctx, db }; +} + +/** Builds a minimal endpoint context when the cache is not under test. */ +function makeCtx() { + return makeCtxWithDb().ctx; +} + +let lastCall: { url: string; init: RequestInit } | undefined; + +/** Stubs global fetch with a single JSON response and records the request. */ +function mockResponse(body: unknown) { + global.fetch = (async (url: string, init: RequestInit) => { + lastCall = { url, init }; + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), + }; + }) as unknown as typeof global.fetch; +} + +/** Returns the URL, method and parsed body of the last recorded request. */ +function requested() { + if (!lastCall) throw new Error('no request was made'); + return { + url: lastCall.url, + method: lastCall.init.method, + body: lastCall.init.body + ? JSON.parse(String(lastCall.init.body)) + : undefined, + }; +} + +const originalFetch = global.fetch; +afterAll(() => { + global.fetch = originalFetch; +}); +beforeEach(() => { + lastCall = undefined; +}); + +describe('me — collections and account actions', () => { + it('confirms token validity via /me/logged', async () => { + mockResponse({}); + const result = await Me.getLogged(makeCtx(), {}); + expect(requested().url).toBe(`${BASE}/me/logged`); + expect(result).toEqual({ ok: true }); + }); + + it('gets the user location', async () => { + mockResponse({ city: 'Springfield', country_code: 'US' }); + const result = await Me.getLocation(makeCtx(), {}); + expect(requested().url).toBe(`${BASE}/me/location`); + expect(result.city).toBe('Springfield'); + }); + + it('gets the API quota per organization', async () => { + mockResponse([{ organization_id: ORG, remaining: 600, total: 600 }]); + const result = await Me.getQuota(makeCtx(), {}); + expect(requested().url).toBe(`${BASE}/me/quota`); + expect(result[0]?.remaining).toBe(600); + }); + + it('passes `since` through on the user collections', async () => { + mockResponse([]); + await Me.getClients(makeCtx(), { since: 1755000000 }); + expect(requested().url).toContain('since=1755000000'); + + mockResponse([]); + await Me.getProjects(makeCtx(), { since: 1755000000 }); + expect(requested().url).toBe(`${BASE}/me/projects?since=1755000000`); + }); + + it('lists the user tags and passes `since` through', async () => { + mockResponse([{ id: 7, workspace_id: 1, name: 'billable' }]); + const result = await Me.getTags(makeCtx(), { since: 1755000000 }); + expect(requested().url).toBe(`${BASE}/me/tags?since=1755000000`); + expect(requested().method).toBe('GET'); + expect(result[0]?.name).toBe('billable'); + }); + + it('normalises a null tags collection to an empty array', async () => { + mockResponse(null); + expect(await Me.getTags(makeCtx(), {})).toEqual([]); + expect(requested().url).toBe(`${BASE}/me/tags`); + }); + + it('normalises a null tasks collection to an empty array', async () => { + mockResponse(null); + expect(await Me.getTasks(makeCtx(), {})).toEqual([]); + }); + + it('feeds the entity cache from the /me collections', async () => { + // These return the same records as the workspace-scoped lists, so reading + // through /me must not leave the local mirror stale. + const { ctx, db } = makeCtxWithDb(); + + mockResponse([{ id: 4000001, wid: WS, name: 'Acme Corp' }]); + await Me.getClients(ctx, {}); + expect(db.clients.upsertByEntityId).toHaveBeenCalledWith( + '4000001', + expect.objectContaining({ workspace_id: WS, name: 'Acme Corp' }), + ); + + mockResponse([{ id: 5000001, workspace_id: WS, name: 'Website' }]); + await Me.getProjects(ctx, {}); + expect(db.projects.upsertByEntityId).toHaveBeenCalledWith( + '5000001', + expect.objectContaining({ name: 'Website' }), + ); + + mockResponse([{ id: 6000001, workspace_id: WS, name: 'billable' }]); + await Me.getTags(ctx, {}); + expect(db.tags.upsertByEntityId).toHaveBeenCalledWith( + '6000001', + expect.objectContaining({ name: 'billable' }), + ); + }); + + it('posts the unsubscribe code for product emails', async () => { + mockResponse({}); + await Me.disableProductEmails(makeCtx(), { disable_code: 'code-123' }); + expect(requested().url).toBe(`${BASE}/me/disable_product_emails`); + expect(requested().method).toBe('POST'); + expect(requested().body).toMatchObject({ disable_code: 'code-123' }); + }); + + it('disables the weekly report', async () => { + mockResponse({}); + const result = await Me.disableWeeklyReport(makeCtx(), { code: 'abc' }); + expect(requested().url).toBe(`${BASE}/me/disable_weekly_report`); + expect(result).toEqual({ ok: true }); + }); +}); + +describe('reference data', () => { + it('lists countries', async () => { + mockResponse([{ id: 1, name: 'United States' }]); + const result = await Reference.getCountries(makeCtx(), {}); + expect(requested().url).toBe(`${BASE}/countries`); + expect(result[0]?.name).toBe('United States'); + }); + + it('lists country subdivisions for a country id', async () => { + mockResponse([{ name: 'California', iso_code: 'US-CA', country_id: 235 }]); + await Reference.getCountrySubdivisions(makeCtx(), { country_id: 235 }); + expect(requested().url).toBe(`${BASE}/countries/235/subdivisions`); + }); + + it('lists currencies', async () => { + mockResponse([{ currency_id: 1, iso_code: 'USD', symbol: '$' }]); + const result = await Reference.getCurrencies(makeCtx(), {}); + expect(result[0]?.iso_code).toBe('USD'); + }); + + it('reads timezones and offsets from their separate paths', async () => { + mockResponse(['Europe/Tallinn']); + await Reference.getTimezones(makeCtx(), {}); + expect(requested().url).toBe(`${BASE}/timezones`); + + mockResponse([{ name: 'Europe/Tallinn', utc: '3' }]); + await Reference.getTimezoneOffsets(makeCtx(), {}); + expect(requested().url).toBe(`${BASE}/timezones/offsets`); + }); + + it('gets the JWKS keyset', async () => { + mockResponse({ keys: [{ alg: 'EdDSA', kid: '2023-07-25' }] }); + const result = await Reference.getKeys(makeCtx(), {}); + expect(requested().url).toBe(`${BASE}/keys`); + expect(result.keys).toHaveLength(1); + }); +}); + +describe('organizations — groups, users, invitations, plans', () => { + it('creates an organization with its first workspace', async () => { + mockResponse({ id: ORG, name: 'Example Org' }); + await Organizations.create(makeCtx(), { + name: 'Example Org', + workspace_name: 'Main', + }); + expect(requested().url).toBe(`${BASE}/organizations`); + expect(requested().method).toBe('POST'); + expect(requested().body).toMatchObject({ workspace_name: 'Main' }); + }); + + it('lists groups', async () => { + mockResponse([]); + expect( + await Organizations.getGroups(makeCtx(), { organization_id: ORG }), + ).toEqual([]); + expect(requested().url).toBe(`${BASE}/organizations/${ORG}/groups`); + }); + + it('creates a group', async () => { + mockResponse({ id: 9, name: 'Engineering' }); + await Organizations.createGroup(makeCtx(), { + organization_id: ORG, + name: 'Engineering', + }); + expect(requested().method).toBe('POST'); + expect(requested().body).toMatchObject({ name: 'Engineering' }); + }); + + it('deletes a group', async () => { + mockResponse({}); + const result = await Organizations.deleteGroup(makeCtx(), { + organization_id: ORG, + group_id: 9, + }); + expect(requested().method).toBe('DELETE'); + expect(result).toEqual({ deleted: true, id: 9 }); + }); + + it('lists organization users with filters applied', async () => { + mockResponse([{ id: 1, email: 'a@b.com' }]); + await Organizations.getUsers(makeCtx(), { + organization_id: ORG, + only_admins: true, + page: 2, + }); + expect(requested().url).toContain('only_admins=true'); + expect(requested().url).toContain('page=2'); + }); + + it('creates an invitation', async () => { + mockResponse({}); + await Organizations.createInvitation(makeCtx(), { + organization_id: ORG, + emails: ['new@example.com'], + workspaces: [{ workspace_id: WS, admin: false }], + }); + expect(requested().url).toBe(`${BASE}/organizations/${ORG}/invitations`); + expect(requested().body).toMatchObject({ emails: ['new@example.com'] }); + }); + + it('reads plan and subscription information', async () => { + mockResponse({ user_count: 1 }); + await Organizations.getPlans(makeCtx(), { organization_id: ORG }); + expect(requested().url).toBe(`${BASE}/organizations/${ORG}/plans`); + + mockResponse({}); + await Organizations.getSubscriptionPlans(makeCtx(), { + organization_id: ORG, + }); + expect(requested().url).toBe( + `${BASE}/organizations/${ORG}/subscription_plans`, + ); + }); +}); + +describe('workspaces — logo, preferences and workspace-wide tasks', () => { + it('gets the workspace logo', async () => { + mockResponse({ logo: 'https://example.com/logo.png' }); + const result = await Workspaces.getLogo(makeCtx(), { workspace_id: WS }); + expect(requested().url).toBe(`${BASE}/workspaces/${WS}/logo`); + expect(result.logo).toContain('logo.png'); + }); + + it('gets workspace preferences', async () => { + mockResponse({ initial_pricing_plan: 0, hide_start_end_times: false }); + const result = await Workspaces.getPreferences(makeCtx(), { + workspace_id: WS, + }); + expect(result.hide_start_end_times).toBe(false); + }); + + it('lists workspace-wide tasks and unwraps the paginated envelope', async () => { + // Omitting project_id selects the workspace route, which wraps results. + mockResponse({ + total_count: 1, + page: 1, + data: [{ id: 1, name: 'Task', workspace_id: WS }], + }); + const result = await Tasks.list(makeCtx(), { workspace_id: WS }); + expect(requested().url).toContain(`${BASE}/workspaces/${WS}/tasks`); + expect(result).toHaveLength(1); + }); + + it('returns an empty array when the envelope carries no data', async () => { + mockResponse({ total_count: 0, data: null }); + expect(await Tasks.list(makeCtx(), { workspace_id: WS })).toEqual([]); + }); + + it('uses the project route and a bare array when project_id is given', async () => { + mockResponse([{ id: 1, name: 'Task', workspace_id: WS, project_id: 5 }]); + const result = await Tasks.list(makeCtx(), { + workspace_id: WS, + project_id: 5, + }); + expect(requested().url).toContain( + `${BASE}/workspaces/${WS}/projects/5/tasks`, + ); + expect(result).toHaveLength(1); + }); +}); + +describe('projects — members and groups', () => { + it('adds a user to a project', async () => { + mockResponse({ id: 1, project_id: 5000001, user_id: 42 }); + await Projects.addUser(makeCtx(), { + workspace_id: WS, + project_id: 5000001, + user_id: 42, + manager: true, + }); + expect(requested().url).toBe(`${BASE}/workspaces/${WS}/project_users`); + expect(requested().body).toMatchObject({ user_id: 42, manager: true }); + }); + + it('deletes a project group', async () => { + mockResponse({}); + const result = await Projects.deleteGroup(makeCtx(), { + workspace_id: WS, + project_group_id: 7, + }); + expect(requested().method).toBe('DELETE'); + expect(result).toEqual({ deleted: true, id: 7 }); + }); +}); + +describe('time entries — bulk edit', () => { + it('sends JSON Patch operations against a comma-joined id list', async () => { + mockResponse({ success: [1, 2], failure: [] }); + const result = await TimeEntries.bulkEdit(makeCtx(), { + workspace_id: WS, + time_entry_ids: [1, 2], + operations: [{ op: 'replace', path: '/billable', value: true }], + }); + expect(requested().method).toBe('PATCH'); + expect(requested().url).toBe(`${BASE}/workspaces/${WS}/time_entries/1,2`); + expect(requested().body).toEqual([ + { op: 'replace', path: '/billable', value: true }, + ]); + expect(result.success).toEqual([1, 2]); + }); +}); + +describe('webhook subscriptions', () => { + it('reads the service status from the webhooks host, not the v9 API', async () => { + mockResponse({ status: 'OK' }); + const result = await Webhooks.getStatus(makeCtx(), {}); + expect(requested().url).toBe(`${WEBHOOKS}/status`); + expect(result.status).toBe('OK'); + }); + + it('lists the available event filters', async () => { + mockResponse({ client: ['created', 'updated', 'deleted'] }); + const result = await Webhooks.getEventFilters(makeCtx(), {}); + expect(requested().url).toBe(`${WEBHOOKS}/event_filters`); + expect(result.client).toContain('created'); + }); + + it('lists subscriptions for a workspace', async () => { + mockResponse([]); + await Webhooks.listSubscriptions(makeCtx(), { workspace_id: WS }); + expect(requested().url).toBe(`${WEBHOOKS}/subscriptions/${WS}`); + }); + + it('deletes a subscription', async () => { + mockResponse({}); + const result = await Webhooks.deleteSubscription(makeCtx(), { + workspace_id: WS, + subscription_id: 55, + }); + expect(requested().method).toBe('DELETE'); + expect(requested().url).toBe(`${WEBHOOKS}/subscriptions/${WS}/55`); + expect(result).toEqual({ deleted: true, id: 55 }); + }); +}); + +describe('transactional mail', () => { + it('sends a demo request', async () => { + mockResponse({}); + const result = await Smail.sendDemo(makeCtx(), { email: 'a@b.com' }); + expect(requested().url).toBe(`${BASE}/smail/demo`); + expect(requested().method).toBe('POST'); + expect(result).toEqual({ ok: true }); + }); + + it('sends a contact email', async () => { + mockResponse({}); + await Smail.sendContact(makeCtx(), { + email: 'a@b.com', + name: 'A', + message: 'hello', + }); + expect(requested().url).toBe(`${BASE}/smail/contact`); + }); + + it('sends a meet invitation', async () => { + mockResponse({}); + await Smail.sendMeet(makeCtx(), { + email: 'a@b.com', + location: 'Tallinn', + }); + expect(requested().url).toBe(`${BASE}/smail/meet`); + expect(requested().body).toMatchObject({ location: 'Tallinn' }); + }); +}); diff --git a/packages/toggl/endpoints.test.ts b/packages/toggl/endpoints.test.ts new file mode 100644 index 000000000..ccf9a690d --- /dev/null +++ b/packages/toggl/endpoints.test.ts @@ -0,0 +1,668 @@ +/** + * Exercises every endpoint wrapper: the request path, HTTP method, query and + * body it builds, the normalisation it applies, and the local cache writes it + * performs. Network access is mocked, so this runs in CI. + */ +import { + Clients, + Me, + Organizations, + Projects, + Tags, + Tasks, + TimeEntries, + Workspaces, +} from './endpoints'; + +const WS = 3000001; + +type Store = { + upsertByEntityId: jest.Mock; + deleteByEntityId: jest.Mock; +}; + +/** Builds a spying entity store so cache writes and evictions can be asserted. */ +function makeStore(): Store { + return { + upsertByEntityId: jest.fn(async () => undefined), + deleteByEntityId: jest.fn(async () => true), + }; +} + +// The endpoints only touch `key`, `db`, and the event-logging members; a narrow +// assertion keeps the fixture readable instead of restating the whole context. +type Ctx = Parameters[0]; + +/** Builds a minimal endpoint context with spying stores. */ +function makeCtx() { + const db = { + workspaces: makeStore(), + clients: makeStore(), + projects: makeStore(), + tags: makeStore(), + }; + const ctx = { + key: 'fake-toggl-token-for-tests-only', + db, + database: undefined, + $getAccountId: async () => 'test-account', + } as unknown as Ctx; + return { ctx, db }; +} + +let lastCall: { url: string; init: RequestInit } | undefined; + +/** Stubs global fetch with a single JSON response and records the request. */ +function mockResponse(body: unknown, status = 200) { + global.fetch = (async (url: string, init: RequestInit) => { + lastCall = { url, init }; + return { + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), + }; + }) as unknown as typeof global.fetch; +} + +/** Returns the URL, method and parsed body of the last recorded request. */ +function requested() { + if (!lastCall) throw new Error('no request was made'); + return { + url: lastCall.url, + method: lastCall.init.method, + body: lastCall.init.body + ? JSON.parse(String(lastCall.init.body)) + : undefined, + }; +} + +const BASE = 'https://api.track.toggl.com/api/v9'; + +const workspace = { id: WS, organization_id: 2000001, name: 'Workspace' }; +const client = { id: 4000001, wid: WS, name: 'Acme Corp' }; +const project = { id: 5000001, workspace_id: WS, name: 'Website Redesign' }; +const task = { id: 1, workspace_id: WS, project_id: project.id, name: 'Task' }; +const tag = { id: 6000001, workspace_id: WS, name: 'billable' }; +const entry = { + id: 7000001, + workspace_id: WS, + start: '2026-08-09T09:50:42Z', + duration: 3600, +}; + +const originalFetch = global.fetch; +afterAll(() => { + global.fetch = originalFetch; +}); +beforeEach(() => { + lastCall = undefined; +}); + +describe('me', () => { + it('gets the profile and strips the api_token', async () => { + const { ctx } = makeCtx(); + mockResponse({ id: 1, email: 'a@b.com', api_token: 'super-secret' }); + + const result = await Me.get(ctx, {}); + + expect(requested().url).toBe(`${BASE}/me`); + expect(requested().method).toBe('GET'); + expect(result).not.toHaveProperty('api_token'); + expect(result.email).toBe('a@b.com'); + }); + + it('strips the api_token on update too', async () => { + const { ctx } = makeCtx(); + mockResponse({ id: 1, email: 'a@b.com', api_token: 'super-secret' }); + + const result = await Me.update(ctx, { fullname: 'New Name' }); + + expect(requested().method).toBe('PUT'); + expect(requested().body).toMatchObject({ fullname: 'New Name' }); + expect(result).not.toHaveProperty('api_token'); + }); + + it('reads preferences', async () => { + const { ctx } = makeCtx(); + mockResponse({ date_format: 'YYYY-MM-DD' }); + await Me.getPreferences(ctx, {}); + expect(requested().url).toBe(`${BASE}/me/preferences`); + expect(requested().method).toBe('GET'); + }); + + it('writes preferences with POST', async () => { + const { ctx } = makeCtx(); + mockResponse({ date_format: 'DD/MM/YYYY' }); + await Me.updatePreferences(ctx, { date_format: 'DD/MM/YYYY' }); + expect(requested().method).toBe('POST'); + expect(requested().body).toMatchObject({ date_format: 'DD/MM/YYYY' }); + }); +}); + +describe('workspaces', () => { + it('lists and caches each workspace', async () => { + const { ctx, db } = makeCtx(); + mockResponse([workspace]); + + const result = await Workspaces.list(ctx, {}); + + expect(requested().url).toBe(`${BASE}/workspaces`); + expect(result).toHaveLength(1); + expect(db.workspaces.upsertByEntityId).toHaveBeenCalledWith( + String(WS), + expect.objectContaining({ id: WS, name: 'Workspace' }), + ); + }); + + it('gets one workspace and caches it', async () => { + const { ctx, db } = makeCtx(); + mockResponse(workspace); + await Workspaces.get(ctx, { workspace_id: WS }); + expect(requested().url).toBe(`${BASE}/workspaces/${WS}`); + expect(db.workspaces.upsertByEntityId).toHaveBeenCalledTimes(1); + }); + + it('updates a workspace with PUT', async () => { + const { ctx } = makeCtx(); + mockResponse(workspace); + await Workspaces.update(ctx, { workspace_id: WS, name: 'Renamed' }); + expect(requested().method).toBe('PUT'); + expect(requested().body).toMatchObject({ name: 'Renamed' }); + }); + + it('lists workspace users', async () => { + const { ctx } = makeCtx(); + mockResponse([{ id: 1, email: 'a@b.com' }]); + const result = await Workspaces.getUsers(ctx, { workspace_id: WS }); + expect(requested().url).toBe(`${BASE}/workspaces/${WS}/users`); + expect(result).toHaveLength(1); + }); +}); + +describe('organizations', () => { + it('gets an organization', async () => { + const { ctx } = makeCtx(); + mockResponse({ id: 2000001, name: 'Example Org' }); + await Organizations.get(ctx, { organization_id: 2000001 }); + expect(requested().url).toBe(`${BASE}/organizations/2000001`); + }); + + it('renames an organization', async () => { + const { ctx } = makeCtx(); + mockResponse({ id: 2000001, name: 'New Name' }); + await Organizations.update(ctx, { + organization_id: 2000001, + name: 'New Name', + }); + expect(requested().method).toBe('PUT'); + expect(requested().body).toMatchObject({ name: 'New Name' }); + }); + + it('lists organization workspaces and caches them', async () => { + const { ctx, db } = makeCtx(); + mockResponse([workspace]); + await Organizations.getWorkspaces(ctx, { organization_id: 2000001 }); + expect(requested().url).toBe(`${BASE}/organizations/2000001/workspaces`); + // Same records as workspaces.list, so the mirror must not go stale. + expect(db.workspaces.upsertByEntityId).toHaveBeenCalledWith( + String(WS), + expect.objectContaining({ id: WS, name: 'Workspace' }), + ); + }); +}); + +describe('clients', () => { + it('lists and caches clients', async () => { + const { ctx, db } = makeCtx(); + mockResponse([client]); + const result = await Clients.list(ctx, { workspace_id: WS }); + expect(requested().url).toContain(`${BASE}/workspaces/${WS}/clients`); + expect(result).toHaveLength(1); + expect(db.clients.upsertByEntityId).toHaveBeenCalledWith( + String(client.id), + expect.objectContaining({ workspace_id: WS, name: 'Acme Corp' }), + ); + }); + + it('normalises a null client list into an empty array', async () => { + const { ctx } = makeCtx(); + mockResponse(null); + const result = await Clients.list(ctx, { workspace_id: WS }); + expect(result).toEqual([]); + }); + + it('gets a client', async () => { + const { ctx } = makeCtx(); + mockResponse(client); + await Clients.get(ctx, { workspace_id: WS, client_id: client.id }); + expect(requested().url).toBe( + `${BASE}/workspaces/${WS}/clients/${client.id}`, + ); + }); + + it('creates a client without restating the workspace in the body', async () => { + const { ctx } = makeCtx(); + mockResponse(client); + await Clients.create(ctx, { workspace_id: WS, name: 'Acme Corp' }); + expect(requested().method).toBe('POST'); + expect(requested().url).toBe(`${BASE}/workspaces/${WS}/clients`); + expect(requested().body).toMatchObject({ name: 'Acme Corp' }); + // The route already identifies the workspace. + expect(requested().body).not.toHaveProperty('wid'); + }); + + it('updates a client with name and notes, never archived', async () => { + const { ctx } = makeCtx(); + mockResponse({ ...client, notes: 'updated' }); + await Clients.update(ctx, { + workspace_id: WS, + client_id: client.id, + name: 'Acme Corp', + notes: 'updated', + }); + expect(requested().method).toBe('PUT'); + // Toggl requires name on every update; archiving is a separate route. + expect(requested().body).toMatchObject({ + name: 'Acme Corp', + notes: 'updated', + }); + expect(requested().body).not.toHaveProperty('archived'); + }); + + it('archives a client through its own route and caches the record', async () => { + const { ctx, db } = makeCtx(); + mockResponse({ ...client, archived: true }); + const result = await Clients.archive(ctx, { + workspace_id: WS, + client_id: client.id, + }); + expect(requested().method).toBe('POST'); + expect(requested().url).toBe( + `${BASE}/workspaces/${WS}/clients/${client.id}/archive`, + ); + expect(result).toMatchObject({ id: client.id, archived: true }); + expect(db.clients.upsertByEntityId).toHaveBeenCalledTimes(1); + }); + + it('re-reads the client when archive answers with an id envelope', async () => { + // Toggl answers with the ids it touched rather than a client record. That + // carries no fields to cache, but leaving the row untouched would keep + // reporting the client as active, so the client is re-read instead. + const { ctx, db } = makeCtx(); + let call = 0; + global.fetch = (async (url: string, init: RequestInit) => { + call += 1; + lastCall = { url, init }; + const body = + call === 1 ? { items: [client.id] } : { ...client, archived: true }; + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), + }; + }) as unknown as typeof global.fetch; + + const result = await Clients.archive(ctx, { + workspace_id: WS, + client_id: client.id, + }); + + expect(result).toMatchObject({ items: [client.id] }); + expect(call).toBe(2); + expect(requested().url).toBe( + `${BASE}/workspaces/${WS}/clients/${client.id}`, + ); + expect(db.clients.upsertByEntityId).toHaveBeenCalledWith( + String(client.id), + expect.objectContaining({ archived: true }), + ); + }); + + it('evicts the cached client when the re-read fails', async () => { + const { ctx, db } = makeCtx(); + let call = 0; + global.fetch = (async (url: string, init: RequestInit) => { + call += 1; + lastCall = { url, init }; + if (call > 1) throw new Error('network down'); + const body = { items: [client.id] }; + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), + }; + }) as unknown as typeof global.fetch; + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await Clients.archive(ctx, { workspace_id: WS, client_id: client.id }); + + // A cache miss is safe; a stale hit saying the client is active is not. + expect(db.clients.deleteByEntityId).toHaveBeenCalledWith(String(client.id)); + expect(db.clients.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('sends notes on create', async () => { + const { ctx } = makeCtx(); + mockResponse({ ...client, notes: 'a note' }); + await Clients.create(ctx, { + workspace_id: WS, + name: 'Acme Corp', + notes: 'a note', + }); + expect(requested().body).toMatchObject({ notes: 'a note' }); + }); + + it('deletes a client and evicts it from the cache', async () => { + const { ctx, db } = makeCtx(); + mockResponse({}); + const result = await Clients.delete(ctx, { + workspace_id: WS, + client_id: client.id, + }); + expect(requested().method).toBe('DELETE'); + expect(result).toEqual({ deleted: true, id: client.id }); + expect(db.clients.deleteByEntityId).toHaveBeenCalledWith(String(client.id)); + }); +}); + +describe('projects', () => { + it('lists and caches projects, passing pagination through', async () => { + const { ctx, db } = makeCtx(); + mockResponse([project]); + await Projects.list(ctx, { workspace_id: WS, page: 2, per_page: 50 }); + expect(requested().url).toContain('page=2'); + expect(requested().url).toContain('per_page=50'); + expect(db.projects.upsertByEntityId).toHaveBeenCalledTimes(1); + }); + + it('gets a project', async () => { + const { ctx } = makeCtx(); + mockResponse(project); + await Projects.get(ctx, { workspace_id: WS, project_id: project.id }); + expect(requested().url).toBe( + `${BASE}/workspaces/${WS}/projects/${project.id}`, + ); + }); + + it('creates a project', async () => { + const { ctx } = makeCtx(); + mockResponse(project); + await Projects.create(ctx, { + workspace_id: WS, + name: 'Website Redesign', + client_id: client.id, + }); + expect(requested().method).toBe('POST'); + expect(requested().body).toMatchObject({ + name: 'Website Redesign', + client_id: client.id, + }); + }); + + it('updates a project', async () => { + const { ctx } = makeCtx(); + mockResponse({ ...project, active: false }); + await Projects.update(ctx, { + workspace_id: WS, + project_id: project.id, + active: false, + }); + expect(requested().method).toBe('PUT'); + expect(requested().body).toMatchObject({ active: false }); + }); + + it('deletes a project and evicts it', async () => { + const { ctx, db } = makeCtx(); + mockResponse({}); + const result = await Projects.delete(ctx, { + workspace_id: WS, + project_id: project.id, + }); + expect(result).toEqual({ deleted: true, id: project.id }); + expect(db.projects.deleteByEntityId).toHaveBeenCalledWith( + String(project.id), + ); + }); +}); + +describe('tasks', () => { + it('lists tasks under a project', async () => { + const { ctx } = makeCtx(); + mockResponse([task]); + const result = await Tasks.list(ctx, { + workspace_id: WS, + project_id: project.id, + }); + expect(requested().url).toBe( + `${BASE}/workspaces/${WS}/projects/${project.id}/tasks`, + ); + expect(result).toHaveLength(1); + }); + + it('omits page params on the project-scoped route', async () => { + const { ctx } = makeCtx(); + mockResponse([task]); + await Tasks.list(ctx, { + workspace_id: WS, + project_id: project.id, + active: true, + page: 2, + per_page: 50, + }); + // Toggl documents `active` alone on this route. + expect(requested().url).toBe( + `${BASE}/workspaces/${WS}/projects/${project.id}/tasks?active=true`, + ); + }); + + it('paginates the workspace-wide route and unwraps its envelope', async () => { + const { ctx } = makeCtx(); + mockResponse({ data: [task] }); + const result = await Tasks.list(ctx, { + workspace_id: WS, + page: 2, + per_page: 50, + }); + expect(requested().url).toBe( + `${BASE}/workspaces/${WS}/tasks?page=2&per_page=50`, + ); + expect(result).toHaveLength(1); + }); + + it('gets a task', async () => { + const { ctx } = makeCtx(); + mockResponse(task); + await Tasks.get(ctx, { + workspace_id: WS, + project_id: project.id, + task_id: task.id, + }); + expect(requested().url).toContain(`/tasks/${task.id}`); + }); + + it('creates a task', async () => { + const { ctx } = makeCtx(); + mockResponse(task); + await Tasks.create(ctx, { + workspace_id: WS, + project_id: project.id, + name: 'Task', + }); + expect(requested().method).toBe('POST'); + expect(requested().body).toMatchObject({ name: 'Task' }); + }); + + it('updates a task', async () => { + const { ctx } = makeCtx(); + mockResponse({ ...task, active: false }); + await Tasks.update(ctx, { + workspace_id: WS, + project_id: project.id, + task_id: task.id, + active: false, + }); + expect(requested().method).toBe('PUT'); + }); + + it('deletes a task', async () => { + const { ctx } = makeCtx(); + mockResponse({}); + const result = await Tasks.delete(ctx, { + workspace_id: WS, + project_id: project.id, + task_id: task.id, + }); + expect(requested().method).toBe('DELETE'); + expect(result).toEqual({ deleted: true, id: task.id }); + }); +}); + +describe('tags', () => { + it('lists and caches tags', async () => { + const { ctx, db } = makeCtx(); + mockResponse([tag]); + await Tags.list(ctx, { workspace_id: WS }); + expect(requested().url).toBe(`${BASE}/workspaces/${WS}/tags`); + expect(db.tags.upsertByEntityId).toHaveBeenCalledWith( + String(tag.id), + expect.objectContaining({ name: 'billable' }), + ); + }); + + it('creates a tag', async () => { + const { ctx } = makeCtx(); + mockResponse(tag); + await Tags.create(ctx, { workspace_id: WS, name: 'billable' }); + expect(requested().method).toBe('POST'); + expect(requested().body).toMatchObject({ name: 'billable' }); + }); + + it('renames a tag', async () => { + const { ctx } = makeCtx(); + mockResponse({ ...tag, name: 'renamed' }); + await Tags.update(ctx, { + workspace_id: WS, + tag_id: tag.id, + name: 'renamed', + }); + expect(requested().method).toBe('PUT'); + expect(requested().body).toMatchObject({ name: 'renamed' }); + }); + + it('deletes a tag and evicts it', async () => { + const { ctx, db } = makeCtx(); + mockResponse({}); + const result = await Tags.delete(ctx, { workspace_id: WS, tag_id: tag.id }); + expect(result).toEqual({ deleted: true, id: tag.id }); + expect(db.tags.deleteByEntityId).toHaveBeenCalledWith(String(tag.id)); + }); +}); + +describe('time entries', () => { + it('lists the current user entries', async () => { + const { ctx } = makeCtx(); + mockResponse([entry]); + const result = await TimeEntries.list(ctx, { + start_date: '2026-08-01', + end_date: '2026-08-12', + }); + expect(requested().url).toContain(`${BASE}/me/time_entries`); + expect(requested().url).toContain('start_date=2026-08-01'); + expect(result).toHaveLength(1); + }); + + it('normalises a null entry list into an empty array', async () => { + const { ctx } = makeCtx(); + mockResponse(null); + expect(await TimeEntries.list(ctx, {})).toEqual([]); + }); + + it('returns null when no timer is running', async () => { + const { ctx } = makeCtx(); + mockResponse(null); + expect(await TimeEntries.getCurrent(ctx, {})).toBeNull(); + }); + + it('gets one entry', async () => { + const { ctx } = makeCtx(); + mockResponse(entry); + await TimeEntries.get(ctx, { time_entry_id: entry.id }); + expect(requested().url).toBe(`${BASE}/me/time_entries/${entry.id}`); + }); + + it('creates an entry and defaults created_with', async () => { + const { ctx } = makeCtx(); + mockResponse(entry); + await TimeEntries.create(ctx, { + workspace_id: WS, + start: '2026-08-12T10:00:00Z', + duration: -1, + }); + expect(requested().url).toBe(`${BASE}/workspaces/${WS}/time_entries`); + expect(requested().body).toMatchObject({ + workspace_id: WS, + duration: -1, + created_with: 'corsair', + }); + }); + + it('respects an explicit created_with', async () => { + const { ctx } = makeCtx(); + mockResponse(entry); + await TimeEntries.create(ctx, { + workspace_id: WS, + start: '2026-08-12T10:00:00Z', + duration: 60, + created_with: 'my-app', + }); + expect(requested().body).toMatchObject({ created_with: 'my-app' }); + }); + + it('updates an entry', async () => { + const { ctx } = makeCtx(); + mockResponse({ ...entry, description: 'Updated' }); + await TimeEntries.update(ctx, { + workspace_id: WS, + time_entry_id: entry.id, + description: 'Updated', + }); + expect(requested().method).toBe('PUT'); + expect(requested().body).toMatchObject({ description: 'Updated' }); + }); + + it('stops a running entry with PATCH', async () => { + const { ctx } = makeCtx(); + mockResponse(entry); + await TimeEntries.stop(ctx, { + workspace_id: WS, + time_entry_id: entry.id, + }); + expect(requested().method).toBe('PATCH'); + expect(requested().url).toBe( + `${BASE}/workspaces/${WS}/time_entries/${entry.id}/stop`, + ); + }); + + it('deletes an entry', async () => { + const { ctx } = makeCtx(); + mockResponse({}); + const result = await TimeEntries.delete(ctx, { + workspace_id: WS, + time_entry_id: entry.id, + }); + expect(requested().method).toBe('DELETE'); + expect(result).toEqual({ deleted: true, id: entry.id }); + }); +}); diff --git a/packages/toggl/endpoints/clients.ts b/packages/toggl/endpoints/clients.ts new file mode 100644 index 000000000..167b47c6f --- /dev/null +++ b/packages/toggl/endpoints/clients.ts @@ -0,0 +1,174 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheClient, evictEntity } from './persist'; +import type { TogglEndpointOutputs } from './types'; + +/** + * Lists a workspace's clients, optionally narrowed by status or name, and + * mirrors each record into the cache. + */ +export const list: TogglEndpoints['clientsList'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/clients`, + ctx.key, + { + method: 'GET', + query: { status: input.status, name: input.name }, + }, + ); + + // Toggl answers a workspace with no clients with `null` rather than []. + const clients = result ?? []; + + for (const client of clients) { + await cacheClient(ctx.db.clients, client); + } + + await logEventFromContext( + ctx, + 'toggl.clients.list', + auditPayload(input, ['workspace_id', 'status']), + 'completed', + ); + return clients; +}; + +/** Reads a single client and refreshes its cached copy. */ +export const get: TogglEndpoints['clientsGet'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/clients/${input.client_id}`, + ctx.key, + { method: 'GET' }, + ); + + await cacheClient(ctx.db.clients, result); + + await logEventFromContext( + ctx, + 'toggl.clients.get', + auditPayload(input, ['workspace_id', 'client_id']), + 'completed', + ); + return result; +}; + +/** Creates a client in a workspace. */ +export const create: TogglEndpoints['clientsCreate'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/clients`, + ctx.key, + { + method: 'POST', + // The workspace is already identified by the route, so the documented + // body carries only the client's own fields. + body: { name: input.name, notes: input.notes }, + }, + ); + + await cacheClient(ctx.db.clients, result); + + await logEventFromContext( + ctx, + 'toggl.clients.create', + auditPayload(input, ['workspace_id']), + 'completed', + ); + return result; +}; + +/** Renames a client or replaces its notes. */ +export const update: TogglEndpoints['clientsUpdate'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/clients/${input.client_id}`, + ctx.key, + { + method: 'PUT', + // Toggl requires name on every update, and archiving is a separate route. + body: { name: input.name, notes: input.notes }, + }, + ); + + await cacheClient(ctx.db.clients, result); + + await logEventFromContext( + ctx, + 'toggl.clients.update', + auditPayload(input, ['workspace_id', 'client_id']), + 'completed', + ); + return result; +}; + +/** Deletes a client and evicts it from the cache. */ +export const remove: TogglEndpoints['clientsDelete'] = async (ctx, input) => { + await makeTogglRequest( + `workspaces/${input.workspace_id}/clients/${input.client_id}`, + ctx.key, + { method: 'DELETE' }, + ); + + await evictEntity(ctx.db.clients, input.client_id, 'client'); + + await logEventFromContext( + ctx, + 'toggl.clients.delete', + auditPayload(input, ['workspace_id', 'client_id']), + 'completed', + ); + // Toggl returns an empty body on a successful delete. + return { deleted: true, id: input.client_id }; +}; + +/** Narrows an archive response to the branch that carries client fields. */ +const isClientRecord = ( + result: TogglEndpointOutputs['clientsArchive'], +): result is TogglEndpointOutputs['clientsGet'] => + typeof (result as { id?: unknown })?.id === 'number'; + +/** + * Archives a client. Toggl exposes this as its own route rather than an + * `archived` field on the update call. + */ +export const archive: TogglEndpoints['clientsArchive'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/clients/${input.client_id}/archive`, + ctx.key, + { method: 'POST' }, + ); + + if (isClientRecord(result)) { + await cacheClient(ctx.db.clients, result); + } else if (ctx.db.clients) { + // Toggl's documented `{ items: [...] }` envelope carries no client fields, + // so there is nothing to write. Leaving the cache untouched would keep + // serving the row as unarchived, so re-read the client and store that. + // Falls back to eviction, because a cache miss is safe where a stale hit + // reporting the client as active is not. + try { + const refreshed = await makeTogglRequest< + TogglEndpointOutputs['clientsGet'] + >( + `workspaces/${input.workspace_id}/clients/${input.client_id}`, + ctx.key, + { method: 'GET' }, + ); + await cacheClient(ctx.db.clients, refreshed); + } catch (error) { + console.warn( + `[TOGGL:clients.archive] could not refresh cached client ${input.client_id}, evicting instead:`, + error, + ); + await evictEntity(ctx.db.clients, input.client_id, 'client'); + } + } + + await logEventFromContext( + ctx, + 'toggl.clients.archive', + auditPayload(input, ['workspace_id', 'client_id']), + 'completed', + ); + return result; +}; diff --git a/packages/toggl/endpoints/index.ts b/packages/toggl/endpoints/index.ts new file mode 100644 index 000000000..66a53534e --- /dev/null +++ b/packages/toggl/endpoints/index.ts @@ -0,0 +1,203 @@ +import { + archive as clientsArchive, + create as clientsCreate, + get as clientsGet, + list as clientsList, + remove as clientsRemove, + update as clientsUpdate, +} from './clients'; +import { + disableProductEmails as meDisableProductEmails, + disableWeeklyReport as meDisableWeeklyReport, + get as meGet, + getClients as meGetClients, + getLocation as meGetLocation, + getLogged as meGetLogged, + getPreferences as meGetPreferences, + getProjects as meGetProjects, + getQuota as meGetQuota, + getTags as meGetTags, + getTasks as meGetTasks, + update as meUpdate, + updatePreferences as meUpdatePreferences, +} from './me'; +import { + create as organizationsCreate, + createGroup as organizationsCreateGroup, + createInvitation as organizationsCreateInvitation, + deleteGroup as organizationsDeleteGroup, + get as organizationsGet, + getGroups as organizationsGetGroups, + getPlans as organizationsGetPlans, + getSubscriptionPlans as organizationsGetSubscriptionPlans, + getUsers as organizationsGetUsers, + getWorkspaces as organizationsGetWorkspaces, + update as organizationsUpdate, +} from './organizations'; +import { + addUser as projectsAddUser, + create as projectsCreate, + deleteGroup as projectsDeleteGroup, + get as projectsGet, + list as projectsList, + remove as projectsRemove, + update as projectsUpdate, +} from './projects'; +import { + getCountries as referenceGetCountries, + getCountrySubdivisions as referenceGetCountrySubdivisions, + getCurrencies as referenceGetCurrencies, + getKeys as referenceGetKeys, + getTimezoneOffsets as referenceGetTimezoneOffsets, + getTimezones as referenceGetTimezones, +} from './reference'; +import { + sendContact as smailSendContact, + sendDemo as smailSendDemo, + sendMeet as smailSendMeet, +} from './smail'; +import { + create as tagsCreate, + list as tagsList, + remove as tagsRemove, + update as tagsUpdate, +} from './tags'; +import { + create as tasksCreate, + get as tasksGet, + list as tasksList, + remove as tasksRemove, + update as tasksUpdate, +} from './tasks'; +import { + bulkEdit as timeEntriesBulkEdit, + create as timeEntriesCreate, + get as timeEntriesGet, + getCurrent as timeEntriesGetCurrent, + list as timeEntriesList, + remove as timeEntriesRemove, + stop as timeEntriesStop, + update as timeEntriesUpdate, +} from './time-entries'; +import { + deleteSubscription as webhooksDeleteSubscription, + getEventFilters as webhooksGetEventFilters, + getStatus as webhooksGetStatus, + listSubscriptions as webhooksListSubscriptions, +} from './webhook-subscriptions'; +import { + get as workspacesGet, + getLogo as workspacesGetLogo, + getPreferences as workspacesGetPreferences, + getUsers as workspacesGetUsers, + list as workspacesList, + update as workspacesUpdate, +} from './workspaces'; + +export const Me = { + get: meGet, + update: meUpdate, + getPreferences: meGetPreferences, + updatePreferences: meUpdatePreferences, + getLogged: meGetLogged, + getLocation: meGetLocation, + getQuota: meGetQuota, + getClients: meGetClients, + getProjects: meGetProjects, + getTags: meGetTags, + getTasks: meGetTasks, + disableProductEmails: meDisableProductEmails, + disableWeeklyReport: meDisableWeeklyReport, +}; + +export const Workspaces = { + list: workspacesList, + get: workspacesGet, + update: workspacesUpdate, + getUsers: workspacesGetUsers, + getLogo: workspacesGetLogo, + getPreferences: workspacesGetPreferences, +}; + +export const Organizations = { + get: organizationsGet, + update: organizationsUpdate, + getWorkspaces: organizationsGetWorkspaces, + create: organizationsCreate, + getGroups: organizationsGetGroups, + createGroup: organizationsCreateGroup, + deleteGroup: organizationsDeleteGroup, + getUsers: organizationsGetUsers, + createInvitation: organizationsCreateInvitation, + getPlans: organizationsGetPlans, + getSubscriptionPlans: organizationsGetSubscriptionPlans, +}; + +export const Clients = { + list: clientsList, + get: clientsGet, + create: clientsCreate, + update: clientsUpdate, + archive: clientsArchive, + delete: clientsRemove, +}; + +export const Projects = { + list: projectsList, + get: projectsGet, + create: projectsCreate, + update: projectsUpdate, + delete: projectsRemove, + addUser: projectsAddUser, + deleteGroup: projectsDeleteGroup, +}; + +export const Tasks = { + list: tasksList, + get: tasksGet, + create: tasksCreate, + update: tasksUpdate, + delete: tasksRemove, +}; + +export const Tags = { + list: tagsList, + create: tagsCreate, + update: tagsUpdate, + delete: tagsRemove, +}; + +export const TimeEntries = { + list: timeEntriesList, + getCurrent: timeEntriesGetCurrent, + get: timeEntriesGet, + create: timeEntriesCreate, + update: timeEntriesUpdate, + stop: timeEntriesStop, + delete: timeEntriesRemove, + bulkEdit: timeEntriesBulkEdit, +}; + +export const Reference = { + getCountries: referenceGetCountries, + getCountrySubdivisions: referenceGetCountrySubdivisions, + getCurrencies: referenceGetCurrencies, + getTimezones: referenceGetTimezones, + getTimezoneOffsets: referenceGetTimezoneOffsets, + getKeys: referenceGetKeys, +}; + +export const Webhooks = { + getStatus: webhooksGetStatus, + getEventFilters: webhooksGetEventFilters, + listSubscriptions: webhooksListSubscriptions, + deleteSubscription: webhooksDeleteSubscription, +}; + +export const Smail = { + sendDemo: smailSendDemo, + sendContact: smailSendContact, + sendMeet: smailSendMeet, +}; + +export * from './types'; diff --git a/packages/toggl/endpoints/logging.ts b/packages/toggl/endpoints/logging.ts new file mode 100644 index 000000000..54741dd2f --- /dev/null +++ b/packages/toggl/endpoints/logging.ts @@ -0,0 +1,31 @@ +/** + * Builds the payload recorded in `corsair_events`. + * + * `logEventFromContext` persists whatever it is handed, and those rows inherit + * the event log's retention. Spreading a raw endpoint input would therefore + * park user-authored content — time entry descriptions, client and project + * names, profile email and full name — in the log indefinitely. + * + * So only explicitly named identifier fields are recorded. The names of the + * other supplied fields are kept, without their values, so an operator can + * still see what a call attempted to change. + */ +export function auditPayload>( + input: T, + identifierKeys: readonly (keyof T & string)[], +): Record { + const payload: Record = {}; + + for (const key of identifierKeys) { + if (input[key] !== undefined) { + payload[key] = input[key]; + } + } + + const supplied = Object.keys(input).filter((key) => input[key] !== undefined); + if (supplied.length > 0) { + payload.fields = supplied; + } + + return payload; +} diff --git a/packages/toggl/endpoints/me.ts b/packages/toggl/endpoints/me.ts new file mode 100644 index 000000000..871019703 --- /dev/null +++ b/packages/toggl/endpoints/me.ts @@ -0,0 +1,293 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheClient, cacheProject, cacheTag } from './persist'; +import type { TogglEndpointOutputs } from './types'; + +/** + * Toggl's `/me` response carries `api_token` — the caller's reusable, + * non-expiring account credential. Returning it would hand a full-account key + * to anything allowed to read a profile, so it is dropped before the result + * leaves the plugin. + */ +function withoutCredentials( + user: TogglEndpointOutputs['meGet'] & { api_token?: unknown }, +): TogglEndpointOutputs['meGet'] { + const { api_token: _discarded, ...safe } = user; + return safe; +} + +/** Reads the authenticated user's profile with the account credential stripped. */ +export const get: TogglEndpoints['meGet'] = async (ctx, input) => { + const result = await makeTogglRequest( + 'me', + ctx.key, + { + method: 'GET', + query: { with_related_data: input.with_related_data }, + }, + ); + + await logEventFromContext( + ctx, + 'toggl.me.get', + auditPayload(input, ['with_related_data']), + 'completed', + ); + return withoutCredentials(result); +}; + +/** Updates the authenticated user's profile, answering without the credential. */ +export const update: TogglEndpoints['meUpdate'] = async (ctx, input) => { + const result = await makeTogglRequest( + 'me', + ctx.key, + { + method: 'PUT', + body: { + fullname: input.fullname, + email: input.email, + timezone: input.timezone, + beginning_of_week: input.beginning_of_week, + default_workspace_id: input.default_workspace_id, + }, + }, + ); + + // email and fullname are personal data; only the ids are recorded. + await logEventFromContext( + ctx, + 'toggl.me.update', + auditPayload(input, ['default_workspace_id']), + 'completed', + ); + return withoutCredentials(result); +}; + +/** Reads the caller's display, notification and alpha-feature preferences. */ +export const getPreferences: TogglEndpoints['meGetPreferences'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['meGetPreferences'] + >('me/preferences', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.me.getPreferences', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** Updates the caller's preferences. */ +export const updatePreferences: TogglEndpoints['meUpdatePreferences'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['meUpdatePreferences'] + >('me/preferences', ctx.key, { + method: 'POST', + body: { + timeofday_format: input.timeofday_format, + date_format: input.date_format, + duration_format: input.duration_format, + }, + }); + + await logEventFromContext( + ctx, + 'toggl.me.updatePreferences', + auditPayload(input, ['timeofday_format', 'date_format', 'duration_format']), + 'completed', + ); + return result; +}; + +/** Confirms the token is valid; Toggl answers 200 with an empty body. */ +export const getLogged: TogglEndpoints['meGetLogged'] = async (ctx, input) => { + await makeTogglRequest('me/logged', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.me.getLogged', + auditPayload(input, []), + 'completed', + ); + return { ok: true }; +}; + +/** Reads the location Toggl last inferred for the caller from its request IP. */ +export const getLocation: TogglEndpoints['meGetLocation'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest( + 'me/location', + ctx.key, + { method: 'GET' }, + ); + + // The response is geolocation data about the user; nothing of it is logged. + await logEventFromContext( + ctx, + 'toggl.me.getLocation', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** Reads the caller's remaining API quota, one record per organization. */ +export const getQuota: TogglEndpoints['meGetQuota'] = async (ctx, input) => { + const result = await makeTogglRequest( + 'me/quota', + ctx.key, + { method: 'GET' }, + ); + + await logEventFromContext( + ctx, + 'toggl.me.getQuota', + auditPayload(input, []), + 'completed', + ); + return result ?? []; +}; + +/** Lists every client the caller can reach, across all their workspaces. */ +export const getClients: TogglEndpoints['meGetClients'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest( + 'me/clients', + ctx.key, + { method: 'GET', query: { since: input.since } }, + ); + + const clients = result ?? []; + + // These are the same records the workspace-scoped list returns, so they feed + // the cache the same way; otherwise reading via /me would leave it stale. + for (const client of clients) { + await cacheClient(ctx.db.clients, client); + } + + await logEventFromContext( + ctx, + 'toggl.me.getClients', + auditPayload(input, ['since']), + 'completed', + ); + return clients; +}; + +/** Lists every project the caller can reach, across all their workspaces. */ +export const getProjects: TogglEndpoints['meGetProjects'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest( + 'me/projects', + ctx.key, + { method: 'GET', query: { since: input.since } }, + ); + + const projects = result ?? []; + + for (const project of projects) { + await cacheProject(ctx.db.projects, project); + } + + await logEventFromContext( + ctx, + 'toggl.me.getProjects', + auditPayload(input, ['since']), + 'completed', + ); + return projects; +}; + +/** Lists every tag the caller can reach, across all their workspaces. */ +export const getTags: TogglEndpoints['meGetTags'] = async (ctx, input) => { + const result = await makeTogglRequest( + 'me/tags', + ctx.key, + { method: 'GET', query: { since: input.since } }, + ); + + const tags = result ?? []; + + for (const tag of tags) { + await cacheTag(ctx.db.tags, tag); + } + + await logEventFromContext( + ctx, + 'toggl.me.getTags', + auditPayload(input, ['since']), + 'completed', + ); + return tags; +}; + +/** Lists every task the caller can reach, across all their workspaces. */ +export const getTasks: TogglEndpoints['meGetTasks'] = async (ctx, input) => { + const result = await makeTogglRequest( + 'me/tasks', + ctx.key, + { method: 'GET', query: { since: input.since } }, + ); + + await logEventFromContext( + ctx, + 'toggl.me.getTasks', + auditPayload(input, ['since']), + 'completed', + ); + return result ?? []; +}; + +/** + * Unsubscribes the account from Toggl product emails using a code taken from an + * unsubscribe link. Never exercised by the live suite — it would opt the test + * account out of Toggl's mail for real. + */ +export const disableProductEmails: TogglEndpoints['meDisableProductEmails'] = + async (ctx, input) => { + await makeTogglRequest('me/disable_product_emails', ctx.key, { + method: 'POST', + body: { disable_code: input.disable_code }, + }); + + // The unsubscribe code acts as a bearer secret; keep it out of the log. + await logEventFromContext( + ctx, + 'toggl.me.disableProductEmails', + auditPayload(input, []), + 'completed', + ); + return { ok: true }; + }; + +/** As above, for the weekly report email. Also never live-tested. */ +export const disableWeeklyReport: TogglEndpoints['meDisableWeeklyReport'] = + async (ctx, input) => { + await makeTogglRequest('me/disable_weekly_report', ctx.key, { + method: 'POST', + body: { code: input.code }, + }); + + await logEventFromContext( + ctx, + 'toggl.me.disableWeeklyReport', + auditPayload(input, []), + 'completed', + ); + return { ok: true }; + }; diff --git a/packages/toggl/endpoints/organizations.ts b/packages/toggl/endpoints/organizations.ts new file mode 100644 index 000000000..66fcc5b75 --- /dev/null +++ b/packages/toggl/endpoints/organizations.ts @@ -0,0 +1,242 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheWorkspace } from './persist'; +import type { TogglEndpointOutputs } from './types'; + +/** Reads an organization, including its pricing plan and trial state. */ +export const get: TogglEndpoints['organizationsGet'] = async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsGet'] + >(`organizations/${input.organization_id}`, ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.organizations.get', + auditPayload(input, ['organization_id']), + 'completed', + ); + return result; +}; + +/** Renames an organization. */ +export const update: TogglEndpoints['organizationsUpdate'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsUpdate'] + >(`organizations/${input.organization_id}`, ctx.key, { + method: 'PUT', + body: { name: input.name }, + }); + + await logEventFromContext( + ctx, + 'toggl.organizations.update', + auditPayload(input, ['organization_id']), + 'completed', + ); + return result; +}; + +export const getWorkspaces: TogglEndpoints['organizationsGetWorkspaces'] = + async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsGetWorkspaces'] + >(`organizations/${input.organization_id}/workspaces`, ctx.key, { + method: 'GET', + }); + + const workspaces = result ?? []; + + // Same records as workspaces.list, so they populate the cache identically. + for (const workspace of workspaces) { + await cacheWorkspace(ctx.db.workspaces, workspace); + } + + await logEventFromContext( + ctx, + 'toggl.organizations.getWorkspaces', + auditPayload(input, ['organization_id']), + 'completed', + ); + return workspaces; + }; + +/** + * Creates an organization and its default workspace in one call. The + * authenticated user becomes the owner. + */ +export const create: TogglEndpoints['organizationsCreate'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsCreate'] + >('organizations', ctx.key, { + method: 'POST', + body: { name: input.name, workspace_name: input.workspace_name }, + }); + + await logEventFromContext( + ctx, + 'toggl.organizations.create', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** Lists an organization's groups with their members and workspace assignments. */ +export const getGroups: TogglEndpoints['organizationsGetGroups'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsGetGroups'] + >(`organizations/${input.organization_id}/groups`, ctx.key, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'toggl.organizations.getGroups', + auditPayload(input, ['organization_id']), + 'completed', + ); + return result ?? []; +}; + +/** Creates a group in an organization. */ +export const createGroup: TogglEndpoints['organizationsCreateGroup'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsCreateGroup'] + >(`organizations/${input.organization_id}/groups`, ctx.key, { + method: 'POST', + body: { name: input.name }, + }); + + await logEventFromContext( + ctx, + 'toggl.organizations.createGroup', + auditPayload(input, ['organization_id']), + 'completed', + ); + return result; +}; + +/** Deletes a group along with the permissions attached to it. */ +export const deleteGroup: TogglEndpoints['organizationsDeleteGroup'] = async ( + ctx, + input, +) => { + await makeTogglRequest( + `organizations/${input.organization_id}/groups/${input.group_id}`, + ctx.key, + { method: 'DELETE' }, + ); + + await logEventFromContext( + ctx, + 'toggl.organizations.deleteGroup', + auditPayload(input, ['organization_id', 'group_id']), + 'completed', + ); + return { deleted: true, id: input.group_id }; +}; + +/** Lists an organization's users, with filtering by name, status and role. */ +export const getUsers: TogglEndpoints['organizationsGetUsers'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsGetUsers'] + >(`organizations/${input.organization_id}/users`, ctx.key, { + method: 'GET', + query: { + filter: input.filter, + active: input.active, + only_admins: input.only_admins, + groups: input.groups, + page: input.page, + per_page: input.per_page, + }, + }); + + // The filter can carry a name or email; it is not recorded. + await logEventFromContext( + ctx, + 'toggl.organizations.getUsers', + auditPayload(input, ['organization_id', 'active', 'only_admins', 'page']), + 'completed', + ); + return result ?? []; +}; + +/** + * Invites people to an organization. Toggl sends the invitation email unless + * `prevent_email_notification` is set, so this is never live-tested. + */ +export const createInvitation: TogglEndpoints['organizationsCreateInvitation'] = + async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsCreateInvitation'] + >(`organizations/${input.organization_id}/invitations`, ctx.key, { + method: 'POST', + body: { + emails: input.emails, + workspaces: input.workspaces, + prevent_email_notification: input.prevent_email_notification, + }, + }); + + // Invitee email addresses are personal data and stay out of the log. + await logEventFromContext( + ctx, + 'toggl.organizations.createInvitation', + auditPayload(input, ['organization_id']), + 'completed', + ); + return result ?? {}; + }; + +/** Lists the plans available to a specific organization. */ +export const getPlans: TogglEndpoints['organizationsGetPlans'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsGetPlans'] + >(`organizations/${input.organization_id}/plans`, ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.organizations.getPlans', + auditPayload(input, ['organization_id']), + 'completed', + ); + return result ?? {}; +}; + +export const getSubscriptionPlans: TogglEndpoints['organizationsGetSubscriptionPlans'] = + async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['organizationsGetSubscriptionPlans'] + >(`organizations/${input.organization_id}/subscription_plans`, ctx.key, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'toggl.organizations.getSubscriptionPlans', + auditPayload(input, ['organization_id']), + 'completed', + ); + return result ?? {}; + }; diff --git a/packages/toggl/endpoints/persist.ts b/packages/toggl/endpoints/persist.ts new file mode 100644 index 000000000..39ff1c7e0 --- /dev/null +++ b/packages/toggl/endpoints/persist.ts @@ -0,0 +1,141 @@ +import type { + TogglClientEntity, + TogglProjectEntity, + TogglTagEntity, + TogglWorkspaceEntity, +} from '../schema/database'; +import type { + TogglClient, + TogglProject, + TogglTag, + TogglWorkspace, +} from './types'; + +/** + * Minimal structural view of a Corsair entity store. Only the two operations + * the Toggl endpoints need are declared, so the helpers below stay usable + * whatever else the concrete store exposes. + */ +type EntityStore = { + upsertByEntityId: (entityId: string, data: T) => Promise; + deleteByEntityId?: (entityId: string) => Promise; +}; + +/** + * Caching is best-effort: a plugin call must not fail because the local mirror + * could not be written. Failures are warned about and swallowed, matching the + * behaviour of the other provider plugins. + */ +async function safely(operation: () => Promise, what: string) { + try { + await operation(); + } catch (error) { + console.warn(`[TOGGL] failed to cache ${what}:`, error); + } +} + +/** Mirrors a workspace into the local cache. */ +export async function cacheWorkspace( + store: EntityStore | undefined, + workspace: TogglWorkspace | undefined | null, +) { + if (!store || !workspace) return; + await safely( + () => + store.upsertByEntityId(String(workspace.id), { + id: workspace.id, + organization_id: workspace.organization_id, + name: workspace.name, + premium: workspace.premium, + role: workspace.role, + default_currency: workspace.default_currency, + at: workspace.at ? new Date(workspace.at) : null, + }), + `workspace ${workspace.id}`, + ); +} + +/** + * Mirrors a client into the local cache, mapping Toggl's `wid` onto + * `workspace_id`. + */ +export async function cacheClient( + store: EntityStore | undefined, + client: TogglClient | undefined | null, +) { + if (!store || !client) return; + await safely( + () => + store.upsertByEntityId(String(client.id), { + id: client.id, + // Toggl names the workspace id `wid` on client payloads. + workspace_id: client.wid, + name: client.name, + archived: client.archived, + at: client.at ? new Date(client.at) : null, + }), + `client ${client.id}`, + ); +} + +/** Mirrors a project into the local cache. */ +export async function cacheProject( + store: EntityStore | undefined, + project: TogglProject | undefined | null, +) { + if (!store || !project) return; + await safely( + () => + store.upsertByEntityId(String(project.id), { + id: project.id, + workspace_id: project.workspace_id, + client_id: project.client_id, + name: project.name, + active: project.active, + billable: project.billable, + color: project.color, + at: project.at ? new Date(project.at) : null, + }), + `project ${project.id}`, + ); +} + +/** Mirrors a tag into the local cache. */ +export async function cacheTag( + store: EntityStore | undefined, + tag: TogglTag | undefined | null, +) { + if (!store || !tag) return; + await safely( + () => + store.upsertByEntityId(String(tag.id), { + id: tag.id, + workspace_id: tag.workspace_id, + name: tag.name, + at: tag.at ? new Date(tag.at) : null, + }), + `tag ${tag.id}`, + ); +} + +/** + * Drops a cached record after the provider confirmed the delete. + * + * This takes only the delete half of the store: referencing the upsert + * signature here would make the parameter invariant in the entity type and + * reject the concrete per-entity clients. + */ +type DeletableStore = { + deleteByEntityId?: (entityId: string) => Promise; +}; + +/** Drops a cached record once the provider confirmed the delete. */ +export async function evictEntity( + store: DeletableStore | undefined, + id: number, + what: string, +) { + const remove = store?.deleteByEntityId; + if (!remove) return; + await safely(() => remove(String(id)), `${what} ${id}`); +} diff --git a/packages/toggl/endpoints/projects.ts b/packages/toggl/endpoints/projects.ts new file mode 100644 index 000000000..03cc92448 --- /dev/null +++ b/packages/toggl/endpoints/projects.ts @@ -0,0 +1,199 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheProject, evictEntity } from './persist'; +import type { TogglEndpointOutputs } from './types'; + +/** Lists a workspace's projects and mirrors each into the cache. */ +export const list: TogglEndpoints['projectsList'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/projects`, + ctx.key, + { + method: 'GET', + query: { + active: input.active, + name: input.name, + page: input.page, + per_page: input.per_page, + }, + }, + ); + + const projects = result ?? []; + + for (const project of projects) { + await cacheProject(ctx.db.projects, project); + } + + await logEventFromContext( + ctx, + 'toggl.projects.list', + auditPayload(input, ['workspace_id', 'active', 'page', 'per_page']), + 'completed', + ); + return projects; +}; + +/** Reads a single project and refreshes its cached copy. */ +export const get: TogglEndpoints['projectsGet'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/projects/${input.project_id}`, + ctx.key, + { method: 'GET' }, + ); + + await cacheProject(ctx.db.projects, result); + + await logEventFromContext( + ctx, + 'toggl.projects.get', + auditPayload(input, ['workspace_id', 'project_id']), + 'completed', + ); + return result; +}; + +/** + * Creates a project in a workspace. Colours, templates and rates are + * accepted but only honoured on paid Toggl plans. + */ +export const create: TogglEndpoints['projectsCreate'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/projects`, + ctx.key, + { + method: 'POST', + body: { + name: input.name, + client_id: input.client_id, + active: input.active, + is_private: input.is_private, + billable: input.billable, + color: input.color, + start_date: input.start_date, + end_date: input.end_date, + estimated_hours: input.estimated_hours, + }, + }, + ); + + await cacheProject(ctx.db.projects, result); + + await logEventFromContext( + ctx, + 'toggl.projects.create', + auditPayload(input, [ + 'workspace_id', + 'client_id', + 'active', + 'is_private', + 'billable', + ]), + 'completed', + ); + return result; +}; + +/** Updates a project's name, client, visibility, billing or estimate. */ +export const update: TogglEndpoints['projectsUpdate'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/projects/${input.project_id}`, + ctx.key, + { + method: 'PUT', + body: { + name: input.name, + client_id: input.client_id, + active: input.active, + is_private: input.is_private, + billable: input.billable, + color: input.color, + }, + }, + ); + + await cacheProject(ctx.db.projects, result); + + await logEventFromContext( + ctx, + 'toggl.projects.update', + auditPayload(input, [ + 'workspace_id', + 'project_id', + 'client_id', + 'active', + 'is_private', + 'billable', + ]), + 'completed', + ); + return result; +}; + +/** Deletes a project and evicts it from the cache. */ +export const remove: TogglEndpoints['projectsDelete'] = async (ctx, input) => { + await makeTogglRequest( + `workspaces/${input.workspace_id}/projects/${input.project_id}`, + ctx.key, + { method: 'DELETE' }, + ); + + await evictEntity(ctx.db.projects, input.project_id, 'project'); + + await logEventFromContext( + ctx, + 'toggl.projects.delete', + auditPayload(input, ['workspace_id', 'project_id']), + 'completed', + ); + return { deleted: true, id: input.project_id }; +}; + +/** Assigns a user to a project, optionally as a manager and with a rate. */ +export const addUser: TogglEndpoints['projectsAddUser'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['projectsAddUser'] + >(`workspaces/${input.workspace_id}/project_users`, ctx.key, { + method: 'POST', + body: { + project_id: input.project_id, + user_id: input.user_id, + manager: input.manager, + rate: input.rate, + labour_cost: input.labour_cost, + }, + }); + + await logEventFromContext( + ctx, + 'toggl.projects.addUser', + auditPayload(input, ['workspace_id', 'project_id', 'user_id', 'manager']), + 'completed', + ); + return result; +}; + +/** Removes a project group from a workspace. */ +export const deleteGroup: TogglEndpoints['projectsDeleteGroup'] = async ( + ctx, + input, +) => { + await makeTogglRequest( + `workspaces/${input.workspace_id}/project_groups/${input.project_group_id}`, + ctx.key, + { method: 'DELETE' }, + ); + + await logEventFromContext( + ctx, + 'toggl.projects.deleteGroup', + auditPayload(input, ['workspace_id', 'project_group_id']), + 'completed', + ); + return { deleted: true, id: input.project_group_id }; +}; diff --git a/packages/toggl/endpoints/reference.ts b/packages/toggl/endpoints/reference.ts new file mode 100644 index 000000000..36187594f --- /dev/null +++ b/packages/toggl/endpoints/reference.ts @@ -0,0 +1,115 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import type { TogglEndpointOutputs } from './types'; + +/** + * Static reference data. These endpoints describe Toggl itself rather than the + * authenticated account, so nothing here is workspace-scoped and none of it is + * worth persisting locally. + */ + +/** Lists the countries Toggl supports, with VAT and currency defaults. */ +export const getCountries: TogglEndpoints['referenceGetCountries'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['referenceGetCountries'] + >('countries', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.reference.getCountries', + auditPayload(input, []), + 'completed', + ); + return result ?? []; +}; + +export const getCountrySubdivisions: TogglEndpoints['referenceGetCountrySubdivisions'] = + async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['referenceGetCountrySubdivisions'] + >(`countries/${input.country_id}/subdivisions`, ctx.key, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'toggl.reference.getCountrySubdivisions', + auditPayload(input, ['country_id']), + 'completed', + ); + return result ?? []; + }; + +/** Lists the currencies Toggl supports. */ +export const getCurrencies: TogglEndpoints['referenceGetCurrencies'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['referenceGetCurrencies'] + >('currencies', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.reference.getCurrencies', + auditPayload(input, []), + 'completed', + ); + return result ?? []; +}; + +/** Lists the timezone names Toggl accepts. */ +export const getTimezones: TogglEndpoints['referenceGetTimezones'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['referenceGetTimezones'] + >('timezones', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.reference.getTimezones', + auditPayload(input, []), + 'completed', + ); + return result ?? []; +}; + +export const getTimezoneOffsets: TogglEndpoints['referenceGetTimezoneOffsets'] = + async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['referenceGetTimezoneOffsets'] + >('timezones/offsets', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.reference.getTimezoneOffsets', + auditPayload(input, []), + 'completed', + ); + return result ?? []; + }; + +/** JWKS keyset for verifying the signature on Toggl-issued JWTs. */ +export const getKeys: TogglEndpoints['referenceGetKeys'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['referenceGetKeys'] + >('keys', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.reference.getKeys', + auditPayload(input, []), + 'completed', + ); + return result; +}; diff --git a/packages/toggl/endpoints/smail.ts b/packages/toggl/endpoints/smail.ts new file mode 100644 index 000000000..f0a92e739 --- /dev/null +++ b/packages/toggl/endpoints/smail.ts @@ -0,0 +1,80 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import type { TogglEndpointOutputs } from './types'; + +/** + * Toggl's transactional mail ("smail") endpoints. + * + * Every operation here causes Toggl to send a real email, so they are marked + * `write` and are covered by mocked tests only — the live integration suite + * deliberately never calls them. Recipient addresses and message bodies are + * kept out of the event log for the same reason the profile endpoints are. + */ + +/** Requests a product demo through Toggl's transactional mail service. */ +export const sendDemo: TogglEndpoints['smailSendDemo'] = async (ctx, input) => { + await makeTogglRequest('smail/demo', ctx.key, { + method: 'POST', + body: { + email: input.email, + name: input.name, + company: input.company, + message: input.message, + }, + }); + + await logEventFromContext( + ctx, + 'toggl.smail.sendDemo', + auditPayload(input, []), + 'completed', + ); + return { ok: true } satisfies TogglEndpointOutputs['smailSendDemo']; +}; + +/** Sends a message to a named contact through Toggl's mail service. */ +export const sendContact: TogglEndpoints['smailSendContact'] = async ( + ctx, + input, +) => { + await makeTogglRequest('smail/contact', ctx.key, { + method: 'POST', + body: { + email: input.email, + name: input.name, + message: input.message, + }, + }); + + await logEventFromContext( + ctx, + 'toggl.smail.sendContact', + auditPayload(input, []), + 'completed', + ); + return { ok: true } satisfies TogglEndpointOutputs['smailSendContact']; +}; + +/** Sends a meeting invitation through Toggl's mail service. */ +export const sendMeet: TogglEndpoints['smailSendMeet'] = async (ctx, input) => { + await makeTogglRequest('smail/meet', ctx.key, { + method: 'POST', + body: { + email: input.email, + name: input.name, + location: input.location, + }, + }); + + await logEventFromContext( + ctx, + 'toggl.smail.sendMeet', + // A meeting location can carry a street address, so it is recorded as a + // supplied field name only — never as an identifier value. + auditPayload(input, []), + 'completed', + ); + return { ok: true } satisfies TogglEndpointOutputs['smailSendMeet']; +}; diff --git a/packages/toggl/endpoints/tags.ts b/packages/toggl/endpoints/tags.ts new file mode 100644 index 000000000..a38b43698 --- /dev/null +++ b/packages/toggl/endpoints/tags.ts @@ -0,0 +1,99 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheTag, evictEntity } from './persist'; +import type { TogglEndpointOutputs } from './types'; + +/** Lists a workspace's tags and mirrors each into the cache. */ +export const list: TogglEndpoints['tagsList'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/tags`, + ctx.key, + { + method: 'GET', + query: { + page: input.page, + per_page: input.per_page, + search: input.search, + }, + }, + ); + + const tags = result ?? []; + + for (const tag of tags) { + await cacheTag(ctx.db.tags, tag); + } + + await logEventFromContext( + ctx, + 'toggl.tags.list', + auditPayload(input, ['workspace_id', 'page', 'per_page']), + 'completed', + ); + return tags; +}; + +/** Creates a tag in a workspace. */ +export const create: TogglEndpoints['tagsCreate'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/tags`, + ctx.key, + { + method: 'POST', + body: { name: input.name }, + }, + ); + + await cacheTag(ctx.db.tags, result); + + await logEventFromContext( + ctx, + 'toggl.tags.create', + auditPayload(input, ['workspace_id']), + 'completed', + ); + return result; +}; + +/** Renames a tag. */ +export const update: TogglEndpoints['tagsUpdate'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/tags/${input.tag_id}`, + ctx.key, + { + method: 'PUT', + body: { name: input.name }, + }, + ); + + await cacheTag(ctx.db.tags, result); + + await logEventFromContext( + ctx, + 'toggl.tags.update', + auditPayload(input, ['workspace_id', 'tag_id']), + 'completed', + ); + return result; +}; + +/** Deletes a tag and evicts it from the cache. */ +export const remove: TogglEndpoints['tagsDelete'] = async (ctx, input) => { + await makeTogglRequest( + `workspaces/${input.workspace_id}/tags/${input.tag_id}`, + ctx.key, + { method: 'DELETE' }, + ); + + await evictEntity(ctx.db.tags, input.tag_id, 'tag'); + + await logEventFromContext( + ctx, + 'toggl.tags.delete', + auditPayload(input, ['workspace_id', 'tag_id']), + 'completed', + ); + return { deleted: true, id: input.tag_id }; +}; diff --git a/packages/toggl/endpoints/tasks.ts b/packages/toggl/endpoints/tasks.ts new file mode 100644 index 000000000..fcd6f8fd2 --- /dev/null +++ b/packages/toggl/endpoints/tasks.ts @@ -0,0 +1,147 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import type { TogglEndpointOutputs } from './types'; + +/** + * Lists tasks in a workspace, or within one project when `project_id` is given. + * Only the workspace-wide route paginates. + */ +export const list: TogglEndpoints['tasksList'] = async (ctx, input) => { + // Project-scoped reads return a bare array; the workspace-wide route wraps + // the same records in a paginated envelope. + const projectScoped = input.project_id !== undefined; + const path = projectScoped + ? `workspaces/${input.workspace_id}/projects/${input.project_id}/tasks` + : `workspaces/${input.workspace_id}/tasks`; + + const result = await makeTogglRequest< + | TogglEndpointOutputs['tasksList'] + | { data?: TogglEndpointOutputs['tasksList'] | null } + | null + >(path, ctx.key, { + method: 'GET', + // Only the workspace-wide route paginates; the project-scoped one + // documents `active` alone, so sending page params there would imply a + // narrowing the API does not apply. + query: projectScoped + ? { active: input.active } + : { + active: input.active, + page: input.page, + per_page: input.per_page, + }, + }); + + const tasks = Array.isArray(result) ? result : (result?.data ?? []); + + await logEventFromContext( + ctx, + 'toggl.tasks.list', + auditPayload(input, [ + 'workspace_id', + 'project_id', + 'active', + 'page', + 'per_page', + ]), + 'completed', + ); + return tasks; +}; + +/** Reads a single task within a project. */ +export const get: TogglEndpoints['tasksGet'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/projects/${input.project_id}/tasks/${input.task_id}`, + ctx.key, + { method: 'GET' }, + ); + + await logEventFromContext( + ctx, + 'toggl.tasks.get', + auditPayload(input, ['workspace_id', 'project_id', 'task_id']), + 'completed', + ); + return result; +}; + +/** Creates a task under a project. */ +export const create: TogglEndpoints['tasksCreate'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/projects/${input.project_id}/tasks`, + ctx.key, + { + method: 'POST', + body: { + name: input.name, + active: input.active, + estimated_seconds: input.estimated_seconds, + user_id: input.user_id, + }, + }, + ); + + await logEventFromContext( + ctx, + 'toggl.tasks.create', + auditPayload(input, [ + 'workspace_id', + 'project_id', + 'active', + 'estimated_seconds', + 'user_id', + ]), + 'completed', + ); + return result; +}; + +/** Updates a task's name, active state or estimate. */ +export const update: TogglEndpoints['tasksUpdate'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}/projects/${input.project_id}/tasks/${input.task_id}`, + ctx.key, + { + method: 'PUT', + body: { + name: input.name, + active: input.active, + estimated_seconds: input.estimated_seconds, + }, + }, + ); + + await logEventFromContext( + ctx, + 'toggl.tasks.update', + auditPayload(input, [ + 'workspace_id', + 'project_id', + 'task_id', + 'active', + 'estimated_seconds', + ]), + 'completed', + ); + return result; +}; + +/** Deletes a task. */ +export const remove: TogglEndpoints['tasksDelete'] = async (ctx, input) => { + await makeTogglRequest( + `workspaces/${input.workspace_id}/projects/${input.project_id}/tasks/${input.task_id}`, + ctx.key, + { method: 'DELETE' }, + ); + + await logEventFromContext( + ctx, + 'toggl.tasks.delete', + auditPayload(input, ['workspace_id', 'project_id', 'task_id']), + 'completed', + ); + return { deleted: true, id: input.task_id }; +}; diff --git a/packages/toggl/endpoints/time-entries.ts b/packages/toggl/endpoints/time-entries.ts new file mode 100644 index 000000000..d437fb673 --- /dev/null +++ b/packages/toggl/endpoints/time-entries.ts @@ -0,0 +1,221 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import type { TogglEndpointOutputs } from './types'; + +/** Lists the caller's time entries, by date range or modification time. */ +export const list: TogglEndpoints['timeEntriesList'] = async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['timeEntriesList'] + >('me/time_entries', ctx.key, { + method: 'GET', + query: { + start_date: input.start_date, + end_date: input.end_date, + since: input.since, + before: input.before, + meta: input.meta, + }, + }); + + const entries = result ?? []; + + await logEventFromContext( + ctx, + 'toggl.timeEntries.list', + auditPayload(input, ['start_date', 'end_date', 'since', 'before', 'meta']), + 'completed', + ); + return entries; +}; + +/** Reads the caller's running timer, or null when none is running. */ +export const getCurrent: TogglEndpoints['timeEntriesGetCurrent'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['timeEntriesGetCurrent'] + >('me/time_entries/current', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.timeEntries.getCurrent', + auditPayload(input, []), + 'completed', + ); + // Toggl returns null when no timer is running. + return result ?? null; +}; + +/** Reads one of the caller's time entries by id. */ +export const get: TogglEndpoints['timeEntriesGet'] = async (ctx, input) => { + const result = await makeTogglRequest( + `me/time_entries/${input.time_entry_id}`, + ctx.key, + { method: 'GET' }, + ); + + await logEventFromContext( + ctx, + 'toggl.timeEntries.get', + auditPayload(input, ['time_entry_id']), + 'completed', + ); + return result; +}; + +/** + * Creates a time entry. A negative duration starts a running timer, and + * `created_with` defaults to this plugin when the caller omits it. + */ +export const create: TogglEndpoints['timeEntriesCreate'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['timeEntriesCreate'] + >(`workspaces/${input.workspace_id}/time_entries`, ctx.key, { + method: 'POST', + body: { + description: input.description, + start: input.start, + stop: input.stop, + duration: input.duration, + workspace_id: input.workspace_id, + project_id: input.project_id, + task_id: input.task_id, + billable: input.billable, + tags: input.tags, + tag_ids: input.tag_ids, + // Toggl requires a client identifier on writes. + created_with: input.created_with ?? 'corsair', + }, + }); + + await logEventFromContext( + ctx, + 'toggl.timeEntries.create', + auditPayload(input, [ + 'workspace_id', + 'project_id', + 'task_id', + 'billable', + 'duration', + 'start', + 'stop', + ]), + 'completed', + ); + return result; +}; + +/** Updates a single time entry. */ +export const update: TogglEndpoints['timeEntriesUpdate'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['timeEntriesUpdate'] + >( + `workspaces/${input.workspace_id}/time_entries/${input.time_entry_id}`, + ctx.key, + { + method: 'PUT', + body: { + description: input.description, + start: input.start, + stop: input.stop, + duration: input.duration, + project_id: input.project_id, + task_id: input.task_id, + billable: input.billable, + tags: input.tags, + tag_ids: input.tag_ids, + }, + }, + ); + + await logEventFromContext( + ctx, + 'toggl.timeEntries.update', + auditPayload(input, [ + 'workspace_id', + 'time_entry_id', + 'project_id', + 'task_id', + 'billable', + 'duration', + 'start', + 'stop', + ]), + 'completed', + ); + return result; +}; + +/** Stops a running time entry, fixing its duration. */ +export const stop: TogglEndpoints['timeEntriesStop'] = async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['timeEntriesStop'] + >( + `workspaces/${input.workspace_id}/time_entries/${input.time_entry_id}/stop`, + ctx.key, + { method: 'PATCH' }, + ); + + await logEventFromContext( + ctx, + 'toggl.timeEntries.stop', + auditPayload(input, ['workspace_id', 'time_entry_id']), + 'completed', + ); + return result; +}; + +/** Deletes a time entry. */ +export const remove: TogglEndpoints['timeEntriesDelete'] = async ( + ctx, + input, +) => { + await makeTogglRequest( + `workspaces/${input.workspace_id}/time_entries/${input.time_entry_id}`, + ctx.key, + { method: 'DELETE' }, + ); + + await logEventFromContext( + ctx, + 'toggl.timeEntries.delete', + auditPayload(input, ['workspace_id', 'time_entry_id']), + 'completed', + ); + return { deleted: true, id: input.time_entry_id }; +}; + +/** + * Applies the same JSON Patch operations to many entries at once. Toggl caps a + * request at 100 entries and reports per-entry success and failure rather than + * failing the whole batch. + */ +export const bulkEdit: TogglEndpoints['timeEntriesBulkEdit'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['timeEntriesBulkEdit'] + >( + `workspaces/${input.workspace_id}/time_entries/${input.time_entry_ids.join(',')}`, + ctx.key, + { method: 'PATCH', body: input.operations }, + ); + + await logEventFromContext( + ctx, + 'toggl.timeEntries.bulkEdit', + auditPayload(input, ['workspace_id']), + 'completed', + ); + return result ?? {}; +}; diff --git a/packages/toggl/endpoints/types.ts b/packages/toggl/endpoints/types.ts new file mode 100644 index 000000000..6dceb76a3 --- /dev/null +++ b/packages/toggl/endpoints/types.ts @@ -0,0 +1,1352 @@ +import { z } from 'zod'; + +/** + * Shared entity shapes returned by the Toggl Track API v9. + * + * Toggl returns `null` for a large number of fields rather than omitting them, + * and adds fields over time, so optional/nullable is the norm here rather than + * the exception. + * + * @see https://engineering.toggl.com/docs/ + */ + +/** + * Toggl's `/me` response includes `api_token`, the caller's reusable, + * non-expiring account credential. It is deliberately absent from this schema + * and stripped by the profile endpoints, so it is never handed back to an + * endpoint consumer. + */ +export const TogglUserSchema = z.object({ + id: z.number(), + email: z.string(), + fullname: z.string().nullable().optional(), + timezone: z.string().nullable().optional(), + toggl_accounts_id: z.string().nullable().optional(), + user_account_id: z.number().nullable().optional(), + default_workspace_id: z.number().nullable().optional(), + beginning_of_week: z.number().nullable().optional(), + image_url: z.string().nullable().optional(), + country_id: z.number().nullable().optional(), + has_password: z.boolean().nullable().optional(), + openid_email: z.string().nullable().optional(), + openid_enabled: z.boolean().nullable().optional(), + oauth_providers: z.array(z.string()).nullable().optional(), + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + at: z.string().nullable().optional(), +}); +export type TogglUser = z.infer; + +/** + * Field names here are taken from a live `/me/preferences` response. Toggl + * mixes casing conventions in this payload (`BeginningOfWeek` alongside + * snake_case), and the set varies by plan, so the schema is loose. + */ +export const TogglUserPreferencesSchema = z + .object({ + BeginningOfWeek: z.number().nullable().optional(), + timeofday_format: z.string().nullable().optional(), + date_format: z.string().nullable().optional(), + duration_format: z.string().nullable().optional(), + record_timeline: z.boolean().nullable().optional(), + send_product_emails: z.boolean().nullable().optional(), + send_timer_notifications: z.boolean().nullable().optional(), + send_weekly_report: z.boolean().nullable().optional(), + pg_time_zone_name: z.string().nullable().optional(), + alpha_features: z + .array(z.record(z.string(), z.unknown())) + .nullable() + .optional(), + }) + .loose(); +export type TogglUserPreferences = z.infer; + +export const TogglWorkspaceSchema = z + .object({ + id: z.number(), + organization_id: z.number().nullable().optional(), + name: z.string(), + premium: z.boolean().nullable().optional(), + business_ws: z.boolean().nullable().optional(), + admin: z.boolean().nullable().optional(), + role: z.string().nullable().optional(), + default_currency: z.string().nullable().optional(), + default_hourly_rate: z.number().nullable().optional(), + only_admins_may_create_projects: z.boolean().nullable().optional(), + only_admins_may_create_tags: z.boolean().nullable().optional(), + only_admins_see_billable_rates: z.boolean().nullable().optional(), + rounding: z.number().nullable().optional(), + rounding_minutes: z.number().nullable().optional(), + suspended_at: z.string().nullable().optional(), + server_deleted_at: z.string().nullable().optional(), + logo_url: z.string().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglWorkspace = z.infer; + +export const TogglWorkspaceUserSchema = z + .object({ + id: z.number(), + user_id: z.number().nullable().optional(), + workspace_id: z.number().nullable().optional(), + admin: z.boolean().nullable().optional(), + active: z.boolean().nullable().optional(), + email: z.string().nullable().optional(), + name: z.string().nullable().optional(), + role: z.string().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglWorkspaceUser = z.infer; + +export const TogglOrganizationSchema = z + .object({ + id: z.number(), + name: z.string(), + pricing_plan_id: z.number().nullable().optional(), + created_at: z.string().nullable().optional(), + is_multi_workspace_enabled: z.boolean().nullable().optional(), + max_workspaces: z.number().nullable().optional(), + admin: z.boolean().nullable().optional(), + owner: z.boolean().nullable().optional(), + trial_info: z.unknown().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglOrganization = z.infer; + +export const TogglClientSchema = z + .object({ + id: z.number(), + wid: z.number().nullable().optional(), + name: z.string(), + notes: z.string().nullable().optional(), + archived: z.boolean().nullable().optional(), + creator_id: z.number().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglClient = z.infer; + +export const TogglProjectSchema = z + .object({ + id: z.number(), + workspace_id: z.number().nullable().optional(), + client_id: z.number().nullable().optional(), + name: z.string(), + is_private: z.boolean().nullable().optional(), + active: z.boolean().nullable().optional(), + billable: z.boolean().nullable().optional(), + color: z.string().nullable().optional(), + currency: z.string().nullable().optional(), + rate: z.number().nullable().optional(), + estimated_hours: z.number().nullable().optional(), + actual_hours: z.number().nullable().optional(), + start_date: z.string().nullable().optional(), + end_date: z.string().nullable().optional(), + template: z.boolean().nullable().optional(), + recurring: z.boolean().nullable().optional(), + server_deleted_at: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglProject = z.infer; + +export const TogglTaskSchema = z + .object({ + id: z.number(), + workspace_id: z.number().nullable().optional(), + project_id: z.number().nullable().optional(), + user_id: z.number().nullable().optional(), + name: z.string(), + active: z.boolean().nullable().optional(), + estimated_seconds: z.number().nullable().optional(), + tracked_seconds: z.number().nullable().optional(), + server_deleted_at: z.string().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglTask = z.infer; + +export const TogglTagSchema = z + .object({ + id: z.number(), + workspace_id: z.number().nullable().optional(), + name: z.string(), + creator_id: z.number().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglTag = z.infer; + +export const TogglTimeEntrySchema = z + .object({ + id: z.number(), + workspace_id: z.number().nullable().optional(), + project_id: z.number().nullable().optional(), + task_id: z.number().nullable().optional(), + user_id: z.number().nullable().optional(), + description: z.string().nullable().optional(), + start: z.string(), + stop: z.string().nullable().optional(), + duration: z.number(), + billable: z.boolean().nullable().optional(), + duronly: z.boolean().nullable().optional(), + tags: z.array(z.string()).nullable().optional(), + tag_ids: z.array(z.number()).nullable().optional(), + server_deleted_at: z.string().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglTimeEntry = z.infer; + +export const TogglLocationSchema = z + .object({ + city: z.string().nullable().optional(), + city_lat_long: z.string().nullable().optional(), + state: z.string().nullable().optional(), + country_code: z.string().nullable().optional(), + country_name: z.string().nullable().optional(), + }) + .loose(); +export type TogglLocation = z.infer; + +export const TogglQuotaSchema = z + .object({ + /** Null on the unscoped record Toggl returns alongside per-org quotas. */ + organization_id: z.number().nullable(), + remaining: z.number(), + total: z.number(), + resets_in_secs: z.number().nullable().optional(), + }) + .loose(); +export type TogglQuota = z.infer; + +export const TogglCountrySchema = z + .object({ + id: z.number(), + name: z.string(), + country_code: z.string().nullable().optional(), + vat_applicable: z.boolean().nullable().optional(), + vat_percentage: z.number().nullable().optional(), + vat_regex: z.string().nullable().optional(), + }) + .loose(); +export type TogglCountry = z.infer; + +export const TogglCountrySubdivisionSchema = z + .object({ + country_subdivision_id: z.number().nullable().optional(), + country_id: z.number().nullable().optional(), + name: z.string(), + /** ISO 3166-2 code, e.g. `US-AL`. */ + iso_code: z.string().nullable().optional(), + }) + .loose(); +export type TogglCountrySubdivision = z.infer< + typeof TogglCountrySubdivisionSchema +>; + +export const TogglCurrencySchema = z + .object({ + currency_id: z.number().nullable().optional(), + iso_code: z.string(), + symbol: z.string().nullable().optional(), + }) + .loose(); +export type TogglCurrency = z.infer; + +export const TogglTimezoneOffsetSchema = z + .object({ + name: z.string(), + utc: z.string(), + }) + .loose(); +export type TogglTimezoneOffset = z.infer; + +/** JWKS keyset used to verify Toggl-issued JWTs. */ +export const TogglKeysetSchema = z + .object({ + keys: z.array(z.record(z.string(), z.unknown())), + }) + .loose(); +export type TogglKeyset = z.infer; + +export const TogglGroupSchema = z + .object({ + group_id: z.number().nullable().optional(), + id: z.number().nullable().optional(), + organization_id: z.number().nullable().optional(), + name: z.string(), + users: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + workspaces: z + .array(z.record(z.string(), z.unknown())) + .nullable() + .optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglGroup = z.infer; + +export const TogglOrganizationUserSchema = z + .object({ + id: z.number(), + user_id: z.number().nullable().optional(), + organization_id: z.number().nullable().optional(), + email: z.string().nullable().optional(), + name: z.string().nullable().optional(), + admin: z.boolean().nullable().optional(), + owner: z.boolean().nullable().optional(), + joined: z.boolean().nullable().optional(), + inactive: z.boolean().nullable().optional(), + workspaces: z + .array(z.record(z.string(), z.unknown())) + .nullable() + .optional(), + groups: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + }) + .loose(); +export type TogglOrganizationUser = z.infer; + +/** Toggl returns plan/billing data with a shape that varies by tier. */ +export const TogglPlanInfoSchema = z.record(z.string(), z.unknown()); +export type TogglPlanInfo = z.infer; + +export const TogglProjectUserSchema = z + .object({ + id: z.number(), + project_id: z.number().nullable().optional(), + user_id: z.number().nullable().optional(), + workspace_id: z.number().nullable().optional(), + manager: z.boolean().nullable().optional(), + rate: z.number().nullable().optional(), + labour_cost: z.number().nullable().optional(), + at: z.string().nullable().optional(), + }) + .loose(); +export type TogglProjectUser = z.infer; + +export const TogglWorkspacePreferencesSchema = z + .object({ + initial_pricing_plan: z.number().nullable().optional(), + hide_start_end_times: z.boolean().nullable().optional(), + }) + .loose(); +export type TogglWorkspacePreferences = z.infer< + typeof TogglWorkspacePreferencesSchema +>; + +export const TogglWorkspaceLogoSchema = z + .object({ + logo: z.string().nullable().optional(), + }) + .loose(); +export type TogglWorkspaceLogo = z.infer; + +export const TogglWebhooksStatusSchema = z + .object({ + status: z.string(), + }) + .loose(); +export type TogglWebhooksStatus = z.infer; + +/** Map of entity name to the event names that can be subscribed to. */ +export const TogglEventFiltersSchema = z.record( + z.string(), + z.array(z.string()), +); +export type TogglEventFilters = z.infer; + +export const TogglSubscriptionSchema = z + .object({ + subscription_id: z.number().nullable().optional(), + workspace_id: z.number().nullable().optional(), + user_id: z.number().nullable().optional(), + url_callback: z.string().nullable().optional(), + enabled: z.boolean().nullable().optional(), + description: z.string().nullable().optional(), + event_filters: z + .array(z.record(z.string(), z.unknown())) + .nullable() + .optional(), + created_at: z.string().nullable().optional(), + }) + .loose(); +export type TogglSubscription = z.infer; + +/** Toggl's transactional mail endpoints answer with a bare acknowledgement. */ +export const TogglAcknowledgementSchema = z + .object({ + ok: z.literal(true), + }) + .loose(); +export type TogglAcknowledgement = z.infer; + +/** + * `POST …/clients/{client_id}/archive` does not answer with a client record on + * every account — it can also return an `{ items: [...] }` envelope carrying + * the ids it touched. Both shapes are accepted so the declared contract cannot + * drift from the live response; callers needing the full record should re-read + * the client. + */ +export const TogglClientArchiveResultSchema = z.union([ + TogglClientSchema, + z.object({ items: z.array(z.number()).nullable().optional() }).loose(), +]); +export type TogglClientArchiveResult = z.infer< + typeof TogglClientArchiveResultSchema +>; + +/** + * Every Toggl resource identifier is an integer, so inputs reject fractional + * values rather than forwarding them and letting the API answer with a 400. + */ +const TogglIdSchema = z.number().int(); + +/** + * Time-entry writes carry RFC3339 timestamps. The offset form is accepted + * alongside plain UTC so a caller holding a local-offset timestamp does not + * have to normalise it first. + */ +const TogglTimestampSchema = z.iso.datetime({ offset: true }); + +/** Range filters accept either a calendar date or a full RFC3339 timestamp. */ +const TogglDateOrTimestampSchema = z.union([ + z.iso.date(), + TogglTimestampSchema, +]); + +/* -------------------------------------------------------------------------- */ +/* me */ +/* -------------------------------------------------------------------------- */ + +const MeGetInputSchema = z.object({ + with_related_data: z.boolean().optional(), +}); +export type MeGetInput = z.infer; + +const MeUpdateInputSchema = z.object({ + fullname: z.string().optional(), + email: z.string().optional(), + timezone: z.string().optional(), + beginning_of_week: z.number().int().min(0).max(6).optional(), + default_workspace_id: TogglIdSchema.optional(), +}); +export type MeUpdateInput = z.infer; + +const MeGetPreferencesInputSchema = z.object({}); +export type MeGetPreferencesInput = z.infer; + +const MeUpdatePreferencesInputSchema = z.object({ + timeofday_format: z.string().optional(), + date_format: z.string().optional(), + duration_format: z.string().optional(), +}); +export type MeUpdatePreferencesInput = z.infer< + typeof MeUpdatePreferencesInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* workspaces */ +/* -------------------------------------------------------------------------- */ + +const WorkspacesListInputSchema = z.object({ + /** Only return workspaces changed since this UNIX timestamp. */ + since: z.number().optional(), +}); +export type WorkspacesListInput = z.infer; + +const WorkspacesGetInputSchema = z.object({ + workspace_id: TogglIdSchema, +}); +export type WorkspacesGetInput = z.infer; + +const WorkspacesUpdateInputSchema = z.object({ + workspace_id: TogglIdSchema, + name: z.string().optional(), + default_currency: z.string().optional(), + default_hourly_rate: z.number().optional(), + only_admins_may_create_projects: z.boolean().optional(), + only_admins_may_create_tags: z.boolean().optional(), +}); +export type WorkspacesUpdateInput = z.infer; + +const WorkspacesGetUsersInputSchema = z.object({ + workspace_id: TogglIdSchema, +}); +export type WorkspacesGetUsersInput = z.infer< + typeof WorkspacesGetUsersInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* organizations */ +/* -------------------------------------------------------------------------- */ + +const OrganizationsGetInputSchema = z.object({ + organization_id: TogglIdSchema, +}); +export type OrganizationsGetInput = z.infer; + +const OrganizationsUpdateInputSchema = z.object({ + organization_id: TogglIdSchema, + name: z.string(), +}); +export type OrganizationsUpdateInput = z.infer< + typeof OrganizationsUpdateInputSchema +>; + +const OrganizationsGetWorkspacesInputSchema = z.object({ + organization_id: TogglIdSchema, +}); +export type OrganizationsGetWorkspacesInput = z.infer< + typeof OrganizationsGetWorkspacesInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* clients */ +/* -------------------------------------------------------------------------- */ + +const ClientsListInputSchema = z.object({ + workspace_id: TogglIdSchema, + /** Filter by archived state. Omit to return both. */ + status: z.enum(['active', 'archived', 'both']).optional(), + /** Case-insensitive substring match on the client name. */ + name: z.string().optional(), +}); +export type ClientsListInput = z.infer; + +const ClientsGetInputSchema = z.object({ + workspace_id: TogglIdSchema, + client_id: TogglIdSchema, +}); +export type ClientsGetInput = z.infer; + +const ClientsCreateInputSchema = z.object({ + workspace_id: TogglIdSchema, + name: z.string().min(1), + notes: z.string().optional(), +}); +export type ClientsCreateInput = z.infer; + +const ClientsUpdateInputSchema = z.object({ + workspace_id: TogglIdSchema, + client_id: TogglIdSchema, + /** Toggl rejects an update that omits the name, even when only notes change. */ + name: z.string().min(1), + notes: z.string().optional(), +}); + +const ClientsArchiveInputSchema = z.object({ + workspace_id: TogglIdSchema, + client_id: TogglIdSchema, +}); +export type ClientsArchiveInput = z.infer; +export type ClientsUpdateInput = z.infer; + +const ClientsDeleteInputSchema = z.object({ + workspace_id: TogglIdSchema, + client_id: TogglIdSchema, +}); +export type ClientsDeleteInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* projects */ +/* -------------------------------------------------------------------------- */ + +const ProjectsListInputSchema = z.object({ + workspace_id: TogglIdSchema, + active: z.boolean().optional(), + /** Substring match on project name. */ + name: z.string().optional(), + /** 1-based page number; Toggl pages projects rather than using cursors. */ + page: z.number().int().positive().optional(), + per_page: z.number().int().positive().max(200).optional(), +}); +export type ProjectsListInput = z.infer; + +const ProjectsGetInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_id: TogglIdSchema, +}); +export type ProjectsGetInput = z.infer; + +const ProjectsCreateInputSchema = z.object({ + workspace_id: TogglIdSchema, + name: z.string().min(1), + client_id: TogglIdSchema.optional(), + active: z.boolean().optional(), + is_private: z.boolean().optional(), + billable: z.boolean().optional(), + color: z.string().optional(), + start_date: z.string().optional(), + end_date: z.string().optional(), + estimated_hours: z.number().optional(), +}); +export type ProjectsCreateInput = z.infer; + +const ProjectsUpdateInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_id: TogglIdSchema, + name: z.string().min(1).optional(), + client_id: TogglIdSchema.nullable().optional(), + active: z.boolean().optional(), + is_private: z.boolean().optional(), + billable: z.boolean().optional(), + color: z.string().optional(), +}); +export type ProjectsUpdateInput = z.infer; + +const ProjectsDeleteInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_id: TogglIdSchema, +}); +export type ProjectsDeleteInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* tasks */ +/* -------------------------------------------------------------------------- */ + +const TasksListInputSchema = z.object({ + workspace_id: TogglIdSchema, + /** Omit to list every task in the workspace. */ + project_id: TogglIdSchema.optional(), + active: z.boolean().optional(), + /** Honoured only on the workspace-wide route; Toggl's project-scoped task + * route documents `active` alone and is sent without page params. */ + page: z.number().int().positive().optional(), + per_page: z.number().int().positive().max(200).optional(), +}); +export type TasksListInput = z.infer; + +const TasksGetInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_id: TogglIdSchema, + task_id: TogglIdSchema, +}); +export type TasksGetInput = z.infer; + +const TasksCreateInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_id: TogglIdSchema, + name: z.string().min(1), + active: z.boolean().optional(), + estimated_seconds: z.number().optional(), + user_id: TogglIdSchema.optional(), +}); +export type TasksCreateInput = z.infer; + +const TasksUpdateInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_id: TogglIdSchema, + task_id: TogglIdSchema, + name: z.string().min(1).optional(), + active: z.boolean().optional(), + estimated_seconds: z.number().optional(), +}); +export type TasksUpdateInput = z.infer; + +const TasksDeleteInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_id: TogglIdSchema, + task_id: TogglIdSchema, +}); +export type TasksDeleteInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* tags */ +/* -------------------------------------------------------------------------- */ + +const TagsListInputSchema = z.object({ + workspace_id: TogglIdSchema, + page: z.number().int().positive().optional(), + per_page: z.number().int().positive().max(200).optional(), + /** Case-insensitive substring match on the tag name. */ + search: z.string().optional(), +}); +export type TagsListInput = z.infer; + +const TagsCreateInputSchema = z.object({ + workspace_id: TogglIdSchema, + name: z.string().min(1), +}); +export type TagsCreateInput = z.infer; + +const TagsUpdateInputSchema = z.object({ + workspace_id: TogglIdSchema, + tag_id: TogglIdSchema, + name: z.string().min(1), +}); +export type TagsUpdateInput = z.infer; + +const TagsDeleteInputSchema = z.object({ + workspace_id: TogglIdSchema, + tag_id: TogglIdSchema, +}); +export type TagsDeleteInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* time entries */ +/* -------------------------------------------------------------------------- */ + +const TimeEntriesListInputSchema = z + .object({ + /** RFC3339 or YYYY-MM-DD. Must be supplied together with `end_date`. */ + start_date: TogglDateOrTimestampSchema.optional(), + end_date: TogglDateOrTimestampSchema.optional(), + /** UNIX timestamp; returns entries modified since then. */ + since: z.number().int().optional(), + before: TogglDateOrTimestampSchema.optional(), + meta: z.boolean().optional(), + }) + .refine( + (value) => + (value.start_date === undefined) === (value.end_date === undefined), + { + message: 'start_date and end_date must be supplied together', + path: ['end_date'], + }, + ); +export type TimeEntriesListInput = z.infer; + +const TimeEntriesGetCurrentInputSchema = z.object({}); +export type TimeEntriesGetCurrentInput = z.infer< + typeof TimeEntriesGetCurrentInputSchema +>; + +const TimeEntriesGetInputSchema = z.object({ + time_entry_id: TogglIdSchema, +}); +export type TimeEntriesGetInput = z.infer; + +const TimeEntriesCreateInputSchema = z.object({ + workspace_id: TogglIdSchema, + /** RFC3339. Toggl rejects entries without an explicit start. */ + start: TogglTimestampSchema, + /** + * Whole seconds. A negative value marks the entry as still running, in which + * case Toggl expects -1 by convention. + */ + duration: z.number().int(), + description: z.string().optional(), + project_id: TogglIdSchema.optional(), + task_id: TogglIdSchema.optional(), + billable: z.boolean().optional(), + tags: z.array(z.string()).optional(), + tag_ids: z.array(TogglIdSchema).optional(), + stop: TogglTimestampSchema.optional(), + /** Required by Toggl to identify the writing client. */ + created_with: z.string().optional(), +}); +export type TimeEntriesCreateInput = z.infer< + typeof TimeEntriesCreateInputSchema +>; + +const TimeEntriesUpdateInputSchema = z.object({ + workspace_id: TogglIdSchema, + time_entry_id: TogglIdSchema, + description: z.string().optional(), + start: TogglTimestampSchema.optional(), + stop: TogglTimestampSchema.optional(), + duration: z.number().int().optional(), + project_id: TogglIdSchema.nullable().optional(), + task_id: TogglIdSchema.nullable().optional(), + billable: z.boolean().optional(), + tags: z.array(z.string()).optional(), + tag_ids: z.array(TogglIdSchema).optional(), +}); +export type TimeEntriesUpdateInput = z.infer< + typeof TimeEntriesUpdateInputSchema +>; + +const TimeEntriesStopInputSchema = z.object({ + workspace_id: TogglIdSchema, + time_entry_id: TogglIdSchema, +}); +export type TimeEntriesStopInput = z.infer; + +const TimeEntriesDeleteInputSchema = z.object({ + workspace_id: TogglIdSchema, + time_entry_id: TogglIdSchema, +}); +export type TimeEntriesDeleteInput = z.infer< + typeof TimeEntriesDeleteInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* me — collections and account actions */ +/* -------------------------------------------------------------------------- */ + +const EmptyInputSchema = z.object({}); +export type EmptyInput = z.infer; + +/** `since` filters most /me collections to records changed after a UNIX time. */ +const SinceInputSchema = z.object({ + since: z.number().optional(), +}); +export type SinceInput = z.infer; + +const MeDisableProductEmailsInputSchema = z.object({ + /** Code taken from the unsubscribe link in a Toggl product email. */ + disable_code: z.string().min(1), +}); +export type MeDisableProductEmailsInput = z.infer< + typeof MeDisableProductEmailsInputSchema +>; + +const MeDisableWeeklyReportInputSchema = z.object({ + /** Code taken from the footer of a weekly report email. */ + code: z.string().min(1), +}); +export type MeDisableWeeklyReportInput = z.infer< + typeof MeDisableWeeklyReportInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* reference data */ +/* -------------------------------------------------------------------------- */ + +const ReferenceGetCountrySubdivisionsInputSchema = z.object({ + /** Numeric id from `reference.getCountries` — ISO codes are rejected by Toggl. */ + country_id: TogglIdSchema, +}); +export type ReferenceGetCountrySubdivisionsInput = z.infer< + typeof ReferenceGetCountrySubdivisionsInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* organizations — groups, users, invitations, plans */ +/* -------------------------------------------------------------------------- */ + +const OrganizationsCreateInputSchema = z.object({ + name: z.string().min(1), + /** Name for the organization's first workspace. */ + workspace_name: z.string().min(1).optional(), +}); +export type OrganizationsCreateInput = z.infer< + typeof OrganizationsCreateInputSchema +>; + +const OrganizationsGetGroupsInputSchema = z.object({ + organization_id: TogglIdSchema, +}); +export type OrganizationsGetGroupsInput = z.infer< + typeof OrganizationsGetGroupsInputSchema +>; + +const OrganizationsCreateGroupInputSchema = z.object({ + organization_id: TogglIdSchema, + name: z.string().min(1), +}); +export type OrganizationsCreateGroupInput = z.infer< + typeof OrganizationsCreateGroupInputSchema +>; + +const OrganizationsDeleteGroupInputSchema = z.object({ + organization_id: TogglIdSchema, + group_id: TogglIdSchema, +}); +export type OrganizationsDeleteGroupInput = z.infer< + typeof OrganizationsDeleteGroupInputSchema +>; + +const OrganizationsGetUsersInputSchema = z.object({ + organization_id: TogglIdSchema, + /** Case-insensitive match against name or email. */ + filter: z.string().optional(), + active: z.boolean().optional(), + only_admins: z.boolean().optional(), + groups: z.boolean().optional(), + page: z.number().int().positive().optional(), + per_page: z.number().int().positive().max(200).optional(), +}); +export type OrganizationsGetUsersInput = z.infer< + typeof OrganizationsGetUsersInputSchema +>; + +const OrganizationsCreateInvitationInputSchema = z.object({ + organization_id: TogglIdSchema, + emails: z.array(z.string()).min(1), + /** Workspaces the invitee should be added to. */ + workspaces: z + .array( + z.object({ + workspace_id: TogglIdSchema, + admin: z.boolean().optional(), + }), + ) + .optional(), + prevent_email_notification: z.boolean().optional(), +}); +export type OrganizationsCreateInvitationInput = z.infer< + typeof OrganizationsCreateInvitationInputSchema +>; + +const OrganizationsGetPlansInputSchema = z.object({ + organization_id: TogglIdSchema, +}); +export type OrganizationsGetPlansInput = z.infer< + typeof OrganizationsGetPlansInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* workspaces — logo, preferences, tasks */ +/* -------------------------------------------------------------------------- */ + +const WorkspacesGetLogoInputSchema = z.object({ + workspace_id: TogglIdSchema, +}); +export type WorkspacesGetLogoInput = z.infer< + typeof WorkspacesGetLogoInputSchema +>; + +const WorkspacesGetPreferencesInputSchema = z.object({ + workspace_id: TogglIdSchema, +}); +export type WorkspacesGetPreferencesInput = z.infer< + typeof WorkspacesGetPreferencesInputSchema +>; + +const TasksListWorkspaceInputSchema = z.object({ + workspace_id: TogglIdSchema, + active: z.boolean().optional(), + page: z.number().int().positive().optional(), + per_page: z.number().int().positive().max(200).optional(), +}); +export type TasksListWorkspaceInput = z.infer< + typeof TasksListWorkspaceInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* projects — members and groups */ +/* -------------------------------------------------------------------------- */ + +const ProjectsAddUserInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_id: TogglIdSchema, + user_id: TogglIdSchema, + manager: z.boolean().optional(), + rate: z.number().optional(), + labour_cost: z.number().optional(), +}); +export type ProjectsAddUserInput = z.infer; + +const ProjectsDeleteGroupInputSchema = z.object({ + workspace_id: TogglIdSchema, + project_group_id: TogglIdSchema, +}); +export type ProjectsDeleteGroupInput = z.infer< + typeof ProjectsDeleteGroupInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* time entries — bulk edit */ +/* -------------------------------------------------------------------------- */ + +const TimeEntriesBulkEditInputSchema = z.object({ + workspace_id: TogglIdSchema, + /** Toggl caps a bulk edit at 100 entries per request. */ + time_entry_ids: z.array(TogglIdSchema).min(1).max(100), + /** JSON Patch operations applied to every listed entry. */ + operations: z + .array( + z + .object({ + op: z.enum(['add', 'remove', 'replace']), + path: z.string().min(1), + value: z.unknown().optional(), + }) + // RFC 6902 requires `value` on add and replace; remove takes none. + // Expressed as a refinement rather than a discriminated union + // because zod cannot mark an `unknown` field as required. + .refine( + (operation) => operation.op === 'remove' || 'value' in operation, + { + message: 'value is required for add and replace operations', + path: ['value'], + }, + ), + ) + .min(1), +}); +export type TimeEntriesBulkEditInput = z.infer< + typeof TimeEntriesBulkEditInputSchema +>; + +const BulkEditResultSchema = z.object({ + success: z.array(z.number()).nullable().optional(), + failure: z + .array( + z.object({ + id: z.number().nullable().optional(), + message: z.string().nullable().optional(), + }), + ) + .nullable() + .optional(), +}); +export type BulkEditResult = z.infer; + +/* -------------------------------------------------------------------------- */ +/* webhook subscriptions */ +/* -------------------------------------------------------------------------- */ + +const WebhooksListSubscriptionsInputSchema = z.object({ + workspace_id: TogglIdSchema, +}); +export type WebhooksListSubscriptionsInput = z.infer< + typeof WebhooksListSubscriptionsInputSchema +>; + +const WebhooksDeleteSubscriptionInputSchema = z.object({ + workspace_id: TogglIdSchema, + subscription_id: TogglIdSchema, +}); +export type WebhooksDeleteSubscriptionInput = z.infer< + typeof WebhooksDeleteSubscriptionInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* transactional mail */ +/* -------------------------------------------------------------------------- */ + +const SmailSendDemoInputSchema = z.object({ + email: z.string().min(1), + name: z.string().optional(), + company: z.string().optional(), + message: z.string().optional(), +}); +export type SmailSendDemoInput = z.infer; + +const SmailSendContactInputSchema = z.object({ + email: z.string().min(1), + name: z.string().min(1), + message: z.string().min(1), +}); +export type SmailSendContactInput = z.infer; + +const SmailSendMeetInputSchema = z.object({ + email: z.string().min(1), + name: z.string().optional(), + location: z.string().min(1), +}); +export type SmailSendMeetInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* outputs */ +/* -------------------------------------------------------------------------- */ + +/** + * Toggl answers DELETE with an empty body and a 200. The endpoints normalise + * that into an explicit result so callers get something typed back. + */ +const DeletedResultSchema = z.object({ + deleted: z.literal(true), + id: z.number(), +}); +export type DeletedResult = z.infer; + +export type TogglEndpointInputs = { + meGet: MeGetInput; + meUpdate: MeUpdateInput; + meGetPreferences: MeGetPreferencesInput; + meUpdatePreferences: MeUpdatePreferencesInput; + workspacesList: WorkspacesListInput; + workspacesGet: WorkspacesGetInput; + workspacesUpdate: WorkspacesUpdateInput; + workspacesGetUsers: WorkspacesGetUsersInput; + organizationsGet: OrganizationsGetInput; + organizationsUpdate: OrganizationsUpdateInput; + organizationsGetWorkspaces: OrganizationsGetWorkspacesInput; + clientsList: ClientsListInput; + clientsGet: ClientsGetInput; + clientsCreate: ClientsCreateInput; + clientsUpdate: ClientsUpdateInput; + clientsArchive: ClientsArchiveInput; + clientsDelete: ClientsDeleteInput; + projectsList: ProjectsListInput; + projectsGet: ProjectsGetInput; + projectsCreate: ProjectsCreateInput; + projectsUpdate: ProjectsUpdateInput; + projectsDelete: ProjectsDeleteInput; + tasksList: TasksListInput; + tasksGet: TasksGetInput; + tasksCreate: TasksCreateInput; + tasksUpdate: TasksUpdateInput; + tasksDelete: TasksDeleteInput; + tagsList: TagsListInput; + tagsCreate: TagsCreateInput; + tagsUpdate: TagsUpdateInput; + tagsDelete: TagsDeleteInput; + timeEntriesList: TimeEntriesListInput; + timeEntriesGetCurrent: TimeEntriesGetCurrentInput; + timeEntriesGet: TimeEntriesGetInput; + timeEntriesCreate: TimeEntriesCreateInput; + timeEntriesUpdate: TimeEntriesUpdateInput; + timeEntriesStop: TimeEntriesStopInput; + timeEntriesDelete: TimeEntriesDeleteInput; + meGetLogged: EmptyInput; + meGetLocation: EmptyInput; + meGetQuota: EmptyInput; + meGetClients: SinceInput; + meGetProjects: SinceInput; + meGetTags: SinceInput; + meGetTasks: SinceInput; + meDisableProductEmails: MeDisableProductEmailsInput; + meDisableWeeklyReport: MeDisableWeeklyReportInput; + referenceGetCountries: EmptyInput; + referenceGetCountrySubdivisions: ReferenceGetCountrySubdivisionsInput; + referenceGetCurrencies: EmptyInput; + referenceGetTimezones: EmptyInput; + referenceGetTimezoneOffsets: EmptyInput; + referenceGetKeys: EmptyInput; + organizationsCreate: OrganizationsCreateInput; + organizationsGetGroups: OrganizationsGetGroupsInput; + organizationsCreateGroup: OrganizationsCreateGroupInput; + organizationsDeleteGroup: OrganizationsDeleteGroupInput; + organizationsGetUsers: OrganizationsGetUsersInput; + organizationsCreateInvitation: OrganizationsCreateInvitationInput; + organizationsGetPlans: OrganizationsGetPlansInput; + organizationsGetSubscriptionPlans: OrganizationsGetPlansInput; + workspacesGetLogo: WorkspacesGetLogoInput; + workspacesGetPreferences: WorkspacesGetPreferencesInput; + projectsAddUser: ProjectsAddUserInput; + projectsDeleteGroup: ProjectsDeleteGroupInput; + timeEntriesBulkEdit: TimeEntriesBulkEditInput; + webhooksGetStatus: EmptyInput; + webhooksGetEventFilters: EmptyInput; + webhooksListSubscriptions: WebhooksListSubscriptionsInput; + webhooksDeleteSubscription: WebhooksDeleteSubscriptionInput; + smailSendDemo: SmailSendDemoInput; + smailSendContact: SmailSendContactInput; + smailSendMeet: SmailSendMeetInput; +}; + +export type TogglEndpointOutputs = { + meGet: TogglUser; + meUpdate: TogglUser; + meGetPreferences: TogglUserPreferences; + meUpdatePreferences: TogglUserPreferences; + workspacesList: TogglWorkspace[]; + workspacesGet: TogglWorkspace; + workspacesUpdate: TogglWorkspace; + workspacesGetUsers: TogglWorkspaceUser[]; + organizationsGet: TogglOrganization; + organizationsUpdate: TogglOrganization; + organizationsGetWorkspaces: TogglWorkspace[]; + clientsList: TogglClient[]; + clientsGet: TogglClient; + clientsCreate: TogglClient; + clientsUpdate: TogglClient; + clientsArchive: TogglClientArchiveResult; + clientsDelete: DeletedResult; + projectsList: TogglProject[]; + projectsGet: TogglProject; + projectsCreate: TogglProject; + projectsUpdate: TogglProject; + projectsDelete: DeletedResult; + tasksList: TogglTask[]; + tasksGet: TogglTask; + tasksCreate: TogglTask; + tasksUpdate: TogglTask; + tasksDelete: DeletedResult; + tagsList: TogglTag[]; + tagsCreate: TogglTag; + tagsUpdate: TogglTag; + tagsDelete: DeletedResult; + timeEntriesList: TogglTimeEntry[]; + timeEntriesGetCurrent: TogglTimeEntry | null; + timeEntriesGet: TogglTimeEntry; + timeEntriesCreate: TogglTimeEntry; + timeEntriesUpdate: TogglTimeEntry; + timeEntriesStop: TogglTimeEntry; + timeEntriesDelete: DeletedResult; + meGetLogged: TogglAcknowledgement; + meGetLocation: TogglLocation; + meGetQuota: TogglQuota[]; + meGetClients: TogglClient[]; + meGetProjects: TogglProject[]; + meGetTags: TogglTag[]; + meGetTasks: TogglTask[]; + meDisableProductEmails: TogglAcknowledgement; + meDisableWeeklyReport: TogglAcknowledgement; + referenceGetCountries: TogglCountry[]; + referenceGetCountrySubdivisions: TogglCountrySubdivision[]; + referenceGetCurrencies: TogglCurrency[]; + referenceGetTimezones: string[]; + referenceGetTimezoneOffsets: TogglTimezoneOffset[]; + referenceGetKeys: TogglKeyset; + organizationsCreate: TogglOrganization; + organizationsGetGroups: TogglGroup[]; + organizationsCreateGroup: TogglGroup; + organizationsDeleteGroup: DeletedResult; + organizationsGetUsers: TogglOrganizationUser[]; + organizationsCreateInvitation: TogglPlanInfo; + organizationsGetPlans: TogglPlanInfo; + organizationsGetSubscriptionPlans: TogglPlanInfo; + workspacesGetLogo: TogglWorkspaceLogo; + workspacesGetPreferences: TogglWorkspacePreferences; + projectsAddUser: TogglProjectUser; + projectsDeleteGroup: DeletedResult; + timeEntriesBulkEdit: BulkEditResult; + webhooksGetStatus: TogglWebhooksStatus; + webhooksGetEventFilters: TogglEventFilters; + webhooksListSubscriptions: TogglSubscription[]; + webhooksDeleteSubscription: DeletedResult; + smailSendDemo: TogglAcknowledgement; + smailSendContact: TogglAcknowledgement; + smailSendMeet: TogglAcknowledgement; +}; + +export const TogglEndpointInputSchemas = { + meGet: MeGetInputSchema, + meUpdate: MeUpdateInputSchema, + meGetPreferences: MeGetPreferencesInputSchema, + meUpdatePreferences: MeUpdatePreferencesInputSchema, + workspacesList: WorkspacesListInputSchema, + workspacesGet: WorkspacesGetInputSchema, + workspacesUpdate: WorkspacesUpdateInputSchema, + workspacesGetUsers: WorkspacesGetUsersInputSchema, + organizationsGet: OrganizationsGetInputSchema, + organizationsUpdate: OrganizationsUpdateInputSchema, + organizationsGetWorkspaces: OrganizationsGetWorkspacesInputSchema, + clientsList: ClientsListInputSchema, + clientsGet: ClientsGetInputSchema, + clientsCreate: ClientsCreateInputSchema, + clientsUpdate: ClientsUpdateInputSchema, + clientsArchive: ClientsArchiveInputSchema, + clientsDelete: ClientsDeleteInputSchema, + projectsList: ProjectsListInputSchema, + projectsGet: ProjectsGetInputSchema, + projectsCreate: ProjectsCreateInputSchema, + projectsUpdate: ProjectsUpdateInputSchema, + projectsDelete: ProjectsDeleteInputSchema, + tasksList: TasksListInputSchema, + tasksGet: TasksGetInputSchema, + tasksCreate: TasksCreateInputSchema, + tasksUpdate: TasksUpdateInputSchema, + tasksDelete: TasksDeleteInputSchema, + tagsList: TagsListInputSchema, + tagsCreate: TagsCreateInputSchema, + tagsUpdate: TagsUpdateInputSchema, + tagsDelete: TagsDeleteInputSchema, + timeEntriesList: TimeEntriesListInputSchema, + timeEntriesGetCurrent: TimeEntriesGetCurrentInputSchema, + timeEntriesGet: TimeEntriesGetInputSchema, + timeEntriesCreate: TimeEntriesCreateInputSchema, + timeEntriesUpdate: TimeEntriesUpdateInputSchema, + timeEntriesStop: TimeEntriesStopInputSchema, + timeEntriesDelete: TimeEntriesDeleteInputSchema, + meGetLogged: EmptyInputSchema, + meGetLocation: EmptyInputSchema, + meGetQuota: EmptyInputSchema, + meGetClients: SinceInputSchema, + meGetProjects: SinceInputSchema, + meGetTags: SinceInputSchema, + meGetTasks: SinceInputSchema, + meDisableProductEmails: MeDisableProductEmailsInputSchema, + meDisableWeeklyReport: MeDisableWeeklyReportInputSchema, + referenceGetCountries: EmptyInputSchema, + referenceGetCountrySubdivisions: ReferenceGetCountrySubdivisionsInputSchema, + referenceGetCurrencies: EmptyInputSchema, + referenceGetTimezones: EmptyInputSchema, + referenceGetTimezoneOffsets: EmptyInputSchema, + referenceGetKeys: EmptyInputSchema, + organizationsCreate: OrganizationsCreateInputSchema, + organizationsGetGroups: OrganizationsGetGroupsInputSchema, + organizationsCreateGroup: OrganizationsCreateGroupInputSchema, + organizationsDeleteGroup: OrganizationsDeleteGroupInputSchema, + organizationsGetUsers: OrganizationsGetUsersInputSchema, + organizationsCreateInvitation: OrganizationsCreateInvitationInputSchema, + organizationsGetPlans: OrganizationsGetPlansInputSchema, + organizationsGetSubscriptionPlans: OrganizationsGetPlansInputSchema, + workspacesGetLogo: WorkspacesGetLogoInputSchema, + workspacesGetPreferences: WorkspacesGetPreferencesInputSchema, + projectsAddUser: ProjectsAddUserInputSchema, + projectsDeleteGroup: ProjectsDeleteGroupInputSchema, + timeEntriesBulkEdit: TimeEntriesBulkEditInputSchema, + webhooksGetStatus: EmptyInputSchema, + webhooksGetEventFilters: EmptyInputSchema, + webhooksListSubscriptions: WebhooksListSubscriptionsInputSchema, + webhooksDeleteSubscription: WebhooksDeleteSubscriptionInputSchema, + smailSendDemo: SmailSendDemoInputSchema, + smailSendContact: SmailSendContactInputSchema, + smailSendMeet: SmailSendMeetInputSchema, +} as const; + +export const TogglEndpointOutputSchemas = { + meGet: TogglUserSchema, + meUpdate: TogglUserSchema, + meGetPreferences: TogglUserPreferencesSchema, + meUpdatePreferences: TogglUserPreferencesSchema, + workspacesList: z.array(TogglWorkspaceSchema), + workspacesGet: TogglWorkspaceSchema, + workspacesUpdate: TogglWorkspaceSchema, + workspacesGetUsers: z.array(TogglWorkspaceUserSchema), + organizationsGet: TogglOrganizationSchema, + organizationsUpdate: TogglOrganizationSchema, + organizationsGetWorkspaces: z.array(TogglWorkspaceSchema), + clientsList: z.array(TogglClientSchema), + clientsGet: TogglClientSchema, + clientsCreate: TogglClientSchema, + clientsUpdate: TogglClientSchema, + clientsArchive: TogglClientArchiveResultSchema, + clientsDelete: DeletedResultSchema, + projectsList: z.array(TogglProjectSchema), + projectsGet: TogglProjectSchema, + projectsCreate: TogglProjectSchema, + projectsUpdate: TogglProjectSchema, + projectsDelete: DeletedResultSchema, + tasksList: z.array(TogglTaskSchema), + tasksGet: TogglTaskSchema, + tasksCreate: TogglTaskSchema, + tasksUpdate: TogglTaskSchema, + tasksDelete: DeletedResultSchema, + tagsList: z.array(TogglTagSchema), + tagsCreate: TogglTagSchema, + tagsUpdate: TogglTagSchema, + tagsDelete: DeletedResultSchema, + timeEntriesList: z.array(TogglTimeEntrySchema), + timeEntriesGetCurrent: TogglTimeEntrySchema.nullable(), + timeEntriesGet: TogglTimeEntrySchema, + timeEntriesCreate: TogglTimeEntrySchema, + timeEntriesUpdate: TogglTimeEntrySchema, + timeEntriesStop: TogglTimeEntrySchema, + timeEntriesDelete: DeletedResultSchema, + meGetLogged: TogglAcknowledgementSchema, + meGetLocation: TogglLocationSchema, + meGetQuota: z.array(TogglQuotaSchema), + meGetClients: z.array(TogglClientSchema), + meGetProjects: z.array(TogglProjectSchema), + meGetTags: z.array(TogglTagSchema), + meGetTasks: z.array(TogglTaskSchema), + meDisableProductEmails: TogglAcknowledgementSchema, + meDisableWeeklyReport: TogglAcknowledgementSchema, + referenceGetCountries: z.array(TogglCountrySchema), + referenceGetCountrySubdivisions: z.array(TogglCountrySubdivisionSchema), + referenceGetCurrencies: z.array(TogglCurrencySchema), + referenceGetTimezones: z.array(z.string()), + referenceGetTimezoneOffsets: z.array(TogglTimezoneOffsetSchema), + referenceGetKeys: TogglKeysetSchema, + organizationsCreate: TogglOrganizationSchema, + organizationsGetGroups: z.array(TogglGroupSchema), + organizationsCreateGroup: TogglGroupSchema, + organizationsDeleteGroup: DeletedResultSchema, + organizationsGetUsers: z.array(TogglOrganizationUserSchema), + organizationsCreateInvitation: TogglPlanInfoSchema, + organizationsGetPlans: TogglPlanInfoSchema, + organizationsGetSubscriptionPlans: TogglPlanInfoSchema, + workspacesGetLogo: TogglWorkspaceLogoSchema, + workspacesGetPreferences: TogglWorkspacePreferencesSchema, + projectsAddUser: TogglProjectUserSchema, + projectsDeleteGroup: DeletedResultSchema, + timeEntriesBulkEdit: BulkEditResultSchema, + webhooksGetStatus: TogglWebhooksStatusSchema, + webhooksGetEventFilters: TogglEventFiltersSchema, + webhooksListSubscriptions: z.array(TogglSubscriptionSchema), + webhooksDeleteSubscription: DeletedResultSchema, + smailSendDemo: TogglAcknowledgementSchema, + smailSendContact: TogglAcknowledgementSchema, + smailSendMeet: TogglAcknowledgementSchema, +} as const; diff --git a/packages/toggl/endpoints/webhook-subscriptions.ts b/packages/toggl/endpoints/webhook-subscriptions.ts new file mode 100644 index 000000000..e239ffb89 --- /dev/null +++ b/packages/toggl/endpoints/webhook-subscriptions.ts @@ -0,0 +1,81 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import type { TogglEndpointOutputs } from './types'; + +/** + * Toggl's webhook subscription management, served from a separate host path + * (`/webhooks/api/v1`) rather than the Track v9 API. + * + * These manage subscriptions on Toggl's side. The plugin still registers no + * Corsair webhook handlers — see the note in index.ts. + */ + +/** Reads the health of Toggl's webhooks service, which runs on its own host. */ +export const getStatus: TogglEndpoints['webhooksGetStatus'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['webhooksGetStatus'] + >('status', ctx.key, { method: 'GET', base: 'webhooks' }); + + await logEventFromContext( + ctx, + 'toggl.webhooks.getStatus', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +export const getEventFilters: TogglEndpoints['webhooksGetEventFilters'] = + async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['webhooksGetEventFilters'] + >('event_filters', ctx.key, { method: 'GET', base: 'webhooks' }); + + await logEventFromContext( + ctx, + 'toggl.webhooks.getEventFilters', + auditPayload(input, []), + 'completed', + ); + return result ?? {}; + }; + +export const listSubscriptions: TogglEndpoints['webhooksListSubscriptions'] = + async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['webhooksListSubscriptions'] + >(`subscriptions/${input.workspace_id}`, ctx.key, { + method: 'GET', + base: 'webhooks', + }); + + await logEventFromContext( + ctx, + 'toggl.webhooks.listSubscriptions', + auditPayload(input, ['workspace_id']), + 'completed', + ); + return result ?? []; + }; + +export const deleteSubscription: TogglEndpoints['webhooksDeleteSubscription'] = + async (ctx, input) => { + await makeTogglRequest( + `subscriptions/${input.workspace_id}/${input.subscription_id}`, + ctx.key, + { method: 'DELETE', base: 'webhooks' }, + ); + + await logEventFromContext( + ctx, + 'toggl.webhooks.deleteSubscription', + auditPayload(input, ['workspace_id', 'subscription_id']), + 'completed', + ); + return { deleted: true, id: input.subscription_id }; + }; diff --git a/packages/toggl/endpoints/workspaces.ts b/packages/toggl/endpoints/workspaces.ts new file mode 100644 index 000000000..646838881 --- /dev/null +++ b/packages/toggl/endpoints/workspaces.ts @@ -0,0 +1,139 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeTogglRequest } from '../client'; +import type { TogglEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheWorkspace } from './persist'; +import type { TogglEndpointOutputs } from './types'; + +/** Lists the workspaces the caller belongs to and caches each one. */ +export const list: TogglEndpoints['workspacesList'] = async (ctx, input) => { + const result = await makeTogglRequest( + 'workspaces', + ctx.key, + { + method: 'GET', + query: { since: input.since }, + }, + ); + + const workspaces = result ?? []; + + for (const workspace of workspaces) { + await cacheWorkspace(ctx.db.workspaces, workspace); + } + + await logEventFromContext( + ctx, + 'toggl.workspaces.list', + auditPayload(input, ['since']), + 'completed', + ); + return workspaces; +}; + +/** Reads a single workspace and refreshes its cached copy. */ +export const get: TogglEndpoints['workspacesGet'] = async (ctx, input) => { + const result = await makeTogglRequest( + `workspaces/${input.workspace_id}`, + ctx.key, + { method: 'GET' }, + ); + + await cacheWorkspace(ctx.db.workspaces, result); + + await logEventFromContext( + ctx, + 'toggl.workspaces.get', + auditPayload(input, ['workspace_id']), + 'completed', + ); + return result; +}; + +/** Updates a workspace's name and default project, billing or rounding settings. */ +export const update: TogglEndpoints['workspacesUpdate'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['workspacesUpdate'] + >(`workspaces/${input.workspace_id}`, ctx.key, { + method: 'PUT', + body: { + name: input.name, + default_currency: input.default_currency, + default_hourly_rate: input.default_hourly_rate, + only_admins_may_create_projects: input.only_admins_may_create_projects, + only_admins_may_create_tags: input.only_admins_may_create_tags, + }, + }); + + await cacheWorkspace(ctx.db.workspaces, result); + + await logEventFromContext( + ctx, + 'toggl.workspaces.update', + auditPayload(input, [ + 'workspace_id', + 'default_currency', + 'default_hourly_rate', + 'only_admins_may_create_projects', + 'only_admins_may_create_tags', + ]), + 'completed', + ); + return result; +}; + +/** Lists a workspace's members with their role and activity state. */ +export const getUsers: TogglEndpoints['workspacesGetUsers'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['workspacesGetUsers'] + >(`workspaces/${input.workspace_id}/users`, ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.workspaces.getUsers', + auditPayload(input, ['workspace_id']), + 'completed', + ); + return result ?? []; +}; + +/** Reads the logo associated with a workspace. */ +export const getLogo: TogglEndpoints['workspacesGetLogo'] = async ( + ctx, + input, +) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['workspacesGetLogo'] + >(`workspaces/${input.workspace_id}/logo`, ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'toggl.workspaces.getLogo', + auditPayload(input, ['workspace_id']), + 'completed', + ); + return result; +}; + +export const getPreferences: TogglEndpoints['workspacesGetPreferences'] = + async (ctx, input) => { + const result = await makeTogglRequest< + TogglEndpointOutputs['workspacesGetPreferences'] + >(`workspaces/${input.workspace_id}/preferences`, ctx.key, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'toggl.workspaces.getPreferences', + auditPayload(input, ['workspace_id']), + 'completed', + ); + return result; + }; diff --git a/packages/toggl/error-handlers.ts b/packages/toggl/error-handlers.ts new file mode 100644 index 000000000..60f74d867 --- /dev/null +++ b/packages/toggl/error-handlers.ts @@ -0,0 +1,183 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +/** + * Toggl answers a bad or revoked API token with 403 — the same status it uses + * for a genuine permission failure — so the status code alone cannot separate + * the two. The response body is what distinguishes them: an invalid credential + * comes back as "Incorrect username and/or password". + */ +function looksLikeInvalidCredentials(error: Error): boolean { + const parts: string[] = [error.message]; + if (error instanceof ApiError) { + parts.push( + typeof error.body === 'string' ? error.body : JSON.stringify(error.body), + ); + } + + const haystack = parts.join(' ').toLowerCase(); + return ( + haystack.includes('incorrect username and/or password') || + haystack.includes('invalid api token') || + haystack.includes('invalid token') + ); +} + +export const errorHandlers = { + /** + * Toggl paces requests with a leaky bucket at roughly 1 req/sec per token + * per IP and returns 429 once it overflows. The `/me` endpoint is stricter + * still: 30 requests per hour per user regardless of plan. + */ + RATE_LIMIT_ERROR: { + match: (error, context) => { + // 429 is the leaky bucket. 402 is the separate sliding-window quota + // Toggl applies per organization, which also clears with time. + if ( + error instanceof ApiError && + (error.status === 429 || error.status === 402) + ) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('too many requests') || + errorMessage.includes('quota exceeded') || + error.message.includes('429') + ); + }, + handler: async (error, context) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + + return { + maxRetries: 5, + headersRetryAfterMs: retryAfterMs, + }; + }, + }, + /** + * Matched before PERMISSION_ERROR so that a 403 carrying an invalid-token + * body is reported as an authentication failure rather than a missing + * permission. The two handlers are mutually exclusive. + */ + AUTH_ERROR: { + match: (error, context) => { + if (error instanceof ApiError && error.status === 401) { + return true; + } + if (looksLikeInvalidCredentials(error)) { + return true; + } + return error.message.toLowerCase().includes('authentication'); + }, + handler: async (error, context) => { + console.warn( + `[TOGGL:${context.operation}] Authentication failed - check your API token (Profile Settings > API Token)`, + ); + + return { + maxRetries: 0, + }; + }, + }, + PERMISSION_ERROR: { + match: (error, context) => { + // A 403 caused by a bad credential belongs to AUTH_ERROR. + if (looksLikeInvalidCredentials(error)) { + return false; + } + if (error instanceof ApiError && error.status === 403) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('forbidden') || + errorMessage.includes('user does not have access') || + errorMessage.includes('insufficient permissions') + ); + }, + handler: async (error, context) => { + console.warn( + `[TOGGL:${context.operation}] Permission denied: ${error.message}`, + ); + + return { + maxRetries: 0, + }; + }, + }, + NOT_FOUND_ERROR: { + match: (error, context) => { + if (error instanceof ApiError && error.status === 404) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('not found') || + errorMessage.includes('does not exist') + ); + }, + handler: async (error, context) => { + console.warn( + `[TOGGL:${context.operation}] Resource not found: ${error.message}`, + ); + + return { + maxRetries: 0, + }; + }, + }, + VALIDATION_ERROR: { + match: (error, context) => { + return error instanceof ApiError && error.status === 400; + }, + handler: async (error, context) => { + console.warn( + `[TOGGL:${context.operation}] Invalid request: ${error.message}`, + ); + + return { + maxRetries: 0, + }; + }, + }, + NETWORK_ERROR: { + match: (error, context) => { + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('network') || + errorMessage.includes('connection') || + errorMessage.includes('econnrefused') || + errorMessage.includes('enotfound') || + errorMessage.includes('etimedout') || + errorMessage.includes('fetch failed') + ); + }, + handler: async (error, context) => { + console.warn( + `[TOGGL:${context.operation}] Network error: ${error.message}`, + ); + + return { + maxRetries: 3, + }; + }, + }, + DEFAULT: { + match: (error, context) => { + return true; + }, + handler: async (error, context) => { + console.error( + `[TOGGL:${context.operation}] Unhandled error: ${error.message}`, + ); + + return { + maxRetries: 0, + }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/toggl/index.ts b/packages/toggl/index.ts new file mode 100644 index 000000000..40ea79dbe --- /dev/null +++ b/packages/toggl/index.ts @@ -0,0 +1,892 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { + Clients, + Me, + Organizations, + Projects, + Reference, + Smail, + Tags, + Tasks, + TimeEntries, + Webhooks, + Workspaces, +} from './endpoints'; +import type { + TogglEndpointInputs, + TogglEndpointOutputs, +} from './endpoints/types'; +import { + TogglEndpointInputSchemas, + TogglEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { TogglSchema } from './schema'; +import { resolveTogglOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; +import { matchTogglTenantWebhook } from './webhooks/tenant-matcher'; + +export type TogglPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalTogglPlugin['hooks']; + webhookHooks?: InternalTogglPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type TogglContext = CorsairPluginContext< + typeof TogglSchema, + TogglPluginOptions +>; + +export type TogglKeyBuilderContext = KeyBuilderContext; + +export type TogglBoundEndpoints = BindEndpoints; + +type TogglEndpoint = CorsairEndpoint< + TogglContext, + TogglEndpointInputs[K], + TogglEndpointOutputs[K] +>; + +export type TogglEndpoints = { + meGet: TogglEndpoint<'meGet'>; + meUpdate: TogglEndpoint<'meUpdate'>; + meGetPreferences: TogglEndpoint<'meGetPreferences'>; + meUpdatePreferences: TogglEndpoint<'meUpdatePreferences'>; + workspacesList: TogglEndpoint<'workspacesList'>; + workspacesGet: TogglEndpoint<'workspacesGet'>; + workspacesUpdate: TogglEndpoint<'workspacesUpdate'>; + workspacesGetUsers: TogglEndpoint<'workspacesGetUsers'>; + organizationsGet: TogglEndpoint<'organizationsGet'>; + organizationsUpdate: TogglEndpoint<'organizationsUpdate'>; + organizationsGetWorkspaces: TogglEndpoint<'organizationsGetWorkspaces'>; + clientsList: TogglEndpoint<'clientsList'>; + clientsGet: TogglEndpoint<'clientsGet'>; + clientsCreate: TogglEndpoint<'clientsCreate'>; + clientsUpdate: TogglEndpoint<'clientsUpdate'>; + clientsArchive: TogglEndpoint<'clientsArchive'>; + clientsDelete: TogglEndpoint<'clientsDelete'>; + projectsList: TogglEndpoint<'projectsList'>; + projectsGet: TogglEndpoint<'projectsGet'>; + projectsCreate: TogglEndpoint<'projectsCreate'>; + projectsUpdate: TogglEndpoint<'projectsUpdate'>; + projectsDelete: TogglEndpoint<'projectsDelete'>; + tasksList: TogglEndpoint<'tasksList'>; + tasksGet: TogglEndpoint<'tasksGet'>; + tasksCreate: TogglEndpoint<'tasksCreate'>; + tasksUpdate: TogglEndpoint<'tasksUpdate'>; + tasksDelete: TogglEndpoint<'tasksDelete'>; + tagsList: TogglEndpoint<'tagsList'>; + tagsCreate: TogglEndpoint<'tagsCreate'>; + tagsUpdate: TogglEndpoint<'tagsUpdate'>; + tagsDelete: TogglEndpoint<'tagsDelete'>; + timeEntriesList: TogglEndpoint<'timeEntriesList'>; + timeEntriesGetCurrent: TogglEndpoint<'timeEntriesGetCurrent'>; + timeEntriesGet: TogglEndpoint<'timeEntriesGet'>; + timeEntriesCreate: TogglEndpoint<'timeEntriesCreate'>; + timeEntriesUpdate: TogglEndpoint<'timeEntriesUpdate'>; + timeEntriesStop: TogglEndpoint<'timeEntriesStop'>; + timeEntriesDelete: TogglEndpoint<'timeEntriesDelete'>; + meGetLogged: TogglEndpoint<'meGetLogged'>; + meGetLocation: TogglEndpoint<'meGetLocation'>; + meGetQuota: TogglEndpoint<'meGetQuota'>; + meGetClients: TogglEndpoint<'meGetClients'>; + meGetProjects: TogglEndpoint<'meGetProjects'>; + meGetTags: TogglEndpoint<'meGetTags'>; + meGetTasks: TogglEndpoint<'meGetTasks'>; + meDisableProductEmails: TogglEndpoint<'meDisableProductEmails'>; + meDisableWeeklyReport: TogglEndpoint<'meDisableWeeklyReport'>; + referenceGetCountries: TogglEndpoint<'referenceGetCountries'>; + referenceGetCountrySubdivisions: TogglEndpoint<'referenceGetCountrySubdivisions'>; + referenceGetCurrencies: TogglEndpoint<'referenceGetCurrencies'>; + referenceGetTimezones: TogglEndpoint<'referenceGetTimezones'>; + referenceGetTimezoneOffsets: TogglEndpoint<'referenceGetTimezoneOffsets'>; + referenceGetKeys: TogglEndpoint<'referenceGetKeys'>; + organizationsCreate: TogglEndpoint<'organizationsCreate'>; + organizationsGetGroups: TogglEndpoint<'organizationsGetGroups'>; + organizationsCreateGroup: TogglEndpoint<'organizationsCreateGroup'>; + organizationsDeleteGroup: TogglEndpoint<'organizationsDeleteGroup'>; + organizationsGetUsers: TogglEndpoint<'organizationsGetUsers'>; + organizationsCreateInvitation: TogglEndpoint<'organizationsCreateInvitation'>; + organizationsGetPlans: TogglEndpoint<'organizationsGetPlans'>; + organizationsGetSubscriptionPlans: TogglEndpoint<'organizationsGetSubscriptionPlans'>; + workspacesGetLogo: TogglEndpoint<'workspacesGetLogo'>; + workspacesGetPreferences: TogglEndpoint<'workspacesGetPreferences'>; + projectsAddUser: TogglEndpoint<'projectsAddUser'>; + projectsDeleteGroup: TogglEndpoint<'projectsDeleteGroup'>; + timeEntriesBulkEdit: TogglEndpoint<'timeEntriesBulkEdit'>; + webhooksGetStatus: TogglEndpoint<'webhooksGetStatus'>; + webhooksGetEventFilters: TogglEndpoint<'webhooksGetEventFilters'>; + webhooksListSubscriptions: TogglEndpoint<'webhooksListSubscriptions'>; + webhooksDeleteSubscription: TogglEndpoint<'webhooksDeleteSubscription'>; + smailSendDemo: TogglEndpoint<'smailSendDemo'>; + smailSendContact: TogglEndpoint<'smailSendContact'>; + smailSendMeet: TogglEndpoint<'smailSendMeet'>; +}; + +export type TogglWebhooks = Record; + +export type TogglBoundWebhooks = BindWebhooks; + +const togglEndpointsNested = { + me: { + get: Me.get, + update: Me.update, + getPreferences: Me.getPreferences, + updatePreferences: Me.updatePreferences, + getLogged: Me.getLogged, + getLocation: Me.getLocation, + getQuota: Me.getQuota, + getClients: Me.getClients, + getProjects: Me.getProjects, + getTags: Me.getTags, + getTasks: Me.getTasks, + disableProductEmails: Me.disableProductEmails, + disableWeeklyReport: Me.disableWeeklyReport, + }, + workspaces: { + list: Workspaces.list, + get: Workspaces.get, + update: Workspaces.update, + getUsers: Workspaces.getUsers, + getLogo: Workspaces.getLogo, + getPreferences: Workspaces.getPreferences, + }, + organizations: { + get: Organizations.get, + update: Organizations.update, + getWorkspaces: Organizations.getWorkspaces, + create: Organizations.create, + getGroups: Organizations.getGroups, + createGroup: Organizations.createGroup, + deleteGroup: Organizations.deleteGroup, + getUsers: Organizations.getUsers, + createInvitation: Organizations.createInvitation, + getPlans: Organizations.getPlans, + getSubscriptionPlans: Organizations.getSubscriptionPlans, + }, + clients: { + list: Clients.list, + get: Clients.get, + create: Clients.create, + update: Clients.update, + archive: Clients.archive, + delete: Clients.delete, + }, + projects: { + list: Projects.list, + get: Projects.get, + create: Projects.create, + update: Projects.update, + delete: Projects.delete, + addUser: Projects.addUser, + deleteGroup: Projects.deleteGroup, + }, + tasks: { + list: Tasks.list, + get: Tasks.get, + create: Tasks.create, + update: Tasks.update, + delete: Tasks.delete, + }, + tags: { + list: Tags.list, + create: Tags.create, + update: Tags.update, + delete: Tags.delete, + }, + timeEntries: { + list: TimeEntries.list, + getCurrent: TimeEntries.getCurrent, + get: TimeEntries.get, + create: TimeEntries.create, + update: TimeEntries.update, + stop: TimeEntries.stop, + delete: TimeEntries.delete, + bulkEdit: TimeEntries.bulkEdit, + }, + reference: { + getCountries: Reference.getCountries, + getCountrySubdivisions: Reference.getCountrySubdivisions, + getCurrencies: Reference.getCurrencies, + getTimezones: Reference.getTimezones, + getTimezoneOffsets: Reference.getTimezoneOffsets, + getKeys: Reference.getKeys, + }, + webhooks: { + getStatus: Webhooks.getStatus, + getEventFilters: Webhooks.getEventFilters, + listSubscriptions: Webhooks.listSubscriptions, + deleteSubscription: Webhooks.deleteSubscription, + }, + smail: { + sendDemo: Smail.sendDemo, + sendContact: Smail.sendContact, + sendMeet: Smail.sendMeet, + }, +} as const; + +/** + * Toggl's Webhooks API is not wired up in this plugin. The OSS catalog lists + * zero triggers for Toggl, so webhook support is tracked separately rather than + * shipped half-built here. + */ +const togglWebhooksNested = {} as const; + +export const togglEndpointSchemas = { + 'me.get': { + input: TogglEndpointInputSchemas.meGet, + output: TogglEndpointOutputSchemas.meGet, + }, + 'me.update': { + input: TogglEndpointInputSchemas.meUpdate, + output: TogglEndpointOutputSchemas.meUpdate, + }, + 'me.getPreferences': { + input: TogglEndpointInputSchemas.meGetPreferences, + output: TogglEndpointOutputSchemas.meGetPreferences, + }, + 'me.updatePreferences': { + input: TogglEndpointInputSchemas.meUpdatePreferences, + output: TogglEndpointOutputSchemas.meUpdatePreferences, + }, + 'workspaces.list': { + input: TogglEndpointInputSchemas.workspacesList, + output: TogglEndpointOutputSchemas.workspacesList, + }, + 'workspaces.get': { + input: TogglEndpointInputSchemas.workspacesGet, + output: TogglEndpointOutputSchemas.workspacesGet, + }, + 'workspaces.update': { + input: TogglEndpointInputSchemas.workspacesUpdate, + output: TogglEndpointOutputSchemas.workspacesUpdate, + }, + 'workspaces.getUsers': { + input: TogglEndpointInputSchemas.workspacesGetUsers, + output: TogglEndpointOutputSchemas.workspacesGetUsers, + }, + 'organizations.get': { + input: TogglEndpointInputSchemas.organizationsGet, + output: TogglEndpointOutputSchemas.organizationsGet, + }, + 'organizations.update': { + input: TogglEndpointInputSchemas.organizationsUpdate, + output: TogglEndpointOutputSchemas.organizationsUpdate, + }, + 'organizations.getWorkspaces': { + input: TogglEndpointInputSchemas.organizationsGetWorkspaces, + output: TogglEndpointOutputSchemas.organizationsGetWorkspaces, + }, + 'clients.list': { + input: TogglEndpointInputSchemas.clientsList, + output: TogglEndpointOutputSchemas.clientsList, + }, + 'clients.get': { + input: TogglEndpointInputSchemas.clientsGet, + output: TogglEndpointOutputSchemas.clientsGet, + }, + 'clients.create': { + input: TogglEndpointInputSchemas.clientsCreate, + output: TogglEndpointOutputSchemas.clientsCreate, + }, + 'clients.update': { + input: TogglEndpointInputSchemas.clientsUpdate, + output: TogglEndpointOutputSchemas.clientsUpdate, + }, + 'clients.archive': { + input: TogglEndpointInputSchemas.clientsArchive, + output: TogglEndpointOutputSchemas.clientsArchive, + }, + 'clients.delete': { + input: TogglEndpointInputSchemas.clientsDelete, + output: TogglEndpointOutputSchemas.clientsDelete, + }, + 'projects.list': { + input: TogglEndpointInputSchemas.projectsList, + output: TogglEndpointOutputSchemas.projectsList, + }, + 'projects.get': { + input: TogglEndpointInputSchemas.projectsGet, + output: TogglEndpointOutputSchemas.projectsGet, + }, + 'projects.create': { + input: TogglEndpointInputSchemas.projectsCreate, + output: TogglEndpointOutputSchemas.projectsCreate, + }, + 'projects.update': { + input: TogglEndpointInputSchemas.projectsUpdate, + output: TogglEndpointOutputSchemas.projectsUpdate, + }, + 'projects.delete': { + input: TogglEndpointInputSchemas.projectsDelete, + output: TogglEndpointOutputSchemas.projectsDelete, + }, + 'tasks.list': { + input: TogglEndpointInputSchemas.tasksList, + output: TogglEndpointOutputSchemas.tasksList, + }, + 'tasks.get': { + input: TogglEndpointInputSchemas.tasksGet, + output: TogglEndpointOutputSchemas.tasksGet, + }, + 'tasks.create': { + input: TogglEndpointInputSchemas.tasksCreate, + output: TogglEndpointOutputSchemas.tasksCreate, + }, + 'tasks.update': { + input: TogglEndpointInputSchemas.tasksUpdate, + output: TogglEndpointOutputSchemas.tasksUpdate, + }, + 'tasks.delete': { + input: TogglEndpointInputSchemas.tasksDelete, + output: TogglEndpointOutputSchemas.tasksDelete, + }, + 'tags.list': { + input: TogglEndpointInputSchemas.tagsList, + output: TogglEndpointOutputSchemas.tagsList, + }, + 'tags.create': { + input: TogglEndpointInputSchemas.tagsCreate, + output: TogglEndpointOutputSchemas.tagsCreate, + }, + 'tags.update': { + input: TogglEndpointInputSchemas.tagsUpdate, + output: TogglEndpointOutputSchemas.tagsUpdate, + }, + 'tags.delete': { + input: TogglEndpointInputSchemas.tagsDelete, + output: TogglEndpointOutputSchemas.tagsDelete, + }, + 'timeEntries.list': { + input: TogglEndpointInputSchemas.timeEntriesList, + output: TogglEndpointOutputSchemas.timeEntriesList, + }, + 'timeEntries.getCurrent': { + input: TogglEndpointInputSchemas.timeEntriesGetCurrent, + output: TogglEndpointOutputSchemas.timeEntriesGetCurrent, + }, + 'timeEntries.get': { + input: TogglEndpointInputSchemas.timeEntriesGet, + output: TogglEndpointOutputSchemas.timeEntriesGet, + }, + 'timeEntries.create': { + input: TogglEndpointInputSchemas.timeEntriesCreate, + output: TogglEndpointOutputSchemas.timeEntriesCreate, + }, + 'timeEntries.update': { + input: TogglEndpointInputSchemas.timeEntriesUpdate, + output: TogglEndpointOutputSchemas.timeEntriesUpdate, + }, + 'timeEntries.stop': { + input: TogglEndpointInputSchemas.timeEntriesStop, + output: TogglEndpointOutputSchemas.timeEntriesStop, + }, + 'timeEntries.delete': { + input: TogglEndpointInputSchemas.timeEntriesDelete, + output: TogglEndpointOutputSchemas.timeEntriesDelete, + }, + 'me.getLogged': { + input: TogglEndpointInputSchemas.meGetLogged, + output: TogglEndpointOutputSchemas.meGetLogged, + }, + 'me.getLocation': { + input: TogglEndpointInputSchemas.meGetLocation, + output: TogglEndpointOutputSchemas.meGetLocation, + }, + 'me.getQuota': { + input: TogglEndpointInputSchemas.meGetQuota, + output: TogglEndpointOutputSchemas.meGetQuota, + }, + 'me.getClients': { + input: TogglEndpointInputSchemas.meGetClients, + output: TogglEndpointOutputSchemas.meGetClients, + }, + 'me.getProjects': { + input: TogglEndpointInputSchemas.meGetProjects, + output: TogglEndpointOutputSchemas.meGetProjects, + }, + 'me.getTags': { + input: TogglEndpointInputSchemas.meGetTags, + output: TogglEndpointOutputSchemas.meGetTags, + }, + 'me.getTasks': { + input: TogglEndpointInputSchemas.meGetTasks, + output: TogglEndpointOutputSchemas.meGetTasks, + }, + 'me.disableProductEmails': { + input: TogglEndpointInputSchemas.meDisableProductEmails, + output: TogglEndpointOutputSchemas.meDisableProductEmails, + }, + 'me.disableWeeklyReport': { + input: TogglEndpointInputSchemas.meDisableWeeklyReport, + output: TogglEndpointOutputSchemas.meDisableWeeklyReport, + }, + 'reference.getCountries': { + input: TogglEndpointInputSchemas.referenceGetCountries, + output: TogglEndpointOutputSchemas.referenceGetCountries, + }, + 'reference.getCountrySubdivisions': { + input: TogglEndpointInputSchemas.referenceGetCountrySubdivisions, + output: TogglEndpointOutputSchemas.referenceGetCountrySubdivisions, + }, + 'reference.getCurrencies': { + input: TogglEndpointInputSchemas.referenceGetCurrencies, + output: TogglEndpointOutputSchemas.referenceGetCurrencies, + }, + 'reference.getTimezones': { + input: TogglEndpointInputSchemas.referenceGetTimezones, + output: TogglEndpointOutputSchemas.referenceGetTimezones, + }, + 'reference.getTimezoneOffsets': { + input: TogglEndpointInputSchemas.referenceGetTimezoneOffsets, + output: TogglEndpointOutputSchemas.referenceGetTimezoneOffsets, + }, + 'reference.getKeys': { + input: TogglEndpointInputSchemas.referenceGetKeys, + output: TogglEndpointOutputSchemas.referenceGetKeys, + }, + 'organizations.create': { + input: TogglEndpointInputSchemas.organizationsCreate, + output: TogglEndpointOutputSchemas.organizationsCreate, + }, + 'organizations.getGroups': { + input: TogglEndpointInputSchemas.organizationsGetGroups, + output: TogglEndpointOutputSchemas.organizationsGetGroups, + }, + 'organizations.createGroup': { + input: TogglEndpointInputSchemas.organizationsCreateGroup, + output: TogglEndpointOutputSchemas.organizationsCreateGroup, + }, + 'organizations.deleteGroup': { + input: TogglEndpointInputSchemas.organizationsDeleteGroup, + output: TogglEndpointOutputSchemas.organizationsDeleteGroup, + }, + 'organizations.getUsers': { + input: TogglEndpointInputSchemas.organizationsGetUsers, + output: TogglEndpointOutputSchemas.organizationsGetUsers, + }, + 'organizations.createInvitation': { + input: TogglEndpointInputSchemas.organizationsCreateInvitation, + output: TogglEndpointOutputSchemas.organizationsCreateInvitation, + }, + 'organizations.getPlans': { + input: TogglEndpointInputSchemas.organizationsGetPlans, + output: TogglEndpointOutputSchemas.organizationsGetPlans, + }, + 'organizations.getSubscriptionPlans': { + input: TogglEndpointInputSchemas.organizationsGetSubscriptionPlans, + output: TogglEndpointOutputSchemas.organizationsGetSubscriptionPlans, + }, + 'workspaces.getLogo': { + input: TogglEndpointInputSchemas.workspacesGetLogo, + output: TogglEndpointOutputSchemas.workspacesGetLogo, + }, + 'workspaces.getPreferences': { + input: TogglEndpointInputSchemas.workspacesGetPreferences, + output: TogglEndpointOutputSchemas.workspacesGetPreferences, + }, + 'projects.addUser': { + input: TogglEndpointInputSchemas.projectsAddUser, + output: TogglEndpointOutputSchemas.projectsAddUser, + }, + 'projects.deleteGroup': { + input: TogglEndpointInputSchemas.projectsDeleteGroup, + output: TogglEndpointOutputSchemas.projectsDeleteGroup, + }, + 'timeEntries.bulkEdit': { + input: TogglEndpointInputSchemas.timeEntriesBulkEdit, + output: TogglEndpointOutputSchemas.timeEntriesBulkEdit, + }, + 'webhooks.getStatus': { + input: TogglEndpointInputSchemas.webhooksGetStatus, + output: TogglEndpointOutputSchemas.webhooksGetStatus, + }, + 'webhooks.getEventFilters': { + input: TogglEndpointInputSchemas.webhooksGetEventFilters, + output: TogglEndpointOutputSchemas.webhooksGetEventFilters, + }, + 'webhooks.listSubscriptions': { + input: TogglEndpointInputSchemas.webhooksListSubscriptions, + output: TogglEndpointOutputSchemas.webhooksListSubscriptions, + }, + 'webhooks.deleteSubscription': { + input: TogglEndpointInputSchemas.webhooksDeleteSubscription, + output: TogglEndpointOutputSchemas.webhooksDeleteSubscription, + }, + 'smail.sendDemo': { + input: TogglEndpointInputSchemas.smailSendDemo, + output: TogglEndpointOutputSchemas.smailSendDemo, + }, + 'smail.sendContact': { + input: TogglEndpointInputSchemas.smailSendContact, + output: TogglEndpointOutputSchemas.smailSendContact, + }, + 'smail.sendMeet': { + input: TogglEndpointInputSchemas.smailSendMeet, + output: TogglEndpointOutputSchemas.smailSendMeet, + }, +} as const satisfies RequiredPluginEndpointSchemas; + +const togglWebhookSchemas = {} as const satisfies RequiredPluginWebhookSchemas< + typeof togglWebhooksNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const togglEndpointMeta = { + 'me.get': { + riskLevel: 'read', + description: 'Get the authenticated Toggl user', + }, + 'me.update': { + riskLevel: 'write', + description: 'Update the authenticated user profile', + }, + 'me.getPreferences': { + riskLevel: 'read', + description: 'Get the authenticated user preferences', + }, + 'me.updatePreferences': { + riskLevel: 'write', + description: 'Update the authenticated user preferences', + }, + 'workspaces.list': { + riskLevel: 'read', + description: 'List workspaces the user belongs to', + }, + 'workspaces.get': { riskLevel: 'read', description: 'Get a workspace by id' }, + 'workspaces.update': { + riskLevel: 'write', + description: 'Update workspace settings', + }, + 'workspaces.getUsers': { + riskLevel: 'read', + description: 'List users in a workspace', + }, + 'organizations.get': { + riskLevel: 'read', + description: 'Get an organization by id', + }, + 'organizations.update': { + riskLevel: 'write', + description: 'Rename an organization', + }, + 'organizations.getWorkspaces': { + riskLevel: 'read', + description: 'List workspaces in an organization', + }, + 'clients.list': { + riskLevel: 'read', + description: 'List clients in a workspace', + }, + 'clients.get': { riskLevel: 'read', description: 'Get a client by id' }, + 'clients.create': { riskLevel: 'write', description: 'Create a client' }, + 'clients.update': { + riskLevel: 'write', + description: 'Update or archive a client', + }, + 'clients.archive': { + riskLevel: 'write', + description: 'Archive a client', + }, + 'clients.delete': { + riskLevel: 'destructive', + description: 'Delete a client [DESTRUCTIVE]', + }, + 'projects.list': { + riskLevel: 'read', + description: 'List projects in a workspace', + }, + 'projects.get': { riskLevel: 'read', description: 'Get a project by id' }, + 'projects.create': { riskLevel: 'write', description: 'Create a project' }, + 'projects.update': { riskLevel: 'write', description: 'Update a project' }, + 'projects.delete': { + riskLevel: 'destructive', + description: 'Delete a project and its time entries [DESTRUCTIVE]', + }, + 'tasks.list': { riskLevel: 'read', description: 'List tasks in a project' }, + 'tasks.get': { riskLevel: 'read', description: 'Get a task by id' }, + 'tasks.create': { riskLevel: 'write', description: 'Create a task' }, + 'tasks.update': { riskLevel: 'write', description: 'Update a task' }, + 'tasks.delete': { + riskLevel: 'destructive', + description: 'Delete a task [DESTRUCTIVE]', + }, + 'tags.list': { riskLevel: 'read', description: 'List tags in a workspace' }, + 'tags.create': { riskLevel: 'write', description: 'Create a tag' }, + 'tags.update': { riskLevel: 'write', description: 'Rename a tag' }, + 'tags.delete': { + riskLevel: 'destructive', + description: 'Delete a tag [DESTRUCTIVE]', + }, + 'timeEntries.list': { + riskLevel: 'read', + description: 'List the current user time entries', + }, + 'timeEntries.getCurrent': { + riskLevel: 'read', + description: 'Get the currently running time entry, if any', + }, + 'timeEntries.get': { + riskLevel: 'read', + description: 'Get a time entry by id', + }, + 'timeEntries.create': { + riskLevel: 'write', + description: 'Create or start a time entry', + }, + 'timeEntries.update': { + riskLevel: 'write', + description: 'Update a time entry', + }, + 'timeEntries.stop': { + riskLevel: 'write', + description: 'Stop a running time entry', + }, + 'timeEntries.delete': { + riskLevel: 'destructive', + description: 'Delete a time entry [DESTRUCTIVE]', + }, + 'me.getLogged': { + riskLevel: 'read', + description: 'Check that the API token is valid', + }, + 'me.getLocation': { + riskLevel: 'read', + description: 'Get the last known location of the authenticated user', + }, + 'me.getQuota': { + riskLevel: 'read', + description: 'Get remaining API request quota per organization', + }, + 'me.getClients': { + riskLevel: 'read', + description: 'List clients across all workspaces the user can access', + }, + 'me.getProjects': { + riskLevel: 'read', + description: 'List projects across all workspaces the user can access', + }, + 'me.getTags': { + riskLevel: 'read', + description: 'List tags across all workspaces the user can access', + }, + 'me.getTasks': { + riskLevel: 'read', + description: 'List tasks across all workspaces the user can access', + }, + 'me.disableProductEmails': { + riskLevel: 'write', + description: 'Unsubscribe the account from Toggl product emails', + }, + 'me.disableWeeklyReport': { + riskLevel: 'write', + description: 'Unsubscribe the account from the weekly report email', + }, + 'reference.getCountries': { + riskLevel: 'read', + description: 'List countries Toggl supports, with VAT settings', + }, + 'reference.getCountrySubdivisions': { + riskLevel: 'read', + description: 'List states or provinces for a country id from getCountries', + }, + 'reference.getCurrencies': { + riskLevel: 'read', + description: 'List currencies Toggl supports', + }, + 'reference.getTimezones': { + riskLevel: 'read', + description: 'List timezones Toggl supports', + }, + 'reference.getTimezoneOffsets': { + riskLevel: 'read', + description: 'List timezones with their UTC offsets', + }, + 'reference.getKeys': { + riskLevel: 'read', + description: 'Get the JWKS keyset used to verify Toggl JWTs', + }, + 'organizations.create': { + riskLevel: 'write', + description: 'Create an organization and its first workspace', + }, + 'organizations.getGroups': { + riskLevel: 'read', + description: 'List groups in an organization', + }, + 'organizations.createGroup': { + riskLevel: 'write', + description: 'Create a group in an organization', + }, + 'organizations.deleteGroup': { + riskLevel: 'destructive', + description: 'Delete an organization group [DESTRUCTIVE]', + }, + 'organizations.getUsers': { + riskLevel: 'read', + description: 'List users in an organization', + }, + 'organizations.createInvitation': { + riskLevel: 'write', + description: 'Invite people to an organization by email', + }, + 'organizations.getPlans': { + riskLevel: 'read', + description: 'Get billing and plan details for an organization', + }, + 'organizations.getSubscriptionPlans': { + riskLevel: 'read', + description: 'List subscription plans available to an organization', + }, + 'workspaces.getLogo': { + riskLevel: 'read', + description: 'Get the workspace logo URL', + }, + 'workspaces.getPreferences': { + riskLevel: 'read', + description: 'Get workspace preferences', + }, + 'projects.addUser': { + riskLevel: 'write', + description: 'Add a user to a project', + }, + 'projects.deleteGroup': { + riskLevel: 'destructive', + description: 'Delete a project group [DESTRUCTIVE]', + }, + 'timeEntries.bulkEdit': { + riskLevel: 'write', + description: 'Bulk edit up to 100 time entries with JSON Patch', + }, + 'webhooks.getStatus': { + riskLevel: 'read', + description: 'Check the Toggl webhooks service status', + }, + 'webhooks.getEventFilters': { + riskLevel: 'read', + description: 'List event types available for webhook subscriptions', + }, + 'webhooks.listSubscriptions': { + riskLevel: 'read', + description: 'List webhook subscriptions for a workspace', + }, + 'webhooks.deleteSubscription': { + riskLevel: 'destructive', + description: 'Delete a webhook subscription [DESTRUCTIVE]', + }, + 'smail.sendDemo': { + riskLevel: 'write', + description: 'Send a product demo request email', + }, + 'smail.sendContact': { + riskLevel: 'write', + description: 'Send an email to a contact', + }, + 'smail.sendMeet': { + riskLevel: 'write', + description: 'Send a meeting invitation email', + }, +} as const satisfies RequiredPluginEndpointMeta; + +/** + * Toggl issues a single per-user API token with no OAuth flow, so account + * scoping keys off the tenant's external id. + */ +export const togglAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseTogglPlugin = CorsairPlugin< + 'toggl', + typeof TogglSchema, + typeof togglEndpointsNested, + typeof togglWebhooksNested, + T, + typeof defaultAuthType +>; + +export type InternalTogglPlugin = BaseTogglPlugin; + +export type ExternalTogglPlugin = + BaseTogglPlugin; + +/** + * Builds the Toggl Track plugin. + * + * Toggl authenticates with a per-user API token over HTTP Basic and has no + * OAuth flow, so only `api_key` auth is offered. + */ +export function toggl( + incomingOptions: TogglPluginOptions & T = {} as TogglPluginOptions & T, +): ExternalTogglPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'toggl', + authConfig: togglAuthConfig, + schema: TogglSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: togglEndpointsNested, + webhooks: togglWebhooksNested, + endpointMeta: togglEndpointMeta, + endpointSchemas: togglEndpointSchemas, + webhookSchemas: togglWebhookSchemas, + pluginWebhookMatcher: () => false, + pluginTenantWebhookMatcher: matchTogglTenantWebhook, + oauthWebhookTenantLinkResolver: resolveTogglOAuthWebhookTenantLink, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: TogglKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalTogglPlugin; +} + +export type { + TogglClient, + TogglEndpointInputs, + TogglEndpointOutputs, + TogglOrganization, + TogglProject, + TogglTag, + TogglTask, + TogglTimeEntry, + TogglUser, + TogglWorkspace, +} from './endpoints/types'; +export type { TogglWebhookOutputs } from './webhooks/types'; diff --git a/packages/toggl/integration.test.ts b/packages/toggl/integration.test.ts new file mode 100644 index 000000000..ebfe3fbdb --- /dev/null +++ b/packages/toggl/integration.test.ts @@ -0,0 +1,234 @@ +/** + * Live integration tests against the real Toggl Track API. + * + * These are excluded from CI (`--testPathIgnorePatterns` in pr-checks.yml) + * because CI has no Toggl credentials. Run them locally to verify the plugin + * against a real account: + * + * TOGGL_API_TOKEN= TOGGL_WORKSPACE_ID= pnpm exec jest integration + * + * The suite skips itself when TOGGL_API_TOKEN is absent, so it never fails a + * checkout that has no credentials. + * + * Everything created is registered for cleanup immediately and removed in + * afterEach, so a failing assertion mid-test cannot leave residue behind in the + * Toggl account. + */ +import { makeTogglRequest } from './client'; +import { + TogglClientSchema, + TogglEndpointOutputSchemas, + TogglProjectSchema, + TogglTagSchema, + TogglTimeEntrySchema, + TogglUserSchema, + TogglWorkspaceSchema, +} from './endpoints/types'; + +const TOKEN = process.env.TOGGL_API_TOKEN; +const WORKSPACE_ID = process.env.TOGGL_WORKSPACE_ID + ? Number(process.env.TOGGL_WORKSPACE_ID) + : undefined; + +const describeLive = TOKEN ? describe : describe.skip; + +// Toggl paces requests at roughly 1/sec per token; keep a margin between calls. +const PACE_MS = 1100; + +/** + * Waits out Toggl's leaky bucket so a live run does not spend its retry budget + * on self-inflicted 429s. + */ +const pace = () => new Promise((resolve) => setTimeout(resolve, PACE_MS)); + +describeLive('Toggl live API', () => { + jest.setTimeout(180_000); + + const token = TOKEN as string; + let workspaceId: number; + + /** Paths to DELETE once the current test finishes, however it finishes. */ + let cleanup: string[] = []; + + function disposable(path: string) { + cleanup.push(path); + } + + afterEach(async () => { + for (const path of cleanup.reverse()) { + try { + await pace(); + await makeTogglRequest(path, token, { method: 'DELETE' }); + } catch (error) { + console.warn(`[cleanup] could not delete ${path}:`, error); + } + } + cleanup = []; + }); + + beforeAll(async () => { + if (WORKSPACE_ID) { + workspaceId = WORKSPACE_ID; + return; + } + const workspaces = await makeTogglRequest('workspaces', token); + const parsed = TogglEndpointOutputSchemas.workspacesList.parse(workspaces); + const first = parsed[0]; + if (!first) { + throw new Error( + 'The Toggl account has no workspace. Set TOGGL_WORKSPACE_ID or create one.', + ); + } + workspaceId = first.id; + }); + + it('returns the authenticated user matching the declared schema', async () => { + // `beforeAll` may have just spent a request discovering the workspace, so + // this keeps the leaky-bucket margin even for the first test. + await pace(); + const me = await makeTogglRequest('me', token); + const parsed = TogglUserSchema.parse(me); + expect(typeof parsed.id).toBe('number'); + expect(parsed.email).toContain('@'); + }); + + it('never exposes the api_token through the declared user schema', async () => { + await pace(); + const me = await makeTogglRequest>('me', token); + // The raw provider response does carry the credential... + expect(me).toHaveProperty('api_token'); + // ...but the schema the plugin returns through must drop it. + expect(TogglUserSchema.parse(me)).not.toHaveProperty('api_token'); + }); + + it('returns workspaces matching the declared schema', async () => { + await pace(); + const workspaces = await makeTogglRequest('workspaces', token); + const parsed = TogglEndpointOutputSchemas.workspacesList.parse(workspaces); + expect(parsed.length).toBeGreaterThan(0); + const match = parsed.find((w) => w.id === workspaceId); + expect(TogglWorkspaceSchema.parse(match).id).toBe(workspaceId); + }); + + it('round-trips a client through create, read and delete', async () => { + await pace(); + const created = await makeTogglRequest( + `workspaces/${workspaceId}/clients`, + token, + { + method: 'POST', + body: { name: 'Corsair Test Client', wid: workspaceId }, + }, + ); + const client = TogglClientSchema.parse(created); + disposable(`workspaces/${workspaceId}/clients/${client.id}`); + expect(client.name).toBe('Corsair Test Client'); + + await pace(); + const fetched = await makeTogglRequest( + `workspaces/${workspaceId}/clients/${client.id}`, + token, + ); + expect(TogglClientSchema.parse(fetched).id).toBe(client.id); + }); + + it('round-trips a project through create, list and delete', async () => { + await pace(); + const created = await makeTogglRequest( + `workspaces/${workspaceId}/projects`, + token, + { method: 'POST', body: { name: 'Corsair Test Project', active: true } }, + ); + const project = TogglProjectSchema.parse(created); + disposable(`workspaces/${workspaceId}/projects/${project.id}`); + expect(project.name).toBe('Corsair Test Project'); + + await pace(); + const listed = await makeTogglRequest( + `workspaces/${workspaceId}/projects`, + token, + ); + const projects = TogglEndpointOutputSchemas.projectsList.parse(listed); + expect(projects.some((p) => p.id === project.id)).toBe(true); + }); + + it('round-trips a tag through create, rename and delete', async () => { + await pace(); + const created = await makeTogglRequest( + `workspaces/${workspaceId}/tags`, + token, + { method: 'POST', body: { name: 'corsair-test-tag' } }, + ); + const tag = TogglTagSchema.parse(created); + disposable(`workspaces/${workspaceId}/tags/${tag.id}`); + + await pace(); + const renamed = await makeTogglRequest( + `workspaces/${workspaceId}/tags/${tag.id}`, + token, + { method: 'PUT', body: { name: 'corsair-test-tag-renamed' } }, + ); + expect(TogglTagSchema.parse(renamed).name).toBe('corsair-test-tag-renamed'); + }); + + it('starts, stops and deletes a running time entry', async () => { + await pace(); + const started = await makeTogglRequest( + `workspaces/${workspaceId}/time_entries`, + token, + { + method: 'POST', + body: { + description: 'Corsair integration test', + start: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + // A negative duration marks the entry as still running. + duration: -1, + workspace_id: workspaceId, + created_with: 'corsair-toggl-plugin', + }, + }, + ); + const entry = TogglTimeEntrySchema.parse(started); + // Registered before any assertion, so a running timer cannot be orphaned. + disposable(`workspaces/${workspaceId}/time_entries/${entry.id}`); + expect(entry.duration).toBeLessThan(0); + + await pace(); + const current = await makeTogglRequest( + 'me/time_entries/current', + token, + ); + expect(TogglTimeEntrySchema.parse(current).id).toBe(entry.id); + + await pace(); + const stopped = await makeTogglRequest( + `workspaces/${workspaceId}/time_entries/${entry.id}/stop`, + token, + { method: 'PATCH' }, + ); + expect(TogglTimeEntrySchema.parse(stopped).duration).toBeGreaterThanOrEqual( + 0, + ); + }); + + it('lists time entries matching the declared schema', async () => { + await pace(); + const entries = await makeTogglRequest('me/time_entries', token); + const parsed = TogglEndpointOutputSchemas.timeEntriesList.parse( + entries ?? [], + ); + expect(Array.isArray(parsed)).toBe(true); + }); + + it('surfaces a clear error for an unknown resource', async () => { + await pace(); + // A syntactically valid id that cannot correspond to a real client, + // rather than a low id that might legitimately exist in the workspace. + await expect( + makeTogglRequest( + `workspaces/${workspaceId}/clients/999999999999`, + token, + ), + ).rejects.toThrow(); + }); +}); diff --git a/packages/toggl/jest.config.cjs b/packages/toggl/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/toggl/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/toggl/package.json b/packages/toggl/package.json new file mode 100644 index 000000000..4b41557ea --- /dev/null +++ b/packages/toggl/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/toggl", + "version": "0.1.0", + "description": "Toggl plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "toggl", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/toggl/persist.test.ts b/packages/toggl/persist.test.ts new file mode 100644 index 000000000..077a0cdce --- /dev/null +++ b/packages/toggl/persist.test.ts @@ -0,0 +1,104 @@ +/** + * The local cache is best effort: a provider call must still succeed when the + * mirror cannot be written. These tests pin that contract, including the + * consequence that a failed eviction leaves a stale record behind. + */ +import { + cacheClient, + cacheProject, + cacheTag, + cacheWorkspace, + evictEntity, +} from './endpoints/persist'; + +type Store = { + upsertByEntityId: jest.Mock; + deleteByEntityId: jest.Mock; +}; + +function makeStore(): Store { + return { + upsertByEntityId: jest.fn(async () => undefined), + deleteByEntityId: jest.fn(async () => true), + }; +} + +let warn: jest.SpyInstance; + +beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('cache writes', () => { + it('maps the client wid onto workspace_id', async () => { + const store = makeStore(); + await cacheClient(store, { id: 7, wid: 42, name: 'Acme' }); + expect(store.upsertByEntityId).toHaveBeenCalledWith( + '7', + expect.objectContaining({ workspace_id: 42, name: 'Acme' }), + ); + }); + + it('keys the row on the entity id as a string', async () => { + const store = makeStore(); + await cacheTag(store, { id: 9, workspace_id: 1, name: 'billable' }); + expect(store.upsertByEntityId.mock.calls[0]?.[0]).toBe('9'); + }); + + it('skips writing when there is no record', async () => { + const store = makeStore(); + await cacheProject(store, null); + await cacheWorkspace(store, undefined); + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('skips writing when the entity is not configured', async () => { + // A consumer may register the plugin without the optional entities. + await expect( + cacheClient(undefined, { id: 1, wid: 1, name: 'Acme' }), + ).resolves.toBeUndefined(); + }); +}); + +describe('cache failures are swallowed', () => { + it('does not reject when an upsert fails', async () => { + const store = makeStore(); + store.upsertByEntityId.mockRejectedValueOnce(new Error('db offline')); + + await expect( + cacheClient(store, { id: 1, wid: 1, name: 'Acme' }), + ).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); + + it('warns once per failed entity in a batch', async () => { + const store = makeStore(); + store.upsertByEntityId.mockRejectedValue(new Error('db offline')); + + await cacheProject(store, { id: 1, workspace_id: 1, name: 'A' }); + await cacheProject(store, { id: 2, workspace_id: 1, name: 'B' }); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('does not reject when an eviction fails, leaving the row stale', async () => { + const store = makeStore(); + store.deleteByEntityId.mockRejectedValueOnce(new Error('db offline')); + + await expect(evictEntity(store, 5, 'client')).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + // The record was never removed, so a later read still sees it. This is + // the accepted trade-off: a stale mirror rather than a failed API call. + expect(store.deleteByEntityId).toHaveBeenCalledWith('5'); + }); + + it('is a no-op when the store cannot delete', async () => { + await expect( + evictEntity({ deleteByEntityId: undefined }, 5, 'client'), + ).resolves.toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/toggl/schema.test.ts b/packages/toggl/schema.test.ts new file mode 100644 index 000000000..a7aef1b34 --- /dev/null +++ b/packages/toggl/schema.test.ts @@ -0,0 +1,412 @@ +import { + TogglClientSchema, + TogglEndpointInputSchemas, + TogglEndpointOutputSchemas, + TogglProjectSchema, + TogglTagSchema, + TogglTimeEntrySchema, + TogglUserSchema, + TogglWorkspaceSchema, +} from './endpoints/types'; +import { TogglSchema } from './schema'; + +describe('Toggl schema', () => { + it('declares a semver version', () => { + expect(TogglSchema.version).toBeDefined(); + expect(TogglSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('persists only the slow-changing structural entities', () => { + expect(Object.keys(TogglSchema.entities).sort()).toEqual([ + 'clients', + 'projects', + 'tags', + 'workspaces', + ]); + }); + + it('does not persist time entries', () => { + expect(TogglSchema.entities).not.toHaveProperty('timeEntries'); + }); +}); + +describe('entity schemas', () => { + // Captured from live Toggl Track API v9 responses. + const user = { + id: 1000001, + email: 'user@example.com', + fullname: 'Example User', + timezone: 'Asia/Colombo', + default_workspace_id: null, + beginning_of_week: 1, + country_id: 102, + has_password: false, + oauth_providers: ['google'], + created_at: '2026-08-12T09:38:11.608139Z', + }; + + const workspace = { + id: 3000001, + organization_id: 2000001, + name: 'Workspace', + premium: true, + admin: true, + role: 'admin', + default_currency: 'USD', + default_hourly_rate: null, + suspended_at: null, + at: '2026-08-12T09:48:00+00:00', + }; + + const client = { + id: 4000001, + wid: 3000001, + archived: false, + name: 'Acme Corp', + creator_id: 1000001, + at: '2026-08-12T09:50:41+00:00', + }; + + const project = { + id: 5000001, + workspace_id: 3000001, + client_id: 4000001, + name: 'Website Redesign', + is_private: false, + active: true, + server_deleted_at: null, + at: '2026-08-12T09:50:42+00:00', + }; + + const tag = { + id: 6000001, + workspace_id: 3000001, + name: 'billable', + creator_id: 1000001, + at: '2026-08-12T09:50:42.542251Z', + }; + + const timeEntry = { + id: 7000001, + workspace_id: 3000001, + project_id: 5000001, + task_id: null, + billable: false, + start: '2026-08-09T09:50:42Z', + stop: '2026-08-09T10:50:42Z', + duration: 3600, + description: 'Design review', + tags: ['billable'], + }; + + it('accepts a real /me payload', () => { + expect(TogglUserSchema.parse(user).id).toBe(1000001); + }); + + it('strips api_token out of a profile payload', () => { + // Toggl returns the account credential on /me; it must never survive + // into a value handed back to an endpoint consumer. + const parsed = TogglUserSchema.parse({ + ...user, + api_token: 'a-reusable-account-credential', + }); + expect(parsed).not.toHaveProperty('api_token'); + }); + + it('accepts a real workspace payload', () => { + const parsed = TogglWorkspaceSchema.parse(workspace); + expect(parsed.organization_id).toBe(2000001); + expect(parsed.role).toBe('admin'); + }); + + it('accepts a real client payload', () => { + expect(TogglClientSchema.parse(client).name).toBe('Acme Corp'); + }); + + it('accepts a real project payload', () => { + expect(TogglProjectSchema.parse(project).client_id).toBe(4000001); + }); + + it('accepts a real tag payload', () => { + expect(TogglTagSchema.parse(tag).name).toBe('billable'); + }); + + it('accepts a real time entry payload', () => { + const parsed = TogglTimeEntrySchema.parse(timeEntry); + expect(parsed.duration).toBe(3600); + expect(parsed.tags).toEqual(['billable']); + }); + + it('tolerates the nulls Toggl returns instead of omitting fields', () => { + const parsed = TogglWorkspaceSchema.parse({ + ...workspace, + default_hourly_rate: null, + suspended_at: null, + logo_url: null, + }); + expect(parsed.default_hourly_rate).toBeNull(); + }); + + it('rejects a payload missing a required id', () => { + expect(() => TogglClientSchema.parse({ name: 'No id' })).toThrow(); + }); + + it('rejects a time entry with a non-numeric duration', () => { + expect(() => + TogglTimeEntrySchema.parse({ ...timeEntry, duration: 'an hour' }), + ).toThrow(); + }); +}); + +describe('endpoint schema registry', () => { + const inputKeys = Object.keys(TogglEndpointInputSchemas).sort(); + const outputKeys = Object.keys(TogglEndpointOutputSchemas).sort(); + + it('declares matching input and output schemas for every operation', () => { + expect(inputKeys).toEqual(outputKeys); + }); + + const groups = [ + 'clients', + 'me', + 'organizations', + 'projects', + 'reference', + 'smail', + 'tags', + 'tasks', + 'timeEntries', + 'webhooks', + 'workspaces', + ]; + + it('has at least one operation in every resource group', () => { + for (const group of groups) { + expect(inputKeys.some((key) => key.startsWith(group))).toBe(true); + } + }); + + it('assigns every operation to a known resource group', () => { + for (const key of inputKeys) { + expect(groups.some((group) => key.startsWith(group))).toBe(true); + } + }); + + it('declares a zod schema, not a bare object, for each side', () => { + for (const key of inputKeys) { + const input = (TogglEndpointInputSchemas as Record)[key]; + const output = (TogglEndpointOutputSchemas as Record)[ + key + ]; + expect(typeof (input as { parse?: unknown })?.parse).toBe('function'); + expect(typeof (output as { parse?: unknown })?.parse).toBe('function'); + } + }); +}); + +describe('input validation', () => { + it('requires a workspace id when creating a client', () => { + expect(() => + TogglEndpointInputSchemas.clientsCreate.parse({ name: 'Acme' }), + ).toThrow(); + }); + + it('rejects an empty client name', () => { + expect(() => + TogglEndpointInputSchemas.clientsCreate.parse({ + workspace_id: 1, + name: '', + }), + ).toThrow(); + }); + + it('requires start and duration when creating a time entry', () => { + expect(() => + TogglEndpointInputSchemas.timeEntriesCreate.parse({ + workspace_id: 1, + description: 'no start', + }), + ).toThrow(); + }); + + it('accepts a negative duration to mark a running timer', () => { + const parsed = TogglEndpointInputSchemas.timeEntriesCreate.parse({ + workspace_id: 3000001, + start: '2026-08-12T10:00:00Z', + duration: -1, + }); + expect(parsed.duration).toBe(-1); + }); + + it('rejects a fractional resource id', () => { + expect(() => + TogglEndpointInputSchemas.clientsGet.parse({ + workspace_id: 1, + client_id: 1.5, + }), + ).toThrow(); + }); + + it('rejects a non-RFC3339 time entry start', () => { + expect(() => + TogglEndpointInputSchemas.timeEntriesCreate.parse({ + workspace_id: 1, + start: '12/08/2026', + // A plainly valid duration, so only `start` can fail the parse. + duration: 60, + }), + ).toThrow(); + }); + + it('accepts an offset timestamp as well as plain UTC', () => { + const parsed = TogglEndpointInputSchemas.timeEntriesCreate.parse({ + workspace_id: 1, + start: '2026-08-12T10:00:00+02:00', + duration: 60, + }); + expect(parsed.start).toBe('2026-08-12T10:00:00+02:00'); + }); + + it('rejects a fractional duration', () => { + expect(() => + TogglEndpointInputSchemas.timeEntriesCreate.parse({ + workspace_id: 1, + start: '2026-08-12T10:00:00Z', + duration: 1.5, + }), + ).toThrow(); + }); + + it('requires a value on add and replace bulk-edit operations', () => { + const base = { workspace_id: 1, time_entry_ids: [1] }; + for (const op of ['add', 'replace'] as const) { + expect(() => + TogglEndpointInputSchemas.timeEntriesBulkEdit.parse({ + ...base, + operations: [{ op, path: '/description' }], + }), + ).toThrow(); + } + // remove carries no value, per RFC 6902. + expect( + TogglEndpointInputSchemas.timeEntriesBulkEdit.parse({ + ...base, + operations: [{ op: 'remove', path: '/description' }], + }).operations, + ).toHaveLength(1); + }); + + it('accepts an unscoped quota record', () => { + const parsed = TogglEndpointOutputSchemas.meGetQuota.parse([ + { organization_id: null, remaining: 600, total: 600 }, + ]); + expect(parsed[0]?.organization_id).toBeNull(); + }); + + it('accepts either archive response shape', () => { + expect( + TogglEndpointOutputSchemas.clientsArchive.parse({ items: [1, 2] }), + ).toMatchObject({ items: [1, 2] }); + expect( + TogglEndpointOutputSchemas.clientsArchive.parse({ + id: 1, + name: 'Acme', + archived: true, + }), + ).toMatchObject({ id: 1, archived: true }); + }); + + it('caps project pagination at Toggl’s per_page maximum', () => { + expect(() => + TogglEndpointInputSchemas.projectsList.parse({ + workspace_id: 1, + per_page: 500, + }), + ).toThrow(); + expect( + TogglEndpointInputSchemas.projectsList.parse({ + workspace_id: 1, + per_page: 200, + }).per_page, + ).toBe(200); + }); + + it('constrains beginning_of_week to a weekday index', () => { + expect(() => + TogglEndpointInputSchemas.meUpdate.parse({ beginning_of_week: 9 }), + ).toThrow(); + }); + + it('models a delete result as an explicit typed value', () => { + const parsed = TogglEndpointOutputSchemas.clientsDelete.parse({ + deleted: true, + id: 4000001, + }); + expect(parsed).toEqual({ deleted: true, id: 4000001 }); + }); + + it('allows a null current time entry when no timer runs', () => { + expect( + TogglEndpointOutputSchemas.timeEntriesGetCurrent.parse(null), + ).toBeNull(); + }); + + it('requires start_date and end_date to be supplied together', () => { + const schema = TogglEndpointInputSchemas.timeEntriesList; + expect(() => schema.parse({ start_date: '2026-08-01' })).toThrow(); + expect(() => schema.parse({ end_date: '2026-08-12' })).toThrow(); + expect( + schema.parse({ start_date: '2026-08-01', end_date: '2026-08-12' }) + .start_date, + ).toBe('2026-08-01'); + // Neither supplied is fine — it just means "recent entries". + expect(schema.parse({})).toEqual({}); + }); + + it('accepts pagination on the tag list', () => { + const parsed = TogglEndpointInputSchemas.tagsList.parse({ + workspace_id: 1, + page: 2, + per_page: 50, + }); + expect(parsed.per_page).toBe(50); + }); + + it('makes project_id optional on the task list', () => { + expect( + TogglEndpointInputSchemas.tasksList.parse({ workspace_id: 1 }).project_id, + ).toBeUndefined(); + }); + + it('requires a name on every client update', () => { + expect(() => + TogglEndpointInputSchemas.clientsUpdate.parse({ + workspace_id: 1, + client_id: 2, + notes: 'only notes', + }), + ).toThrow(); + }); +}); + +describe('schema strictness', () => { + it('keeps unknown provider fields on entity schemas', () => { + // Toggl adds fields over time; dropping them would make the plugin lossy. + const parsed = TogglClientSchema.parse({ + id: 1, + name: 'Acme', + some_new_toggl_field: 'kept', + }) as Record; + expect(parsed.some_new_toggl_field).toBe('kept'); + }); + + it('still strips api_token from the user schema', () => { + // The one schema that must stay strict. + const parsed = TogglUserSchema.parse({ + id: 1, + email: 'user@example.com', + api_token: 'a-reusable-account-credential', + }); + expect(parsed).not.toHaveProperty('api_token'); + }); +}); diff --git a/packages/toggl/schema/database.ts b/packages/toggl/schema/database.ts new file mode 100644 index 000000000..c35991666 --- /dev/null +++ b/packages/toggl/schema/database.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +/** + * Locally persisted Toggl entities. + * + * Only the slow-changing structural records are stored: workspaces, clients, + * projects and tags. These are read constantly to resolve the ids that almost + * every other call needs, they change rarely, and Toggl paces requests at about + * one per second — so caching them locally avoids burning the rate limit on + * lookups. + * + * Time entries are deliberately NOT stored. They are high-volume, mutate while + * a timer runs, and are almost always wanted as a live view rather than a + * stale local copy. + */ + +export const TogglWorkspaceEntity = z.object({ + id: z.number(), + organization_id: z.number().nullable().optional(), + name: z.string(), + premium: z.boolean().nullable().optional(), + role: z.string().nullable().optional(), + default_currency: z.string().nullable().optional(), + at: z.coerce.date().nullable().optional(), +}); +export type TogglWorkspaceEntity = z.infer; + +export const TogglClientEntity = z.object({ + id: z.number(), + workspace_id: z.number().nullable().optional(), + name: z.string(), + archived: z.boolean().nullable().optional(), + at: z.coerce.date().nullable().optional(), +}); +export type TogglClientEntity = z.infer; + +export const TogglProjectEntity = z.object({ + id: z.number(), + workspace_id: z.number().nullable().optional(), + client_id: z.number().nullable().optional(), + name: z.string(), + active: z.boolean().nullable().optional(), + billable: z.boolean().nullable().optional(), + color: z.string().nullable().optional(), + at: z.coerce.date().nullable().optional(), +}); +export type TogglProjectEntity = z.infer; + +export const TogglTagEntity = z.object({ + id: z.number(), + workspace_id: z.number().nullable().optional(), + name: z.string(), + at: z.coerce.date().nullable().optional(), +}); +export type TogglTagEntity = z.infer; diff --git a/packages/toggl/schema/index.ts b/packages/toggl/schema/index.ts new file mode 100644 index 000000000..750c967a8 --- /dev/null +++ b/packages/toggl/schema/index.ts @@ -0,0 +1,16 @@ +import { + TogglClientEntity, + TogglProjectEntity, + TogglTagEntity, + TogglWorkspaceEntity, +} from './database'; + +export const TogglSchema = { + version: '1.0.0', + entities: { + workspaces: TogglWorkspaceEntity, + clients: TogglClientEntity, + projects: TogglProjectEntity, + tags: TogglTagEntity, + }, +} as const; diff --git a/packages/toggl/tsconfig.json b/packages/toggl/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/toggl/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/toggl/tsup.config.ts b/packages/toggl/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/toggl/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/toggl/webhooks/index.ts b/packages/toggl/webhooks/index.ts new file mode 100644 index 000000000..07a99b8fe --- /dev/null +++ b/packages/toggl/webhooks/index.ts @@ -0,0 +1,3 @@ +export * from './oauth-tenant-link'; +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/toggl/webhooks/oauth-tenant-link.ts b/packages/toggl/webhooks/oauth-tenant-link.ts new file mode 100644 index 000000000..22949fd5d --- /dev/null +++ b/packages/toggl/webhooks/oauth-tenant-link.ts @@ -0,0 +1,18 @@ +import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; +import { toExternalId } from 'corsair/core'; + +/** + * Toggl authenticates with a per-user API token and exposes no OAuth flow, so + * there is no token response to derive a routing id from. Tenant linking is + * handled entirely by `matchTogglTenantWebhook` instead. + */ +export async function resolveTogglOAuthWebhookTenantLink( + tokens: TokenResponse, +): Promise { + const externalId = toExternalId(tokens.tenant_external_id); + if (externalId) { + return { linkType: 'tenant_external_id', externalId }; + } + + return null; +} diff --git a/packages/toggl/webhooks/tenant-matcher.ts b/packages/toggl/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..cbdf8a2fd --- /dev/null +++ b/packages/toggl/webhooks/tenant-matcher.ts @@ -0,0 +1,31 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, firstString, readBodyRecord } from 'corsair/core'; + +/** + * Routes an inbound Toggl webhook to a tenant. + * + * Toggl scopes webhook subscriptions to a workspace and includes the workspace + * id in the event metadata, so that is the stable external id to route on. It + * lines up with `togglAuthConfig.api_key.account`. + * + * No webhook handlers are registered yet, so in practice this is not reached; + * it is kept correct so enabling subscriptions later does not require rework. + */ +export function matchTogglTenantWebhook( + request: RawWebhookRequest, +): WebhookTenantMatch | null { + const body = readBodyRecord(request); + if (!body) return null; + + const metadata = asRecord(body.metadata); + const externalId = firstString([ + metadata?.workspace_id, + body.workspace_id, + asRecord(body.payload)?.workspace_id, + ]); + + // Subscription validation pings carry no workspace id. + if (!externalId) return null; + + return { linkType: 'tenant_external_id', externalId }; +} diff --git a/packages/toggl/webhooks/types.ts b/packages/toggl/webhooks/types.ts new file mode 100644 index 000000000..61dd36ea3 --- /dev/null +++ b/packages/toggl/webhooks/types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +/** + * Toggl ships a Webhooks API (https://api.track.toggl.com/webhooks/api/v1) with + * HMAC-signed payloads, but this plugin does not register any webhook handlers + * yet — the OSS catalog lists zero triggers for Toggl. + * + * The payload envelope is kept here so that adding subscriptions later is an + * additive change rather than a restructure. + */ +export const TogglWebhookPayloadSchema = z.object({ + event_id: z.string().optional(), + creator_id: z.number().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + payload: z.unknown().optional(), + subscription_id: z.number().optional(), + timestamp: z.string().optional(), +}); + +export type TogglWebhookPayload = z.infer; + +/** No webhook handlers are registered yet. */ +export type TogglWebhookOutputs = Record; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 020a9c76b..031e09710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3027,6 +3027,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/toggl: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/trello: devDependencies: '@types/jest':