Initial todo app setup
- Backend: Bun + Elysia + Drizzle ORM + PostgreSQL - Frontend: React + Vite + TailwindCSS + Zustand - Auth: better-auth with invite-only system - Features: Tasks, Projects, Sections, Labels, Comments - Hammer API: Dedicated endpoints for AI assistant integration - Unit tests: 24 passing tests - Docker: Compose file for deployment
This commit is contained in:
185
apps/api/src/routes/admin.ts
Normal file
185
apps/api/src/routes/admin.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { users, invites } from '../db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { sendInviteEmail } from '../lib/email';
|
||||
import type { User } from '../lib/auth';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const adminRoutes = new Elysia({ prefix: '/admin' })
|
||||
// Middleware: require admin role
|
||||
.derive(({ user, set }) => {
|
||||
if ((user as User).role !== 'admin') {
|
||||
set.status = 403;
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
return {};
|
||||
})
|
||||
|
||||
// List all users
|
||||
.get('/users', async () => {
|
||||
const allUsers = await db.query.users.findMany({
|
||||
orderBy: [desc(users.createdAt)],
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return allUsers;
|
||||
})
|
||||
|
||||
// Get single user
|
||||
.get('/users/:id', async ({ params, set }) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, params.id),
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
set.status = 404;
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return user;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Update user role
|
||||
.patch('/users/:id/role', async ({ params, body, set }) => {
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({ role: body.role, updatedAt: new Date() })
|
||||
.where(eq(users.id, params.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404;
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return { success: true, user: updated };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
role: t.Union([t.Literal('admin'), t.Literal('user'), t.Literal('service')]),
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete user
|
||||
.delete('/users/:id', async ({ params, user, set }) => {
|
||||
// Prevent self-deletion
|
||||
if (params.id === (user as User).id) {
|
||||
set.status = 400;
|
||||
throw new Error('Cannot delete your own account');
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(users)
|
||||
.where(eq(users.id, params.id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404;
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Create invite
|
||||
.post('/invites', async ({ body, user }) => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
const [invite] = await db.insert(invites).values({
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
token,
|
||||
invitedBy: (user as User).id,
|
||||
expiresAt,
|
||||
}).returning();
|
||||
|
||||
// Send invite email
|
||||
try {
|
||||
await sendInviteEmail({
|
||||
to: body.email,
|
||||
name: body.name,
|
||||
token,
|
||||
inviterName: (user as User).name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send invite email:', error);
|
||||
// Continue anyway - admin can share the link manually
|
||||
}
|
||||
|
||||
const setupUrl = `${process.env.APP_URL || 'https://todo.donovankelly.xyz'}/setup?token=${token}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invite: {
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
name: invite.name,
|
||||
expiresAt: invite.expiresAt,
|
||||
},
|
||||
setupUrl, // Return URL in case email fails
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.String({ format: 'email' }),
|
||||
name: t.String({ minLength: 1 }),
|
||||
}),
|
||||
})
|
||||
|
||||
// List invites
|
||||
.get('/invites', async () => {
|
||||
const allInvites = await db.query.invites.findMany({
|
||||
orderBy: [desc(invites.createdAt)],
|
||||
with: {
|
||||
inviter: {
|
||||
columns: { name: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return allInvites;
|
||||
})
|
||||
|
||||
// Revoke/delete invite
|
||||
.delete('/invites/:id', async ({ params, set }) => {
|
||||
const [deleted] = await db
|
||||
.delete(invites)
|
||||
.where(eq(invites.id, params.id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404;
|
||||
throw new Error('Invite not found');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
});
|
||||
125
apps/api/src/routes/auth.ts
Normal file
125
apps/api/src/routes/auth.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { invites, users, projects } from '../db/schema';
|
||||
import { eq, and, gt } from 'drizzle-orm';
|
||||
import { auth } from '../lib/auth';
|
||||
|
||||
export const authRoutes = new Elysia({ prefix: '/auth' })
|
||||
// Validate invite token (public)
|
||||
.get('/invite/:token', async ({ params, set }) => {
|
||||
const invite = await db.query.invites.findFirst({
|
||||
where: and(
|
||||
eq(invites.token, params.token),
|
||||
eq(invites.status, 'pending'),
|
||||
gt(invites.expiresAt, new Date())
|
||||
),
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
set.status = 404;
|
||||
throw new Error('Invalid or expired invite');
|
||||
}
|
||||
|
||||
return {
|
||||
email: invite.email,
|
||||
name: invite.name,
|
||||
};
|
||||
}, {
|
||||
params: t.Object({
|
||||
token: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Accept invite and create account (public)
|
||||
.post('/invite/:token/accept', async ({ params, body, set }) => {
|
||||
const invite = await db.query.invites.findFirst({
|
||||
where: and(
|
||||
eq(invites.token, params.token),
|
||||
eq(invites.status, 'pending'),
|
||||
gt(invites.expiresAt, new Date())
|
||||
),
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
set.status = 404;
|
||||
throw new Error('Invalid or expired invite');
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.email, invite.email),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
set.status = 400;
|
||||
throw new Error('Account already exists for this email');
|
||||
}
|
||||
|
||||
try {
|
||||
// Create user via BetterAuth
|
||||
const signUpResult = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: invite.email,
|
||||
password: body.password,
|
||||
name: invite.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!signUpResult) {
|
||||
throw new Error('Failed to create account');
|
||||
}
|
||||
|
||||
// Get the created user
|
||||
const newUser = await db.query.users.findFirst({
|
||||
where: eq(users.email, invite.email),
|
||||
});
|
||||
|
||||
if (newUser) {
|
||||
// Create default inbox project
|
||||
await db.insert(projects).values({
|
||||
userId: newUser.id,
|
||||
name: 'Inbox',
|
||||
isInbox: true,
|
||||
color: '#808080',
|
||||
});
|
||||
|
||||
// Create some default projects
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
userId: newUser.id,
|
||||
name: 'Personal',
|
||||
color: '#3b82f6',
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
userId: newUser.id,
|
||||
name: 'Work',
|
||||
color: '#22c55e',
|
||||
sortOrder: 2,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Mark invite as accepted
|
||||
await db
|
||||
.update(invites)
|
||||
.set({
|
||||
status: 'accepted',
|
||||
acceptedAt: new Date(),
|
||||
})
|
||||
.where(eq(invites.id, invite.id));
|
||||
|
||||
return { success: true, message: 'Account created successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error creating account:', error);
|
||||
set.status = 500;
|
||||
throw new Error('Failed to create account');
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
token: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
password: t.String({ minLength: 8 }),
|
||||
}),
|
||||
});
|
||||
146
apps/api/src/routes/comments.ts
Normal file
146
apps/api/src/routes/comments.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { comments, tasks } from '../db/schema';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import type { User } from '../lib/auth';
|
||||
|
||||
export const commentRoutes = new Elysia({ prefix: '/comments' })
|
||||
// Get comments for a task
|
||||
.get('/task/:taskId', async ({ params, user, set }) => {
|
||||
// Verify task ownership
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: and(
|
||||
eq(tasks.id, params.taskId),
|
||||
eq(tasks.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
set.status = 404;
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
const taskComments = await db.query.comments.findMany({
|
||||
where: eq(comments.taskId, params.taskId),
|
||||
orderBy: [asc(comments.createdAt)],
|
||||
with: {
|
||||
user: {
|
||||
columns: { id: true, name: true, image: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return taskComments;
|
||||
}, {
|
||||
params: t.Object({
|
||||
taskId: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Create comment
|
||||
.post('/', async ({ body, user }) => {
|
||||
const userId = (user as User).id;
|
||||
|
||||
// Verify task ownership
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: and(
|
||||
eq(tasks.id, body.taskId),
|
||||
eq(tasks.userId, userId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
const [comment] = await db.insert(comments).values({
|
||||
taskId: body.taskId,
|
||||
userId,
|
||||
content: body.content,
|
||||
attachments: body.attachments || [],
|
||||
}).returning();
|
||||
|
||||
// Return with user info
|
||||
const fullComment = await db.query.comments.findFirst({
|
||||
where: eq(comments.id, comment.id),
|
||||
with: {
|
||||
user: {
|
||||
columns: { id: true, name: true, image: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return fullComment;
|
||||
}, {
|
||||
body: t.Object({
|
||||
taskId: t.String(),
|
||||
content: t.String({ minLength: 1 }),
|
||||
attachments: t.Optional(t.Array(t.Object({
|
||||
name: t.String(),
|
||||
url: t.String(),
|
||||
type: t.String(),
|
||||
}))),
|
||||
}),
|
||||
})
|
||||
|
||||
// Update comment
|
||||
.patch('/:id', async ({ params, body, user, set }) => {
|
||||
const existing = await db.query.comments.findFirst({
|
||||
where: and(
|
||||
eq(comments.id, params.id),
|
||||
eq(comments.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Comment not found');
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(comments)
|
||||
.set({
|
||||
content: body.content,
|
||||
attachments: body.attachments,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(comments.id, params.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
content: t.Optional(t.String({ minLength: 1 })),
|
||||
attachments: t.Optional(t.Array(t.Object({
|
||||
name: t.String(),
|
||||
url: t.String(),
|
||||
type: t.String(),
|
||||
}))),
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete comment
|
||||
.delete('/:id', async ({ params, user, set }) => {
|
||||
const existing = await db.query.comments.findFirst({
|
||||
where: and(
|
||||
eq(comments.id, params.id),
|
||||
eq(comments.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Comment not found');
|
||||
}
|
||||
|
||||
await db.delete(comments).where(eq(comments.id, params.id));
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
});
|
||||
416
apps/api/src/routes/hammer.ts
Normal file
416
apps/api/src/routes/hammer.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { tasks, projects, hammerWebhooks, users, activityLog } from '../db/schema';
|
||||
import { eq, and, asc, desc, sql } from 'drizzle-orm';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// This route uses bearer token auth for Hammer (service account)
|
||||
// The token is set in HAMMER_API_KEY env var
|
||||
|
||||
const validateHammerAuth = (authHeader: string | undefined): boolean => {
|
||||
if (!authHeader) return false;
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
return token === process.env.HAMMER_API_KEY;
|
||||
};
|
||||
|
||||
export const hammerRoutes = new Elysia({ prefix: '/hammer' })
|
||||
// Middleware: require Hammer API key
|
||||
.derive(({ request, set }) => {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (!validateHammerAuth(authHeader)) {
|
||||
set.status = 401;
|
||||
throw new Error('Invalid API key');
|
||||
}
|
||||
return {};
|
||||
})
|
||||
|
||||
// Get Hammer's service user ID
|
||||
.get('/me', async ({ set }) => {
|
||||
const hammerUser = await db.query.users.findFirst({
|
||||
where: eq(users.role, 'service'),
|
||||
});
|
||||
|
||||
if (!hammerUser) {
|
||||
set.status = 404;
|
||||
throw new Error('Hammer service account not found. Please create one via admin.');
|
||||
}
|
||||
|
||||
return {
|
||||
id: hammerUser.id,
|
||||
name: hammerUser.name,
|
||||
email: hammerUser.email,
|
||||
role: hammerUser.role,
|
||||
};
|
||||
})
|
||||
|
||||
// Get tasks assigned to Hammer
|
||||
.get('/tasks', async ({ query, set }) => {
|
||||
const hammerUser = await db.query.users.findFirst({
|
||||
where: eq(users.role, 'service'),
|
||||
});
|
||||
|
||||
if (!hammerUser) {
|
||||
set.status = 404;
|
||||
throw new Error('Hammer service account not found');
|
||||
}
|
||||
|
||||
const conditions = [eq(tasks.assigneeId, hammerUser.id)];
|
||||
|
||||
// Filter by completion status
|
||||
if (query.completed === 'true') {
|
||||
conditions.push(eq(tasks.isCompleted, true));
|
||||
} else if (query.completed === 'false') {
|
||||
conditions.push(eq(tasks.isCompleted, false));
|
||||
}
|
||||
|
||||
// Filter by priority
|
||||
if (query.priority) {
|
||||
conditions.push(eq(tasks.priority, query.priority as 'p1' | 'p2' | 'p3' | 'p4'));
|
||||
}
|
||||
|
||||
const assignedTasks = await db.query.tasks.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [
|
||||
asc(tasks.isCompleted),
|
||||
desc(sql`CASE ${tasks.priority} WHEN 'p1' THEN 1 WHEN 'p2' THEN 2 WHEN 'p3' THEN 3 ELSE 4 END`),
|
||||
asc(tasks.dueDate),
|
||||
],
|
||||
with: {
|
||||
project: {
|
||||
columns: { id: true, name: true, color: true },
|
||||
},
|
||||
user: {
|
||||
columns: { id: true, name: true, email: true },
|
||||
},
|
||||
taskLabels: {
|
||||
with: { label: true },
|
||||
},
|
||||
comments: {
|
||||
orderBy: [desc(sql`created_at`)],
|
||||
limit: 5,
|
||||
with: {
|
||||
user: {
|
||||
columns: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return assignedTasks;
|
||||
}, {
|
||||
query: t.Object({
|
||||
completed: t.Optional(t.String()),
|
||||
priority: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Get single task details
|
||||
.get('/tasks/:id', async ({ params, set }) => {
|
||||
const hammerUser = await db.query.users.findFirst({
|
||||
where: eq(users.role, 'service'),
|
||||
});
|
||||
|
||||
if (!hammerUser) {
|
||||
set.status = 404;
|
||||
throw new Error('Hammer service account not found');
|
||||
}
|
||||
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: and(
|
||||
eq(tasks.id, params.id),
|
||||
eq(tasks.assigneeId, hammerUser.id)
|
||||
),
|
||||
with: {
|
||||
project: true,
|
||||
section: true,
|
||||
user: {
|
||||
columns: { id: true, name: true, email: true },
|
||||
},
|
||||
taskLabels: {
|
||||
with: { label: true },
|
||||
},
|
||||
comments: {
|
||||
orderBy: [asc(sql`created_at`)],
|
||||
with: {
|
||||
user: {
|
||||
columns: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
subtasks: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
set.status = 404;
|
||||
throw new Error('Task not found or not assigned to Hammer');
|
||||
}
|
||||
|
||||
return task;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Create a task (Hammer creating tasks for a user)
|
||||
.post('/tasks', async ({ body, set }) => {
|
||||
// Get the user to create the task for
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.email, body.userEmail),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
set.status = 404;
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Get user's inbox project
|
||||
let projectId = body.projectId;
|
||||
if (!projectId) {
|
||||
const inbox = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.userId, targetUser.id),
|
||||
eq(projects.isInbox, true)
|
||||
),
|
||||
});
|
||||
|
||||
if (!inbox) {
|
||||
set.status = 400;
|
||||
throw new Error('User has no inbox project');
|
||||
}
|
||||
projectId = inbox.id;
|
||||
}
|
||||
|
||||
const hammerUser = await db.query.users.findFirst({
|
||||
where: eq(users.role, 'service'),
|
||||
});
|
||||
|
||||
const [task] = await db.insert(tasks).values({
|
||||
userId: targetUser.id,
|
||||
projectId,
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||
dueTime: body.dueTime,
|
||||
priority: body.priority || 'p4',
|
||||
assigneeId: body.assignToHammer ? hammerUser?.id : null,
|
||||
}).returning();
|
||||
|
||||
// Log activity
|
||||
await db.insert(activityLog).values({
|
||||
userId: hammerUser?.id,
|
||||
taskId: task.id,
|
||||
projectId,
|
||||
action: 'created',
|
||||
changes: { source: { old: null, new: 'hammer-api' } },
|
||||
});
|
||||
|
||||
return task;
|
||||
}, {
|
||||
body: t.Object({
|
||||
userEmail: t.String({ format: 'email' }),
|
||||
title: t.String({ minLength: 1, maxLength: 500 }),
|
||||
description: t.Optional(t.String()),
|
||||
projectId: t.Optional(t.String()),
|
||||
dueDate: t.Optional(t.String()),
|
||||
dueTime: t.Optional(t.String()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal('p1'),
|
||||
t.Literal('p2'),
|
||||
t.Literal('p3'),
|
||||
t.Literal('p4'),
|
||||
])),
|
||||
assignToHammer: t.Optional(t.Boolean()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Update task (complete, add comment, etc.)
|
||||
.patch('/tasks/:id', async ({ params, body, set }) => {
|
||||
const hammerUser = await db.query.users.findFirst({
|
||||
where: eq(users.role, 'service'),
|
||||
});
|
||||
|
||||
if (!hammerUser) {
|
||||
set.status = 404;
|
||||
throw new Error('Hammer service account not found');
|
||||
}
|
||||
|
||||
const existing = await db.query.tasks.findFirst({
|
||||
where: and(
|
||||
eq(tasks.id, params.id),
|
||||
eq(tasks.assigneeId, hammerUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Task not found or not assigned to Hammer');
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (body.isCompleted !== undefined) {
|
||||
updateData.isCompleted = body.isCompleted;
|
||||
updateData.completedAt = body.isCompleted ? new Date() : null;
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
updateData.description = body.description;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(tasks)
|
||||
.set(updateData)
|
||||
.where(eq(tasks.id, params.id))
|
||||
.returning();
|
||||
|
||||
// Log activity
|
||||
const action = body.isCompleted === true ? 'completed' : 'updated';
|
||||
await db.insert(activityLog).values({
|
||||
userId: hammerUser.id,
|
||||
taskId: params.id,
|
||||
projectId: existing.projectId,
|
||||
action,
|
||||
changes: { source: { old: null, new: 'hammer-api' } },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
isCompleted: t.Optional(t.Boolean()),
|
||||
description: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Add comment to task
|
||||
.post('/tasks/:id/comments', async ({ params, body, set }) => {
|
||||
const hammerUser = await db.query.users.findFirst({
|
||||
where: eq(users.role, 'service'),
|
||||
});
|
||||
|
||||
if (!hammerUser) {
|
||||
set.status = 404;
|
||||
throw new Error('Hammer service account not found');
|
||||
}
|
||||
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: and(
|
||||
eq(tasks.id, params.id),
|
||||
eq(tasks.assigneeId, hammerUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
set.status = 404;
|
||||
throw new Error('Task not found or not assigned to Hammer');
|
||||
}
|
||||
|
||||
const { comments } = await import('../db/schema');
|
||||
const [comment] = await db.insert(comments).values({
|
||||
taskId: params.id,
|
||||
userId: hammerUser.id,
|
||||
content: body.content,
|
||||
}).returning();
|
||||
|
||||
return comment;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
content: t.String({ minLength: 1 }),
|
||||
}),
|
||||
})
|
||||
|
||||
// ============= WEBHOOKS =============
|
||||
|
||||
// Register webhook
|
||||
.post('/webhooks', async ({ body }) => {
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const [webhook] = await db.insert(hammerWebhooks).values({
|
||||
url: body.url,
|
||||
secret,
|
||||
events: body.events || ['task.assigned'],
|
||||
}).returning();
|
||||
|
||||
return {
|
||||
id: webhook.id,
|
||||
url: webhook.url,
|
||||
secret, // Only returned once at creation
|
||||
events: webhook.events,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
url: t.String(),
|
||||
events: t.Optional(t.Array(t.String())),
|
||||
}),
|
||||
})
|
||||
|
||||
// List webhooks
|
||||
.get('/webhooks', async () => {
|
||||
const webhooks = await db.query.hammerWebhooks.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
url: true,
|
||||
events: true,
|
||||
isActive: true,
|
||||
lastTriggeredAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return webhooks;
|
||||
})
|
||||
|
||||
// Update webhook
|
||||
.patch('/webhooks/:id', async ({ params, body, set }) => {
|
||||
const [updated] = await db
|
||||
.update(hammerWebhooks)
|
||||
.set({
|
||||
url: body.url,
|
||||
events: body.events,
|
||||
isActive: body.isActive,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(hammerWebhooks.id, params.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404;
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
url: t.Optional(t.String()),
|
||||
events: t.Optional(t.Array(t.String())),
|
||||
isActive: t.Optional(t.Boolean()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete webhook
|
||||
.delete('/webhooks/:id', async ({ params, set }) => {
|
||||
const [deleted] = await db
|
||||
.delete(hammerWebhooks)
|
||||
.where(eq(hammerWebhooks.id, params.id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404;
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
});
|
||||
127
apps/api/src/routes/labels.ts
Normal file
127
apps/api/src/routes/labels.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { labels, taskLabels } from '../db/schema';
|
||||
import { eq, and, asc, sql } from 'drizzle-orm';
|
||||
import type { User } from '../lib/auth';
|
||||
|
||||
export const labelRoutes = new Elysia({ prefix: '/labels' })
|
||||
// List all labels for user
|
||||
.get('/', async ({ user }) => {
|
||||
const userLabels = await db.query.labels.findMany({
|
||||
where: eq(labels.userId, (user as User).id),
|
||||
orderBy: [asc(labels.sortOrder), asc(labels.name)],
|
||||
});
|
||||
|
||||
// Get task counts for each label
|
||||
const labelsWithCounts = await Promise.all(
|
||||
userLabels.map(async (label) => {
|
||||
const taskCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(taskLabels)
|
||||
.where(eq(taskLabels.labelId, label.id));
|
||||
|
||||
return {
|
||||
...label,
|
||||
taskCount: Number(taskCount[0]?.count || 0),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return labelsWithCounts;
|
||||
})
|
||||
|
||||
// Get single label with tasks
|
||||
.get('/:id', async ({ params, user, set }) => {
|
||||
const label = await db.query.labels.findFirst({
|
||||
where: and(
|
||||
eq(labels.id, params.id),
|
||||
eq(labels.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!label) {
|
||||
set.status = 404;
|
||||
throw new Error('Label not found');
|
||||
}
|
||||
|
||||
return label;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Create label
|
||||
.post('/', async ({ body, user }) => {
|
||||
const [label] = await db.insert(labels).values({
|
||||
userId: (user as User).id,
|
||||
name: body.name,
|
||||
color: body.color,
|
||||
}).returning();
|
||||
|
||||
return label;
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1, maxLength: 50 }),
|
||||
color: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Update label
|
||||
.patch('/:id', async ({ params, body, user, set }) => {
|
||||
const existing = await db.query.labels.findFirst({
|
||||
where: and(
|
||||
eq(labels.id, params.id),
|
||||
eq(labels.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Label not found');
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(labels)
|
||||
.set({
|
||||
...body,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(labels.id, params.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String({ minLength: 1, maxLength: 50 })),
|
||||
color: t.Optional(t.String()),
|
||||
isFavorite: t.Optional(t.Boolean()),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete label
|
||||
.delete('/:id', async ({ params, user, set }) => {
|
||||
const existing = await db.query.labels.findFirst({
|
||||
where: and(
|
||||
eq(labels.id, params.id),
|
||||
eq(labels.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Label not found');
|
||||
}
|
||||
|
||||
await db.delete(labels).where(eq(labels.id, params.id));
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
});
|
||||
257
apps/api/src/routes/projects.ts
Normal file
257
apps/api/src/routes/projects.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { projects, sections } from '../db/schema';
|
||||
import { eq, and, asc, desc } from 'drizzle-orm';
|
||||
import type { User } from '../lib/auth';
|
||||
|
||||
export const projectRoutes = new Elysia({ prefix: '/projects' })
|
||||
// List all projects for user
|
||||
.get('/', async ({ user }) => {
|
||||
const userProjects = await db.query.projects.findMany({
|
||||
where: and(
|
||||
eq(projects.userId, (user as User).id),
|
||||
eq(projects.isArchived, false)
|
||||
),
|
||||
orderBy: [desc(projects.isInbox), asc(projects.sortOrder), asc(projects.createdAt)],
|
||||
with: {
|
||||
sections: {
|
||||
orderBy: [asc(sections.sortOrder)],
|
||||
},
|
||||
},
|
||||
});
|
||||
return userProjects;
|
||||
})
|
||||
|
||||
// Get single project with sections and task counts
|
||||
.get('/:id', async ({ params, user, set }) => {
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.id, params.id),
|
||||
eq(projects.userId, (user as User).id)
|
||||
),
|
||||
with: {
|
||||
sections: {
|
||||
orderBy: [asc(sections.sortOrder)],
|
||||
},
|
||||
tasks: {
|
||||
where: eq(projects.isArchived, false),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
set.status = 404;
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
return project;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Create project
|
||||
.post('/', async ({ body, user }) => {
|
||||
const [project] = await db.insert(projects).values({
|
||||
userId: (user as User).id,
|
||||
name: body.name,
|
||||
color: body.color,
|
||||
icon: body.icon,
|
||||
}).returning();
|
||||
|
||||
return project;
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1, maxLength: 100 }),
|
||||
color: t.Optional(t.String()),
|
||||
icon: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Update project
|
||||
.patch('/:id', async ({ params, body, user, set }) => {
|
||||
const existing = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.id, params.id),
|
||||
eq(projects.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
// Prevent modifying inbox
|
||||
if (existing.isInbox && (body.name || body.isArchived)) {
|
||||
set.status = 400;
|
||||
throw new Error('Cannot modify inbox project name or archive status');
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(projects)
|
||||
.set({
|
||||
...body,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(projects.id, params.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String({ minLength: 1, maxLength: 100 })),
|
||||
color: t.Optional(t.String()),
|
||||
icon: t.Optional(t.String()),
|
||||
isFavorite: t.Optional(t.Boolean()),
|
||||
isArchived: t.Optional(t.Boolean()),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete project
|
||||
.delete('/:id', async ({ params, user, set }) => {
|
||||
const existing = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.id, params.id),
|
||||
eq(projects.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
if (existing.isInbox) {
|
||||
set.status = 400;
|
||||
throw new Error('Cannot delete inbox project');
|
||||
}
|
||||
|
||||
await db.delete(projects).where(eq(projects.id, params.id));
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// ============= SECTIONS =============
|
||||
|
||||
// Create section in project
|
||||
.post('/:id/sections', async ({ params, body, user, set }) => {
|
||||
// Verify project ownership
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.id, params.id),
|
||||
eq(projects.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
set.status = 404;
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
const [section] = await db.insert(sections).values({
|
||||
projectId: params.id,
|
||||
name: body.name,
|
||||
sortOrder: body.sortOrder,
|
||||
}).returning();
|
||||
|
||||
return section;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1, maxLength: 100 }),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Update section
|
||||
.patch('/:projectId/sections/:sectionId', async ({ params, body, user, set }) => {
|
||||
// Verify project ownership
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.id, params.projectId),
|
||||
eq(projects.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
set.status = 404;
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(sections)
|
||||
.set({
|
||||
...body,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(sections.id, params.sectionId),
|
||||
eq(sections.projectId, params.projectId)
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404;
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({
|
||||
projectId: t.String(),
|
||||
sectionId: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String({ minLength: 1, maxLength: 100 })),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
isCollapsed: t.Optional(t.Boolean()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete section
|
||||
.delete('/:projectId/sections/:sectionId', async ({ params, user, set }) => {
|
||||
// Verify project ownership
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.id, params.projectId),
|
||||
eq(projects.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
set.status = 404;
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(sections)
|
||||
.where(and(
|
||||
eq(sections.id, params.sectionId),
|
||||
eq(sections.projectId, params.projectId)
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404;
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
projectId: t.String(),
|
||||
sectionId: t.String(),
|
||||
}),
|
||||
});
|
||||
490
apps/api/src/routes/tasks.ts
Normal file
490
apps/api/src/routes/tasks.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { tasks, taskLabels, projects, activityLog, hammerWebhooks } from '../db/schema';
|
||||
import { eq, and, or, asc, desc, isNull, gte, lte, sql } from 'drizzle-orm';
|
||||
import type { User } from '../lib/auth';
|
||||
|
||||
// Helper to trigger Hammer webhooks
|
||||
async function triggerHammerWebhooks(event: string, payload: Record<string, unknown>) {
|
||||
const webhooks = await db.query.hammerWebhooks.findMany({
|
||||
where: eq(hammerWebhooks.isActive, true),
|
||||
});
|
||||
|
||||
for (const webhook of webhooks) {
|
||||
if (webhook.events?.includes(event) || webhook.events?.includes('*')) {
|
||||
try {
|
||||
await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Secret': webhook.secret,
|
||||
'X-Event-Type': event,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
await db
|
||||
.update(hammerWebhooks)
|
||||
.set({ lastTriggeredAt: new Date() })
|
||||
.where(eq(hammerWebhooks.id, webhook.id));
|
||||
} catch (error) {
|
||||
console.error(`Failed to trigger webhook ${webhook.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to log activity
|
||||
async function logActivity(params: {
|
||||
userId: string;
|
||||
taskId?: string;
|
||||
projectId?: string;
|
||||
action: string;
|
||||
changes?: Record<string, { old: unknown; new: unknown }>;
|
||||
}) {
|
||||
await db.insert(activityLog).values(params);
|
||||
}
|
||||
|
||||
export const taskRoutes = new Elysia({ prefix: '/tasks' })
|
||||
// List tasks with filters
|
||||
.get('/', async ({ user, query }) => {
|
||||
const userId = (user as User).id;
|
||||
const conditions = [eq(tasks.userId, userId)];
|
||||
|
||||
// Filter by project
|
||||
if (query.projectId) {
|
||||
conditions.push(eq(tasks.projectId, query.projectId));
|
||||
}
|
||||
|
||||
// Filter by section
|
||||
if (query.sectionId) {
|
||||
conditions.push(eq(tasks.sectionId, query.sectionId));
|
||||
}
|
||||
|
||||
// Filter by completion status
|
||||
if (query.completed !== undefined) {
|
||||
conditions.push(eq(tasks.isCompleted, query.completed === 'true'));
|
||||
} else {
|
||||
// Default: show incomplete tasks
|
||||
conditions.push(eq(tasks.isCompleted, false));
|
||||
}
|
||||
|
||||
// Filter by priority
|
||||
if (query.priority) {
|
||||
conditions.push(eq(tasks.priority, query.priority as 'p1' | 'p2' | 'p3' | 'p4'));
|
||||
}
|
||||
|
||||
// Filter: today's tasks
|
||||
if (query.today === 'true') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
conditions.push(gte(tasks.dueDate, today));
|
||||
conditions.push(lte(tasks.dueDate, tomorrow));
|
||||
}
|
||||
|
||||
// Filter: upcoming (next 7 days)
|
||||
if (query.upcoming === 'true') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
conditions.push(gte(tasks.dueDate, today));
|
||||
conditions.push(lte(tasks.dueDate, nextWeek));
|
||||
}
|
||||
|
||||
// Filter: overdue
|
||||
if (query.overdue === 'true') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
conditions.push(lte(tasks.dueDate, today));
|
||||
conditions.push(eq(tasks.isCompleted, false));
|
||||
}
|
||||
|
||||
// Filter by label
|
||||
if (query.labelId) {
|
||||
const tasksWithLabel = await db.query.taskLabels.findMany({
|
||||
where: eq(taskLabels.labelId, query.labelId),
|
||||
columns: { taskId: true },
|
||||
});
|
||||
const taskIds = tasksWithLabel.map(tl => tl.taskId);
|
||||
if (taskIds.length > 0) {
|
||||
conditions.push(sql`${tasks.id} IN (${sql.join(taskIds.map(id => sql`${id}`), sql`, `)})`);
|
||||
} else {
|
||||
return []; // No tasks with this label
|
||||
}
|
||||
}
|
||||
|
||||
// Only get parent tasks (not subtasks) by default
|
||||
if (query.includeSubtasks !== 'true') {
|
||||
conditions.push(isNull(tasks.parentId));
|
||||
}
|
||||
|
||||
const userTasks = await db.query.tasks.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [
|
||||
asc(tasks.isCompleted),
|
||||
desc(sql`CASE ${tasks.priority} WHEN 'p1' THEN 1 WHEN 'p2' THEN 2 WHEN 'p3' THEN 3 ELSE 4 END`),
|
||||
asc(tasks.dueDate),
|
||||
asc(tasks.sortOrder),
|
||||
],
|
||||
with: {
|
||||
project: {
|
||||
columns: { id: true, name: true, color: true },
|
||||
},
|
||||
section: {
|
||||
columns: { id: true, name: true },
|
||||
},
|
||||
taskLabels: {
|
||||
with: {
|
||||
label: true,
|
||||
},
|
||||
},
|
||||
subtasks: {
|
||||
where: eq(tasks.isCompleted, false),
|
||||
columns: { id: true, title: true, isCompleted: true },
|
||||
},
|
||||
assignee: {
|
||||
columns: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return userTasks;
|
||||
}, {
|
||||
query: t.Object({
|
||||
projectId: t.Optional(t.String()),
|
||||
sectionId: t.Optional(t.String()),
|
||||
completed: t.Optional(t.String()),
|
||||
priority: t.Optional(t.String()),
|
||||
today: t.Optional(t.String()),
|
||||
upcoming: t.Optional(t.String()),
|
||||
overdue: t.Optional(t.String()),
|
||||
labelId: t.Optional(t.String()),
|
||||
includeSubtasks: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Get single task with full details
|
||||
.get('/:id', async ({ params, user, set }) => {
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: and(
|
||||
eq(tasks.id, params.id),
|
||||
eq(tasks.userId, (user as User).id)
|
||||
),
|
||||
with: {
|
||||
project: true,
|
||||
section: true,
|
||||
parent: {
|
||||
columns: { id: true, title: true },
|
||||
},
|
||||
subtasks: {
|
||||
orderBy: [asc(tasks.sortOrder)],
|
||||
},
|
||||
taskLabels: {
|
||||
with: { label: true },
|
||||
},
|
||||
comments: {
|
||||
orderBy: [asc(sql`created_at`)],
|
||||
with: {
|
||||
user: {
|
||||
columns: { id: true, name: true, image: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
reminders: {
|
||||
orderBy: [asc(sql`trigger_at`)],
|
||||
},
|
||||
assignee: {
|
||||
columns: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
set.status = 404;
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
return task;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Create task
|
||||
.post('/', async ({ body, user }) => {
|
||||
const userId = (user as User).id;
|
||||
|
||||
// Get inbox project if no projectId provided
|
||||
let projectId = body.projectId;
|
||||
if (!projectId) {
|
||||
const inbox = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.userId, userId),
|
||||
eq(projects.isInbox, true)
|
||||
),
|
||||
});
|
||||
if (inbox) {
|
||||
projectId = inbox.id;
|
||||
} else {
|
||||
// Create inbox if it doesn't exist
|
||||
const [newInbox] = await db.insert(projects).values({
|
||||
userId,
|
||||
name: 'Inbox',
|
||||
isInbox: true,
|
||||
color: '#808080',
|
||||
}).returning();
|
||||
projectId = newInbox.id;
|
||||
}
|
||||
}
|
||||
|
||||
const [task] = await db.insert(tasks).values({
|
||||
userId,
|
||||
projectId,
|
||||
sectionId: body.sectionId,
|
||||
parentId: body.parentId,
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||
dueTime: body.dueTime,
|
||||
deadline: body.deadline ? new Date(body.deadline) : null,
|
||||
recurrence: body.recurrence,
|
||||
priority: body.priority || 'p4',
|
||||
assigneeId: body.assigneeId,
|
||||
sortOrder: body.sortOrder,
|
||||
}).returning();
|
||||
|
||||
// Add labels if provided
|
||||
if (body.labelIds && body.labelIds.length > 0) {
|
||||
await db.insert(taskLabels).values(
|
||||
body.labelIds.map(labelId => ({
|
||||
taskId: task.id,
|
||||
labelId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await logActivity({
|
||||
userId,
|
||||
taskId: task.id,
|
||||
projectId,
|
||||
action: 'created',
|
||||
});
|
||||
|
||||
// Trigger webhook if assigned to Hammer
|
||||
if (body.assigneeId) {
|
||||
const assignee = await db.query.users.findFirst({
|
||||
where: eq(sql`id`, body.assigneeId),
|
||||
});
|
||||
if (assignee?.role === 'service') {
|
||||
await triggerHammerWebhooks('task.assigned', {
|
||||
task,
|
||||
assignedBy: { id: userId, name: (user as User).name },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return task with relations
|
||||
const fullTask = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.id, task.id),
|
||||
with: {
|
||||
project: { columns: { id: true, name: true, color: true } },
|
||||
taskLabels: { with: { label: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return fullTask;
|
||||
}, {
|
||||
body: t.Object({
|
||||
title: t.String({ minLength: 1, maxLength: 500 }),
|
||||
description: t.Optional(t.String()),
|
||||
projectId: t.Optional(t.String()),
|
||||
sectionId: t.Optional(t.String()),
|
||||
parentId: t.Optional(t.String()),
|
||||
dueDate: t.Optional(t.String()),
|
||||
dueTime: t.Optional(t.String()),
|
||||
deadline: t.Optional(t.String()),
|
||||
recurrence: t.Optional(t.String()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal('p1'),
|
||||
t.Literal('p2'),
|
||||
t.Literal('p3'),
|
||||
t.Literal('p4'),
|
||||
])),
|
||||
assigneeId: t.Optional(t.String()),
|
||||
labelIds: t.Optional(t.Array(t.String())),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Update task
|
||||
.patch('/:id', async ({ params, body, user, set }) => {
|
||||
const userId = (user as User).id;
|
||||
|
||||
const existing = await db.query.tasks.findFirst({
|
||||
where: and(
|
||||
eq(tasks.id, params.id),
|
||||
eq(tasks.userId, userId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
// Track changes for activity log
|
||||
const changes: Record<string, { old: unknown; new: unknown }> = {};
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (body.title !== undefined && body.title !== existing.title) {
|
||||
changes.title = { old: existing.title, new: body.title };
|
||||
updateData.title = body.title;
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
updateData.description = body.description;
|
||||
}
|
||||
if (body.projectId !== undefined) {
|
||||
changes.projectId = { old: existing.projectId, new: body.projectId };
|
||||
updateData.projectId = body.projectId;
|
||||
}
|
||||
if (body.sectionId !== undefined) {
|
||||
updateData.sectionId = body.sectionId || null;
|
||||
}
|
||||
if (body.dueDate !== undefined) {
|
||||
changes.dueDate = { old: existing.dueDate, new: body.dueDate };
|
||||
updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
}
|
||||
if (body.dueTime !== undefined) {
|
||||
updateData.dueTime = body.dueTime || null;
|
||||
}
|
||||
if (body.deadline !== undefined) {
|
||||
updateData.deadline = body.deadline ? new Date(body.deadline) : null;
|
||||
}
|
||||
if (body.recurrence !== undefined) {
|
||||
updateData.recurrence = body.recurrence || null;
|
||||
}
|
||||
if (body.priority !== undefined) {
|
||||
changes.priority = { old: existing.priority, new: body.priority };
|
||||
updateData.priority = body.priority;
|
||||
}
|
||||
if (body.isCompleted !== undefined) {
|
||||
changes.isCompleted = { old: existing.isCompleted, new: body.isCompleted };
|
||||
updateData.isCompleted = body.isCompleted;
|
||||
updateData.completedAt = body.isCompleted ? new Date() : null;
|
||||
}
|
||||
if (body.assigneeId !== undefined) {
|
||||
updateData.assigneeId = body.assigneeId || null;
|
||||
}
|
||||
if (body.sortOrder !== undefined) {
|
||||
updateData.sortOrder = body.sortOrder;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(tasks)
|
||||
.set(updateData)
|
||||
.where(eq(tasks.id, params.id))
|
||||
.returning();
|
||||
|
||||
// Update labels if provided
|
||||
if (body.labelIds !== undefined) {
|
||||
// Remove existing labels
|
||||
await db.delete(taskLabels).where(eq(taskLabels.taskId, params.id));
|
||||
|
||||
// Add new labels
|
||||
if (body.labelIds.length > 0) {
|
||||
await db.insert(taskLabels).values(
|
||||
body.labelIds.map(labelId => ({
|
||||
taskId: params.id,
|
||||
labelId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Log activity
|
||||
const action = body.isCompleted === true ? 'completed' :
|
||||
body.isCompleted === false ? 'reopened' : 'updated';
|
||||
await logActivity({
|
||||
userId,
|
||||
taskId: params.id,
|
||||
projectId: updated.projectId,
|
||||
action,
|
||||
changes: Object.keys(changes).length > 0 ? changes : undefined,
|
||||
});
|
||||
|
||||
// Trigger webhook if newly assigned to service user
|
||||
if (body.assigneeId && body.assigneeId !== existing.assigneeId) {
|
||||
const assignee = await db.query.users.findFirst({
|
||||
where: eq(sql`id`, body.assigneeId),
|
||||
});
|
||||
if (assignee?.role === 'service') {
|
||||
await triggerHammerWebhooks('task.assigned', {
|
||||
task: updated,
|
||||
assignedBy: { id: userId, name: (user as User).name },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
title: t.Optional(t.String({ minLength: 1, maxLength: 500 })),
|
||||
description: t.Optional(t.String()),
|
||||
projectId: t.Optional(t.String()),
|
||||
sectionId: t.Optional(t.String()),
|
||||
dueDate: t.Optional(t.String()),
|
||||
dueTime: t.Optional(t.String()),
|
||||
deadline: t.Optional(t.String()),
|
||||
recurrence: t.Optional(t.String()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal('p1'),
|
||||
t.Literal('p2'),
|
||||
t.Literal('p3'),
|
||||
t.Literal('p4'),
|
||||
])),
|
||||
isCompleted: t.Optional(t.Boolean()),
|
||||
assigneeId: t.Optional(t.String()),
|
||||
labelIds: t.Optional(t.Array(t.String())),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete task
|
||||
.delete('/:id', async ({ params, user, set }) => {
|
||||
const existing = await db.query.tasks.findFirst({
|
||||
where: and(
|
||||
eq(tasks.id, params.id),
|
||||
eq(tasks.userId, (user as User).id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404;
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
await db.delete(tasks).where(eq(tasks.id, params.id));
|
||||
|
||||
// Log activity
|
||||
await logActivity({
|
||||
userId: (user as User).id,
|
||||
projectId: existing.projectId,
|
||||
action: 'deleted',
|
||||
changes: { title: { old: existing.title, new: null } },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user