feat: unified activity feed with comments + progress notes

- New /api/activity endpoint returning combined timeline of progress notes
  and comments across all tasks, sorted chronologically
- Activity page now fetches from unified endpoint instead of extracting
  from task data client-side
- Type filter (progress/comment) and status filter on Activity page
- Comment entries show author avatars and type badges
- 30s auto-refresh on activity feed
This commit is contained in:
2026-01-30 00:06:41 +00:00
parent b7ff8437e4
commit 504215439e
3 changed files with 251 additions and 69 deletions

View File

@@ -5,6 +5,7 @@ import { adminRoutes } from "./routes/admin";
import { projectRoutes } from "./routes/projects";
import { chatRoutes } from "./routes/chat";
import { commentRoutes } from "./routes/comments";
import { activityRoutes } from "./routes/activity";
import { auth } from "./lib/auth";
import { db } from "./db";
import { tasks, users } from "./db/schema";
@@ -117,6 +118,7 @@ const app = new Elysia()
.use(taskRoutes)
.use(commentRoutes)
.use(activityRoutes)
.use(projectRoutes)
.use(adminRoutes)
.use(chatRoutes)

View File

@@ -0,0 +1,105 @@
import { Elysia, t } from "elysia";
import { db } from "../db";
import { tasks, taskComments } from "../db/schema";
import { desc, sql } from "drizzle-orm";
import { auth } from "../lib/auth";
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
const authHeader = headers["authorization"];
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user) return;
} catch {}
throw new Error("Unauthorized");
}
export interface ActivityFeedItem {
type: "progress" | "comment";
timestamp: string;
taskId: string;
taskNumber: number | null;
taskTitle: string;
taskStatus: string;
// For progress notes
note?: string;
// For comments
commentId?: string;
authorName?: string;
authorId?: string | null;
content?: string;
}
export const activityRoutes = new Elysia({ prefix: "/api/activity" })
.onError(({ error, set }) => {
const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") {
set.status = 401;
return { error: "Unauthorized" };
}
set.status = 500;
return { error: "Internal server error" };
})
// GET /api/activity — unified feed of progress notes + comments
.get("/", async ({ request, headers, query }) => {
await requireSessionOrBearer(request, headers);
const limit = Math.min(Number(query.limit) || 50, 200);
// Fetch all tasks with progress notes
const allTasks = await db.select().from(tasks);
// Collect progress note items
const items: ActivityFeedItem[] = [];
for (const task of allTasks) {
const notes = (task.progressNotes || []) as { timestamp: string; note: string }[];
for (const note of notes) {
items.push({
type: "progress",
timestamp: note.timestamp,
taskId: task.id,
taskNumber: task.taskNumber,
taskTitle: task.title,
taskStatus: task.status,
note: note.note,
});
}
}
// Fetch all comments
const allComments = await db
.select()
.from(taskComments)
.orderBy(desc(taskComments.createdAt));
// Build task lookup for comment items
const taskMap = new Map(allTasks.map(t => [t.id, t]));
for (const comment of allComments) {
const task = taskMap.get(comment.taskId);
if (!task) continue;
items.push({
type: "comment",
timestamp: comment.createdAt.toISOString(),
taskId: task.id,
taskNumber: task.taskNumber,
taskTitle: task.title,
taskStatus: task.status,
commentId: comment.id,
authorName: comment.authorName,
authorId: comment.authorId,
content: comment.content,
});
}
// Sort by timestamp descending, take limit
items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
return {
items: items.slice(0, limit),
total: items.length,
};
});