feat: add daily summaries feature
- Backend: daily_summaries table, API routes (GET/POST/PATCH) at /api/summaries - Frontend: SummariesPage with calendar view, markdown rendering, stats bar, highlights - Sidebar nav: added Summaries link between Activity and Chat - Data population script for importing from memory files - Bearer token + session auth support
This commit is contained in:
@@ -117,6 +117,64 @@ export const taskComments = pgTable("task_comments", {
|
||||
export type TaskComment = typeof taskComments.$inferSelect;
|
||||
export type NewTaskComment = typeof taskComments.$inferInsert;
|
||||
|
||||
// ─── Security Audits ───
|
||||
|
||||
export const securityAuditStatusEnum = pgEnum("security_audit_status", [
|
||||
"strong",
|
||||
"needs_improvement",
|
||||
"critical",
|
||||
]);
|
||||
|
||||
export interface SecurityFinding {
|
||||
id: string;
|
||||
status: "strong" | "needs_improvement" | "critical";
|
||||
title: string;
|
||||
description: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export const securityAudits = pgTable("security_audits", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
projectName: text("project_name").notNull(),
|
||||
category: text("category").notNull(),
|
||||
findings: jsonb("findings").$type<SecurityFinding[]>().default([]),
|
||||
score: integer("score").notNull().default(0), // 0-100
|
||||
lastAudited: timestamp("last_audited", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type SecurityAudit = typeof securityAudits.$inferSelect;
|
||||
export type NewSecurityAudit = typeof securityAudits.$inferInsert;
|
||||
|
||||
// ─── Daily Summaries ───
|
||||
|
||||
export interface SummaryHighlight {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SummaryStats {
|
||||
deploys?: number;
|
||||
commits?: number;
|
||||
tasksCompleted?: number;
|
||||
featuresBuilt?: number;
|
||||
bugsFixed?: number;
|
||||
[key: string]: number | undefined;
|
||||
}
|
||||
|
||||
export const dailySummaries = pgTable("daily_summaries", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
date: text("date").notNull().unique(), // YYYY-MM-DD
|
||||
content: text("content").notNull(),
|
||||
highlights: jsonb("highlights").$type<SummaryHighlight[]>().default([]),
|
||||
stats: jsonb("stats").$type<SummaryStats>().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type DailySummary = typeof dailySummaries.$inferSelect;
|
||||
export type NewDailySummary = typeof dailySummaries.$inferInsert;
|
||||
|
||||
// ─── BetterAuth tables ───
|
||||
|
||||
export const users = pgTable("users", {
|
||||
|
||||
188
backend/src/routes/security.ts
Normal file
188
backend/src/routes/security.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { securityAudits } from "../db/schema";
|
||||
import { eq, asc, and } 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) return;
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const findingSchema = t.Object({
|
||||
id: t.String(),
|
||||
status: t.Union([
|
||||
t.Literal("strong"),
|
||||
t.Literal("needs_improvement"),
|
||||
t.Literal("critical"),
|
||||
]),
|
||||
title: t.String(),
|
||||
description: t.String(),
|
||||
recommendation: t.String(),
|
||||
});
|
||||
|
||||
export const securityRoutes = new Elysia({ prefix: "/api/security" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Audit not found") {
|
||||
set.status = 404;
|
||||
return { error: "Audit not found" };
|
||||
}
|
||||
console.error("Security route error:", msg);
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET all audits
|
||||
.get("/", async ({ request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const all = await db
|
||||
.select()
|
||||
.from(securityAudits)
|
||||
.orderBy(asc(securityAudits.projectName), asc(securityAudits.category));
|
||||
return all;
|
||||
})
|
||||
|
||||
// GET summary (aggregate scores per project)
|
||||
.get("/summary", async ({ request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const all = await db
|
||||
.select()
|
||||
.from(securityAudits)
|
||||
.orderBy(asc(securityAudits.projectName));
|
||||
|
||||
const projectMap: Record<
|
||||
string,
|
||||
{ scores: number[]; categories: number; lastAudited: string }
|
||||
> = {};
|
||||
|
||||
for (const audit of all) {
|
||||
if (!projectMap[audit.projectName]) {
|
||||
projectMap[audit.projectName] = {
|
||||
scores: [],
|
||||
categories: 0,
|
||||
lastAudited: audit.lastAudited.toISOString(),
|
||||
};
|
||||
}
|
||||
projectMap[audit.projectName].scores.push(audit.score);
|
||||
projectMap[audit.projectName].categories++;
|
||||
const auditDate = audit.lastAudited.toISOString();
|
||||
if (auditDate > projectMap[audit.projectName].lastAudited) {
|
||||
projectMap[audit.projectName].lastAudited = auditDate;
|
||||
}
|
||||
}
|
||||
|
||||
const summary = Object.entries(projectMap).map(([name, data]) => ({
|
||||
projectName: name,
|
||||
averageScore: Math.round(
|
||||
data.scores.reduce((a, b) => a + b, 0) / data.scores.length
|
||||
),
|
||||
categoriesAudited: data.categories,
|
||||
lastAudited: data.lastAudited,
|
||||
}));
|
||||
|
||||
return summary;
|
||||
})
|
||||
|
||||
// GET audits for a specific project
|
||||
.get(
|
||||
"/project/:projectName",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const audits = await db
|
||||
.select()
|
||||
.from(securityAudits)
|
||||
.where(eq(securityAudits.projectName, decodeURIComponent(params.projectName)))
|
||||
.orderBy(asc(securityAudits.category));
|
||||
return audits;
|
||||
},
|
||||
{ params: t.Object({ projectName: t.String() }) }
|
||||
)
|
||||
|
||||
// POST create audit entry
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const newAudit = await db
|
||||
.insert(securityAudits)
|
||||
.values({
|
||||
projectName: body.projectName,
|
||||
category: body.category,
|
||||
findings: body.findings || [],
|
||||
score: body.score,
|
||||
lastAudited: new Date(),
|
||||
})
|
||||
.returning();
|
||||
return newAudit[0];
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
projectName: t.String(),
|
||||
category: t.String(),
|
||||
findings: t.Optional(t.Array(findingSchema)),
|
||||
score: t.Number(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// PATCH update audit entry
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
if (body.projectName !== undefined) updates.projectName = body.projectName;
|
||||
if (body.category !== undefined) updates.category = body.category;
|
||||
if (body.findings !== undefined) updates.findings = body.findings;
|
||||
if (body.score !== undefined) updates.score = body.score;
|
||||
if (body.refreshAuditDate) updates.lastAudited = new Date();
|
||||
|
||||
const updated = await db
|
||||
.update(securityAudits)
|
||||
.set(updates)
|
||||
.where(eq(securityAudits.id, params.id))
|
||||
.returning();
|
||||
if (!updated.length) throw new Error("Audit not found");
|
||||
return updated[0];
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
projectName: t.Optional(t.String()),
|
||||
category: t.Optional(t.String()),
|
||||
findings: t.Optional(t.Array(findingSchema)),
|
||||
score: t.Optional(t.Number()),
|
||||
refreshAuditDate: t.Optional(t.Boolean()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// DELETE audit entry
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const deleted = await db
|
||||
.delete(securityAudits)
|
||||
.where(eq(securityAudits.id, params.id))
|
||||
.returning();
|
||||
if (!deleted.length) throw new Error("Audit not found");
|
||||
return { success: true };
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) }
|
||||
);
|
||||
192
backend/src/routes/summaries.ts
Normal file
192
backend/src/routes/summaries.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { dailySummaries } from "../db/schema";
|
||||
import { desc, eq, 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 const summaryRoutes = new Elysia({ prefix: "/api/summaries" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Not found") {
|
||||
set.status = 404;
|
||||
return { error: "Summary not found" };
|
||||
}
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET /api/summaries — list all summaries (paginated, newest first)
|
||||
.get("/", async ({ request, headers, query }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const page = Math.max(1, Number(query.page) || 1);
|
||||
const limit = Math.min(Number(query.limit) || 50, 200);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.orderBy(desc(dailySummaries.date))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(dailySummaries),
|
||||
]);
|
||||
|
||||
const total = Number(countResult[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
})
|
||||
|
||||
// GET /api/summaries/dates — list all dates that have summaries (for calendar)
|
||||
.get("/dates", async ({ request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const rows = await db
|
||||
.select({ date: dailySummaries.date })
|
||||
.from(dailySummaries)
|
||||
.orderBy(desc(dailySummaries.date));
|
||||
|
||||
return { dates: rows.map((r) => r.date) };
|
||||
})
|
||||
|
||||
// GET /api/summaries/:date — get summary for specific date
|
||||
.get("/:date", async ({ request, headers, params }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const { date } = params;
|
||||
// Validate date format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
return result[0];
|
||||
})
|
||||
|
||||
// POST /api/summaries — create/upsert summary for a date
|
||||
.post("/", async ({ request, headers, body }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const { date, content, highlights, stats } = body as {
|
||||
date: string;
|
||||
content: string;
|
||||
highlights?: { text: string }[];
|
||||
stats?: Record<string, number>;
|
||||
};
|
||||
|
||||
if (!date || !content) {
|
||||
throw new Error("date and content are required");
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error("date must be YYYY-MM-DD format");
|
||||
}
|
||||
|
||||
// Upsert: insert or update on conflict
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await db
|
||||
.update(dailySummaries)
|
||||
.set({
|
||||
content,
|
||||
highlights: highlights || existing[0].highlights,
|
||||
stats: stats || existing[0].stats,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.returning();
|
||||
return updated[0];
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(dailySummaries)
|
||||
.values({
|
||||
date,
|
||||
content,
|
||||
highlights: highlights || [],
|
||||
stats: stats || {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return inserted[0];
|
||||
})
|
||||
|
||||
// PATCH /api/summaries/:date — update existing summary
|
||||
.patch("/:date", async ({ request, headers, params, body }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const { date } = params;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
const { content, highlights, stats } = body as {
|
||||
content?: string;
|
||||
highlights?: { text: string }[];
|
||||
stats?: Record<string, number>;
|
||||
};
|
||||
|
||||
if (content !== undefined) updates.content = content;
|
||||
if (highlights !== undefined) updates.highlights = highlights;
|
||||
if (stats !== undefined) updates.stats = stats;
|
||||
|
||||
const updated = await db
|
||||
.update(dailySummaries)
|
||||
.set(updates)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.returning();
|
||||
|
||||
return updated[0];
|
||||
});
|
||||
101
backend/src/scripts/populate-summaries.ts
Normal file
101
backend/src/scripts/populate-summaries.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Populate daily_summaries from ~/clawd/memory/*.md files.
|
||||
* Usage: bun run src/scripts/populate-summaries.ts
|
||||
*/
|
||||
import { db } from "../db";
|
||||
import { dailySummaries } from "../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { readdir, readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
const MEMORY_DIR = process.env.MEMORY_DIR || "/home/clawdbot/clawd/memory";
|
||||
|
||||
function extractHighlights(content: string): { text: string }[] {
|
||||
const highlights: { text: string }[] = [];
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Match ## headings as key sections
|
||||
const h2Match = line.match(/^## (.+)/);
|
||||
if (h2Match) {
|
||||
highlights.push({ text: h2Match[1].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return highlights.slice(0, 20); // Cap at 20 highlights
|
||||
}
|
||||
|
||||
function extractStats(content: string): Record<string, number> {
|
||||
const lower = content.toLowerCase();
|
||||
const stats: Record<string, number> = {};
|
||||
|
||||
// Count deploy mentions
|
||||
const deployMatches = lower.match(/\b(deploy|deployed|deployment|redeployed)\b/g);
|
||||
if (deployMatches) stats.deploys = deployMatches.length;
|
||||
|
||||
// Count commit/push mentions
|
||||
const commitMatches = lower.match(/\b(commit|committed|pushed|push)\b/g);
|
||||
if (commitMatches) stats.commits = commitMatches.length;
|
||||
|
||||
// Count task mentions
|
||||
const taskMatches = lower.match(/\b(completed|task completed|hq-\d+.*completed)\b/g);
|
||||
if (taskMatches) stats.tasksCompleted = taskMatches.length;
|
||||
|
||||
// Count feature mentions
|
||||
const featureMatches = lower.match(/\b(feature|built|implemented|added|created)\b/g);
|
||||
if (featureMatches) stats.featuresBuilt = Math.min(featureMatches.length, 30);
|
||||
|
||||
// Count fix mentions
|
||||
const fixMatches = lower.match(/\b(fix|fixed|bug|bugfix|hotfix)\b/g);
|
||||
if (fixMatches) stats.bugsFixed = fixMatches.length;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Reading memory files from ${MEMORY_DIR}...`);
|
||||
|
||||
const files = await readdir(MEMORY_DIR);
|
||||
const mdFiles = files
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
||||
.sort();
|
||||
|
||||
console.log(`Found ${mdFiles.length} memory files`);
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const date = file.replace(".md", "");
|
||||
const filePath = join(MEMORY_DIR, file);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
|
||||
const highlights = extractHighlights(content);
|
||||
const stats = extractStats(content);
|
||||
|
||||
// Upsert
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(dailySummaries)
|
||||
.set({ content, highlights, stats, updatedAt: new Date() })
|
||||
.where(eq(dailySummaries.date, date));
|
||||
console.log(`Updated: ${date}`);
|
||||
} else {
|
||||
await db
|
||||
.insert(dailySummaries)
|
||||
.values({ date, content, highlights, stats });
|
||||
console.log(`Inserted: ${date}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user