Adds habit edit, archive, and undo log features
Enables users to update or archive habits and to undo the latest habit log. Adds PATCH/DELETE API endpoints for habit edit and soft deletion. Introduces UI dialogs and controls for editing and archiving habits, as well as for undoing the most recent log entry directly from the dashboard. Improves log statistics handling by ordering and simplifies last log detection.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, habits, users, habitLogs } from '@/lib/db';
|
||||
import { getTokenCookie } from '@/lib/auth/cookies';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
|
||||
async function getUserFromToken() {
|
||||
const token = await getTokenCookie();
|
||||
@@ -58,3 +58,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const habitId = parseInt(id);
|
||||
|
||||
if (isNaN(habitId)) {
|
||||
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await getUserFromToken();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify habit belongs to user
|
||||
const habitRows = await db
|
||||
.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||
|
||||
if (habitRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find latest log
|
||||
const latestLog = await db
|
||||
.select()
|
||||
.from(habitLogs)
|
||||
.where(eq(habitLogs.habitId, habitId))
|
||||
.orderBy(desc(habitLogs.loggedAt))
|
||||
.limit(1);
|
||||
|
||||
if (latestLog.length === 0) {
|
||||
return NextResponse.json({ error: 'No logs to undo' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete latest log
|
||||
await db.delete(habitLogs).where(eq(habitLogs.id, latestLog[0].id));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Undo log error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
117
app/api/habits/[id]/route.ts
Normal file
117
app/api/habits/[id]/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, habits, users } from '@/lib/db';
|
||||
import { getTokenCookie } from '@/lib/auth/cookies';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
async function getUserFromToken() {
|
||||
const token = await getTokenCookie();
|
||||
if (!token) return null;
|
||||
|
||||
const userRows = await db.select().from(users).where(eq(users.token, token));
|
||||
return userRows.length > 0 ? userRows[0] : null;
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const habitId = parseInt(id);
|
||||
|
||||
if (isNaN(habitId)) {
|
||||
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await getUserFromToken();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify habit belongs to user
|
||||
const habitRows = await db
|
||||
.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||
|
||||
if (habitRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as {
|
||||
name?: string;
|
||||
type?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
|
||||
};
|
||||
|
||||
const { name, type, color, icon, targetFrequency } = body;
|
||||
|
||||
// Validate type if provided
|
||||
if (type && !['positive', 'neutral', 'negative'].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Type must be one of: positive, neutral, negative' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const updatedHabitRows = await db
|
||||
.update(habits)
|
||||
.set({
|
||||
...(name && { name }),
|
||||
...(type && { type: type as 'positive' | 'neutral' | 'negative' }),
|
||||
...(color && { color }),
|
||||
...(icon && { icon }),
|
||||
...(targetFrequency && { targetFrequency }),
|
||||
})
|
||||
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ habit: updatedHabitRows[0] });
|
||||
} catch (error) {
|
||||
console.error('Update habit error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const habitId = parseInt(id);
|
||||
|
||||
if (isNaN(habitId)) {
|
||||
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await getUserFromToken();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify habit belongs to user
|
||||
const habitRows = await db
|
||||
.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||
|
||||
if (habitRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Soft delete (archive)
|
||||
await db
|
||||
.update(habits)
|
||||
.set({ isArchived: true, archivedAt: new Date() })
|
||||
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete habit error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,26 +33,22 @@ export async function GET() {
|
||||
// Then get aggregated log data for each habit
|
||||
const habitsWithStats = await Promise.all(
|
||||
userHabitsBase.map(async (habit) => {
|
||||
// Get all logs for this habit
|
||||
// Get all logs for this habit, ordered by date desc
|
||||
const logs = await db
|
||||
.select({
|
||||
id: habitLogs.id,
|
||||
loggedAt: habitLogs.loggedAt,
|
||||
})
|
||||
.from(habitLogs)
|
||||
.where(eq(habitLogs.habitId, habit.id));
|
||||
.where(eq(habitLogs.habitId, habit.id))
|
||||
.orderBy(desc(habitLogs.loggedAt));
|
||||
|
||||
// Calculate statistics
|
||||
const totalLogs = logs.length;
|
||||
const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
|
||||
const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
|
||||
const lastLoggedAt =
|
||||
logs.length > 0
|
||||
? logs.reduce(
|
||||
(latest, log) => (log.loggedAt > latest ? log.loggedAt : latest),
|
||||
logs[0].loggedAt,
|
||||
)
|
||||
: null;
|
||||
|
||||
const lastLoggedAt = logs.length > 0 ? logs[0].loggedAt : null;
|
||||
|
||||
return {
|
||||
id: habit.id,
|
||||
name: habit.name,
|
||||
|
||||
Reference in New Issue
Block a user