diff --git a/README.md b/README.md index e46af21..4e6b205 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ 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 | -| `linear` | 4005 | Linear GraphQL API (read-only): issues, projects, teams, users, orgs, labels, workflow states, Relay pagination | +| `asana` | 4005 | Workspaces, teams, projects, sections, tasks, tags, stories, webhooks (REST API v1.0) | +| `linear` | 4006 | Linear GraphQL API (read-only): issues, projects, teams, users, orgs, labels, workflow states, Relay pagination | ## Getting started @@ -112,6 +113,7 @@ packages/ toss-payments/ # @pleaseai/emulate-toss-payments firebase/ # @pleaseai/emulate-firebase supabase/ # @pleaseai/emulate-supabase + asana/ # @pleaseai/emulate-asana linear/ # @pleaseai/emulate-linear docs/ EMULATOR-CONVENTIONS.md # guide for adding new emulators diff --git a/bun.lock b/bun.lock index 5e22061..2a513ff 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-linear": "workspace:*", @@ -228,6 +240,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..cdc5ad2 --- /dev/null +++ b/packages/asana/src/__tests__/asana.test.ts @@ -0,0 +1,979 @@ +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) + }) +}) + +// ── 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) + }) +}) 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..3e0f3b0 --- /dev/null +++ b/packages/asana/src/routes/projects.ts @@ -0,0 +1,222 @@ +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') + + 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) + } + 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 | 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, + 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..2ba1445 --- /dev/null +++ b/packages/asana/src/routes/tasks.ts @@ -0,0 +1,640 @@ +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') + + 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)) + } + 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') + } + + // 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') + } + 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)) { + 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: assigneeGid, + workspace_gid: workspaceGid, + 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 | undefined) ?? '', + liked: false, + num_likes: 0, + parent_gid: parentGid, + 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 dfe7b2f..7fffe0e 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -21,6 +21,7 @@ "tosspayments", "firebase", "supabase", + "asana", "linear", "oauth", "testing", @@ -54,6 +55,7 @@ }, "dependencies": { "@emulators/core": "^0.6.0", + "@pleaseai/emulate-asana": "workspace:*", "@pleaseai/emulate-firebase": "workspace:*", "@pleaseai/emulate-kakao": "workspace:*", "@pleaseai/emulate-linear": "workspace:*", diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 4f42d2a..1c8faca 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', 'linear'] as const +const SERVICE_NAME_LIST = ['kakao', 'naver', 'tosspayments', 'firebase', 'supabase', 'asana', 'linear'] as const export type ServiceName = (typeof SERVICE_NAME_LIST)[number] export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST @@ -174,6 +174,29 @@ 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' }], + }, + }, + }, + linear: { label: 'Linear GraphQL API emulator', endpoints: