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:
2026-01-28 14:02:15 +00:00
commit 98ea0427bb
58 changed files with 6605 additions and 0 deletions

View 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
View 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 }),
}),
});

View 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(),
}),
});

View 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(),
}),
});

View 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(),
}),
});

View 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(),
}),
});

View 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(),
}),
});