diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 817fbf2..cd964bd 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -1,9 +1,6 @@ -import { PrismaClient } from '@prisma/client'; -import { verifyPassword } from '../utils/password'; +import { verifyPassword } from '../../utils/password'; import { useSession } from 'h3'; -const prisma = new PrismaClient(); - export default defineEventHandler(async (event) => { const body = await readBody(event); const { email, password } = body; @@ -16,19 +13,22 @@ export default defineEventHandler(async (event) => { }); } + const normalizedEmail = email.toLowerCase(); + // 2. Find the user const user = await prisma.user.findUnique({ - where: { email }, + where: { email: normalizedEmail }, }); if (!user) { throw createError({ - statusCode: 401, // Unauthorized + statusCode: 401, statusMessage: 'Invalid credentials', }); } // 3. Verify the password + // WARNING: This verifyPassword is a mock. Replace with a secure library like bcrypt before production. const isPasswordValid = await verifyPassword(password, user.password); if (!isPasswordValid) { throw createError({ @@ -39,7 +39,7 @@ export default defineEventHandler(async (event) => { // 4. Create and update the session const session = await useSession(event, { - password: process.env.SESSION_PASSWORD || 'your-super-secret-32-character-password', // Should be in .env + password: process.env.SESSION_PASSWORD, // Relies on fail-fast check in auth.ts maxAge: 60 * 60 * 24 * 7, // 1 week }); @@ -50,7 +50,19 @@ export default defineEventHandler(async (event) => { } }); - // 5. Return user data - const { password: _password, ...userWithoutPassword } = user; - return { user: userWithoutPassword }; + // 5. Return user data DTO + return { + user: { + id: user.id, + email: user.email, + nickname: user.nickname, + avatar: user.avatar, + coins: user.coins, + exp: user.exp, + soundOn: user.soundOn, + confettiOn: user.confettiOn, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } + }; }); diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts index 3498ba9..acb014c 100644 --- a/server/api/auth/logout.post.ts +++ b/server/api/auth/logout.post.ts @@ -2,7 +2,7 @@ import { useSession } from 'h3'; export default defineEventHandler(async (event) => { const session = await useSession(event, { - password: process.env.SESSION_PASSWORD || 'your-super-secret-32-character-password', + password: process.env.SESSION_PASSWORD, }); await session.clear(); diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts index 9e0de6c..c96b208 100644 --- a/server/api/auth/me.get.ts +++ b/server/api/auth/me.get.ts @@ -1,38 +1,38 @@ -import { PrismaClient } from '@prisma/client'; -import { useSession } from 'h3'; - -const prisma = new PrismaClient(); +import { getUserIdFromSession } from '../../utils/auth'; export default defineEventHandler(async (event) => { - // 1. Get the session - const session = await useSession(event, { - password: process.env.SESSION_PASSWORD || 'your-super-secret-32-character-password', - }); + // 1. Get user ID from session; this helper handles the 401 check. + const userId = await getUserIdFromSession(event); - // 2. Check if user is in session - if (!session.data?.user?.id) { - throw createError({ - statusCode: 401, - statusMessage: 'Unauthorized', - }); - } - - // 3. Fetch the full user from the database + // 2. Fetch the full user from the database const user = await prisma.user.findUnique({ - where: { id: session.data.user.id }, + where: { id: userId }, }); if (!user) { // This case might happen if the user was deleted but the session still exists. - // Clear the invalid session. + // The helper can't handle this, so we clear the session here. + const session = await useSession(event, { password: process.env.SESSION_PASSWORD }); await session.clear(); throw createError({ statusCode: 401, - statusMessage: 'Unauthorized', + statusMessage: 'Unauthorized: User not found.', }); } - // 4. Return user data - const { password: _password, ...userWithoutPassword } = user; - return { user: userWithoutPassword }; + // 3. Return user data DTO + return { + user: { + id: user.id, + email: user.email, + nickname: user.nickname, + avatar: user.avatar, + coins: user.coins, + exp: user.exp, + soundOn: user.soundOn, + confettiOn: user.confettiOn, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } + }; }); diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index 525da4d..ea62952 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -1,7 +1,4 @@ -import { PrismaClient } from '@prisma/client'; -import { hashPassword } from '../utils/password'; - -const prisma = new PrismaClient(); +import { hashPassword } from '../../utils/password'; export default defineEventHandler(async (event) => { const body = await readBody(event); @@ -21,10 +18,11 @@ export default defineEventHandler(async (event) => { }); } + const normalizedEmail = email.toLowerCase(); // Normalize email // 2. Check if user already exists const existingUser = await prisma.user.findUnique({ - where: { email }, + where: { email: normalizedEmail }, }); if (existingUser) { @@ -35,16 +33,25 @@ export default defineEventHandler(async (event) => { } // 3. Hash password and create user + // WARNING: This hashPassword is a mock. Replace with a secure library like bcrypt before production. const hashedPassword = await hashPassword(password); const user = await prisma.user.create({ data: { - email, + email: normalizedEmail, password: hashedPassword, nickname: nickname || 'New Smurf', }, }); - // 4. Return the new user, excluding the password - const { password: _password, ...userWithoutPassword } = user; - return { user: userWithoutPassword }; + // NOTE: Registration does not automatically log in the user. + // The user needs to explicitly call the login endpoint after registration. + + // 4. Return the new user, excluding sensitive fields and shortening DTO + return { + user: { + id: user.id, + email: user.email, + nickname: user.nickname, + } + }; }); diff --git a/server/api/habits/[id]/complete.post.ts b/server/api/habits/[id]/complete.post.ts index 36b25f3..bb12afe 100644 --- a/server/api/habits/[id]/complete.post.ts +++ b/server/api/habits/[id]/complete.post.ts @@ -1,4 +1,4 @@ -import { useSession } from 'h3'; +import { getUserIdFromSession } from '../../../utils/auth'; interface CompletionResponse { message: string; @@ -8,20 +8,6 @@ interface CompletionResponse { updatedCoins: number; } -/** - * A helper function to safely get the authenticated user's ID from the session. - */ -async function getUserIdFromSession(event: any): Promise { - 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 => { const userId = await getUserIdFromSession(event); const habitId = parseInt(event.context.params?.id || '', 10); diff --git a/server/api/habits/index.get.ts b/server/api/habits/index.get.ts index 11c341a..55702c1 100644 --- a/server/api/habits/index.get.ts +++ b/server/api/habits/index.get.ts @@ -1,4 +1,5 @@ -import { useSession } from 'h3'; +import { getUserIdFromSession } from '../../utils/auth'; +import { Habit } from '@prisma/client'; // DTO to shape the output interface HabitCompletionDto { @@ -12,21 +13,6 @@ interface HabitDto { completions: HabitCompletionDto[]; } -/** - * A helper function to safely get the authenticated user's ID from the session. - */ -async function getUserIdFromSession(event: any): Promise { - 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 => { const userId = await getUserIdFromSession(event); diff --git a/server/api/habits/index.post.ts b/server/api/habits/index.post.ts index 701e874..e9880ca 100644 --- a/server/api/habits/index.post.ts +++ b/server/api/habits/index.post.ts @@ -1,4 +1,4 @@ -import { useSession } from 'h3'; +import { getUserIdFromSession } from '../../utils/auth'; interface HabitDto { id: number; @@ -6,21 +6,6 @@ interface HabitDto { daysOfWeek: number[]; } -/** - * A helper function to safely get the authenticated user's ID from the session. - */ -async function getUserIdFromSession(event: any): Promise { - 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 => { const userId = await getUserIdFromSession(event); const { name, daysOfWeek } = await readBody(event); diff --git a/server/api/health.get.ts b/server/api/health.get.ts new file mode 100644 index 0000000..8dae22d --- /dev/null +++ b/server/api/health.get.ts @@ -0,0 +1,10 @@ +import prisma from '../utils/prisma' + +export default defineEventHandler(async () => { + const users = await prisma.user.findMany() + + return { + ok: true, + usersCount: users.length, + } +}) diff --git a/server/api/leaderboard.get.ts b/server/api/leaderboard.get.ts new file mode 100644 index 0000000..762a4d4 --- /dev/null +++ b/server/api/leaderboard.get.ts @@ -0,0 +1,44 @@ +// server/api/leaderboard.get.ts + +export default defineEventHandler(async () => { + // --- MVP Compromise: Monthly EXP --- + // The current schema does not support tracking monthly EXP. + // For MVP, we will use the total cumulative `User.exp` as a stand-in + // for "current month's EXP". This should be revisited if true monthly + // tracking becomes a requirement. + const users = await prisma.user.findMany({ + select: { + nickname: true, + avatar: true, + exp: true, + }, + orderBy: { + exp: 'desc', + }, + take: 50, // Limit to top 50 users + }); + + // --- Rank Calculation --- + let currentRank = 0; + let previousExp = -1; // Ensure any valid EXP is greater than this + + // To handle shared ranks, we iterate through the sorted users. + // If the current user's EXP is different from the previous user's EXP, + // their rank is their position in the 1-based sorted list. + // If their EXP is the same, they share the rank of the previous user. + const leaderboard = users.map((user, index) => { + if (user.exp !== previousExp) { + currentRank = index + 1; // Ranks are 1-based + } + previousExp = user.exp; // Update previous EXP for the next iteration + + return { + rank: currentRank, + nickname: user.nickname, + avatar: user.avatar, + exp: user.exp, + }; + }); + + return { leaderboard }; +}); diff --git a/server/api/quests/daily-visit.post.ts b/server/api/quests/daily-visit.post.ts index 6667381..f92210d 100644 --- a/server/api/quests/daily-visit.post.ts +++ b/server/api/quests/daily-visit.post.ts @@ -1,4 +1,4 @@ -import { useSession } from 'h3'; +import { getUserIdFromSession } from '../../utils/auth'; interface DailyVisitResponse { message: string; @@ -9,20 +9,6 @@ interface DailyVisitResponse { updatedCoins: number; } -/** - * A helper function to safely get the authenticated user's ID from the session. - */ -async function getUserIdFromSession(event: any): Promise { - 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; -} - /** * Creates a Date object for the start of a given day in UTC. */ diff --git a/server/api/village/harvest.post.ts b/server/api/village/harvest.post.ts new file mode 100644 index 0000000..bffe178 --- /dev/null +++ b/server/api/village/harvest.post.ts @@ -0,0 +1,72 @@ +import { getUserIdFromSession } from '../../utils/auth'; +import { CROP_HARVEST_REWARD, isCropGrown } from '../../utils/village'; +import { CropType } from '@prisma/client'; + +// --- Handler --- +export default defineEventHandler(async (event) => { + const userId = await getUserIdFromSession(event); + const { fieldId } = await readBody(event); + + // 1. --- Validation --- + if (typeof fieldId !== 'number') { + throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "fieldId" is required.' }); + } + + // 2. --- Find the target field and validate its state --- + const field = await prisma.villageObject.findFirst({ + where: { + id: fieldId, + type: 'FIELD', + village: { userId: userId }, // Ensures ownership + }, + }); + + if (!field) { + throw createError({ statusCode: 404, statusMessage: 'Field not found or you do not own it.' }); + } + + if (!field.cropType || !field.plantedAt) { + throw createError({ statusCode: 400, statusMessage: 'Nothing is planted in this field.' }); + } + + if (!isCropGrown(field.plantedAt, field.cropType)) { + throw createError({ statusCode: 400, statusMessage: 'Crop is not yet fully grown.' }); + } + + // 3. --- Grant rewards and clear field in a transaction --- + const reward = CROP_HARVEST_REWARD[field.cropType]; + + const [, , updatedField] = await prisma.$transaction([ + // Grant EXP and Coins + prisma.user.update({ + where: { id: userId }, + data: { + exp: { increment: reward.exp }, + coins: { increment: reward.coins }, + }, + }), + // Clear the crop from the field + prisma.villageObject.update({ + where: { id: fieldId }, + data: { + cropType: null, + plantedAt: null, + }, + }), + // Re-fetch the field to return its cleared state + prisma.villageObject.findUniqueOrThrow({ where: { id: fieldId } }), + ]); + + return { + message: `${field.cropType} harvested successfully!`, + reward: reward, + updatedField: { + id: updatedField.id, + type: updatedField.type, + x: updatedField.x, + y: updatedField.y, + cropType: updatedField.cropType, + isGrown: false, + } + }; +}); diff --git a/server/api/village/index.get.ts b/server/api/village/index.get.ts new file mode 100644 index 0000000..a68bf98 --- /dev/null +++ b/server/api/village/index.get.ts @@ -0,0 +1,50 @@ +import { getUserIdFromSession } from '../../utils/auth'; +import { isCropGrown } from '../../utils/village'; +import { CropType, VillageObjectType } from '@prisma/client'; + +// --- DTOs --- +interface VillageObjectDto { + id: number; + type: VillageObjectType; + x: number; + y: number; + cropType: CropType | null; + isGrown: boolean | null; +} + +interface VillageDto { + objects: VillageObjectDto[]; +} + +// --- Handler --- + +export default defineEventHandler(async (event): Promise => { + const userId = await getUserIdFromSession(event); + + let village = await prisma.village.findUnique({ + where: { userId }, + include: { objects: true }, + }); + + // If the user has no village yet, create one automatically. + if (!village) { + village = await prisma.village.create({ + data: { userId }, + include: { objects: true }, + }); + } + + // Map Prisma objects to clean DTOs, computing `isGrown`. + const objectDtos: VillageObjectDto[] = village.objects.map(obj => ({ + id: obj.id, + type: obj.type, + x: obj.x, + y: obj.y, + cropType: obj.cropType, + isGrown: obj.type === 'FIELD' ? isCropGrown(obj.plantedAt, obj.cropType) : null, + })); + + return { + objects: objectDtos, + }; +}); diff --git a/server/api/village/objects.post.ts b/server/api/village/objects.post.ts new file mode 100644 index 0000000..d3a3b70 --- /dev/null +++ b/server/api/village/objects.post.ts @@ -0,0 +1,78 @@ +import { getUserIdFromSession } from '../../utils/auth'; +import { VILLAGE_GRID_SIZE, ITEM_COSTS } from '../../utils/village'; +import { VillageObjectType } from '@prisma/client'; + +// --- Handler --- +export default defineEventHandler(async (event) => { + const userId = await getUserIdFromSession(event); + const { type, x, y } = await readBody(event); + + // 1. --- Validation --- + if (!type || typeof x !== 'number' || typeof y !== 'number') { + throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "type", "x", and "y" are required.' }); + } + if (x < 0 || x >= VILLAGE_GRID_SIZE.width || y < 0 || y >= VILLAGE_GRID_SIZE.height) { + throw createError({ statusCode: 400, statusMessage: 'Object placed outside of village bounds.' }); + } + const cost = ITEM_COSTS[type as VillageObjectType]; + if (cost === undefined) { + throw createError({ statusCode: 400, statusMessage: 'Cannot place objects of this type.' }); + } + + // 2. --- Fetch current state and enforce rules --- + const village = await prisma.village.findUnique({ + where: { userId }, + include: { objects: true }, + }); + + if (!village) { + // This should not happen if GET /village is called first, but as a safeguard: + throw createError({ statusCode: 404, statusMessage: 'Village not found.' }); + } + + // Rule: Cell must be empty + if (village.objects.some(obj => obj.x === x && obj.y === y)) { + throw createError({ statusCode: 409, statusMessage: 'A building already exists on this cell.' }); + } + + // Rule: Fields require available workers + if (type === 'FIELD') { + const houseCount = village.objects.filter(obj => obj.type === 'HOUSE').length; + const fieldCount = village.objects.filter(obj => obj.type === 'FIELD').length; + if (fieldCount >= houseCount) { + throw createError({ statusCode: 400, statusMessage: 'Not enough available workers to build a new field. Build more houses first.' }); + } + } + + // 3. --- Perform atomic transaction --- + try { + const [, newObject] = await prisma.$transaction([ + prisma.user.update({ + where: { id: userId, coins: { gte: cost } }, + data: { coins: { decrement: cost } }, + }), + prisma.villageObject.create({ + data: { + villageId: village.id, + type, + x, + y, + }, + }), + ]); + + setResponseStatus(event, 201); + return { + id: newObject.id, + type: newObject.type, + x: newObject.x, + y: newObject.y, + cropType: null, + isGrown: null, + }; + + } catch (e) { + // Catches failed transactions, likely from the user.update 'where' clause (insufficient funds) + throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to build.' }); + } +}); diff --git a/server/api/village/objects/[id].delete.ts b/server/api/village/objects/[id].delete.ts new file mode 100644 index 0000000..3c9287a --- /dev/null +++ b/server/api/village/objects/[id].delete.ts @@ -0,0 +1,74 @@ +import { getUserIdFromSession } from '../../../utils/auth'; +import { OBSTACLE_CLEAR_COST } from '../../../utils/village'; + +export default defineEventHandler(async (event) => { + // 1. Get objectId from params + const objectId = parseInt(event.context.params?.id || '', 10); + if (isNaN(objectId)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid object ID.' }); + } + + const userId = await getUserIdFromSession(event); + + // 2. Find village + objects + const village = await prisma.village.findUnique({ + where: { userId }, + include: { objects: true } + }); + + if (!village) { + // This case is unlikely if user has ever fetched their village, but is a good safeguard. + throw createError({ statusCode: 404, statusMessage: 'Village not found.' }); + } + + // 3. Find the object + const objectToDelete = village.objects.find(o => o.id === objectId); + + // 4. If not found -> 404 + if (!objectToDelete) { + throw createError({ statusCode: 404, statusMessage: 'Object not found.' }); + } + + // 5. If OBSTACLE + if (objectToDelete.type === 'OBSTACLE') { + const cost = OBSTACLE_CLEAR_COST[objectToDelete.obstacleMetadata || 'DEFAULT'] ?? OBSTACLE_CLEAR_COST.DEFAULT; + + try { + // Atomically check coins, deduct, and delete + await prisma.$transaction([ + prisma.user.update({ + where: { id: userId, coins: { gte: cost } }, + data: { coins: { decrement: cost } }, + }), + prisma.villageObject.delete({ where: { id: objectId } }), + ]); + } catch (e) { + // The transaction fails if the user update fails due to insufficient coins + throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to clear obstacle.' }); + } + + // 6. If HOUSE + } else if (objectToDelete.type === 'HOUSE') { + const houseCount = village.objects.filter(o => o.type === 'HOUSE').length; + const fieldCount = village.objects.filter(o => o.type === 'FIELD').length; + + // Check if removing this house violates the worker rule + if (fieldCount > (houseCount - 1)) { + throw createError({ + statusCode: 400, + statusMessage: `Cannot remove house. You have ${fieldCount} fields and need at least ${fieldCount} workers.` + }); + } + + // Delete the house (no cost) + await prisma.villageObject.delete({ where: { id: objectId } }); + + // 7. Otherwise (FIELD, ROAD, FENCE) + } else { + // Delete the object (no cost) + await prisma.villageObject.delete({ where: { id: objectId } }); + } + + // 8. Return success message + return { message: "Object removed successfully" }; +}); \ No newline at end of file diff --git a/server/api/village/objects/[id].patch.ts b/server/api/village/objects/[id].patch.ts new file mode 100644 index 0000000..b99642c --- /dev/null +++ b/server/api/village/objects/[id].patch.ts @@ -0,0 +1,73 @@ +import { getUserIdFromSession } from '../../../utils/auth'; +import { VILLAGE_GRID_SIZE, MOVE_COST, isCropGrown } from '../../../utils/village'; + +// --- Handler --- +export default defineEventHandler(async (event) => { + const userId = await getUserIdFromSession(event); + const objectId = parseInt(event.context.params?.id || '', 10); + const { x, y } = await readBody(event); + + // 1. --- Validation --- + if (isNaN(objectId)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid object ID.' }); + } + if (typeof x !== 'number' || typeof y !== 'number') { + throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "x" and "y" are required.' }); + } + if (x < 0 || x >= VILLAGE_GRID_SIZE.width || y < 0 || y >= VILLAGE_GRID_SIZE.height) { + throw createError({ statusCode: 400, statusMessage: 'Cannot move object outside of village bounds.' }); + } + + // 2. --- Fetch state and enforce rules --- + const village = await prisma.village.findUnique({ + where: { userId }, + include: { objects: true }, + }); + + if (!village) { + throw createError({ statusCode: 404, statusMessage: 'Village not found.' }); + } + + const objectToMove = village.objects.find(obj => obj.id === objectId); + + // Rule: Object must exist and belong to the user (implicit via village) + if (!objectToMove) { + throw createError({ statusCode: 404, statusMessage: 'Object not found.' }); + } + + // Rule: Cannot move obstacles + if (objectToMove.type === 'OBSTACLE') { + throw createError({ statusCode: 400, statusMessage: 'Cannot move obstacles. They must be cleared.' }); + } + + // Rule: Target cell must be empty (and not the same cell) + if (village.objects.some(obj => obj.x === x && obj.y === y)) { + throw createError({ statusCode: 409, statusMessage: 'Target cell is already occupied.' }); + } + + // 3. --- Perform atomic transaction --- + try { + const [, updatedObject] = await prisma.$transaction([ + prisma.user.update({ + where: { id: userId, coins: { gte: MOVE_COST } }, + data: { coins: { decrement: MOVE_COST } }, + }), + prisma.villageObject.update({ + where: { id: objectId }, + data: { x, y }, + }), + ]); + + return { + id: updatedObject.id, + type: updatedObject.type, + x: updatedObject.x, + y: updatedObject.y, + cropType: updatedObject.cropType, + isGrown: isCropGrown(updatedObject.plantedAt, updatedObject.cropType), + }; + + } catch (e) { + throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to move object.' }); + } +}); diff --git a/server/api/village/plant.post.ts b/server/api/village/plant.post.ts new file mode 100644 index 0000000..e2c5e91 --- /dev/null +++ b/server/api/village/plant.post.ts @@ -0,0 +1,63 @@ +import { getUserIdFromSession } from '../../utils/auth'; +import { PLANTING_COST, isCropGrown } from '../../utils/village'; +import { CropType } from '@prisma/client'; + +// --- Handler --- +export default defineEventHandler(async (event) => { + const userId = await getUserIdFromSession(event); + const { fieldId, cropType } = await readBody(event); + + // 1. --- Validation --- + if (typeof fieldId !== 'number' || !cropType) { + throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "fieldId" and "cropType" are required.' }); + } + if (!Object.values(CropType).includes(cropType)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid crop type.' }); + } + + // 2. --- Find the target field and validate its state --- + const field = await prisma.villageObject.findFirst({ + where: { + id: fieldId, + type: 'FIELD', + village: { userId: userId }, // Ensures ownership + }, + }); + + if (!field) { + throw createError({ statusCode: 404, statusMessage: 'Field not found or you do not own it.' }); + } + + if (field.cropType !== null) { + throw createError({ statusCode: 409, statusMessage: 'A crop is already planted in this field.' }); + } + + // 3. --- Perform atomic transaction --- + try { + const [, updatedField] = await prisma.$transaction([ + prisma.user.update({ + where: { id: userId, coins: { gte: PLANTING_COST } }, + data: { coins: { decrement: PLANTING_COST } }, + }), + prisma.villageObject.update({ + where: { id: fieldId }, + data: { + cropType: cropType, + plantedAt: new Date(), + }, + }), + ]); + + return { + id: updatedField.id, + type: updatedField.type, + x: updatedField.x, + y: updatedField.y, + cropType: updatedField.cropType, + isGrown: isCropGrown(updatedField.plantedAt, updatedField.cropType), // will be false + }; + + } catch (e) { + throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to plant seeds.' }); + } +}); diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 0000000..e39eb48 --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,22 @@ +// server/utils/auth.ts +import { useSession } from 'h3'; + +if (!process.env.SESSION_PASSWORD) { + // Fail-fast if the session password is not configured + throw new Error('FATAL ERROR: SESSION_PASSWORD environment variable is not set. Session management will not work securely.'); +} + +/** + * A helper function to safely get the authenticated user's ID from the session. + * Throws a 401 Unauthorized error if the user is not authenticated. + */ +export async function getUserIdFromSession(event: any): Promise { + const session = await useSession(event, { + password: process.env.SESSION_PASSWORD, // No fallback here, rely on the fail-fast check + }); + const userId = session.data?.user?.id; + if (!userId) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }); + } + return userId; +} diff --git a/server/utils/prisma.ts b/server/utils/prisma.ts new file mode 100644 index 0000000..4590932 --- /dev/null +++ b/server/utils/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default prisma diff --git a/server/utils/village.ts b/server/utils/village.ts new file mode 100644 index 0000000..151ef52 --- /dev/null +++ b/server/utils/village.ts @@ -0,0 +1,52 @@ +// server/utils/village.ts +import { CropType, VillageObjectType } from '@prisma/client'; + +// --- Game Economy & Rules --- +export const VILLAGE_GRID_SIZE = { width: 15, height: 15 }; + +export const ITEM_COSTS: Partial> = { + HOUSE: 50, + FIELD: 15, + ROAD: 5, + FENCE: 10, +}; + +export const OBSTACLE_CLEAR_COST: Record = { + ROCK: 20, + BUSH: 5, + MUSHROOM: 10, + DEFAULT: 15, // Fallback cost for untyped obstacles +}; + +export const PLANTING_COST = 2; // A small, flat cost for seeds +export const MOVE_COST = 1; // Cost to move any player-built item + +// --- Crop Timings (in milliseconds) --- +export const CROP_GROWTH_TIME: Record = { + BLUEBERRIES: 60 * 60 * 1000, // 1 hour + CORN: 4 * 60 * 60 * 1000, // 4 hours +}; + +// --- Rewards --- +export const CROP_HARVEST_REWARD: Record = { + BLUEBERRIES: { exp: 5, coins: 0 }, + CORN: { exp: 10, coins: 1 }, +}; + +/** + * Checks if a crop is grown based on when it was planted. + * @param plantedAt The ISO string or Date object when the crop was planted. + * @param cropType The type of crop. + * @returns True if the crop has finished growing. + */ +export function isCropGrown(plantedAt: Date | null, cropType: CropType | null): boolean { + if (!plantedAt || !cropType) { + return false; + } + const growthTime = CROP_GROWTH_TIME[cropType]; + if (growthTime === undefined) { + return false; + } + + return (Date.now() - new Date(plantedAt).getTime()) > growthTime; +}