feat: habits completion with daily validation and rewards

This commit is contained in:
Alexander Andreev 2026-01-02 15:19:01 +03:00
parent 2c48c8b55b
commit abfca19833
3 changed files with 205 additions and 0 deletions

View File

@ -0,0 +1,87 @@
import { useSession } from 'h3';
interface CompletionResponse {
message: string;
reward: {
coins: number;
};
updatedCoins: number;
}
/**
* A helper function to safely get the authenticated user's ID from the session.
*/
async function getUserIdFromSession(event: any): Promise<number> {
const session = await useSession(event, {
password: process.env.SESSION_PASSWORD,
});
const userId = session.data?.user?.id;
if (!userId) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
return userId;
}
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
const userId = await getUserIdFromSession(event);
const habitId = parseInt(event.context.params?.id || '', 10);
if (isNaN(habitId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
}
const habit = await prisma.habit.findFirst({
where: { id: habitId, userId },
});
if (!habit) {
throw createError({ statusCode: 404, statusMessage: 'Habit not found.' });
}
const today = new Date();
const dayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday)
if (!(habit.daysOfWeek as number[]).includes(dayOfWeek)) {
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
}
// Normalize date to the beginning of the day for consistent checks
const startOfToday = new Date();
startOfToday.setUTCHours(0, 0, 0, 0);
const existingCompletion = await prisma.habitCompletion.findFirst({
where: {
habitId: habitId,
date: startOfToday, // Use precise equality check
},
});
if (existingCompletion) {
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
}
const rewardCoins = 3;
const [, updatedUser] = await prisma.$transaction([
prisma.habitCompletion.create({
data: {
habitId: habitId,
date: startOfToday, // Save the normalized date
},
}),
prisma.user.update({
where: { id: userId },
data: {
coins: {
increment: rewardCoins,
},
},
}),
]);
return {
message: 'Habit completed successfully!',
reward: {
coins: rewardCoins,
},
updatedCoins: updatedUser.coins,
};
});

View File

@ -0,0 +1,56 @@
import { useSession } from 'h3';
// DTO to shape the output
interface HabitCompletionDto {
date: Date;
}
interface HabitDto {
id: number;
name: string;
daysOfWeek: number[];
completions: HabitCompletionDto[];
}
/**
* A helper function to safely get the authenticated user's ID from the session.
*/
async function getUserIdFromSession(event: any): Promise<number> {
const session = await useSession(event, {
password: process.env.SESSION_PASSWORD,
});
const userId = session.data?.user?.id;
if (!userId) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
return userId;
}
export default defineEventHandler(async (event): Promise<HabitDto[]> => {
const userId = await getUserIdFromSession(event);
const habits = await prisma.habit.findMany({
where: {
userId: userId,
},
include: {
completions: {
orderBy: {
date: 'desc',
},
take: 30, // Limit completions to the last 30 for performance
},
},
});
// Map to DTOs
return habits.map((habit: any) => ({
id: habit.id,
name: habit.name,
daysOfWeek: habit.daysOfWeek, // Assuming daysOfWeek is already in the correct format
completions: habit.completions.map((comp: any) => ({
date: comp.date,
})),
}));
});

View File

@ -0,0 +1,62 @@
import { useSession } from 'h3';
interface HabitDto {
id: number;
name: string;
daysOfWeek: number[];
}
/**
* A helper function to safely get the authenticated user's ID from the session.
*/
async function getUserIdFromSession(event: any): Promise<number> {
const session = await useSession(event, {
password: process.env.SESSION_PASSWORD,
});
const userId = session.data?.user?.id;
if (!userId) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
return userId;
}
export default defineEventHandler(async (event): Promise<HabitDto> => {
const userId = await getUserIdFromSession(event);
const { name, daysOfWeek } = await readBody(event);
// --- Validation ---
if (!name || !Array.isArray(daysOfWeek)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid input: name and daysOfWeek are required.' });
}
// Sanitize daysOfWeek to ensure it's a unique set of valid numbers
const validDays = daysOfWeek.filter(day => typeof day === 'number' && day >= 0 && day <= 6);
const sanitizedDays = [...new Set(validDays)].sort();
// --- Business Rule: Max 3 Habits ---
const habitCount = await prisma.habit.count({ where: { userId } });
if (habitCount >= 3) {
throw createError({ statusCode: 400, statusMessage: 'Maximum number of 3 habits reached.' });
}
// --- Create Habit ---
const newHabit = await prisma.habit.create({
data: {
userId,
name,
daysOfWeek: sanitizedDays,
},
});
// Set 201 Created status
setResponseStatus(event, 201);
// Return DTO
return {
id: newHabit.id,
name: newHabit.name,
daysOfWeek: newHabit.daysOfWeek as number[],
};
});