T3/Phase 2B-2: port habits routes to Hono (~7 routes)
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db";
|
||||
import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
export const habitRoutes = new Hono();
|
||||
|
||||
const habitFrequencyEnum = z.enum(["daily", "weekly", "custom"]);
|
||||
const habitDifficultyEnum = z.enum(["easy", "medium", "hard"]);
|
||||
|
||||
const createHabitSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional().nullable(),
|
||||
domain: z.string().min(1, "Domain is required"),
|
||||
frequency: habitFrequencyEnum.optional().default("daily"),
|
||||
difficulty: habitDifficultyEnum.optional().default("medium"),
|
||||
goalPerPeriod: z.number().int().positive().optional().default(1),
|
||||
unit: z.string().optional().nullable(),
|
||||
reminderTime: z.string().optional().nullable(),
|
||||
skipDays: z.array(z.number().int().min(0).max(6)).optional().default([]),
|
||||
moodTracking: z.boolean().optional().default(false),
|
||||
active: z.boolean().optional().default(true),
|
||||
tagIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
const updateHabitSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
frequency: habitFrequencyEnum.optional(),
|
||||
difficulty: habitDifficultyEnum.optional(),
|
||||
goalPerPeriod: z.number().int().positive().optional(),
|
||||
unit: z.string().optional().nullable(),
|
||||
reminderTime: z.string().optional().nullable(),
|
||||
skipDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||
moodTracking: z.boolean().optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const completeHabitSchema = z.object({
|
||||
value: z.number().int().positive().optional().default(1),
|
||||
mood: z.number().int().min(1).max(5).optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculate the current streak for a habit.
|
||||
*/
|
||||
async function calculateStreak(habitId: string, skipDays: number[]): Promise<number> {
|
||||
const completions = await db.select({ date: habitCompletions.date })
|
||||
.from(habitCompletions)
|
||||
.where(eq(habitCompletions.habitId, habitId))
|
||||
.orderBy(desc(habitCompletions.date));
|
||||
|
||||
if (completions.length === 0) return 0;
|
||||
|
||||
const completionDates = new Set(
|
||||
completions.map(c => c.date.toISOString().split("T")[0])
|
||||
);
|
||||
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const checkDate = new Date(today);
|
||||
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const dateStr = checkDate.toISOString().split("T")[0];
|
||||
const dayOfWeek = checkDate.getDay();
|
||||
|
||||
if (skipDays.includes(dayOfWeek)) {
|
||||
checkDate.setDate(checkDate.getDate() - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (completionDates.has(dateStr)) {
|
||||
streak++;
|
||||
checkDate.setDate(checkDate.getDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
// GET /api/habits — List habits with filtering, sorting, pagination
|
||||
habitRoutes.get("/", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const url = new URL(c.req.url);
|
||||
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
|
||||
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
|
||||
const filter = url.searchParams.get("filter") || undefined;
|
||||
const sort = url.searchParams.get("sort") || "-created";
|
||||
const active = url.searchParams.get("active");
|
||||
const frequency = url.searchParams.get("frequency");
|
||||
const difficulty = url.searchParams.get("difficulty");
|
||||
const search = url.searchParams.get("search");
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
const order = url.searchParams.get("order") || "asc";
|
||||
|
||||
let domainId = url.searchParams.get("domain") || undefined;
|
||||
if (!domainId) {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
const conditions: any[] = [
|
||||
eq(habits.domainId, domainId),
|
||||
isNull(habits.deletedAt),
|
||||
];
|
||||
|
||||
if (active === "true") conditions.push(eq(habits.active, true));
|
||||
else if (active === "false") conditions.push(eq(habits.active, false));
|
||||
if (frequency) conditions.push(eq(habits.frequency, frequency as any));
|
||||
if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any));
|
||||
if (search) conditions.push(ilike(habits.name, `%${search}%`));
|
||||
if (filter) conditions.push(ilike(habits.name, `%${filter}%`));
|
||||
|
||||
const sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||
const sortField = sort.replace(/^-/, "");
|
||||
const sortColumns: Record<string, any> = {
|
||||
created: habits.createdAt,
|
||||
updated: habits.updatedAt,
|
||||
name: habits.name,
|
||||
frequency: habits.frequency,
|
||||
difficulty: habits.difficulty,
|
||||
streak_count: habits.streakCount,
|
||||
created_at: habits.createdAt,
|
||||
updated_at: habits.updatedAt,
|
||||
};
|
||||
const orderColumn = sortDir === "asc"
|
||||
? asc(sortColumns[sortField] || habits.createdAt)
|
||||
: desc(sortColumns[sortField] || habits.createdAt);
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(habits)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderColumn)
|
||||
.limit(limit || perPage)
|
||||
.offset(offset || (page - 1) * perPage),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(habits)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
const totalItems = Number(countResult[0]?.count || 0);
|
||||
|
||||
// Fetch tags for all habits
|
||||
let habitTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||
if (items.length > 0) {
|
||||
const habitIds = items.map(h => h.id);
|
||||
const tagRows = await db.select({
|
||||
habitId: habitTags.habitId,
|
||||
id: tagsTable.id,
|
||||
name: tagsTable.name,
|
||||
color: tagsTable.color,
|
||||
})
|
||||
.from(habitTags)
|
||||
.innerJoin(tagsTable, eq(habitTags.tagId, tagsTable.id))
|
||||
.where(inArray(habitTags.habitId, habitIds));
|
||||
|
||||
for (const row of tagRows) {
|
||||
if (!habitTagMap.has(row.habitId)) habitTagMap.set(row.habitId, []);
|
||||
habitTagMap.get(row.habitId)!.push({ id: row.id, name: row.name, color: row.color });
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithTags = items.map(h => ({
|
||||
...h,
|
||||
tags: habitTagMap.get(h.id) || [],
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
items: itemsWithTags,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / (limit || perPage)),
|
||||
page,
|
||||
perPage: limit || perPage,
|
||||
limit: limit || perPage,
|
||||
offset: offset || (page - 1) * perPage,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[habits] GET error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list habits" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/habits — Create a habit
|
||||
habitRoutes.post("/", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const body = await c.req.json();
|
||||
const data = createHabitSchema.parse({
|
||||
...body,
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
const [habit] = await db.insert(habits).values({
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
domainId: data.domain,
|
||||
frequency: data.frequency,
|
||||
difficulty: data.difficulty,
|
||||
goalPerPeriod: data.goalPerPeriod,
|
||||
unit: data.unit ?? null,
|
||||
reminderTime: data.reminderTime ?? null,
|
||||
skipDays: data.skipDays,
|
||||
moodTracking: data.moodTracking,
|
||||
active: data.active,
|
||||
}).returning();
|
||||
|
||||
if (data.tagIds && data.tagIds.length > 0) {
|
||||
await db.insert(habitTags).values(
|
||||
data.tagIds.map(tagId => ({ habitId: habit.id, tagId }))
|
||||
);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "created",
|
||||
entityType: "habit",
|
||||
entityId: habit.id,
|
||||
changes: { name: habit.name, frequency: habit.frequency, difficulty: habit.difficulty },
|
||||
workspaceId: data.domain,
|
||||
});
|
||||
|
||||
return c.json(habit, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[habits] POST error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create habit" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/habits/:id — Get a single habit with streak + recent completions
|
||||
habitRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [habit] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!habit) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
// Fetch recent completions (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const recentCompletions = await db.select()
|
||||
.from(habitCompletions)
|
||||
.where(and(
|
||||
eq(habitCompletions.habitId, id),
|
||||
gte(habitCompletions.date, thirtyDaysAgo),
|
||||
))
|
||||
.orderBy(desc(habitCompletions.date));
|
||||
|
||||
// Fetch tags
|
||||
const tagRows = await db.select({
|
||||
id: tagsTable.id,
|
||||
name: tagsTable.name,
|
||||
color: tagsTable.color,
|
||||
})
|
||||
.from(habitTags)
|
||||
.innerJoin(tagsTable, eq(habitTags.tagId, tagsTable.id))
|
||||
.where(eq(habitTags.habitId, id));
|
||||
|
||||
return c.json({
|
||||
...habit,
|
||||
recentCompletions,
|
||||
tags: tagRows,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[habits] GET/:id error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get habit" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/habits/:id — Update a habit
|
||||
habitRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const data = updateHabitSchema.parse(body);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateValues.name = data.name;
|
||||
if (data.description !== undefined) updateValues.description = data.description;
|
||||
if (data.frequency !== undefined) updateValues.frequency = data.frequency;
|
||||
if (data.difficulty !== undefined) updateValues.difficulty = data.difficulty;
|
||||
if (data.goalPerPeriod !== undefined) updateValues.goalPerPeriod = data.goalPerPeriod;
|
||||
if (data.unit !== undefined) updateValues.unit = data.unit;
|
||||
if (data.reminderTime !== undefined) updateValues.reminderTime = data.reminderTime;
|
||||
if (data.skipDays !== undefined) updateValues.skipDays = data.skipDays;
|
||||
if (data.moodTracking !== undefined) updateValues.moodTracking = data.moodTracking;
|
||||
if (data.active !== undefined) updateValues.active = data.active;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [updated] = await db.update(habits)
|
||||
.set(updateValues)
|
||||
.where(eq(habits.id, id))
|
||||
.returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "updated",
|
||||
entityType: "habit",
|
||||
entityId: id,
|
||||
changes: { ...data, previousName: existing.name },
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[habits] PATCH error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update habit" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/habits/:id — Soft delete a habit
|
||||
habitRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
await db.update(habits)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(habits.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "deleted",
|
||||
entityType: "habit",
|
||||
entityId: id,
|
||||
changes: { name: existing.name },
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[habits] DELETE error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete habit" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/habits/:id/complete — Complete a habit for today
|
||||
habitRoutes.post("/:id/complete", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const data = completeHabitSchema.parse(body);
|
||||
|
||||
const [habit] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!habit) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
const [completion] = await db.insert(habitCompletions).values({
|
||||
habitId: id,
|
||||
date: new Date(),
|
||||
value: data.value,
|
||||
mood: data.mood ?? null,
|
||||
notes: data.notes ?? null,
|
||||
}).returning();
|
||||
|
||||
// Recalculate streak
|
||||
const skipDays = habit.skipDays || [];
|
||||
const newStreak = await calculateStreak(id, skipDays);
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
streakCount: newStreak,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (newStreak > (habit.bestStreak || 0)) {
|
||||
updateData.bestStreak = newStreak;
|
||||
}
|
||||
|
||||
await db.update(habits)
|
||||
.set(updateData)
|
||||
.where(eq(habits.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "completed",
|
||||
entityType: "habit",
|
||||
entityId: id,
|
||||
changes: { value: data.value, mood: data.mood, streak: newStreak },
|
||||
workspaceId: habit.domainId,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
completion,
|
||||
streakCount: newStreak,
|
||||
bestStreak: Math.max(newStreak, habit.bestStreak || 0),
|
||||
}, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[habits] POST /:id/complete error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to complete habit" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/habits/:id/completions — Completion history + streak calc
|
||||
habitRoutes.get("/:id/completions", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const url = new URL(c.req.url);
|
||||
|
||||
const [habit] = await db.select({ id: habits.id })
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!habit) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "365"), 1000);
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
const order = url.searchParams.get("order") || "desc";
|
||||
|
||||
const conditions: any[] = [eq(habitCompletions.habitId, id)];
|
||||
|
||||
if (from) conditions.push(gte(habitCompletions.date, new Date(from)));
|
||||
if (to) conditions.push(lte(habitCompletions.date, new Date(to)));
|
||||
|
||||
const orderFn = order === "asc" ? asc : desc;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(habitCompletions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderFn(habitCompletions.date))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(habitCompletions)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
items,
|
||||
totalItems: Number(countResult[0]?.count || 0),
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[habits] GET /:id/completions error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get completions" } }, 500);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user