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