From da7a5fd96fb998475cdf8a9ff9fabcaea22e4ea0 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 8 Jun 2026 18:52:46 +0900 Subject: [PATCH 1/4] feat(asana): add Asana project management API emulator Port the Asana emulator from the amondnet/minsu-lee/asana branch, adapted to this repo's conventions: package @pleaseai/emulate-asana, bun test, tsgo type-check, @emulators/core ^0.6.0 (Hono/Context imported from core instead of a direct hono dependency), and the repo lint style. Covers users, workspaces, teams, projects, sections, tasks, tags, stories, and webhooks over the Asana REST API v1.0. Registered in the emulate service registry (default port 4005) with seed config support. --- README.md | 2 + bun.lock | 14 + packages/asana/package.json | 40 ++ packages/asana/src/__tests__/asana.test.ts | 789 +++++++++++++++++++++ packages/asana/src/entities.ts | 137 ++++ packages/asana/src/helpers.ts | 286 ++++++++ packages/asana/src/index.ts | 285 ++++++++ packages/asana/src/routes/projects.ts | 212 ++++++ packages/asana/src/routes/sections.ts | 139 ++++ packages/asana/src/routes/stories.ts | 50 ++ packages/asana/src/routes/tags.ts | 159 +++++ packages/asana/src/routes/tasks.ts | 598 ++++++++++++++++ packages/asana/src/routes/teams.ts | 154 ++++ packages/asana/src/routes/users.ts | 56 ++ packages/asana/src/routes/webhooks.ts | 91 +++ packages/asana/src/routes/workspaces.ts | 45 ++ packages/asana/src/store.ts | 50 ++ packages/asana/tsconfig.json | 4 + packages/asana/tsup.config.ts | 8 + packages/emulate/package.json | 2 + packages/emulate/src/registry.ts | 25 +- 21 files changed, 3145 insertions(+), 1 deletion(-) create mode 100644 packages/asana/package.json create mode 100644 packages/asana/src/__tests__/asana.test.ts create mode 100644 packages/asana/src/entities.ts create mode 100644 packages/asana/src/helpers.ts create mode 100644 packages/asana/src/index.ts create mode 100644 packages/asana/src/routes/projects.ts create mode 100644 packages/asana/src/routes/sections.ts create mode 100644 packages/asana/src/routes/stories.ts create mode 100644 packages/asana/src/routes/tags.ts create mode 100644 packages/asana/src/routes/tasks.ts create mode 100644 packages/asana/src/routes/teams.ts create mode 100644 packages/asana/src/routes/users.ts create mode 100644 packages/asana/src/routes/webhooks.ts create mode 100644 packages/asana/src/routes/workspaces.ts create mode 100644 packages/asana/src/store.ts create mode 100644 packages/asana/tsconfig.json create mode 100644 packages/asana/tsup.config.ts diff --git a/README.md b/README.md index d4e6aba..2c5430a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ using [`@emulators/core`](https://www.npmjs.com/package/@emulators/core). | `tosspayments` | 4002 | Payment confirm/lookup/cancel, order lookup, checkout simulation, webhooks | | `firebase` | 4003 | Auth (Identity Toolkit REST), Secure Token, FCM v1 | | `supabase` | 4004 | GoTrue Auth (signup/token/user), PostgREST table CRUD + filters | +| `asana` | 4005 | Workspaces, teams, projects, sections, tasks, tags, stories, webhooks (REST API v1.0) | ## Getting started @@ -111,6 +112,7 @@ packages/ toss-payments/ # @pleaseai/emulate-toss-payments firebase/ # @pleaseai/emulate-firebase supabase/ # @pleaseai/emulate-supabase + asana/ # @pleaseai/emulate-asana docs/ EMULATOR-CONVENTIONS.md # guide for adding new emulators ``` diff --git a/bun.lock b/bun.lock index cf44d3d..86b5f69 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,17 @@ "typescript": "^6", }, }, + "packages/asana": { + "name": "@pleaseai/emulate-asana", + "version": "0.1.0", + "dependencies": { + "@emulators/core": "^0.6.0", + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^6", + }, + }, "packages/emulate": { "name": "@pleaseai/emulate", "version": "0.1.0", @@ -22,6 +33,7 @@ }, "dependencies": { "@emulators/core": "^0.6.0", + "@pleaseai/emulate-asana": "workspace:*", "@pleaseai/emulate-firebase": "workspace:*", "@pleaseai/emulate-kakao": "workspace:*", "@pleaseai/emulate-naver": "workspace:*", @@ -215,6 +227,8 @@ "@pleaseai/emulate": ["@pleaseai/emulate@workspace:packages/emulate"], + "@pleaseai/emulate-asana": ["@pleaseai/emulate-asana@workspace:packages/asana"], + "@pleaseai/emulate-firebase": ["@pleaseai/emulate-firebase@workspace:packages/firebase"], "@pleaseai/emulate-kakao": ["@pleaseai/emulate-kakao@workspace:packages/kakao"], diff --git a/packages/asana/package.json b/packages/asana/package.json new file mode 100644 index 0000000..baaaf99 --- /dev/null +++ b/packages/asana/package.json @@ -0,0 +1,40 @@ +{ + "name": "@pleaseai/emulate-asana", + "type": "module", + "version": "0.1.0", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/pleaseai/emulate.git", + "directory": "packages/asana" + }, + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "dev": "tsup --watch", + "test": "bun test", + "clean": "rm -rf dist .turbo", + "type-check": "tsgo --noEmit" + }, + "dependencies": { + "@emulators/core": "^0.6.0" + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^6" + } +} diff --git a/packages/asana/src/__tests__/asana.test.ts b/packages/asana/src/__tests__/asana.test.ts new file mode 100644 index 0000000..351cacb --- /dev/null +++ b/packages/asana/src/__tests__/asana.test.ts @@ -0,0 +1,789 @@ +import type { TokenMap } from '@emulators/core' +import { authMiddleware, createApiErrorHandler, createErrorHandler, Hono, Store, WebhookDispatcher } from '@emulators/core' +import { beforeEach, describe, expect, it } from 'bun:test' +import { asanaPlugin, getAsanaStore, seedFromConfig } from '../index.js' + +const base = 'http://localhost:4000' + +function createTestApp() { + const store = new Store() + const webhooks = new WebhookDispatcher() + const tokenMap: TokenMap = new Map() + tokenMap.set('test-token', { + login: 'dev@example.com', + id: 1, + scopes: [], + }) + + const app = new Hono() + app.onError(createApiErrorHandler()) + app.use('*', createErrorHandler()) + app.use('*', authMiddleware(tokenMap)) + asanaPlugin.register(app as any, store, webhooks, base, tokenMap) + asanaPlugin.seed?.(store, base) + seedFromConfig(store, base, { + workspaces: [{ name: 'Test Workspace', is_organization: true }], + users: [{ name: 'Developer', email: 'dev@example.com' }], + teams: [{ name: 'Engineering', workspace: 'Test Workspace' }], + projects: [{ name: 'Test Project', workspace: 'Test Workspace', team: 'Engineering', owner: 'Developer' }], + sections: [{ name: 'To Do', project: 'Test Project' }, { name: 'In Progress', project: 'Test Project' }], + tags: [{ name: 'urgent', workspace: 'Test Workspace', color: 'red' }], + }) + + return { app, store, webhooks, tokenMap } +} + +function authHeaders(): HeadersInit { + return { 'Authorization': 'Bearer test-token', 'Content-Type': 'application/json' } +} + +// ── Users ────────────────────────────────────────────────── + +describe('Asana - Users', () => { + let app: Hono + + beforeEach(() => { + app = createTestApp().app + }) + + it('GET /users/me returns authenticated user', async () => { + const res = await app.request(`${base}/api/1.0/users/me`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.email).toBe('dev@example.com') + expect(body.data.name).toBe('Developer') + expect(body.data.workspaces).toBeDefined() + }) + + it('GET /users/:gid returns user by gid', async () => { + const meRes = await app.request(`${base}/api/1.0/users/me`, { headers: authHeaders() }) + const me = (await meRes.json() as any).data + + const res = await app.request(`${base}/api/1.0/users/${me.gid}`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.gid).toBe(me.gid) + }) + + it('GET /users requires workspace parameter', async () => { + const res = await app.request(`${base}/api/1.0/users`, { headers: authHeaders() }) + expect(res.status).toBe(400) + }) + + it('GET /users lists users in workspace', async () => { + const meRes = await app.request(`${base}/api/1.0/users/me`, { headers: authHeaders() }) + const workspaces = (await meRes.json() as any).data.workspaces + const wsGid = workspaces[0].gid + + const res = await app.request(`${base}/api/1.0/users?workspace=${wsGid}`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.length).toBeGreaterThanOrEqual(1) + expect(body.next_page).toBeNull() + }) +}) + +// ── Workspaces ───────────────────────────────────────────── + +describe('Asana - Workspaces', () => { + let app: Hono + + beforeEach(() => { + app = createTestApp().app + }) + + it('GET /workspaces lists workspaces', async () => { + const res = await app.request(`${base}/api/1.0/workspaces`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.length).toBeGreaterThanOrEqual(1) + expect(body.data.some((w: any) => w.name === 'Test Workspace')).toBe(true) + }) + + it('PUT /workspaces/:gid updates workspace', async () => { + const listRes = await app.request(`${base}/api/1.0/workspaces`, { headers: authHeaders() }) + const ws = (await listRes.json() as any).data.find((w: any) => w.name === 'Test Workspace') + + const res = await app.request(`${base}/api/1.0/workspaces/${ws.gid}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Renamed Workspace' } }), + }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.name).toBe('Renamed Workspace') + }) +}) + +// ── Projects ─────────────────────────────────────────────── + +describe('Asana - Projects', () => { + let app: Hono + let workspaceGid: string + + beforeEach(async () => { + app = createTestApp().app + const wsRes = await app.request(`${base}/api/1.0/workspaces`, { headers: authHeaders() }) + const wsData = await wsRes.json() as any + workspaceGid = wsData.data.find((w: any) => w.name === 'Test Workspace').gid + }) + + it('GET /projects lists projects', async () => { + const res = await app.request(`${base}/api/1.0/projects?workspace=${workspaceGid}`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.length).toBeGreaterThanOrEqual(1) + }) + + it('POST /projects creates a project', async () => { + const res = await app.request(`${base}/api/1.0/projects`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'New Project', workspace: workspaceGid } }), + }) + expect(res.status).toBe(201) + const body = await res.json() as any + expect(body.data.name).toBe('New Project') + expect(body.data.gid).toBeDefined() + }) + + it('PUT /projects/:gid updates a project', async () => { + const listRes = await app.request(`${base}/api/1.0/projects?workspace=${workspaceGid}`, { headers: authHeaders() }) + const project = (await listRes.json() as any).data[0] + + const res = await app.request(`${base}/api/1.0/projects/${project.gid}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Updated Project' } }), + }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.name).toBe('Updated Project') + }) + + it('DELETE /projects/:gid deletes a project', async () => { + const createRes = await app.request(`${base}/api/1.0/projects`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'To Delete', workspace: workspaceGid } }), + }) + const { data: { gid } } = await createRes.json() as any + + const res = await app.request(`${base}/api/1.0/projects/${gid}`, { + method: 'DELETE', + headers: authHeaders(), + }) + expect(res.status).toBe(200) + + const getRes = await app.request(`${base}/api/1.0/projects/${gid}`, { headers: authHeaders() }) + expect(getRes.status).toBe(404) + }) + + it('GET /projects/:gid/task_counts returns counts', async () => { + const listRes = await app.request(`${base}/api/1.0/projects?workspace=${workspaceGid}`, { headers: authHeaders() }) + const project = (await listRes.json() as any).data[0] + + const res = await app.request(`${base}/api/1.0/projects/${project.gid}/task_counts`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.num_tasks).toBeDefined() + }) +}) + +// ── Sections ─────────────────────────────────────────────── + +describe('Asana - Sections', () => { + let app: Hono + let projectGid: string + + beforeEach(async () => { + const testApp = createTestApp() + app = testApp.app + const as = getAsanaStore(testApp.store) + projectGid = as.projects.all().find(p => p.name === 'Test Project')!.gid + }) + + it('GET /projects/:gid/sections lists sections', async () => { + const res = await app.request(`${base}/api/1.0/projects/${projectGid}/sections`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.length).toBe(2) + }) + + it('POST /projects/:gid/sections creates a section', async () => { + const res = await app.request(`${base}/api/1.0/projects/${projectGid}/sections`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Done' } }), + }) + expect(res.status).toBe(201) + const body = await res.json() as any + expect(body.data.name).toBe('Done') + }) + + it('DELETE /sections/:gid deletes a section', async () => { + const createRes = await app.request(`${base}/api/1.0/projects/${projectGid}/sections`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Temp' } }), + }) + const { data: { gid } } = await createRes.json() as any + + const res = await app.request(`${base}/api/1.0/sections/${gid}`, { + method: 'DELETE', + headers: authHeaders(), + }) + expect(res.status).toBe(200) + }) +}) + +// ── Tasks ────────────────────────────────────────────────── + +describe('Asana - Tasks', () => { + let app: Hono + let testStore: Store + let workspaceGid: string + let projectGid: string + + beforeEach(() => { + const testApp = createTestApp() + app = testApp.app + testStore = testApp.store + const as = getAsanaStore(testStore) + workspaceGid = as.workspaces.all().find(w => w.name === 'Test Workspace')!.gid + projectGid = as.projects.all().find(p => p.name === 'Test Project')!.gid + }) + + it('POST /tasks creates a task', async () => { + const res = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ + data: { + name: 'New Task', + workspace: workspaceGid, + projects: [projectGid], + }, + }), + }) + expect(res.status).toBe(201) + const body = await res.json() as any + expect(body.data.name).toBe('New Task') + expect(body.data.projects.length).toBe(1) + }) + + it('GET /tasks/:gid returns task details', async () => { + const createRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Detail Task', workspace: workspaceGid } }), + }) + const { data: { gid } } = await createRes.json() as any + + const res = await app.request(`${base}/api/1.0/tasks/${gid}`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.name).toBe('Detail Task') + expect(body.data.resource_type).toBe('task') + }) + + it('PUT /tasks/:gid updates a task', async () => { + const createRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'To Update', workspace: workspaceGid } }), + }) + const { data: { gid } } = await createRes.json() as any + + const res = await app.request(`${base}/api/1.0/tasks/${gid}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Updated', completed: true } }), + }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.name).toBe('Updated') + expect(body.data.completed).toBe(true) + expect(body.data.completed_at).toBeDefined() + }) + + it('DELETE /tasks/:gid deletes a task', async () => { + const createRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'To Delete', workspace: workspaceGid } }), + }) + const { data: { gid } } = await createRes.json() as any + + const res = await app.request(`${base}/api/1.0/tasks/${gid}`, { + method: 'DELETE', + headers: authHeaders(), + }) + expect(res.status).toBe(200) + + const getRes = await app.request(`${base}/api/1.0/tasks/${gid}`, { headers: authHeaders() }) + expect(getRes.status).toBe(404) + }) + + it('GET /tasks filters by project', async () => { + await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Project Task', workspace: workspaceGid, projects: [projectGid] } }), + }) + + const res = await app.request(`${base}/api/1.0/tasks?project=${projectGid}`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.length).toBeGreaterThanOrEqual(1) + }) + + it('subtasks - create and list', async () => { + const parentRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Parent Task', workspace: workspaceGid } }), + }) + const parentGid = (await parentRes.json() as any).data.gid + + const subRes = await app.request(`${base}/api/1.0/tasks/${parentGid}/subtasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Child Task' } }), + }) + expect(subRes.status).toBe(201) + const subBody = await subRes.json() as any + expect(subBody.data.parent.gid).toBe(parentGid) + + const listRes = await app.request(`${base}/api/1.0/tasks/${parentGid}/subtasks`, { headers: authHeaders() }) + const listBody = await listRes.json() as any + expect(listBody.data.length).toBe(1) + }) + + it('addProject / removeProject', async () => { + const taskRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Unassigned', workspace: workspaceGid } }), + }) + const taskGid = (await taskRes.json() as any).data.gid + + // Add to project + const addRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/addProject`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { project: projectGid } }), + }) + expect(addRes.status).toBe(200) + + const projRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/projects`, { headers: authHeaders() }) + const projBody = await projRes.json() as any + expect(projBody.data.length).toBe(1) + + // Remove from project + const removeRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/removeProject`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { project: projectGid } }), + }) + expect(removeRes.status).toBe(200) + + const projRes2 = await app.request(`${base}/api/1.0/tasks/${taskGid}/projects`, { headers: authHeaders() }) + const projBody2 = await projRes2.json() as any + expect(projBody2.data.length).toBe(0) + }) + + it('addTag / removeTag', async () => { + const taskRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Tagged', workspace: workspaceGid } }), + }) + const taskGid = (await taskRes.json() as any).data.gid + + const as = getAsanaStore(testStore) + const tagGid = as.tags.all()[0].gid + + await app.request(`${base}/api/1.0/tasks/${taskGid}/addTag`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { tag: tagGid } }), + }) + + const tagsRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/tags`, { headers: authHeaders() }) + const tagsBody = await tagsRes.json() as any + expect(tagsBody.data.length).toBe(1) + + await app.request(`${base}/api/1.0/tasks/${taskGid}/removeTag`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { tag: tagGid } }), + }) + + const tagsRes2 = await app.request(`${base}/api/1.0/tasks/${taskGid}/tags`, { headers: authHeaders() }) + const tagsBody2 = await tagsRes2.json() as any + expect(tagsBody2.data.length).toBe(0) + }) + + it('addDependencies / removeDependencies', async () => { + const task1Res = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Task A', workspace: workspaceGid } }), + }) + const task1Gid = (await task1Res.json() as any).data.gid + + const task2Res = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Task B', workspace: workspaceGid } }), + }) + const task2Gid = (await task2Res.json() as any).data.gid + + await app.request(`${base}/api/1.0/tasks/${task1Gid}/addDependencies`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { dependencies: [task2Gid] } }), + }) + + const depsRes = await app.request(`${base}/api/1.0/tasks/${task1Gid}/dependencies`, { headers: authHeaders() }) + const depsBody = await depsRes.json() as any + expect(depsBody.data.length).toBe(1) + expect(depsBody.data[0].gid).toBe(task2Gid) + + // Check dependents from the other side + const deptsRes = await app.request(`${base}/api/1.0/tasks/${task2Gid}/dependents`, { headers: authHeaders() }) + const deptsBody = await deptsRes.json() as any + expect(deptsBody.data.length).toBe(1) + expect(deptsBody.data[0].gid).toBe(task1Gid) + + await app.request(`${base}/api/1.0/tasks/${task1Gid}/removeDependencies`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { dependencies: [task2Gid] } }), + }) + + const depsRes2 = await app.request(`${base}/api/1.0/tasks/${task1Gid}/dependencies`, { headers: authHeaders() }) + const depsBody2 = await depsRes2.json() as any + expect(depsBody2.data.length).toBe(0) + }) + + it('setParent moves task to new parent', async () => { + const parentRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'New Parent', workspace: workspaceGid } }), + }) + const parentGid = (await parentRes.json() as any).data.gid + + const childRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Child', workspace: workspaceGid } }), + }) + const childGid = (await childRes.json() as any).data.gid + + const res = await app.request(`${base}/api/1.0/tasks/${childGid}/setParent`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { parent: parentGid } }), + }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.parent.gid).toBe(parentGid) + }) + + it('stories - create comment and list', async () => { + const taskRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'With Stories', workspace: workspaceGid } }), + }) + const taskGid = (await taskRes.json() as any).data.gid + + const commentRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/stories`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { text: 'This is a comment' } }), + }) + expect(commentRes.status).toBe(201) + const commentBody = await commentRes.json() as any + expect(commentBody.data.text).toBe('This is a comment') + expect(commentBody.data.type).toBe('comment') + + const listRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/stories`, { headers: authHeaders() }) + const listBody = await listRes.json() as any + expect(listBody.data.length).toBe(1) + }) +}) + +// ── Tags ─────────────────────────────────────────────────── + +describe('Asana - Tags', () => { + let app: Hono + let workspaceGid: string + + beforeEach(async () => { + const testApp = createTestApp() + app = testApp.app + workspaceGid = getAsanaStore(testApp.store).workspaces.all().find(w => w.name === 'Test Workspace')!.gid + }) + + it('POST /tags creates a tag', async () => { + const res = await app.request(`${base}/api/1.0/tags`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'bug', workspace: workspaceGid } }), + }) + expect(res.status).toBe(201) + const body = await res.json() as any + expect(body.data.name).toBe('bug') + }) + + it('GET /tags lists tags', async () => { + const res = await app.request(`${base}/api/1.0/tags?workspace=${workspaceGid}`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.length).toBeGreaterThanOrEqual(1) + }) + + it('PUT /tags/:gid updates a tag', async () => { + const createRes = await app.request(`${base}/api/1.0/tags`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'old-name', workspace: workspaceGid } }), + }) + const tagGid = (await createRes.json() as any).data.gid + + const res = await app.request(`${base}/api/1.0/tags/${tagGid}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'new-name' } }), + }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.name).toBe('new-name') + }) + + it('DELETE /tags/:gid deletes a tag', async () => { + const createRes = await app.request(`${base}/api/1.0/tags`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'to-delete', workspace: workspaceGid } }), + }) + const tagGid = (await createRes.json() as any).data.gid + + const res = await app.request(`${base}/api/1.0/tags/${tagGid}`, { + method: 'DELETE', + headers: authHeaders(), + }) + expect(res.status).toBe(200) + }) + + it('GET /workspaces/:gid/tags lists workspace tags', async () => { + const res = await app.request(`${base}/api/1.0/workspaces/${workspaceGid}/tags`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.length).toBeGreaterThanOrEqual(1) + }) +}) + +// ── Stories ──────────────────────────────────────────────── + +describe('Asana - Stories', () => { + let app: Hono + let workspaceGid: string + + beforeEach(async () => { + const testApp = createTestApp() + app = testApp.app + workspaceGid = getAsanaStore(testApp.store).workspaces.all().find(w => w.name === 'Test Workspace')!.gid + }) + + it('PUT /stories/:gid updates a story', async () => { + // Create task and story first + const taskRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Story Task', workspace: workspaceGid } }), + }) + const taskGid = (await taskRes.json() as any).data.gid + + const storyRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/stories`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { text: 'Original' } }), + }) + const storyGid = (await storyRes.json() as any).data.gid + + const res = await app.request(`${base}/api/1.0/stories/${storyGid}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ data: { text: 'Updated comment' } }), + }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.text).toBe('Updated comment') + }) + + it('DELETE /stories/:gid deletes a story', async () => { + const taskRes = await app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Del Story Task', workspace: workspaceGid } }), + }) + const taskGid = (await taskRes.json() as any).data.gid + + const storyRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/stories`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { text: 'To delete' } }), + }) + const storyGid = (await storyRes.json() as any).data.gid + + const res = await app.request(`${base}/api/1.0/stories/${storyGid}`, { + method: 'DELETE', + headers: authHeaders(), + }) + expect(res.status).toBe(200) + }) +}) + +// ── Teams ────────────────────────────────────────────────── + +describe('Asana - Teams', () => { + let app: Hono + let testStore: Store + let workspaceGid: string + + beforeEach(() => { + const testApp = createTestApp() + app = testApp.app + testStore = testApp.store + workspaceGid = getAsanaStore(testStore).workspaces.all().find(w => w.name === 'Test Workspace')!.gid + }) + + it('POST /teams creates a team', async () => { + const res = await app.request(`${base}/api/1.0/teams`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Design', organization: workspaceGid } }), + }) + expect(res.status).toBe(201) + const body = await res.json() as any + expect(body.data.name).toBe('Design') + }) + + it('GET /workspaces/:gid/teams lists teams', async () => { + const res = await app.request(`${base}/api/1.0/workspaces/${workspaceGid}/teams`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.length).toBeGreaterThanOrEqual(1) + }) + + it('addUser / removeUser manages membership', async () => { + const as = getAsanaStore(testStore) + const teamGid = as.teams.all()[0].gid + const userGid = as.users.all()[0].gid + + const addRes = await app.request(`${base}/api/1.0/teams/${teamGid}/addUser`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { user: userGid } }), + }) + expect(addRes.status).toBe(200) + + const usersRes = await app.request(`${base}/api/1.0/teams/${teamGid}/users`, { headers: authHeaders() }) + const usersBody = await usersRes.json() as any + expect(usersBody.data.length).toBe(1) + + await app.request(`${base}/api/1.0/teams/${teamGid}/removeUser`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { user: userGid } }), + }) + + const usersRes2 = await app.request(`${base}/api/1.0/teams/${teamGid}/users`, { headers: authHeaders() }) + const usersBody2 = await usersRes2.json() as any + expect(usersBody2.data.length).toBe(0) + }) +}) + +// ── Webhooks ─────────────────────────────────────────────── + +describe('Asana - Webhooks', () => { + let app: Hono + + beforeEach(() => { + app = createTestApp().app + }) + + it('CRUD webhooks', async () => { + // Create + const createRes = await app.request(`${base}/api/1.0/webhooks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { resource: '12345', target: 'https://example.com/webhook' } }), + }) + expect(createRes.status).toBe(201) + const { data: webhook } = await createRes.json() as any + expect(webhook.active).toBe(true) + + // Get + const getRes = await app.request(`${base}/api/1.0/webhooks/${webhook.gid}`, { headers: authHeaders() }) + expect(getRes.status).toBe(200) + + // List + const listRes = await app.request(`${base}/api/1.0/webhooks`, { headers: authHeaders() }) + const listBody = await listRes.json() as any + expect(listBody.data.length).toBeGreaterThanOrEqual(1) + + // Update + const updateRes = await app.request(`${base}/api/1.0/webhooks/${webhook.gid}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ data: { active: false } }), + }) + expect(updateRes.status).toBe(200) + const updatedBody = await updateRes.json() as any + expect(updatedBody.data.active).toBe(false) + + // Delete + const deleteRes = await app.request(`${base}/api/1.0/webhooks/${webhook.gid}`, { + method: 'DELETE', + headers: authHeaders(), + }) + expect(deleteRes.status).toBe(200) + }) +}) + +// ── Seed Config ──────────────────────────────────────────── + +describe('Asana - seedFromConfig', () => { + it('seeds all resource types from config', () => { + const { store } = createTestApp() + const as = getAsanaStore(store) + + expect(as.workspaces.all().length).toBeGreaterThanOrEqual(1) + expect(as.users.all().length).toBeGreaterThanOrEqual(1) + expect(as.teams.all().length).toBeGreaterThanOrEqual(1) + expect(as.projects.all().length).toBeGreaterThanOrEqual(1) + expect(as.sections.all().length).toBe(2) + expect(as.tags.all().length).toBeGreaterThanOrEqual(1) + }) + + it('does not create duplicates on re-seed', () => { + const store = new Store() + const config = { + workspaces: [{ name: 'WS' }], + users: [{ name: 'User', email: 'user@test.com' }], + } + + seedFromConfig(store, base, config) + seedFromConfig(store, base, config) + + const as = getAsanaStore(store) + expect(as.workspaces.all().filter(w => w.name === 'WS').length).toBe(1) + expect(as.users.all().filter(u => u.email === 'user@test.com').length).toBe(1) + }) +}) diff --git a/packages/asana/src/entities.ts b/packages/asana/src/entities.ts new file mode 100644 index 0000000..de9bc48 --- /dev/null +++ b/packages/asana/src/entities.ts @@ -0,0 +1,137 @@ +import type { Entity } from '@emulators/core' + +export interface AsanaUser extends Entity { + gid: string + resource_type: 'user' + name: string + email: string + photo: { + image_21x21: string + image_27x27: string + image_36x36: string + image_60x60: string + image_128x128: string + } | null +} + +export interface AsanaWorkspace extends Entity { + gid: string + resource_type: 'workspace' + name: string + is_organization: boolean + email_domains: string[] +} + +export interface AsanaTeam extends Entity { + gid: string + resource_type: 'team' + name: string + workspace_gid: string + description: string + html_description: string + visibility: 'secret' | 'request_to_join' | 'public' + permalink_url: string +} + +export interface AsanaTeamMembership extends Entity { + gid: string + resource_type: 'team_membership' + user_gid: string + team_gid: string + is_guest: boolean + is_admin: boolean +} + +export interface AsanaProject extends Entity { + gid: string + resource_type: 'project' + name: string + workspace_gid: string + owner_gid: string | null + team_gid: string | null + archived: boolean + color: string | null + notes: string + html_notes: string + privacy_setting: 'public_to_workspace' | 'private_to_team' | 'private' + default_view: 'list' | 'board' | 'calendar' | 'timeline' + completed: boolean + completed_at: string | null + permalink_url: string +} + +export interface AsanaSection extends Entity { + gid: string + resource_type: 'section' + name: string + project_gid: string +} + +export interface AsanaTask extends Entity { + gid: string + resource_type: 'task' + resource_subtype: 'default_task' | 'milestone' | 'approval' + name: string + assignee_gid: string | null + workspace_gid: string + completed: boolean + completed_at: string | null + due_on: string | null + due_at: string | null + start_on: string | null + notes: string + html_notes: string + liked: boolean + num_likes: number + parent_gid: string | null + permalink_url: string + follower_gids: string[] +} + +export interface AsanaTaskProject extends Entity { + task_gid: string + project_gid: string + section_gid: string | null +} + +export interface AsanaTaskTag extends Entity { + task_gid: string + tag_gid: string +} + +export interface AsanaTaskDependency extends Entity { + task_gid: string + dependency_gid: string +} + +export interface AsanaTag extends Entity { + gid: string + resource_type: 'tag' + name: string + workspace_gid: string + color: string | null + permalink_url: string +} + +export interface AsanaStory extends Entity { + gid: string + resource_type: 'story' + resource_subtype: string + task_gid: string + text: string + html_text: string + type: 'comment' | 'system' + is_editable: boolean + created_by_gid: string +} + +export interface AsanaWebhook extends Entity { + gid: string + resource_type: 'webhook' + resource_gid: string + target: string + active: boolean + last_success_at: string | null + last_failure_at: string | null + last_failure_content: string +} diff --git a/packages/asana/src/helpers.ts b/packages/asana/src/helpers.ts new file mode 100644 index 0000000..eee2012 --- /dev/null +++ b/packages/asana/src/helpers.ts @@ -0,0 +1,286 @@ +import type { ContentfulStatusCode, Context } from '@emulators/core' +import type { + AsanaProject, + AsanaSection, + AsanaStory, + AsanaTag, + AsanaTask, + AsanaTeam, + AsanaTeamMembership, + AsanaUser, + AsanaWebhook, + AsanaWorkspace, +} from './entities.js' +import type { AsanaStore } from './store.js' +import { randomBytes } from 'node:crypto' +import { escapeHtml } from '@emulators/core' + +export { escapeHtml } + +export function generateGid(): string { + const bytes = randomBytes(8) + let gid = '' + for (const b of bytes) { + gid += b.toString(10).padStart(3, '0') + } + return gid.slice(0, 16) +} + +export function asanaError(c: Context, status: number, message: string) { + return c.json({ errors: [{ message }] }, status as ContentfulStatusCode) +} + +export function asanaData(data: unknown) { + return { data } +} + +export function parsePagination(c: Context): { limit: number, offset: number } { + const limit = Math.min(Math.max(Number(c.req.query('limit')) || 20, 1), 100) + const offset = Math.max(Number(c.req.query('offset')) || 0, 0) + return { limit, offset } +} + +export function applyPagination( + items: T[], + pagination: { limit: number, offset: number }, + basePath: string, + baseUrl: string, +): { data: T[], next_page: { offset: string, path: string, uri: string } | null } { + const { limit, offset } = pagination + const paged = items.slice(offset, offset + limit) + const hasMore = offset + limit < items.length + + const nextPage = hasMore + ? { + offset: String(offset + limit), + path: `${basePath}?limit=${limit}&offset=${offset + limit}`, + uri: `${baseUrl}${basePath}?limit=${limit}&offset=${offset + limit}`, + } + : null + + return { data: paged, next_page: nextPage } +} + +export async function parseAsanaBody(c: Context): Promise> { + try { + const body = await c.req.json() + if (body && typeof body === 'object' && !Array.isArray(body)) { + const obj = body as Record + if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) { + return obj.data as Record + } + return obj + } + return {} + } + catch { + return {} + } +} + +export function compact(gid: string, resource_type: string, name?: string) { + const result: Record = { gid, resource_type } + if (name !== undefined) { + result.name = name + } + return result +} + +export function resolveUser(as: AsanaStore, login: string): AsanaUser | undefined { + return ( + as.users.findOneBy('gid', login) + ?? as.users.findOneBy('email', login) + ?? as.users.all().find(u => u.name === login) + ) +} + +function formatUser(user: AsanaUser) { + return { + gid: user.gid, + resource_type: user.resource_type, + name: user.name, + email: user.email, + photo: user.photo, + } +} + +export function formatUserWithWorkspaces(user: AsanaUser, as: AsanaStore) { + const workspaces = as.workspaces.all().map(w => compact(w.gid, w.resource_type, w.name)) + return { ...formatUser(user), workspaces } +} + +export function formatWorkspace(ws: AsanaWorkspace) { + return { + gid: ws.gid, + resource_type: ws.resource_type, + name: ws.name, + is_organization: ws.is_organization, + email_domains: ws.email_domains, + } +} + +export function formatProject(project: AsanaProject, as: AsanaStore, baseUrl: string) { + const owner = project.owner_gid ? as.users.findOneBy('gid', project.owner_gid) : null + const team = project.team_gid ? as.teams.findOneBy('gid', project.team_gid) : null + const workspace = as.workspaces.findOneBy('gid', project.workspace_gid) + + return { + gid: project.gid, + resource_type: project.resource_type, + name: project.name, + archived: project.archived, + color: project.color, + notes: project.notes, + html_notes: project.html_notes, + privacy_setting: project.privacy_setting, + default_view: project.default_view, + completed: project.completed, + completed_at: project.completed_at, + created_at: project.created_at, + modified_at: project.updated_at, + owner: owner ? compact(owner.gid, owner.resource_type, owner.name) : null, + team: team ? compact(team.gid, team.resource_type, team.name) : null, + workspace: workspace ? compact(workspace.gid, workspace.resource_type, workspace.name) : null, + permalink_url: project.permalink_url || `${baseUrl}/0/${project.gid}`, + } +} + +export function formatSection(section: AsanaSection, as: AsanaStore) { + const project = as.projects.findOneBy('gid', section.project_gid) + return { + gid: section.gid, + resource_type: section.resource_type, + name: section.name, + created_at: section.created_at, + project: project ? compact(project.gid, project.resource_type, project.name) : null, + } +} + +export function formatTask(task: AsanaTask, as: AsanaStore, baseUrl: string) { + const assignee = task.assignee_gid ? as.users.findOneBy('gid', task.assignee_gid) : null + const parent = task.parent_gid ? as.tasks.findOneBy('gid', task.parent_gid) : null + const workspace = as.workspaces.findOneBy('gid', task.workspace_gid) + + const taskProjectRels = as.taskProjects.findBy('task_gid', task.gid) + const projects = taskProjectRels + .map(tp => as.projects.findOneBy('gid', tp.project_gid)) + .filter(Boolean) + .map(p => compact(p!.gid, p!.resource_type, p!.name)) + + const memberships = taskProjectRels.map((tp) => { + const p = as.projects.findOneBy('gid', tp.project_gid) + const s = tp.section_gid ? as.sections.findOneBy('gid', tp.section_gid) : null + return { + project: p ? compact(p.gid, p.resource_type, p.name) : null, + section: s ? compact(s.gid, s.resource_type, s.name) : null, + } + }) + + const taskTagRels = as.taskTags.findBy('task_gid', task.gid) + const tags = taskTagRels + .map(tt => as.tags.findOneBy('gid', tt.tag_gid)) + .filter(Boolean) + .map(t => compact(t!.gid, t!.resource_type, t!.name)) + + const followers = task.follower_gids + .map(gid => as.users.findOneBy('gid', gid)) + .filter(Boolean) + .map(u => compact(u!.gid, u!.resource_type, u!.name)) + + return { + gid: task.gid, + resource_type: task.resource_type, + resource_subtype: task.resource_subtype, + name: task.name, + assignee: assignee ? compact(assignee.gid, assignee.resource_type, assignee.name) : null, + completed: task.completed, + completed_at: task.completed_at, + due_on: task.due_on, + due_at: task.due_at, + start_on: task.start_on, + notes: task.notes, + html_notes: task.html_notes, + liked: task.liked, + num_likes: task.num_likes, + parent: parent ? compact(parent.gid, parent.resource_type, parent.name) : null, + projects, + memberships, + tags, + followers, + workspace: workspace ? compact(workspace.gid, workspace.resource_type, workspace.name) : null, + permalink_url: task.permalink_url || `${baseUrl}/0/0/task/${task.gid}`, + created_at: task.created_at, + modified_at: task.updated_at, + } +} + +export function formatTag(tag: AsanaTag, as: AsanaStore, baseUrl: string) { + const workspace = as.workspaces.findOneBy('gid', tag.workspace_gid) + return { + gid: tag.gid, + resource_type: tag.resource_type, + name: tag.name, + color: tag.color, + created_at: tag.created_at, + workspace: workspace ? compact(workspace.gid, workspace.resource_type, workspace.name) : null, + permalink_url: tag.permalink_url || `${baseUrl}/0/${tag.gid}`, + } +} + +export function formatStory(story: AsanaStory, as: AsanaStore) { + const createdBy = as.users.findOneBy('gid', story.created_by_gid) + return { + gid: story.gid, + resource_type: story.resource_type, + resource_subtype: story.resource_subtype, + text: story.text, + html_text: story.html_text, + type: story.type, + is_editable: story.is_editable, + created_at: story.created_at, + created_by: createdBy ? compact(createdBy.gid, createdBy.resource_type, createdBy.name) : null, + } +} + +export function formatTeam(team: AsanaTeam, as: AsanaStore, baseUrl: string) { + const workspace = as.workspaces.findOneBy('gid', team.workspace_gid) + return { + gid: team.gid, + resource_type: team.resource_type, + name: team.name, + description: team.description, + html_description: team.html_description, + visibility: team.visibility, + organization: workspace ? compact(workspace.gid, workspace.resource_type, workspace.name) : null, + permalink_url: team.permalink_url || `${baseUrl}/0/team/${team.gid}`, + } +} + +export function formatTeamMembership(tm: AsanaTeamMembership, as: AsanaStore) { + const user = as.users.findOneBy('gid', tm.user_gid) + const team = as.teams.findOneBy('gid', tm.team_gid) + return { + gid: tm.gid, + resource_type: tm.resource_type, + user: user ? compact(user.gid, user.resource_type, user.name) : null, + team: team ? compact(team.gid, team.resource_type, team.name) : null, + is_guest: tm.is_guest, + is_admin: tm.is_admin, + } +} + +export function formatWebhook(webhook: AsanaWebhook, as: AsanaStore) { + const resourceType = as.projects.findOneBy('gid', webhook.resource_gid) ? 'project' : 'task' + + return { + gid: webhook.gid, + resource_type: webhook.resource_type, + resource: { gid: webhook.resource_gid, resource_type: resourceType }, + target: webhook.target, + active: webhook.active, + created_at: webhook.created_at, + last_success_at: webhook.last_success_at, + last_failure_at: webhook.last_failure_at, + last_failure_content: webhook.last_failure_content, + } +} diff --git a/packages/asana/src/index.ts b/packages/asana/src/index.ts new file mode 100644 index 0000000..fb281e5 --- /dev/null +++ b/packages/asana/src/index.ts @@ -0,0 +1,285 @@ +import type { AppEnv, Hono, RouteContext, ServicePlugin, Store, TokenMap, WebhookDispatcher } from '@emulators/core' +import { generateGid } from './helpers.js' +import { projectRoutes } from './routes/projects.js' +import { sectionRoutes } from './routes/sections.js' +import { storyRoutes } from './routes/stories.js' +import { tagRoutes } from './routes/tags.js' +import { taskRoutes } from './routes/tasks.js' +import { teamRoutes } from './routes/teams.js' +import { userRoutes } from './routes/users.js' +import { webhookRoutes } from './routes/webhooks.js' +import { workspaceRoutes } from './routes/workspaces.js' +import { getAsanaStore } from './store.js' + +export * from './entities.js' +export { type AsanaStore, getAsanaStore } from './store.js' + +export interface AsanaSeedConfig { + port?: number + workspaces?: Array<{ + name: string + is_organization?: boolean + }> + users?: Array<{ + name: string + email?: string + }> + teams?: Array<{ + name: string + workspace?: string + visibility?: 'secret' | 'request_to_join' | 'public' + }> + projects?: Array<{ + name: string + workspace?: string + team?: string + owner?: string + color?: string + default_view?: 'list' | 'board' | 'calendar' | 'timeline' + }> + sections?: Array<{ + name: string + project?: string + }> + tasks?: Array<{ + name: string + project?: string + section?: string + assignee?: string + completed?: boolean + due_on?: string + }> + tags?: Array<{ + name: string + workspace?: string + color?: string + }> +} + +export function seedFromConfig(store: Store, baseUrl: string, config: AsanaSeedConfig): void { + const as = getAsanaStore(store) + + if (config.workspaces) { + for (const ws of config.workspaces) { + const existing = as.workspaces.all().find(w => w.name === ws.name) + if (existing) { + continue + } + as.workspaces.insert({ + gid: generateGid(), + resource_type: 'workspace', + name: ws.name, + is_organization: ws.is_organization ?? false, + email_domains: [], + }) + } + } + + const defaultWorkspace = as.workspaces.all()[0] + const findWorkspaceGid = (name?: string): string | undefined => + name ? (as.workspaces.all().find(w => w.name === name)?.gid ?? defaultWorkspace?.gid) : defaultWorkspace?.gid + + if (config.users) { + for (const u of config.users) { + const email = u.email ?? `${u.name.toLowerCase().replace(/\s+/g, '.')}@example.com` + const existing = as.users.findOneBy('email', email) + if (existing) { + continue + } + as.users.insert({ + gid: generateGid(), + resource_type: 'user', + name: u.name, + email, + photo: null, + }) + } + } + + if (config.teams) { + for (const t of config.teams) { + const existing = as.teams.all().find(team => team.name === t.name) + if (existing) { + continue + } + + const wsGid = findWorkspaceGid(t.workspace) + if (!wsGid) { + continue + } + + as.teams.insert({ + gid: generateGid(), + resource_type: 'team', + name: t.name, + workspace_gid: wsGid, + description: '', + html_description: '', + visibility: t.visibility ?? 'secret', + permalink_url: '', + }) + } + } + + if (config.projects) { + for (const p of config.projects) { + const existing = as.projects.all().find(proj => proj.name === p.name) + if (existing) { + continue + } + + const wsGid = findWorkspaceGid(p.workspace) + if (!wsGid) { + continue + } + + const ownerGid = p.owner + ? (as.users.all().find(u => u.name === p.owner)?.gid ?? null) + : null + const teamGid = p.team + ? (as.teams.all().find(t => t.name === p.team)?.gid ?? null) + : null + + as.projects.insert({ + gid: generateGid(), + resource_type: 'project', + name: p.name, + workspace_gid: wsGid, + owner_gid: ownerGid, + team_gid: teamGid, + archived: false, + color: p.color ?? null, + notes: '', + html_notes: '', + privacy_setting: 'public_to_workspace', + default_view: p.default_view ?? 'list', + completed: false, + completed_at: null, + permalink_url: '', + }) + } + } + + if (config.sections) { + for (const s of config.sections) { + const project = s.project ? as.projects.all().find(p => p.name === s.project) : null + if (!project) { + continue + } + + const existing = as.sections.findBy('project_gid', project.gid).find(sec => sec.name === s.name) + if (existing) { + continue + } + + as.sections.insert({ + gid: generateGid(), + resource_type: 'section', + name: s.name, + project_gid: project.gid, + }) + } + } + + if (config.tags) { + for (const t of config.tags) { + const wsGid = findWorkspaceGid(t.workspace) + if (!wsGid) { + continue + } + + const existing = as.tags.findBy('workspace_gid', wsGid).find(tag => tag.name === t.name) + if (existing) { + continue + } + + as.tags.insert({ + gid: generateGid(), + resource_type: 'tag', + name: t.name, + workspace_gid: wsGid, + color: t.color ?? null, + permalink_url: '', + }) + } + } + + // Seed tasks + if (config.tasks) { + for (const t of config.tasks) { + const project = t.project ? as.projects.all().find(p => p.name === t.project) : null + const wsGid = project?.workspace_gid ?? defaultWorkspace?.gid + if (!wsGid) { + continue + } + + const assigneeGid = t.assignee + ? (as.users.all().find(u => u.name === t.assignee)?.gid ?? null) + : null + + const taskGid = generateGid() + as.tasks.insert({ + gid: taskGid, + resource_type: 'task', + resource_subtype: 'default_task', + name: t.name, + assignee_gid: assigneeGid, + workspace_gid: wsGid, + completed: t.completed ?? false, + completed_at: null, + due_on: t.due_on ?? null, + due_at: null, + start_on: null, + notes: '', + html_notes: '', + liked: false, + num_likes: 0, + parent_gid: null, + permalink_url: '', + follower_gids: [], + }) + + if (project) { + const sectionGid = t.section + ? (as.sections.findBy('project_gid', project.gid).find(s => s.name === t.section)?.gid ?? null) + : null + as.taskProjects.insert({ + task_gid: taskGid, + project_gid: project.gid, + section_gid: sectionGid, + }) + } + } + } +} + +export const asanaPlugin: ServicePlugin = { + name: 'asana', + register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap } + userRoutes(ctx) + workspaceRoutes(ctx) + projectRoutes(ctx) + sectionRoutes(ctx) + taskRoutes(ctx) + tagRoutes(ctx) + storyRoutes(ctx) + teamRoutes(ctx) + webhookRoutes(ctx) + }, + seed(store: Store, _baseUrl: string): void { + const as = getAsanaStore(store) + // Seed a default workspace + if (as.workspaces.all().length === 0) { + as.workspaces.insert({ + gid: generateGid(), + resource_type: 'workspace', + name: 'My Workspace', + is_organization: false, + email_domains: [], + }) + } + }, +} + +export default asanaPlugin diff --git a/packages/asana/src/routes/projects.ts b/packages/asana/src/routes/projects.ts new file mode 100644 index 0000000..407e49f --- /dev/null +++ b/packages/asana/src/routes/projects.ts @@ -0,0 +1,212 @@ +import type { RouteContext } from '@emulators/core' +import type { AsanaProject } from '../entities.js' +import { + applyPagination, + asanaData, + asanaError, + compact, + formatProject, + formatSection, + generateGid, + parseAsanaBody, + parsePagination, +} from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function projectRoutes({ app, store, baseUrl }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.get('/api/1.0/projects', (c) => { + const pagination = parsePagination(c) + const workspaceGid = c.req.query('workspace') + const teamGid = c.req.query('team') + + let projects = as().projects.all() + if (workspaceGid) { + projects = projects.filter(p => p.workspace_gid === workspaceGid) + } + if (teamGid) { + projects = projects.filter(p => p.team_gid === teamGid) + } + + const formatted = projects.map(p => compact(p.gid, p.resource_type, p.name)) + const result = applyPagination(formatted, pagination, '/api/1.0/projects', baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/projects', async (c) => { + const body = await parseAsanaBody(c) + if (!body.name) { + return asanaError(c, 400, 'name: Missing input') + } + if (!body.workspace) { + return asanaError(c, 400, 'workspace: Missing input') + } + + const workspaceGid = body.workspace as string + const ws = as().workspaces.findOneBy('gid', workspaceGid) + if (!ws) { + return asanaError(c, 404, 'workspace: Not Found') + } + + const gid = generateGid() + const project = as().projects.insert({ + gid, + resource_type: 'project', + name: body.name as string, + workspace_gid: workspaceGid, + owner_gid: (body.owner as string) ?? null, + team_gid: (body.team as string) ?? null, + archived: false, + color: (body.color as string) ?? null, + notes: (body.notes as string) ?? '', + html_notes: (body.html_notes as string) ?? '', + privacy_setting: (body.privacy_setting as AsanaProject['privacy_setting']) ?? 'public_to_workspace', + default_view: (body.default_view as AsanaProject['default_view']) ?? 'list', + completed: false, + completed_at: null, + permalink_url: '', + }) + + return c.json(asanaData(formatProject(project, as(), baseUrl)), 201) + }) + + app.get('/api/1.0/projects/:project_gid', (c) => { + const gid = c.req.param('project_gid') + const project = as().projects.findOneBy('gid', gid) + if (!project) { + return asanaError(c, 404, 'project: Not Found') + } + return c.json(asanaData(formatProject(project, as(), baseUrl))) + }) + + app.put('/api/1.0/projects/:project_gid', async (c) => { + const gid = c.req.param('project_gid') + const project = as().projects.findOneBy('gid', gid) + if (!project) { + return asanaError(c, 404, 'project: Not Found') + } + + const body = await parseAsanaBody(c) + const updates: Partial = {} + if (body.name !== undefined) { + updates.name = body.name as string + } + if (body.notes !== undefined) { + updates.notes = body.notes as string + } + if (body.html_notes !== undefined) { + updates.html_notes = body.html_notes as string + } + if (body.color !== undefined) { + updates.color = body.color as string | null + } + if (body.archived !== undefined) { + updates.archived = body.archived as boolean + } + if (body.privacy_setting !== undefined) { + updates.privacy_setting = body.privacy_setting as AsanaProject['privacy_setting'] + } + if (body.default_view !== undefined) { + updates.default_view = body.default_view as AsanaProject['default_view'] + } + if (body.owner !== undefined) { + updates.owner_gid = body.owner as string + } + if (body.team !== undefined) { + updates.team_gid = body.team as string + } + + const updated = as().projects.update(project.id, updates) + return c.json(asanaData(formatProject(updated ?? project, as(), baseUrl))) + }) + + app.delete('/api/1.0/projects/:project_gid', (c) => { + const gid = c.req.param('project_gid') + const project = as().projects.findOneBy('gid', gid) + if (!project) { + return asanaError(c, 404, 'project: Not Found') + } + + // Clean up related data + for (const tp of as().taskProjects.findBy('project_gid', gid)) { + as().taskProjects.delete(tp.id) + } + for (const section of as().sections.findBy('project_gid', gid)) { + as().sections.delete(section.id) + } + + as().projects.delete(project.id) + return c.json(asanaData({})) + }) + + app.get('/api/1.0/projects/:project_gid/tasks', (c) => { + const gid = c.req.param('project_gid') + const project = as().projects.findOneBy('gid', gid) + if (!project) { + return asanaError(c, 404, 'project: Not Found') + } + + const pagination = parsePagination(c) + const taskRels = as().taskProjects.findBy('project_gid', gid) + const tasks = taskRels + .map(tp => as().tasks.findOneBy('gid', tp.task_gid)) + .filter(Boolean) + .map(t => compact(t!.gid, t!.resource_type, t!.name)) + + const result = applyPagination(tasks, pagination, `/api/1.0/projects/${gid}/tasks`, baseUrl) + return c.json(result) + }) + + app.get('/api/1.0/projects/:project_gid/sections', (c) => { + const gid = c.req.param('project_gid') + const project = as().projects.findOneBy('gid', gid) + if (!project) { + return asanaError(c, 404, 'project: Not Found') + } + + const pagination = parsePagination(c) + const sections = as().sections.findBy('project_gid', gid).map(s => formatSection(s, as())) + const result = applyPagination(sections, pagination, `/api/1.0/projects/${gid}/sections`, baseUrl) + return c.json(result) + }) + + app.get('/api/1.0/projects/:project_gid/task_counts', (c) => { + const gid = c.req.param('project_gid') + const project = as().projects.findOneBy('gid', gid) + if (!project) { + return asanaError(c, 404, 'project: Not Found') + } + + const taskRels = as().taskProjects.findBy('project_gid', gid) + let numTasks = 0 + let numCompleted = 0 + let numMilestones = 0 + let numCompletedMilestones = 0 + for (const tp of taskRels) { + const t = as().tasks.findOneBy('gid', tp.task_gid) + if (!t) { + continue + } + numTasks++ + if (t.completed) { + numCompleted++ + } + if (t.resource_subtype === 'milestone') { + numMilestones++ + if (t.completed) { + numCompletedMilestones++ + } + } + } + + return c.json(asanaData({ + num_tasks: numTasks, + num_completed_tasks: numCompleted, + num_incomplete_tasks: numTasks - numCompleted, + num_milestones: numMilestones, + num_incomplete_milestones: numMilestones - numCompletedMilestones, + num_completed_milestones: numCompletedMilestones, + })) + }) +} diff --git a/packages/asana/src/routes/sections.ts b/packages/asana/src/routes/sections.ts new file mode 100644 index 0000000..b3083a0 --- /dev/null +++ b/packages/asana/src/routes/sections.ts @@ -0,0 +1,139 @@ +import type { RouteContext } from '@emulators/core' +import { + applyPagination, + asanaData, + asanaError, + compact, + formatSection, + generateGid, + parseAsanaBody, + parsePagination, +} from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function sectionRoutes({ app, store, baseUrl }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.post('/api/1.0/projects/:project_gid/sections', async (c) => { + const projectGid = c.req.param('project_gid') + const project = as().projects.findOneBy('gid', projectGid) + if (!project) { + return asanaError(c, 404, 'project: Not Found') + } + + const body = await parseAsanaBody(c) + if (!body.name) { + return asanaError(c, 400, 'name: Missing input') + } + + const gid = generateGid() + const section = as().sections.insert({ + gid, + resource_type: 'section', + name: body.name as string, + project_gid: projectGid, + }) + + return c.json(asanaData(formatSection(section, as())), 201) + }) + + app.get('/api/1.0/sections/:section_gid', (c) => { + const gid = c.req.param('section_gid') + const section = as().sections.findOneBy('gid', gid) + if (!section) { + return asanaError(c, 404, 'section: Not Found') + } + return c.json(asanaData(formatSection(section, as()))) + }) + + app.put('/api/1.0/sections/:section_gid', async (c) => { + const gid = c.req.param('section_gid') + const section = as().sections.findOneBy('gid', gid) + if (!section) { + return asanaError(c, 404, 'section: Not Found') + } + + const body = await parseAsanaBody(c) + const updates: Partial<{ name: string }> = {} + if (body.name !== undefined) { + updates.name = body.name as string + } + + const updated = as().sections.update(section.id, updates) + return c.json(asanaData(formatSection(updated ?? section, as()))) + }) + + app.delete('/api/1.0/sections/:section_gid', (c) => { + const gid = c.req.param('section_gid') + const section = as().sections.findOneBy('gid', gid) + if (!section) { + return asanaError(c, 404, 'section: Not Found') + } + + for (const tp of as().taskProjects.findBy('project_gid', section.project_gid).filter(tp => tp.section_gid === gid)) { + as().taskProjects.update(tp.id, { section_gid: null }) + } + + as().sections.delete(section.id) + return c.json(asanaData({})) + }) + + app.get('/api/1.0/sections/:section_gid/tasks', (c) => { + const gid = c.req.param('section_gid') + const section = as().sections.findOneBy('gid', gid) + if (!section) { + return asanaError(c, 404, 'section: Not Found') + } + + const pagination = parsePagination(c) + const taskRels = as() + .taskProjects + .findBy('project_gid', section.project_gid) + .filter(tp => tp.section_gid === gid) + const tasks = taskRels + .map(tp => as().tasks.findOneBy('gid', tp.task_gid)) + .filter(Boolean) + .map(t => compact(t!.gid, t!.resource_type, t!.name)) + + const result = applyPagination(tasks, pagination, `/api/1.0/sections/${gid}/tasks`, baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/sections/:section_gid/addTask', async (c) => { + const sectionGid = c.req.param('section_gid') + const section = as().sections.findOneBy('gid', sectionGid) + if (!section) { + return asanaError(c, 404, 'section: Not Found') + } + + const body = await parseAsanaBody(c) + const taskGid = body.task as string + if (!taskGid) { + return asanaError(c, 400, 'task: Missing input') + } + + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + // Find or create task-project relationship + const existing = as() + .taskProjects + .findBy('task_gid', taskGid) + .find(tp => tp.project_gid === section.project_gid) + + if (existing) { + as().taskProjects.update(existing.id, { section_gid: sectionGid }) + } + else { + as().taskProjects.insert({ + task_gid: taskGid, + project_gid: section.project_gid, + section_gid: sectionGid, + }) + } + + return c.json(asanaData({})) + }) +} diff --git a/packages/asana/src/routes/stories.ts b/packages/asana/src/routes/stories.ts new file mode 100644 index 0000000..700a323 --- /dev/null +++ b/packages/asana/src/routes/stories.ts @@ -0,0 +1,50 @@ +import type { RouteContext } from '@emulators/core' +import { asanaData, asanaError, formatStory, parseAsanaBody } from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function storyRoutes({ app, store }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.get('/api/1.0/stories/:story_gid', (c) => { + const gid = c.req.param('story_gid') + const story = as().stories.findOneBy('gid', gid) + if (!story) { + return asanaError(c, 404, 'story: Not Found') + } + return c.json(asanaData(formatStory(story, as()))) + }) + + app.put('/api/1.0/stories/:story_gid', async (c) => { + const gid = c.req.param('story_gid') + const story = as().stories.findOneBy('gid', gid) + if (!story) { + return asanaError(c, 404, 'story: Not Found') + } + if (!story.is_editable) { + return asanaError(c, 403, 'story: Not editable') + } + + const body = await parseAsanaBody(c) + const updates: Partial<{ text: string, html_text: string }> = {} + if (body.text !== undefined) { + updates.text = body.text as string + } + if (body.html_text !== undefined) { + updates.html_text = body.html_text as string + } + + const updated = as().stories.update(story.id, updates) + return c.json(asanaData(formatStory(updated ?? story, as()))) + }) + + app.delete('/api/1.0/stories/:story_gid', (c) => { + const gid = c.req.param('story_gid') + const story = as().stories.findOneBy('gid', gid) + if (!story) { + return asanaError(c, 404, 'story: Not Found') + } + + as().stories.delete(story.id) + return c.json(asanaData({})) + }) +} diff --git a/packages/asana/src/routes/tags.ts b/packages/asana/src/routes/tags.ts new file mode 100644 index 0000000..c009fe7 --- /dev/null +++ b/packages/asana/src/routes/tags.ts @@ -0,0 +1,159 @@ +import type { RouteContext } from '@emulators/core' +import { + applyPagination, + asanaData, + asanaError, + compact, + formatTag, + generateGid, + parseAsanaBody, + parsePagination, +} from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function tagRoutes({ app, store, baseUrl }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.get('/api/1.0/tags', (c) => { + const pagination = parsePagination(c) + const workspaceGid = c.req.query('workspace') + + let tags = as().tags.all() + if (workspaceGid) { + tags = tags.filter(t => t.workspace_gid === workspaceGid) + } + + const formatted = tags.map(t => compact(t.gid, t.resource_type, t.name)) + const result = applyPagination(formatted, pagination, '/api/1.0/tags', baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/tags', async (c) => { + const body = await parseAsanaBody(c) + if (!body.name) { + return asanaError(c, 400, 'name: Missing input') + } + if (!body.workspace) { + return asanaError(c, 400, 'workspace: Missing input') + } + + const ws = as().workspaces.findOneBy('gid', body.workspace as string) + if (!ws) { + return asanaError(c, 404, 'workspace: Not Found') + } + + const gid = generateGid() + const tag = as().tags.insert({ + gid, + resource_type: 'tag', + name: body.name as string, + workspace_gid: ws.gid, + color: (body.color as string) ?? null, + permalink_url: '', + }) + + return c.json(asanaData(formatTag(tag, as(), baseUrl)), 201) + }) + + app.get('/api/1.0/tags/:tag_gid', (c) => { + const gid = c.req.param('tag_gid') + const tag = as().tags.findOneBy('gid', gid) + if (!tag) { + return asanaError(c, 404, 'tag: Not Found') + } + return c.json(asanaData(formatTag(tag, as(), baseUrl))) + }) + + app.put('/api/1.0/tags/:tag_gid', async (c) => { + const gid = c.req.param('tag_gid') + const tag = as().tags.findOneBy('gid', gid) + if (!tag) { + return asanaError(c, 404, 'tag: Not Found') + } + + const body = await parseAsanaBody(c) + const updates: Partial<{ name: string, color: string | null }> = {} + if (body.name !== undefined) { + updates.name = body.name as string + } + if (body.color !== undefined) { + updates.color = body.color as string | null + } + + const updated = as().tags.update(tag.id, updates) + return c.json(asanaData(formatTag(updated ?? tag, as(), baseUrl))) + }) + + app.delete('/api/1.0/tags/:tag_gid', (c) => { + const gid = c.req.param('tag_gid') + const tag = as().tags.findOneBy('gid', gid) + if (!tag) { + return asanaError(c, 404, 'tag: Not Found') + } + + // Clean up task-tag relationships + for (const tt of as().taskTags.findBy('tag_gid', gid)) { + as().taskTags.delete(tt.id) + } + + as().tags.delete(tag.id) + return c.json(asanaData({})) + }) + + // Get tasks for a tag + app.get('/api/1.0/tags/:tag_gid/tasks', (c) => { + const gid = c.req.param('tag_gid') + const tag = as().tags.findOneBy('gid', gid) + if (!tag) { + return asanaError(c, 404, 'tag: Not Found') + } + + const pagination = parsePagination(c) + const rels = as().taskTags.findBy('tag_gid', gid) + const tasks = rels + .map(tt => as().tasks.findOneBy('gid', tt.task_gid)) + .filter(Boolean) + .map(t => compact(t!.gid, t!.resource_type, t!.name)) + const result = applyPagination(tasks, pagination, `/api/1.0/tags/${gid}/tasks`, baseUrl) + return c.json(result) + }) + + // Workspace tags + app.get('/api/1.0/workspaces/:workspace_gid/tags', (c) => { + const workspaceGid = c.req.param('workspace_gid') + const ws = as().workspaces.findOneBy('gid', workspaceGid) + if (!ws) { + return asanaError(c, 404, 'workspace: Not Found') + } + + const pagination = parsePagination(c) + const tags = as().tags.findBy('workspace_gid', workspaceGid).map(t => compact(t.gid, t.resource_type, t.name)) + const result = applyPagination(tags, pagination, `/api/1.0/workspaces/${workspaceGid}/tags`, baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/workspaces/:workspace_gid/tags', async (c) => { + const workspaceGid = c.req.param('workspace_gid') + const ws = as().workspaces.findOneBy('gid', workspaceGid) + if (!ws) { + return asanaError(c, 404, 'workspace: Not Found') + } + + const body = await parseAsanaBody(c) + if (!body.name) { + return asanaError(c, 400, 'name: Missing input') + } + + const gid = generateGid() + const tag = as().tags.insert({ + gid, + resource_type: 'tag', + name: body.name as string, + workspace_gid: workspaceGid, + color: (body.color as string) ?? null, + permalink_url: '', + }) + + return c.json(asanaData(formatTag(tag, as(), baseUrl)), 201) + }) +} diff --git a/packages/asana/src/routes/tasks.ts b/packages/asana/src/routes/tasks.ts new file mode 100644 index 0000000..2b43d8f --- /dev/null +++ b/packages/asana/src/routes/tasks.ts @@ -0,0 +1,598 @@ +import type { RouteContext } from '@emulators/core' +import type { AsanaTask } from '../entities.js' +import { + applyPagination, + asanaData, + asanaError, + compact, + escapeHtml, + formatStory, + formatTask, + generateGid, + parseAsanaBody, + parsePagination, + resolveUser, +} from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function taskRoutes({ app, store, baseUrl }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.get('/api/1.0/tasks', (c) => { + const pagination = parsePagination(c) + const projectGid = c.req.query('project') + const sectionGid = c.req.query('section') + const assigneeParam = c.req.query('assignee') + const workspaceGid = c.req.query('workspace') + + let taskGids: Set | null = null + + if (projectGid) { + const rels = as().taskProjects.findBy('project_gid', projectGid) + taskGids = new Set(rels.map(r => r.task_gid)) + } + else if (sectionGid) { + const section = as().sections.findOneBy('gid', sectionGid) + if (!section) { + return asanaError(c, 404, 'section: Not Found') + } + const rels = as() + .taskProjects + .findBy('project_gid', section.project_gid) + .filter(tp => tp.section_gid === sectionGid) + taskGids = new Set(rels.map(r => r.task_gid)) + } + + let tasks = taskGids + ? as().tasks.all().filter(t => taskGids!.has(t.gid)) + : as().tasks.all() + + if (assigneeParam) { + const login = assigneeParam === 'me' + ? (c.get('authUser')?.login ?? assigneeParam) + : assigneeParam + const resolvedGid = resolveUser(as(), login)?.gid ?? login + tasks = tasks.filter(t => t.assignee_gid === resolvedGid) + } + if (workspaceGid) { + tasks = tasks.filter(t => t.workspace_gid === workspaceGid) + } + + const formatted = tasks.map(t => compact(t.gid, t.resource_type, t.name)) + const result = applyPagination(formatted, pagination, '/api/1.0/tasks', baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/tasks', async (c) => { + const body = await parseAsanaBody(c) + if (!body.name && body.name !== '') { + return asanaError(c, 400, 'name: Missing input') + } + + // Resolve workspace from body or from projects + let workspaceGid = body.workspace as string | undefined + const projectGids = (body.projects as string[]) ?? [] + const membershipData = body.memberships as Array<{ project: string, section?: string }> | undefined + + if (!workspaceGid && projectGids.length > 0) { + const p = as().projects.findOneBy('gid', projectGids[0]) + if (p) { + workspaceGid = p.workspace_gid + } + } + if (!workspaceGid && membershipData && membershipData.length > 0) { + const p = as().projects.findOneBy('gid', membershipData[0].project) + if (p) { + workspaceGid = p.workspace_gid + } + } + if (!workspaceGid) { + const defaultWs = as().workspaces.all()[0] + if (defaultWs) { + workspaceGid = defaultWs.gid + } + } + if (!workspaceGid) { + return asanaError(c, 400, 'workspace: Missing input') + } + + const gid = generateGid() + const task = as().tasks.insert({ + gid, + resource_type: 'task', + resource_subtype: (body.resource_subtype as AsanaTask['resource_subtype']) ?? 'default_task', + name: body.name as string, + assignee_gid: (body.assignee as string) ?? null, + workspace_gid: workspaceGid, + completed: (body.completed as boolean) ?? false, + completed_at: null, + due_on: (body.due_on as string) ?? null, + due_at: (body.due_at as string) ?? null, + start_on: (body.start_on as string) ?? null, + notes: (body.notes as string) ?? '', + html_notes: (body.html_notes as string) ?? '', + liked: false, + num_likes: 0, + parent_gid: (body.parent as string) ?? null, + permalink_url: '', + follower_gids: (body.followers as string[]) ?? [], + }) + + // Add project memberships + for (const pGid of projectGids) { + as().taskProjects.insert({ task_gid: gid, project_gid: pGid, section_gid: null }) + } + if (membershipData) { + for (const m of membershipData) { + const existing = as().taskProjects.findBy('task_gid', gid).find(tp => tp.project_gid === m.project) + if (existing) { + if (m.section) { + as().taskProjects.update(existing.id, { section_gid: m.section }) + } + } + else { + as().taskProjects.insert({ task_gid: gid, project_gid: m.project, section_gid: m.section ?? null }) + } + } + } + + return c.json(asanaData(formatTask(task, as(), baseUrl)), 201) + }) + + app.get('/api/1.0/tasks/:task_gid', (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + return c.json(asanaData(formatTask(task, as(), baseUrl))) + }) + + app.put('/api/1.0/tasks/:task_gid', async (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const updates: Partial = {} + if (body.name !== undefined) { + updates.name = body.name as string + } + if (body.assignee !== undefined) { + updates.assignee_gid = body.assignee as string | null + } + if (body.completed !== undefined) { + updates.completed = body.completed as boolean + updates.completed_at = body.completed ? new Date().toISOString() : null + } + if (body.due_on !== undefined) { + updates.due_on = body.due_on as string | null + } + if (body.due_at !== undefined) { + updates.due_at = body.due_at as string | null + } + if (body.start_on !== undefined) { + updates.start_on = body.start_on as string | null + } + if (body.notes !== undefined) { + updates.notes = body.notes as string + } + if (body.html_notes !== undefined) { + updates.html_notes = body.html_notes as string + } + if (body.resource_subtype !== undefined) { + updates.resource_subtype = body.resource_subtype as AsanaTask['resource_subtype'] + } + + const updated = as().tasks.update(task.id, updates) + return c.json(asanaData(formatTask(updated ?? task, as(), baseUrl))) + }) + + app.delete('/api/1.0/tasks/:task_gid', (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + for (const tp of as().taskProjects.findBy('task_gid', gid)) { + as().taskProjects.delete(tp.id) + } + for (const tt of as().taskTags.findBy('task_gid', gid)) { + as().taskTags.delete(tt.id) + } + for (const td of as().taskDependencies.findBy('task_gid', gid)) { + as().taskDependencies.delete(td.id) + } + for (const td of as().taskDependencies.findBy('dependency_gid', gid)) { + as().taskDependencies.delete(td.id) + } + for (const story of as().stories.findBy('task_gid', gid)) { + as().stories.delete(story.id) + } + for (const sub of as().tasks.findBy('parent_gid', gid)) { + as().tasks.update(sub.id, { parent_gid: null }) + } + + as().tasks.delete(task.id) + return c.json(asanaData({})) + }) + + // Subtasks + app.get('/api/1.0/tasks/:task_gid/subtasks', (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const pagination = parsePagination(c) + const subtasks = as().tasks.findBy('parent_gid', gid).map(t => compact(t.gid, t.resource_type, t.name)) + const result = applyPagination(subtasks, pagination, `/api/1.0/tasks/${gid}/subtasks`, baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/tasks/:task_gid/subtasks', async (c) => { + const parentGid = c.req.param('task_gid') + const parentTask = as().tasks.findOneBy('gid', parentGid) + if (!parentTask) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + if (!body.name && body.name !== '') { + return asanaError(c, 400, 'name: Missing input') + } + + const gid = generateGid() + const subtask = as().tasks.insert({ + gid, + resource_type: 'task', + resource_subtype: (body.resource_subtype as AsanaTask['resource_subtype']) ?? 'default_task', + name: body.name as string, + assignee_gid: (body.assignee as string) ?? null, + workspace_gid: parentTask.workspace_gid, + completed: false, + completed_at: null, + due_on: (body.due_on as string) ?? null, + due_at: (body.due_at as string) ?? null, + start_on: null, + notes: (body.notes as string) ?? '', + html_notes: (body.html_notes as string) ?? '', + liked: false, + num_likes: 0, + parent_gid: parentGid, + permalink_url: '', + follower_gids: [], + }) + + return c.json(asanaData(formatTask(subtask, as(), baseUrl)), 201) + }) + + // Stories for task + app.get('/api/1.0/tasks/:task_gid/stories', (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const pagination = parsePagination(c) + const stories = as().stories.findBy('task_gid', gid).map(s => formatStory(s, as())) + const result = applyPagination(stories, pagination, `/api/1.0/tasks/${gid}/stories`, baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/tasks/:task_gid/stories', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + if (!body.text) { + return asanaError(c, 400, 'text: Missing input') + } + + const authUser = c.get('authUser') + const user = authUser ? resolveUser(as(), authUser.login) ?? null : null + + const gid = generateGid() + const story = as().stories.insert({ + gid, + resource_type: 'story', + resource_subtype: 'comment_added', + task_gid: taskGid, + text: body.text as string, + html_text: (body.html_text as string) ?? `${escapeHtml(body.text as string)}`, + type: 'comment', + is_editable: true, + created_by_gid: user?.gid ?? '', + }) + + return c.json(asanaData(formatStory(story, as())), 201) + }) + + // Tags for task + app.get('/api/1.0/tasks/:task_gid/tags', (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const pagination = parsePagination(c) + const tagRels = as().taskTags.findBy('task_gid', gid) + const tags = tagRels + .map(tt => as().tags.findOneBy('gid', tt.tag_gid)) + .filter(Boolean) + .map(t => compact(t!.gid, t!.resource_type, t!.name)) + const result = applyPagination(tags, pagination, `/api/1.0/tasks/${gid}/tags`, baseUrl) + return c.json(result) + }) + + // Projects for task + app.get('/api/1.0/tasks/:task_gid/projects', (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const pagination = parsePagination(c) + const rels = as().taskProjects.findBy('task_gid', gid) + const projects = rels + .map(tp => as().projects.findOneBy('gid', tp.project_gid)) + .filter(Boolean) + .map(p => compact(p!.gid, p!.resource_type, p!.name)) + const result = applyPagination(projects, pagination, `/api/1.0/tasks/${gid}/projects`, baseUrl) + return c.json(result) + }) + + // Dependencies + app.get('/api/1.0/tasks/:task_gid/dependencies', (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const pagination = parsePagination(c) + const deps = as().taskDependencies.findBy('task_gid', gid) + const tasks = deps + .map(d => as().tasks.findOneBy('gid', d.dependency_gid)) + .filter(Boolean) + .map(t => compact(t!.gid, t!.resource_type, t!.name)) + const result = applyPagination(tasks, pagination, `/api/1.0/tasks/${gid}/dependencies`, baseUrl) + return c.json(result) + }) + + app.get('/api/1.0/tasks/:task_gid/dependents', (c) => { + const gid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', gid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const pagination = parsePagination(c) + const deps = as().taskDependencies.findBy('dependency_gid', gid) + const tasks = deps + .map(d => as().tasks.findOneBy('gid', d.task_gid)) + .filter(Boolean) + .map(t => compact(t!.gid, t!.resource_type, t!.name)) + const result = applyPagination(tasks, pagination, `/api/1.0/tasks/${gid}/dependents`, baseUrl) + return c.json(result) + }) + + // Add/remove project + app.post('/api/1.0/tasks/:task_gid/addProject', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const projectGid = body.project as string + if (!projectGid) { + return asanaError(c, 400, 'project: Missing input') + } + + const project = as().projects.findOneBy('gid', projectGid) + if (!project) { + return asanaError(c, 404, 'project: Not Found') + } + + const existing = as().taskProjects.findBy('task_gid', taskGid).find(tp => tp.project_gid === projectGid) + if (!existing) { + as().taskProjects.insert({ + task_gid: taskGid, + project_gid: projectGid, + section_gid: (body.section as string) ?? null, + }) + } + + return c.json(asanaData({})) + }) + + app.post('/api/1.0/tasks/:task_gid/removeProject', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const projectGid = body.project as string + if (!projectGid) { + return asanaError(c, 400, 'project: Missing input') + } + + const rel = as().taskProjects.findBy('task_gid', taskGid).find(tp => tp.project_gid === projectGid) + if (rel) { + as().taskProjects.delete(rel.id) + } + + return c.json(asanaData({})) + }) + + // Add/remove tag + app.post('/api/1.0/tasks/:task_gid/addTag', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const tagGid = body.tag as string + if (!tagGid) { + return asanaError(c, 400, 'tag: Missing input') + } + + const tag = as().tags.findOneBy('gid', tagGid) + if (!tag) { + return asanaError(c, 404, 'tag: Not Found') + } + + const existing = as().taskTags.findBy('task_gid', taskGid).find(tt => tt.tag_gid === tagGid) + if (!existing) { + as().taskTags.insert({ task_gid: taskGid, tag_gid: tagGid }) + } + + return c.json(asanaData({})) + }) + + app.post('/api/1.0/tasks/:task_gid/removeTag', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const tagGid = body.tag as string + if (!tagGid) { + return asanaError(c, 400, 'tag: Missing input') + } + + const rel = as().taskTags.findBy('task_gid', taskGid).find(tt => tt.tag_gid === tagGid) + if (rel) { + as().taskTags.delete(rel.id) + } + + return c.json(asanaData({})) + }) + + // Add/remove dependencies + app.post('/api/1.0/tasks/:task_gid/addDependencies', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const dependencies = body.dependencies as string[] + if (!dependencies || !Array.isArray(dependencies)) { + return asanaError(c, 400, 'dependencies: Missing input') + } + + for (const depGid of dependencies) { + const existing = as().taskDependencies.findBy('task_gid', taskGid).find(td => td.dependency_gid === depGid) + if (!existing) { + as().taskDependencies.insert({ task_gid: taskGid, dependency_gid: depGid }) + } + } + + return c.json(asanaData({})) + }) + + app.post('/api/1.0/tasks/:task_gid/removeDependencies', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const dependencies = body.dependencies as string[] + if (!dependencies || !Array.isArray(dependencies)) { + return asanaError(c, 400, 'dependencies: Missing input') + } + + for (const depGid of dependencies) { + const rel = as().taskDependencies.findBy('task_gid', taskGid).find(td => td.dependency_gid === depGid) + if (rel) { + as().taskDependencies.delete(rel.id) + } + } + + return c.json(asanaData({})) + }) + + // Add/remove followers + app.post('/api/1.0/tasks/:task_gid/addFollowers', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const followers = body.followers as string[] + if (!followers || !Array.isArray(followers)) { + return asanaError(c, 400, 'followers: Missing input') + } + + const currentFollowers = new Set(task.follower_gids) + for (const f of followers) { + currentFollowers.add(f) + } + as().tasks.update(task.id, { follower_gids: [...currentFollowers] }) + + const updated = as().tasks.findOneBy('gid', taskGid)! + return c.json(asanaData(formatTask(updated, as(), baseUrl))) + }) + + app.post('/api/1.0/tasks/:task_gid/removeFollowers', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const followers = body.followers as string[] + if (!followers || !Array.isArray(followers)) { + return asanaError(c, 400, 'followers: Missing input') + } + + const toRemove = new Set(followers) + const remaining = task.follower_gids.filter(f => !toRemove.has(f)) + as().tasks.update(task.id, { follower_gids: remaining }) + + const updated = as().tasks.findOneBy('gid', taskGid)! + return c.json(asanaData(formatTask(updated, as(), baseUrl))) + }) + + // Set parent + app.post('/api/1.0/tasks/:task_gid/setParent', async (c) => { + const taskGid = c.req.param('task_gid') + const task = as().tasks.findOneBy('gid', taskGid) + if (!task) { + return asanaError(c, 404, 'task: Not Found') + } + + const body = await parseAsanaBody(c) + const parentGid = (body.parent as string) ?? null + + as().tasks.update(task.id, { parent_gid: parentGid }) + + const updated = as().tasks.findOneBy('gid', taskGid)! + return c.json(asanaData(formatTask(updated, as(), baseUrl))) + }) +} diff --git a/packages/asana/src/routes/teams.ts b/packages/asana/src/routes/teams.ts new file mode 100644 index 0000000..43b1c9e --- /dev/null +++ b/packages/asana/src/routes/teams.ts @@ -0,0 +1,154 @@ +import type { RouteContext } from '@emulators/core' +import type { AsanaTeam } from '../entities.js' +import { + applyPagination, + asanaData, + asanaError, + compact, + formatTeam, + formatTeamMembership, + generateGid, + parseAsanaBody, + parsePagination, +} from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function teamRoutes({ app, store, baseUrl }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.get('/api/1.0/teams/:team_gid', (c) => { + const gid = c.req.param('team_gid') + const team = as().teams.findOneBy('gid', gid) + if (!team) { + return asanaError(c, 404, 'team: Not Found') + } + return c.json(asanaData(formatTeam(team, as(), baseUrl))) + }) + + app.post('/api/1.0/teams', async (c) => { + const body = await parseAsanaBody(c) + if (!body.name) { + return asanaError(c, 400, 'name: Missing input') + } + if (!body.organization) { + return asanaError(c, 400, 'organization: Missing input') + } + + const workspaceGid = body.organization as string + const ws = as().workspaces.findOneBy('gid', workspaceGid) + if (!ws) { + return asanaError(c, 404, 'organization: Not Found') + } + + const gid = generateGid() + const team = as().teams.insert({ + gid, + resource_type: 'team', + name: body.name as string, + workspace_gid: workspaceGid, + description: (body.description as string) ?? '', + html_description: (body.html_description as string) ?? '', + visibility: (body.visibility as AsanaTeam['visibility']) ?? 'secret', + permalink_url: '', + }) + + return c.json(asanaData(formatTeam(team, as(), baseUrl)), 201) + }) + + app.get('/api/1.0/workspaces/:workspace_gid/teams', (c) => { + const workspaceGid = c.req.param('workspace_gid') + const ws = as().workspaces.findOneBy('gid', workspaceGid) + if (!ws) { + return asanaError(c, 404, 'workspace: Not Found') + } + + const pagination = parsePagination(c) + const teams = as().teams.findBy('workspace_gid', workspaceGid).map(t => compact(t.gid, t.resource_type, t.name)) + const result = applyPagination(teams, pagination, `/api/1.0/workspaces/${workspaceGid}/teams`, baseUrl) + return c.json(result) + }) + + app.get('/api/1.0/teams/:team_gid/users', (c) => { + const teamGid = c.req.param('team_gid') + const team = as().teams.findOneBy('gid', teamGid) + if (!team) { + return asanaError(c, 404, 'team: Not Found') + } + + const pagination = parsePagination(c) + const memberships = as().teamMemberships.findBy('team_gid', teamGid) + const users = memberships + .map(tm => as().users.findOneBy('gid', tm.user_gid)) + .filter(Boolean) + .map(u => compact(u!.gid, u!.resource_type, u!.name)) + const result = applyPagination(users, pagination, `/api/1.0/teams/${teamGid}/users`, baseUrl) + return c.json(result) + }) + + app.get('/api/1.0/teams/:team_gid/projects', (c) => { + const teamGid = c.req.param('team_gid') + const team = as().teams.findOneBy('gid', teamGid) + if (!team) { + return asanaError(c, 404, 'team: Not Found') + } + + const pagination = parsePagination(c) + const projects = as().projects.findBy('team_gid', teamGid).map(p => compact(p.gid, p.resource_type, p.name)) + const result = applyPagination(projects, pagination, `/api/1.0/teams/${teamGid}/projects`, baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/teams/:team_gid/addUser', async (c) => { + const teamGid = c.req.param('team_gid') + const team = as().teams.findOneBy('gid', teamGid) + if (!team) { + return asanaError(c, 404, 'team: Not Found') + } + + const body = await parseAsanaBody(c) + const userGid = body.user as string + if (!userGid) { + return asanaError(c, 400, 'user: Missing input') + } + + const existing = as() + .teamMemberships + .findBy('team_gid', teamGid) + .find(tm => tm.user_gid === userGid) + + const membership = existing ?? as().teamMemberships.insert({ + gid: generateGid(), + resource_type: 'team_membership', + user_gid: userGid, + team_gid: teamGid, + is_guest: false, + is_admin: false, + }) + + return c.json(asanaData(formatTeamMembership(membership, as()))) + }) + + app.post('/api/1.0/teams/:team_gid/removeUser', async (c) => { + const teamGid = c.req.param('team_gid') + const team = as().teams.findOneBy('gid', teamGid) + if (!team) { + return asanaError(c, 404, 'team: Not Found') + } + + const body = await parseAsanaBody(c) + const userGid = body.user as string + if (!userGid) { + return asanaError(c, 400, 'user: Missing input') + } + + const membership = as() + .teamMemberships + .findBy('team_gid', teamGid) + .find(tm => tm.user_gid === userGid) + if (membership) { + as().teamMemberships.delete(membership.id) + } + + return c.json(asanaData({})) + }) +} diff --git a/packages/asana/src/routes/users.ts b/packages/asana/src/routes/users.ts new file mode 100644 index 0000000..c1e4f8d --- /dev/null +++ b/packages/asana/src/routes/users.ts @@ -0,0 +1,56 @@ +import type { RouteContext } from '@emulators/core' +import { + applyPagination, + asanaData, + asanaError, + compact, + formatUserWithWorkspaces, + parsePagination, + resolveUser, +} from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function userRoutes({ app, store, baseUrl }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.get('/api/1.0/users/:user_gid', (c) => { + const gid = c.req.param('user_gid') + + if (gid === 'me') { + const authUser = c.get('authUser') + if (!authUser) { + return asanaError(c, 401, 'Not Authorized') + } + const user = resolveUser(as(), authUser.login) + if (!user) { + return asanaError(c, 404, 'user: Not Found') + } + return c.json(asanaData(formatUserWithWorkspaces(user, as()))) + } + + const user = as().users.findOneBy('gid', gid) + if (!user) { + return asanaError(c, 404, 'user: Not Found') + } + return c.json(asanaData(formatUserWithWorkspaces(user, as()))) + }) + + app.get('/api/1.0/users', (c) => { + const workspaceGid = c.req.query('workspace') + if (!workspaceGid) { + return asanaError(c, 400, 'workspace: Missing input') + } + + const ws = as().workspaces.findOneBy('gid', workspaceGid) + if (!ws) { + return asanaError(c, 404, 'workspace: Not Found') + } + + const pagination = parsePagination(c) + const users = as().users.all() + const formatted = users.map(u => compact(u.gid, u.resource_type, u.name)) + + const result = applyPagination(formatted, pagination, '/api/1.0/users', baseUrl) + return c.json(result) + }) +} diff --git a/packages/asana/src/routes/webhooks.ts b/packages/asana/src/routes/webhooks.ts new file mode 100644 index 0000000..6587c55 --- /dev/null +++ b/packages/asana/src/routes/webhooks.ts @@ -0,0 +1,91 @@ +import type { RouteContext } from '@emulators/core' +import { + applyPagination, + asanaData, + asanaError, + formatWebhook, + generateGid, + parseAsanaBody, + parsePagination, +} from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function webhookRoutes({ app, store, baseUrl }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.get('/api/1.0/webhooks', (c) => { + const pagination = parsePagination(c) + const resourceGid = c.req.query('resource') + + let webhooks = as().webhooks.all() + if (resourceGid) { + webhooks = webhooks.filter(w => w.resource_gid === resourceGid) + } + + const s = as() + const formatted = webhooks.map(w => formatWebhook(w, s)) + const result = applyPagination(formatted, pagination, '/api/1.0/webhooks', baseUrl) + return c.json(result) + }) + + app.post('/api/1.0/webhooks', async (c) => { + const body = await parseAsanaBody(c) + if (!body.resource) { + return asanaError(c, 400, 'resource: Missing input') + } + if (!body.target) { + return asanaError(c, 400, 'target: Missing input') + } + + const gid = generateGid() + const webhook = as().webhooks.insert({ + gid, + resource_type: 'webhook', + resource_gid: body.resource as string, + target: body.target as string, + active: true, + last_success_at: null, + last_failure_at: null, + last_failure_content: '', + }) + + return c.json(asanaData(formatWebhook(webhook, as())), 201) + }) + + app.get('/api/1.0/webhooks/:webhook_gid', (c) => { + const gid = c.req.param('webhook_gid') + const webhook = as().webhooks.findOneBy('gid', gid) + if (!webhook) { + return asanaError(c, 404, 'webhook: Not Found') + } + return c.json(asanaData(formatWebhook(webhook, as()))) + }) + + app.put('/api/1.0/webhooks/:webhook_gid', async (c) => { + const gid = c.req.param('webhook_gid') + const webhook = as().webhooks.findOneBy('gid', gid) + if (!webhook) { + return asanaError(c, 404, 'webhook: Not Found') + } + + const body = await parseAsanaBody(c) + const updates: Partial<{ active: boolean }> = {} + if (body.active !== undefined) { + updates.active = body.active as boolean + } + + const updated = as().webhooks.update(webhook.id, updates) + return c.json(asanaData(formatWebhook(updated ?? webhook, as()))) + }) + + app.delete('/api/1.0/webhooks/:webhook_gid', (c) => { + const gid = c.req.param('webhook_gid') + const webhook = as().webhooks.findOneBy('gid', gid) + if (!webhook) { + return asanaError(c, 404, 'webhook: Not Found') + } + + as().webhooks.delete(webhook.id) + return c.json(asanaData({})) + }) +} diff --git a/packages/asana/src/routes/workspaces.ts b/packages/asana/src/routes/workspaces.ts new file mode 100644 index 0000000..85a31fd --- /dev/null +++ b/packages/asana/src/routes/workspaces.ts @@ -0,0 +1,45 @@ +import type { RouteContext } from '@emulators/core' +import { + applyPagination, + asanaData, + asanaError, + formatWorkspace, + parseAsanaBody, + parsePagination, +} from '../helpers.js' +import { getAsanaStore } from '../store.js' + +export function workspaceRoutes({ app, store, baseUrl }: RouteContext): void { + const as = () => getAsanaStore(store) + + app.get('/api/1.0/workspaces', (c) => { + const pagination = parsePagination(c) + const workspaces = as().workspaces.all().map(formatWorkspace) + const result = applyPagination(workspaces, pagination, '/api/1.0/workspaces', baseUrl) + return c.json(result) + }) + + app.get('/api/1.0/workspaces/:workspace_gid', (c) => { + const gid = c.req.param('workspace_gid') + const ws = as().workspaces.findOneBy('gid', gid) + if (!ws) { + return asanaError(c, 404, 'workspace: Not Found') + } + return c.json(asanaData(formatWorkspace(ws))) + }) + + app.put('/api/1.0/workspaces/:workspace_gid', async (c) => { + const gid = c.req.param('workspace_gid') + const ws = as().workspaces.findOneBy('gid', gid) + if (!ws) { + return asanaError(c, 404, 'workspace: Not Found') + } + + const body = await parseAsanaBody(c) + const updated = as().workspaces.update(ws.id, { + ...(body.name !== undefined && { name: body.name as string }), + }) + + return c.json(asanaData(formatWorkspace(updated ?? ws))) + }) +} diff --git a/packages/asana/src/store.ts b/packages/asana/src/store.ts new file mode 100644 index 0000000..9677933 --- /dev/null +++ b/packages/asana/src/store.ts @@ -0,0 +1,50 @@ +import type { Collection, Store } from '@emulators/core' +import type { + AsanaProject, + AsanaSection, + AsanaStory, + AsanaTag, + AsanaTask, + AsanaTaskDependency, + AsanaTaskProject, + AsanaTaskTag, + AsanaTeam, + AsanaTeamMembership, + AsanaUser, + AsanaWebhook, + AsanaWorkspace, +} from './entities.js' + +export interface AsanaStore { + users: Collection + workspaces: Collection + teams: Collection + teamMemberships: Collection + projects: Collection + sections: Collection + tasks: Collection + taskProjects: Collection + taskTags: Collection + taskDependencies: Collection + tags: Collection + stories: Collection + webhooks: Collection +} + +export function getAsanaStore(store: Store): AsanaStore { + return { + users: store.collection('asana.users', ['gid', 'email']), + workspaces: store.collection('asana.workspaces', ['gid']), + teams: store.collection('asana.teams', ['gid', 'workspace_gid']), + teamMemberships: store.collection('asana.team_memberships', ['gid', 'team_gid', 'user_gid']), + projects: store.collection('asana.projects', ['gid', 'workspace_gid', 'team_gid']), + sections: store.collection('asana.sections', ['gid', 'project_gid']), + tasks: store.collection('asana.tasks', ['gid', 'workspace_gid', 'assignee_gid', 'parent_gid']), + taskProjects: store.collection('asana.task_projects', ['task_gid', 'project_gid']), + taskTags: store.collection('asana.task_tags', ['task_gid', 'tag_gid']), + taskDependencies: store.collection('asana.task_dependencies', ['task_gid', 'dependency_gid']), + tags: store.collection('asana.tags', ['gid', 'workspace_gid']), + stories: store.collection('asana.stories', ['gid', 'task_gid']), + webhooks: store.collection('asana.webhooks', ['gid']), + } +} diff --git a/packages/asana/tsconfig.json b/packages/asana/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/asana/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/asana/tsup.config.ts b/packages/asana/tsup.config.ts new file mode 100644 index 0000000..ff87ed4 --- /dev/null +++ b/packages/asana/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + // tsup injects a deprecated `baseUrl` into the dts build; silence it under TS 6 + dts: { compilerOptions: { ignoreDeprecations: '6.0' } }, +}) diff --git a/packages/emulate/package.json b/packages/emulate/package.json index 1e93c0e..f814e8b 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -21,6 +21,7 @@ "tosspayments", "firebase", "supabase", + "asana", "oauth", "testing", "ci", @@ -53,6 +54,7 @@ }, "dependencies": { "@emulators/core": "^0.6.0", + "@pleaseai/emulate-asana": "workspace:*", "@pleaseai/emulate-firebase": "workspace:*", "@pleaseai/emulate-kakao": "workspace:*", "@pleaseai/emulate-naver": "workspace:*", diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 6ec4896..9ed9e48 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -20,7 +20,7 @@ export interface ServiceEntry { initConfig: Record } -const SERVICE_NAME_LIST = ['kakao', 'naver', 'tosspayments', 'firebase', 'supabase'] as const +const SERVICE_NAME_LIST = ['kakao', 'naver', 'tosspayments', 'firebase', 'supabase', 'asana'] as const export type ServiceName = (typeof SERVICE_NAME_LIST)[number] export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST @@ -173,4 +173,27 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + + asana: { + label: 'Asana project management API emulator', + endpoints: 'users, workspaces, projects, sections, tasks, tags, stories, teams, webhooks', + async load() { + const mod = await import('@pleaseai/emulate-asana') + return { plugin: mod.asanaPlugin, seedFromConfig: widenSeed(mod.seedFromConfig) } + }, + defaultFallback(cfg) { + const users = cfg?.users as Array<{ email?: string, name?: string }> | undefined + const firstUser = users?.[0]?.email ?? users?.[0]?.name ?? 'me' + return { login: firstUser, id: 1, scopes: [] } + }, + initConfig: { + asana: { + workspaces: [{ name: 'My Workspace', is_organization: true }], + users: [{ name: 'Developer', email: 'dev@example.com' }], + teams: [{ name: 'Engineering', workspace: 'My Workspace' }], + projects: [{ name: 'My Project', workspace: 'My Workspace', team: 'Engineering', owner: 'Developer' }], + tasks: [{ name: 'Example Task', project: 'My Project', assignee: 'Developer' }], + }, + }, + }, } From c6787311fd1674a97e8d6b7c2594c965672c4792 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 8 Jun 2026 19:19:54 +0900 Subject: [PATCH 2/4] chore(asana): apply AI code review suggestions - GET /projects: require workspace or team, 404 on unknown workspace/team - GET /tasks: require a workspace/project/section filter, 404 on unknown project - POST /tasks: validate referenced projects/sections/parent (404 if missing) - POST /tasks: resolve the 'me' assignee keyword to the authenticated user - POST /tasks: set completed_at when a task is created already completed - tasks list: use optional chaining instead of a non-null assertion - projects: cast html_notes to a nullable type before the ?? default --- packages/asana/src/routes/projects.ts | 12 ++++++- packages/asana/src/routes/tasks.ts | 50 +++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/asana/src/routes/projects.ts b/packages/asana/src/routes/projects.ts index 407e49f..3e0f3b0 100644 --- a/packages/asana/src/routes/projects.ts +++ b/packages/asana/src/routes/projects.ts @@ -21,6 +21,16 @@ export function projectRoutes({ app, store, baseUrl }: RouteContext): void { const workspaceGid = c.req.query('workspace') const teamGid = c.req.query('team') + if (!workspaceGid && !teamGid) { + return asanaError(c, 400, 'workspace or team: Missing input') + } + if (workspaceGid && !as().workspaces.findOneBy('gid', workspaceGid)) { + return asanaError(c, 404, 'workspace: Not Found') + } + if (teamGid && !as().teams.findOneBy('gid', teamGid)) { + return asanaError(c, 404, 'team: Not Found') + } + let projects = as().projects.all() if (workspaceGid) { projects = projects.filter(p => p.workspace_gid === workspaceGid) @@ -60,7 +70,7 @@ export function projectRoutes({ app, store, baseUrl }: RouteContext): void { archived: false, color: (body.color as string) ?? null, notes: (body.notes as string) ?? '', - html_notes: (body.html_notes as string) ?? '', + html_notes: (body.html_notes as string | undefined) ?? '', privacy_setting: (body.privacy_setting as AsanaProject['privacy_setting']) ?? 'public_to_workspace', default_view: (body.default_view as AsanaProject['default_view']) ?? 'list', completed: false, diff --git a/packages/asana/src/routes/tasks.ts b/packages/asana/src/routes/tasks.ts index 2b43d8f..2a0f2ae 100644 --- a/packages/asana/src/routes/tasks.ts +++ b/packages/asana/src/routes/tasks.ts @@ -25,9 +25,16 @@ export function taskRoutes({ app, store, baseUrl }: RouteContext): void { const assigneeParam = c.req.query('assignee') const workspaceGid = c.req.query('workspace') + if (!projectGid && !sectionGid && !workspaceGid) { + return asanaError(c, 400, 'workspace, project, or section: Missing input') + } + let taskGids: Set | null = null if (projectGid) { + if (!as().projects.findOneBy('gid', projectGid)) { + return asanaError(c, 404, 'project: Not Found') + } const rels = as().taskProjects.findBy('project_gid', projectGid) taskGids = new Set(rels.map(r => r.task_gid)) } @@ -44,7 +51,7 @@ export function taskRoutes({ app, store, baseUrl }: RouteContext): void { } let tasks = taskGids - ? as().tasks.all().filter(t => taskGids!.has(t.gid)) + ? as().tasks.all().filter(t => taskGids?.has(t.gid)) : as().tasks.all() if (assigneeParam) { @@ -96,24 +103,55 @@ export function taskRoutes({ app, store, baseUrl }: RouteContext): void { return asanaError(c, 400, 'workspace: Missing input') } + // Validate referenced resources exist before creating the task + for (const pGid of projectGids) { + if (!as().projects.findOneBy('gid', pGid)) { + return asanaError(c, 404, 'project: Not Found') + } + } + for (const m of membershipData ?? []) { + if (!as().projects.findOneBy('gid', m.project)) { + return asanaError(c, 404, 'project: Not Found') + } + if (m.section && !as().sections.findOneBy('gid', m.section)) { + return asanaError(c, 404, 'section: Not Found') + } + } + const parentGid = (body.parent as string) ?? null + if (parentGid && !as().tasks.findOneBy('gid', parentGid)) { + return asanaError(c, 404, 'parent: Not Found') + } + + // Resolve the assignee, mapping the 'me' keyword to the authenticated user + const assigneeInput = body.assignee as string | undefined + let assigneeGid: string | null = null + if (assigneeInput) { + const login = assigneeInput === 'me' + ? (c.get('authUser')?.login ?? assigneeInput) + : assigneeInput + assigneeGid = resolveUser(as(), login)?.gid ?? assigneeInput + } + + const completed = (body.completed as boolean) ?? false + const gid = generateGid() const task = as().tasks.insert({ gid, resource_type: 'task', resource_subtype: (body.resource_subtype as AsanaTask['resource_subtype']) ?? 'default_task', name: body.name as string, - assignee_gid: (body.assignee as string) ?? null, + assignee_gid: assigneeGid, workspace_gid: workspaceGid, - completed: (body.completed as boolean) ?? false, - completed_at: null, + completed, + completed_at: completed ? new Date().toISOString() : null, due_on: (body.due_on as string) ?? null, due_at: (body.due_at as string) ?? null, start_on: (body.start_on as string) ?? null, notes: (body.notes as string) ?? '', - html_notes: (body.html_notes as string) ?? '', + html_notes: (body.html_notes as string | undefined) ?? '', liked: false, num_likes: 0, - parent_gid: (body.parent as string) ?? null, + parent_gid: parentGid, permalink_url: '', follower_gids: (body.followers as string[]) ?? [], }) From 6b646e1636c587d33a3b0ffa6645013a4d71dabf Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 8 Jun 2026 19:25:36 +0900 Subject: [PATCH 3/4] fix(asana): reject membership sections from a different project A task membership could reference a section belonging to a different project, producing inconsistent project/section links. Validate that the section's project matches the membership project, returning 400 otherwise. --- packages/asana/src/routes/tasks.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/asana/src/routes/tasks.ts b/packages/asana/src/routes/tasks.ts index 2a0f2ae..2ba1445 100644 --- a/packages/asana/src/routes/tasks.ts +++ b/packages/asana/src/routes/tasks.ts @@ -113,9 +113,13 @@ export function taskRoutes({ app, store, baseUrl }: RouteContext): void { if (!as().projects.findOneBy('gid', m.project)) { return asanaError(c, 404, 'project: Not Found') } - if (m.section && !as().sections.findOneBy('gid', m.section)) { + const section = m.section ? as().sections.findOneBy('gid', m.section) : null + if (m.section && !section) { return asanaError(c, 404, 'section: Not Found') } + if (section && section.project_gid !== m.project) { + return asanaError(c, 400, 'section: Invalid for project') + } } const parentGid = (body.parent as string) ?? null if (parentGid && !as().tasks.findOneBy('gid', parentGid)) { From 937be6c8787b74a37065c93e65066ced6dcdeed2 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 8 Jun 2026 19:33:43 +0900 Subject: [PATCH 4/4] test(asana): cover new validation branches and follower endpoints Add tests for the request-validation and resource-existence checks introduced during review (GET/POST 400/404 paths, cross-project section rejection, 'me' assignee resolution, completed_at on create) plus the previously untested addFollowers/removeFollowers handlers, project PUT field updates, and milestone task_counts. Raises new-code coverage above the 80% quality gate. --- packages/asana/src/__tests__/asana.test.ts | 190 +++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/packages/asana/src/__tests__/asana.test.ts b/packages/asana/src/__tests__/asana.test.ts index 351cacb..cdc5ad2 100644 --- a/packages/asana/src/__tests__/asana.test.ts +++ b/packages/asana/src/__tests__/asana.test.ts @@ -787,3 +787,193 @@ describe('Asana - seedFromConfig', () => { expect(as.users.all().filter(u => u.email === 'user@test.com').length).toBe(1) }) }) + +// ── Validation & resource checks ─────────────────────────── + +describe('Asana - Validation', () => { + let app: Hono + let workspaceGid: string + let projectGid: string + let sectionGid: string + + beforeEach(() => { + const testApp = createTestApp() + app = testApp.app + const as = getAsanaStore(testApp.store) + workspaceGid = as.workspaces.all().find(w => w.name === 'Test Workspace')!.gid + projectGid = as.projects.all().find(p => p.name === 'Test Project')!.gid + sectionGid = as.sections.findBy('project_gid', projectGid).find(s => s.name === 'To Do')!.gid + }) + + async function postTask(data: Record) { + return app.request(`${base}/api/1.0/tasks`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data }), + }) + } + + it('GET /projects requires workspace or team', async () => { + const res = await app.request(`${base}/api/1.0/projects`, { headers: authHeaders() }) + expect(res.status).toBe(400) + }) + + it('GET /projects returns 404 for an unknown workspace', async () => { + const res = await app.request(`${base}/api/1.0/projects?workspace=999999`, { headers: authHeaders() }) + expect(res.status).toBe(404) + }) + + it('GET /projects returns 404 for an unknown team', async () => { + const res = await app.request(`${base}/api/1.0/projects?team=999999`, { headers: authHeaders() }) + expect(res.status).toBe(404) + }) + + it('GET /tasks requires a workspace, project, or section filter', async () => { + const res = await app.request(`${base}/api/1.0/tasks`, { headers: authHeaders() }) + expect(res.status).toBe(400) + }) + + it('GET /tasks returns 404 for an unknown project', async () => { + const res = await app.request(`${base}/api/1.0/tasks?project=999999`, { headers: authHeaders() }) + expect(res.status).toBe(404) + }) + + it('POST /tasks returns 404 for an unknown project', async () => { + const res = await postTask({ name: 'X', workspace: workspaceGid, projects: ['999999'] }) + expect(res.status).toBe(404) + }) + + it('POST /tasks returns 404 for an unknown membership project', async () => { + const res = await postTask({ name: 'X', workspace: workspaceGid, memberships: [{ project: '999999' }] }) + expect(res.status).toBe(404) + }) + + it('POST /tasks returns 404 for an unknown membership section', async () => { + const res = await postTask({ name: 'X', workspace: workspaceGid, memberships: [{ project: projectGid, section: '999999' }] }) + expect(res.status).toBe(404) + }) + + it('POST /tasks rejects a membership section from a different project', async () => { + const projRes = await app.request(`${base}/api/1.0/projects`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Other Project', workspace: workspaceGid } }), + }) + const otherProjectGid = (await projRes.json() as any).data.gid + const secRes = await app.request(`${base}/api/1.0/projects/${otherProjectGid}/sections`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { name: 'Foreign Section' } }), + }) + const foreignSectionGid = (await secRes.json() as any).data.gid + + const res = await postTask({ name: 'X', workspace: workspaceGid, memberships: [{ project: projectGid, section: foreignSectionGid }] }) + expect(res.status).toBe(400) + }) + + it('POST /tasks returns 404 for an unknown parent', async () => { + const res = await postTask({ name: 'X', workspace: workspaceGid, parent: '999999' }) + expect(res.status).toBe(404) + }) + + it('POST /tasks resolves the "me" assignee to the authenticated user', async () => { + const meRes = await app.request(`${base}/api/1.0/users/me`, { headers: authHeaders() }) + const meGid = (await meRes.json() as any).data.gid + + const res = await postTask({ name: 'Assigned to me', workspace: workspaceGid, assignee: 'me' }) + expect(res.status).toBe(201) + const body = await res.json() as any + expect(body.data.assignee.gid).toBe(meGid) + }) + + it('POST /tasks sets completed_at when created as completed', async () => { + const res = await postTask({ name: 'Done', workspace: workspaceGid, completed: true }) + expect(res.status).toBe(201) + const body = await res.json() as any + expect(body.data.completed).toBe(true) + expect(body.data.completed_at).not.toBeNull() + }) + + it('POST /tasks accepts a membership section from the same project', async () => { + const res = await postTask({ name: 'Sectioned', workspace: workspaceGid, memberships: [{ project: projectGid, section: sectionGid }] }) + expect(res.status).toBe(201) + const body = await res.json() as any + expect(body.data.memberships.length).toBe(1) + }) + + it('POST /tasks/:gid/addFollowers and removeFollowers', async () => { + const meRes = await app.request(`${base}/api/1.0/users/me`, { headers: authHeaders() }) + const meGid = (await meRes.json() as any).data.gid + const createRes = await postTask({ name: 'Followed', workspace: workspaceGid }) + const taskGid = (await createRes.json() as any).data.gid + + const addRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/addFollowers`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { followers: [meGid] } }), + }) + expect(addRes.status).toBe(200) + expect((await addRes.json() as any).data.followers.length).toBe(1) + + const rmRes = await app.request(`${base}/api/1.0/tasks/${taskGid}/removeFollowers`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { followers: [meGid] } }), + }) + expect(rmRes.status).toBe(200) + expect((await rmRes.json() as any).data.followers.length).toBe(0) + }) + + it('follower endpoints return 404 for an unknown task', async () => { + const addRes = await app.request(`${base}/api/1.0/tasks/999999/addFollowers`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { followers: ['1'] } }), + }) + expect(addRes.status).toBe(404) + const rmRes = await app.request(`${base}/api/1.0/tasks/999999/removeFollowers`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ data: { followers: ['1'] } }), + }) + expect(rmRes.status).toBe(404) + }) + + it('PUT /projects updates all mutable fields', async () => { + const res = await app.request(`${base}/api/1.0/projects/${projectGid}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ data: { + name: 'Renamed', + notes: 'n', + html_notes: 'n', + color: 'blue', + archived: true, + privacy_setting: 'private', + default_view: 'board', + owner: '1', + team: '2', + } }), + }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.archived).toBe(true) + expect(body.data.default_view).toBe('board') + }) + + it('GET /projects/:gid/tasks lists tasks in a project', async () => { + await postTask({ name: 'PT', workspace: workspaceGid, projects: [projectGid] }) + const res = await app.request(`${base}/api/1.0/projects/${projectGid}/tasks`, { headers: authHeaders() }) + expect(res.status).toBe(200) + expect((await res.json() as any).data.length).toBeGreaterThanOrEqual(1) + }) + + it('GET /projects/:gid/task_counts counts milestones', async () => { + await postTask({ name: 'MS', workspace: workspaceGid, projects: [projectGid], resource_subtype: 'milestone', completed: true }) + const res = await app.request(`${base}/api/1.0/projects/${projectGid}/task_counts`, { headers: authHeaders() }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.data.num_milestones).toBeGreaterThanOrEqual(1) + expect(body.data.num_completed_milestones).toBeGreaterThanOrEqual(1) + }) +})