diff --git a/.env.example b/.env.example index 9dd5853..6dd83f4 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ -SENTRY_DSN= -NEXT_PUBLIC_SENTRY_DSN= -SENTRY_AUTH_TOKEN= -CODECOV_TOKEN= +DATABASE_HOST= +DATABASE_PORT= +DATABASE_NAME= +DATABASE_USER= +DATABASE_PASSWORD= DATABASE_URL= -DATABASE_AUTH_TOKEN= +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + + diff --git a/.eslintrc.json b/.eslintrc.json index d903f30..db5cf5b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,18 +16,18 @@ }, { "mode": "full", - "type": "use-cases", - "pattern": ["src/application/use-cases/**/*"] + "type": "application-services", + "pattern": ["src/application/modules/**/*"] }, { "mode": "full", "type": "service-interfaces", - "pattern": ["src/application/services/**/*"] + "pattern": ["src/application/modules/**/interfaces/**/*", "src/application/services/**/*"] }, { "mode": "full", "type": "repository-interfaces", - "pattern": ["src/application/repositories/**/*"] + "pattern": ["src/application/modules/**/interfaces/**/*"] }, { "mode": "full", @@ -56,7 +56,7 @@ "rules": [ { "from": "web", - "allow": ["web", "entities", "di"] + "allow": ["web", "entities", "di", "infrastructure", "application-services", "service-interfaces"] }, { "from": "controllers", @@ -64,16 +64,16 @@ "entities", "service-interfaces", "repository-interfaces", - "use-cases" + "application-services" ] }, { "from": "infrastructure", - "allow": ["service-interfaces", "repository-interfaces", "entities"] + "allow": ["service-interfaces", "repository-interfaces", "entities", "infrastructure", "application-services"] }, { - "from": "use-cases", - "allow": ["entities", "service-interfaces", "repository-interfaces"] + "from": "application-services", + "allow": ["entities", "service-interfaces", "repository-interfaces", "infrastructure", "application-services"] }, { "from": "service-interfaces", @@ -94,7 +94,7 @@ "controllers", "service-interfaces", "repository-interfaces", - "use-cases", + "application-services", "infrastructure" ] } diff --git a/.gitignore b/.gitignore index 20341e6..b72be25 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ sqlite.db # Environment variables .env + +# MikroORM metadata cache +/temp/ diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts deleted file mode 100644 index be793b3..0000000 --- a/app/(auth)/actions.ts +++ /dev/null @@ -1,142 +0,0 @@ -'use server'; - -import { cookies } from 'next/headers'; -import { redirect } from 'next/navigation'; - -import { Cookie } from '@/src/entities/models/cookie'; -import { SESSION_COOKIE } from '@/config'; -import { InputParseError } from '@/src/entities/errors/common'; -import { - AuthenticationError, - UnauthenticatedError, -} from '@/src/entities/errors/auth'; -import { getInjection } from '@/di/container'; - -export async function signUp(formData: FormData) { - const instrumentationService = getInjection('IInstrumentationService'); - return await instrumentationService.instrumentServerAction( - 'signUp', - { recordResponse: true }, - async () => { - const username = formData.get('username')?.toString(); - const password = formData.get('password')?.toString(); - const confirmPassword = formData.get('confirm_password')?.toString(); - - let sessionCookie: Cookie; - try { - const signUpController = getInjection('ISignUpController'); - const { cookie } = await signUpController({ - username, - password, - confirm_password: confirmPassword, - }); - sessionCookie = cookie; - } catch (err) { - if (err instanceof InputParseError) { - return { - error: - 'Invalid data. Make sure the Password and Confirm Password match.', - }; - } - if (err instanceof AuthenticationError) { - return { - error: err.message, - }; - } - const crashReporterService = getInjection('ICrashReporterService'); - crashReporterService.report(err); - - return { - error: - 'An error happened. The developers have been notified. Please try again later. Message: ' + - (err as Error).message, - }; - } - - cookies().set( - sessionCookie.name, - sessionCookie.value, - sessionCookie.attributes - ); - - redirect('/'); - } - ); -} - -export async function signIn(formData: FormData) { - const instrumentationService = getInjection('IInstrumentationService'); - return await instrumentationService.instrumentServerAction( - 'signIn', - { recordResponse: true }, - async () => { - const username = formData.get('username')?.toString(); - const password = formData.get('password')?.toString(); - - let sessionCookie: Cookie; - try { - const signInController = getInjection('ISignInController'); - sessionCookie = await signInController({ username, password }); - } catch (err) { - if ( - err instanceof InputParseError || - err instanceof AuthenticationError - ) { - return { - error: 'Incorrect username or password', - }; - } - const crashReporterService = getInjection('ICrashReporterService'); - crashReporterService.report(err); - return { - error: - 'An error happened. The developers have been notified. Please try again later.', - }; - } - - cookies().set( - sessionCookie.name, - sessionCookie.value, - sessionCookie.attributes - ); - - redirect('/'); - } - ); -} - -export async function signOut() { - const instrumentationService = getInjection('IInstrumentationService'); - return await instrumentationService.instrumentServerAction( - 'signOut', - { recordResponse: true }, - async () => { - const cookiesStore = cookies(); - const sessionId = cookiesStore.get(SESSION_COOKIE)?.value; - - let blankCookie: Cookie; - try { - const signOutController = getInjection('ISignOutController'); - blankCookie = await signOutController(sessionId); - } catch (err) { - if ( - err instanceof UnauthenticatedError || - err instanceof InputParseError - ) { - redirect('/sign-in'); - } - const crashReporterService = getInjection('ICrashReporterService'); - crashReporterService.report(err); - throw err; - } - - cookies().set( - blankCookie.name, - blankCookie.value, - blankCookie.attributes - ); - - redirect('/sign-in'); - } - ); -} diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index bff80f9..7376ba1 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,9 +1,10 @@ 'use client'; +import Link from 'next/link'; import { useState } from 'react'; import { Loader } from 'lucide-react'; +import { useRouter } from 'next/navigation'; -import Link from 'next/link'; import { Button } from '../../_components/ui/button'; import { Card, @@ -15,24 +16,47 @@ import { import { Input } from '../../_components/ui/input'; import { Label } from '../../_components/ui/label'; import { Separator } from '../../_components/ui/separator'; -import { signIn } from '../actions'; export default function SignIn() { const [error, setError] = useState(); const [loading, setLoading] = useState(false); + const router = useRouter(); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (loading) return; const formData = new FormData(event.currentTarget); + const username = formData.get('username')?.toString(); + const password = formData.get('password')?.toString(); + + if (!username || !password) { + setError('Username and password are required'); + return; + } setLoading(true); - const res = await signIn(formData); - if (res && res.error) { - setError(res.error); + setError(undefined); + + try { + const response = await fetch('/api/auth/sign-in', { + method: 'POST', + body: formData, + }); + + const result = await response.json(); + + if (response.ok && result.success) { + router.push('/'); + router.refresh(); + } else { + setError(result.error || 'Invalid credentials'); + } + } catch (error) { + setError('Network error. Please try again.'); + } finally { + setLoading(false); } - setLoading(false); }; return ( @@ -40,7 +64,7 @@ export default function SignIn() { Sign in - Enter your email below to login to your account + Enter your username and password to access your account @@ -63,13 +87,13 @@ export default function SignIn() {
Don't have an account?{' '} - Sign up + Create an account
diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx index b7e6d28..5a3a5c3 100644 --- a/app/(auth)/sign-up/page.tsx +++ b/app/(auth)/sign-up/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { useState } from 'react'; import { Loader } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { Button } from '../../_components/ui/button'; import { @@ -15,20 +16,24 @@ import { import { Input } from '../../_components/ui/input'; import { Label } from '../../_components/ui/label'; import { Separator } from '../../_components/ui/separator'; -import { signUp } from '../actions'; export default function SignUp() { const [error, setError] = useState(); const [loading, setLoading] = useState(false); + const router = useRouter(); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (loading) return; const formData = new FormData(event.currentTarget); + const password = formData.get('password')?.toString(); + const confirmPassword = formData.get('confirm_password')?.toString(); - const password = formData.get('password')!.toString(); - const confirmPassword = formData.get('confirm_password')!.toString(); + if (!password || !confirmPassword) { + setError('All fields are required'); + return; + } if (password !== confirmPassword) { setError('Passwords must match'); @@ -36,11 +41,27 @@ export default function SignUp() { } setLoading(true); - const res = await signUp(formData); - if (res && res.error) { - setError(res.error); + setError(undefined); + + try { + const response = await fetch('/api/auth/sign-up', { + method: 'POST', + body: formData, + }); + + const result = await response.json(); + + if (response.ok && result.success) { + router.push('/'); + router.refresh(); + } else { + setError(result.error || 'An unexpected error occurred'); + } + } catch (error) { + setError('Network error. Please try again.'); + } finally { + setLoading(false); } - setLoading(false); }; return ( diff --git a/app/_components/ui/user-menu.tsx b/app/_components/ui/user-menu.tsx index 04dd3c8..57616f8 100644 --- a/app/_components/ui/user-menu.tsx +++ b/app/_components/ui/user-menu.tsx @@ -1,6 +1,6 @@ 'use client'; -import { signOut } from '@/app/(auth)/actions'; +import { useRouter } from 'next/navigation'; import { Avatar, AvatarFallback } from './avatar'; import { DropdownMenu, @@ -10,6 +10,30 @@ import { } from './dropdown-menu'; export function UserMenu() { + const router = useRouter(); + + const handleSignOut = async () => { + try { + const response = await fetch('/api/auth/sign-out', { + method: 'POST', + }); + + if (response.ok) { + // ✅ Redirect to sign-in page after successful sign-out + router.push('/sign-in'); + router.refresh(); // Refresh to update auth state + } else { + console.error('Sign out failed'); + // Still redirect even if there was an error + router.push('/sign-in'); + } + } catch (error) { + console.error('Error during sign out:', error); + // Still redirect even if there was an error + router.push('/sign-in'); + } + }; + return ( @@ -18,7 +42,7 @@ export function UserMenu() { - signOut()}> + Sign out diff --git a/app/actions.ts b/app/actions.ts index fad7cc1..4c4a25c 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -1,110 +1,32 @@ 'use server'; -import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; - -import { SESSION_COOKIE } from '@/config'; import { UnauthenticatedError } from '@/src/entities/errors/auth'; -import { InputParseError, NotFoundError } from '@/src/entities/errors/common'; -import { getInjection } from '@/di/container'; - -export async function createTodo(formData: FormData) { - const instrumentationService = getInjection('IInstrumentationService'); - return await instrumentationService.instrumentServerAction( - 'createTodo', - { recordResponse: true }, - async () => { - try { - const data = Object.fromEntries(formData.entries()); - const sessionId = cookies().get(SESSION_COOKIE)?.value; - const createTodoController = getInjection('ICreateTodoController'); - await createTodoController(data, sessionId); - } catch (err) { - if (err instanceof InputParseError) { - return { error: err.message }; - } - if (err instanceof UnauthenticatedError) { - return { error: 'Must be logged in to create a todo' }; - } - const crashReporterService = getInjection('ICrashReporterService'); - crashReporterService.report(err); - return { - error: - 'An error happened while creating a todo. The developers have been notified. Please try again later.', - }; - } - - revalidatePath('/'); - return { success: true }; +import { SESSION_COOKIE } from '@/config'; +import { withRequestScoped } from '@/src/infrastructure/di/server-container'; +import { USER_APPLICATION_TOKENS, TODO_APPLICATION_TOKENS } from '@/src/application/modules'; +import type { ITodoApplicationService, IAuthApplicationService } from '@/src/application/modules'; +import type { TodoDTO } from './types'; + +// Server Action used by Server Components for data fetching +export async function getTodosAction(): Promise { + return await withRequestScoped(async (getService) => { + const sessionId = cookies().get(SESSION_COOKIE)?.value; + if (!sessionId) { + throw new UnauthenticatedError('No active session found'); } - ); -} -export async function toggleTodo(todoId: number) { - const instrumentationService = getInjection('IInstrumentationService'); - return await instrumentationService.instrumentServerAction( - 'toggleTodo', - { recordResponse: true }, - async () => { - try { - const sessionId = cookies().get(SESSION_COOKIE)?.value; - const toggleTodoController = getInjection('IToggleTodoController'); - await toggleTodoController({ todoId }, sessionId); - } catch (err) { - if (err instanceof InputParseError) { - return { error: err.message }; - } - if (err instanceof UnauthenticatedError) { - return { error: 'Must be logged in to create a todo' }; - } - if (err instanceof NotFoundError) { - return { error: 'Todo does not exist' }; - } - const crashReporterService = getInjection('ICrashReporterService'); - crashReporterService.report(err); - return { - error: - 'An error happened while toggling the todo. The developers have been notified. Please try again later.', - }; - } - - revalidatePath('/'); - return { success: true }; - } - ); + // Get userId from session + const authService = getService(USER_APPLICATION_TOKENS.IAuthApplicationService); + const userId = await authService.getUserIdFromSession(sessionId); + + const todoService = getService(TODO_APPLICATION_TOKENS.ITodoApplicationService); + const todos = await todoService.getTodosForUser(userId); + + // Convert entities to DTOs for safe client serialization using toJSON() + return todos.map(todo => todo.toJSON()); + }); } -export async function bulkUpdate(dirty: number[], deleted: number[]) { - const instrumentationService = getInjection('IInstrumentationService'); - return await instrumentationService.instrumentServerAction( - 'bulkUpdate', - { recordResponse: true }, - async () => { - try { - const sessionId = cookies().get(SESSION_COOKIE)?.value; - const bulkUpdateController = getInjection('IBulkUpdateController'); - await bulkUpdateController({ dirty, deleted }, sessionId); - } catch (err) { - revalidatePath('/'); - if (err instanceof InputParseError) { - return { error: err.message }; - } - if (err instanceof UnauthenticatedError) { - return { error: 'Must be logged in to bulk update todos' }; - } - if (err instanceof NotFoundError) { - return { error: 'Todo does not exist' }; - } - const crashReporterService = getInjection('ICrashReporterService'); - crashReporterService.report(err); - return { - error: - 'An error happened while bulk updating the todos. The developers have been notified. Please try again later.', - }; - } - - revalidatePath('/'); - return { success: true }; - } - ); -} +// Note: Client-side todo mutations (create, toggle, bulk update) now use API routes +// for consistency with auth operations and reliable production deployment diff --git a/app/add-todo.tsx b/app/add-todo.tsx index 0339e50..65c6b26 100644 --- a/app/add-todo.tsx +++ b/app/add-todo.tsx @@ -2,35 +2,52 @@ import { Loader, Plus } from 'lucide-react'; import { useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { Button } from './_components/ui/button'; import { Input } from './_components/ui/input'; -import { createTodo } from './actions'; export function CreateTodo() { const inputRef = useRef(null); const [loading, setLoading] = useState(false); + const router = useRouter(); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (loading) return; const formData = new FormData(event.currentTarget); + const content = formData.get('content')?.toString(); + + if (!content?.trim()) { + toast.error('Content is required'); + return; + } setLoading(true); - const res = await createTodo(formData); + try { + const response = await fetch('/api/todos', { + method: 'POST', + body: formData, + }); - if (res) { - if (res.error) { - toast.error(res.error); - } else if (res.success) { - toast.success('Todo(s) created!'); + const result = await response.json(); + if (response.ok && result.success) { + toast.success('Todo created!'); + if (inputRef.current) { inputRef.current.value = ''; } + + // Refresh the page to show the new todo + router.refresh(); + } else { + toast.error(result.error || 'Failed to create todo'); } + } catch (error) { + toast.error('Failed to create todo'); } setLoading(false); }; @@ -39,7 +56,7 @@ export function CreateTodo() {
diff --git a/app/api/auth/sign-in/route.ts b/app/api/auth/sign-in/route.ts new file mode 100644 index 0000000..ce277fc --- /dev/null +++ b/app/api/auth/sign-in/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +import { SESSION_COOKIE } from '@/config'; +import { withRequestScoped } from '@/src/infrastructure/di/server-container'; +import { USER_APPLICATION_TOKENS } from '@/src/application/modules'; +import type { IAuthApplicationService } from '@/src/application/modules'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + // Validation + const username = formData.get('username') as string; + const password = formData.get('password') as string; + + if (!username || !password) { + return NextResponse.json( + { error: 'Username and password are required' }, + { status: 400 } + ); + } + + // Direct business logic - most reliable approach + const result = await withRequestScoped(async (getService) => { + const authService = getService( + USER_APPLICATION_TOKENS.IAuthApplicationService + ); + return await authService.signIn({ username, password }); + }); + + // Set cookie for successful auth + cookies().set(result.cookie.name, result.cookie.value, result.cookie.attributes); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error during sign in:', error); + + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Invalid credentials', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/auth/sign-out/route.ts b/app/api/auth/sign-out/route.ts new file mode 100644 index 0000000..8e4b8d0 --- /dev/null +++ b/app/api/auth/sign-out/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +import { SESSION_COOKIE } from '@/config'; +import { withRequestScoped } from '@/src/infrastructure/di/server-container'; +import { USER_APPLICATION_TOKENS } from '@/src/application/modules'; +import type { IAuthApplicationService } from '@/src/application/modules'; + +export async function POST(request: NextRequest) { + try { + const sessionId = cookies().get(SESSION_COOKIE)?.value; + + if (sessionId) { + try { + await withRequestScoped(async (getService) => { + const authService = getService( + USER_APPLICATION_TOKENS.IAuthApplicationService + ); + await authService.signOut(sessionId); + }); + } catch (error) { + console.error('Error during sign out:', error); + } + } + + // Delete the session cookie + cookies().delete(SESSION_COOKIE); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error during sign out:', error); + + return NextResponse.json( + { error: 'Failed to sign out' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/auth/sign-up/route.ts b/app/api/auth/sign-up/route.ts new file mode 100644 index 0000000..9aca879 --- /dev/null +++ b/app/api/auth/sign-up/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +import { SESSION_COOKIE } from '@/config'; +import { withRequestScoped } from '@/src/infrastructure/di/server-container'; +import { USER_APPLICATION_TOKENS } from '@/src/application/modules'; +import type { IAuthApplicationService } from '@/src/application/modules'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + // Validation + const username = formData.get('username') as string; + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirm_password') as string; + + if (!username || !password || !confirmPassword) { + return NextResponse.json( + { error: 'All fields are required' }, + { status: 400 } + ); + } + + if (password !== confirmPassword) { + return NextResponse.json( + { error: 'Passwords must match' }, + { status: 400 } + ); + } + + // Direct business logic - most reliable approach + const result = await withRequestScoped(async (getService) => { + const authService = getService( + USER_APPLICATION_TOKENS.IAuthApplicationService + ); + return await authService.signUp({ username, password }); + }); + + // Set cookie for successful auth + cookies().set(result.cookie.name, result.cookie.value, result.cookie.attributes); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error during sign up:', error); + + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'An unexpected error occurred', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts new file mode 100644 index 0000000..71bc27a --- /dev/null +++ b/app/api/debug/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +// Force dynamic rendering for debugging +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + console.log('🔍 Debug API called'); + + // Environment info + const envInfo = { + NODE_ENV: process.env.NODE_ENV, + VERCEL: process.env.VERCEL, + DATABASE_URL: process.env.DATABASE_URL ? '✅ Set' : '❌ Missing', + timestamp: new Date().toISOString(), + workingDirectory: process.cwd(), + }; + + console.log('🌍 Environment:', envInfo); + + // Check if cache file exists + const cacheFilePath = path.join(process.cwd(), 'temp', 'metadata.json'); + + let cacheInfo; + try { + const cacheExists = fs.existsSync(cacheFilePath); + if (cacheExists) { + const cacheStats = fs.statSync(cacheFilePath); + cacheInfo = { + exists: true, + size: cacheStats.size, + modified: cacheStats.mtime.toISOString(), + path: cacheFilePath + }; + } else { + cacheInfo = { + exists: false, + path: cacheFilePath + }; + } + } catch (error: any) { + cacheInfo = { + error: error.message + }; + } + + console.log('📁 Cache Info:', cacheInfo); + + // Check temp directory + let tempDirInfo; + try { + const tempDir = path.join(process.cwd(), 'temp'); + const tempExists = fs.existsSync(tempDir); + if (tempExists) { + const files = fs.readdirSync(tempDir); + tempDirInfo = { + exists: true, + files: files.map((file: string) => { + const filePath = path.join(tempDir, file); + const stats = fs.statSync(filePath); + return { + name: file, + size: stats.size, + modified: stats.mtime.toISOString() + }; + }) + }; + } else { + tempDirInfo = { exists: false }; + } + } catch (error: any) { + tempDirInfo = { error: error.message }; + } + + // Check entity imports + let entityInfo; + try { + const { User, Todo, Session } = await import('@/src/entities'); + entityInfo = { + status: '✅ Entities imported', + entities: [ + { name: 'User', type: typeof User }, + { name: 'Todo', type: typeof Todo }, + { name: 'Session', type: typeof Session } + ] + }; + } catch (error: any) { + entityInfo = { + status: '❌ Entity import failed', + error: error.message + }; + } + + const debugData = { + environment: envInfo, + cache: cacheInfo, + tempDirectory: tempDirInfo, + entities: entityInfo, + userAgent: request.headers.get('user-agent'), + url: request.url, + }; + + console.log('📊 Debug Summary:', JSON.stringify(debugData, null, 2)); + + return NextResponse.json(debugData, { + status: 200, + headers: { + 'Cache-Control': 'no-store' + } + }); + } catch (error: any) { + console.error('💥 Debug API Error:', error); + + return NextResponse.json({ + error: 'Debug API failed', + message: error.message, + stack: error.stack, + timestamp: new Date().toISOString() + }, { + status: 500, + headers: { + 'Cache-Control': 'no-store' + } + }); + } +} \ No newline at end of file diff --git a/app/api/todos/[id]/route.ts b/app/api/todos/[id]/route.ts new file mode 100644 index 0000000..0a26fe0 --- /dev/null +++ b/app/api/todos/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +import { SESSION_COOKIE } from '@/config'; +import { withRequestScoped } from '@/src/infrastructure/di/server-container'; +import { USER_APPLICATION_TOKENS, TODO_APPLICATION_TOKENS } from '@/src/application/modules'; +import type { ITodoApplicationService, IAuthApplicationService } from '@/src/application/modules'; +import { UnauthenticatedError } from '@/src/entities/errors/auth'; + +// PATCH /api/todos/[id] - Toggle todo completion status +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const sessionId = cookies().get(SESSION_COOKIE)?.value; + if (!sessionId) { + return NextResponse.json( + { error: 'No active session found' }, + { status: 401 } + ); + } + + const todoId = parseInt(params.id); + if (isNaN(todoId)) { + return NextResponse.json( + { error: 'Invalid todo ID' }, + { status: 400 } + ); + } + + await withRequestScoped(async (getService) => { + const authService = getService( + USER_APPLICATION_TOKENS.IAuthApplicationService + ); + const userId = await authService.getUserIdFromSession(sessionId); + + const todoService = getService( + TODO_APPLICATION_TOKENS.ITodoApplicationService + ); + await todoService.toggleTodo({ todoId }, userId); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof UnauthenticatedError) { + return NextResponse.json( + { error: 'Unauthenticated' }, + { status: 401 } + ); + } + + console.error('Error toggling todo:', error); + return NextResponse.json( + { error: 'Failed to toggle todo' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/todos/bulk/route.ts b/app/api/todos/bulk/route.ts new file mode 100644 index 0000000..a8eadc2 --- /dev/null +++ b/app/api/todos/bulk/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +import { SESSION_COOKIE } from '@/config'; +import { withRequestScoped } from '@/src/infrastructure/di/server-container'; +import { USER_APPLICATION_TOKENS, TODO_APPLICATION_TOKENS } from '@/src/application/modules'; +import type { ITodoApplicationService, IAuthApplicationService } from '@/src/application/modules'; +import { UnauthenticatedError } from '@/src/entities/errors/auth'; + +// PATCH /api/todos/bulk - Bulk toggle todos +export async function PATCH(request: NextRequest) { + try { + const sessionId = cookies().get(SESSION_COOKIE)?.value; + if (!sessionId) { + return NextResponse.json( + { error: 'No active session found' }, + { status: 401 } + ); + } + + const formData = await request.formData(); + const todoIds = formData.getAll('todoIds').map(id => parseInt(id as string)); + + if (todoIds.length === 0 || todoIds.some(isNaN)) { + return NextResponse.json( + { error: 'Invalid todo IDs' }, + { status: 400 } + ); + } + + await withRequestScoped(async (getService) => { + const authService = getService( + USER_APPLICATION_TOKENS.IAuthApplicationService + ); + const userId = await authService.getUserIdFromSession(sessionId); + + const todoService = getService( + TODO_APPLICATION_TOKENS.ITodoApplicationService + ); + await todoService.bulkToggleTodos({ todoIds }, userId); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof UnauthenticatedError) { + return NextResponse.json( + { error: 'Unauthenticated' }, + { status: 401 } + ); + } + + console.error('Error bulk updating todos:', error); + return NextResponse.json( + { error: 'Failed to update todos' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/todos/route.ts b/app/api/todos/route.ts new file mode 100644 index 0000000..9db38cc --- /dev/null +++ b/app/api/todos/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +import { SESSION_COOKIE } from '@/config'; +import { withRequestScoped } from '@/src/infrastructure/di/server-container'; +import { USER_APPLICATION_TOKENS, TODO_APPLICATION_TOKENS } from '@/src/application/modules'; +import type { ITodoApplicationService, IAuthApplicationService } from '@/src/application/modules'; +import { UnauthenticatedError } from '@/src/entities/errors/auth'; + +// POST /api/todos - Create a new todo +export async function POST(request: NextRequest) { + try { + const sessionId = cookies().get(SESSION_COOKIE)?.value; + if (!sessionId) { + return NextResponse.json( + { error: 'No active session found' }, + { status: 401 } + ); + } + + const formData = await request.formData(); + const content = formData.get('content') as string; + + if (!content?.trim()) { + return NextResponse.json( + { error: 'Content is required' }, + { status: 400 } + ); + } + + await withRequestScoped(async (getService) => { + const authService = getService( + USER_APPLICATION_TOKENS.IAuthApplicationService + ); + const userId = await authService.getUserIdFromSession(sessionId); + + const todoService = getService( + TODO_APPLICATION_TOKENS.ITodoApplicationService + ); + await todoService.createTodo({ content: content.trim() }, userId); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof UnauthenticatedError) { + return NextResponse.json( + { error: 'Unauthenticated' }, + { status: 401 } + ); + } + + console.error('Error creating todo:', error); + return NextResponse.json( + { error: 'Failed to create todo' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index d222ade..caf4f54 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,7 +6,7 @@ import { AuthenticationError, UnauthenticatedError, } from '@/src/entities/errors/auth'; -import { Todo } from '@/src/entities/models/todo'; +import type { TodoDTO } from './types'; import { Card, CardContent, @@ -17,43 +17,25 @@ import { Separator } from './_components/ui/separator'; import { UserMenu } from './_components/ui/user-menu'; import { CreateTodo } from './add-todo'; import { Todos } from './todos'; -import { getInjection } from '@/di/container'; - -async function getTodos(sessionId: string | undefined) { - const instrumentationService = getInjection('IInstrumentationService'); - return await instrumentationService.startSpan( - { - name: 'getTodos', - op: 'function.nextjs', - }, - async () => { - try { - const getTodosForUserController = getInjection( - 'IGetTodosForUserController' - ); - return await getTodosForUserController(sessionId); - } catch (err) { - if ( - err instanceof UnauthenticatedError || - err instanceof AuthenticationError - ) { - redirect('/sign-in'); - } - const crashReporterService = getInjection('ICrashReporterService'); - crashReporterService.report(err); - throw err; - } - } - ); -} +import { getTodosAction } from './actions'; export default async function Home() { const sessionId = cookies().get(SESSION_COOKIE)?.value; - let todos: Todo[]; + // Redirect to sign-in if not authenticated + if (!sessionId) { + redirect('/sign-in'); + } + + let todos: TodoDTO[]; try { - todos = await getTodos(sessionId); + // Call server action instead of directly accessing DI services + todos = await getTodosAction(); } catch (err) { + // Handle authentication errors by redirecting to sign-in + if (err instanceof UnauthenticatedError) { + redirect('/sign-in'); + } throw err; } diff --git a/app/todos.tsx b/app/todos.tsx index 5b801fa..9bd0a68 100644 --- a/app/todos.tsx +++ b/app/todos.tsx @@ -1,21 +1,21 @@ 'use client'; import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { Loader, Trash } from 'lucide-react'; import { toast } from 'sonner'; import { Checkbox } from './_components/ui/checkbox'; import { cn } from './_components/utils'; -import { bulkUpdate, toggleTodo } from './actions'; import { Button } from './_components/ui/button'; +import type { TodoDTO } from './types'; -type Todo = { id: number; todo: string; userId: string; completed: boolean }; - -export function Todos({ todos }: { todos: Todo[] }) { +export function Todos({ todos }: { todos: TodoDTO[] }) { const [bulkMode, setBulkMode] = useState(false); const [dirty, setDirty] = useState([]); const [deleted, setDeleted] = useState([]); const [loading, setLoading] = useState(false); + const router = useRouter(); const handleToggle = useCallback( async (id: number) => { @@ -29,17 +29,25 @@ export function Todos({ todos }: { todos: Todo[] }) { setDirty([...dirty, id]); } } else { - const res = await toggleTodo(id); - if (res) { - if (res.error) { - toast.error(res.error); - } else if (res.success) { + try { + const response = await fetch(`/api/todos/${id}`, { + method: 'PATCH', + }); + + const result = await response.json(); + + if (response.ok && result.success) { toast.success('Todo toggled!'); + router.refresh(); // Refresh to show updated state + } else { + toast.error(result.error || 'Failed to toggle todo'); } + } catch (error) { + toast.error('Failed to toggle todo'); } } }, - [bulkMode, dirty] + [bulkMode, dirty, router] ); const markForDeletion = useCallback( @@ -65,18 +73,30 @@ export function Todos({ todos }: { todos: Todo[] }) { const updateAll = async () => { setLoading(true); - const res = await bulkUpdate(dirty, deleted); + try { + const formData = new FormData(); + dirty.forEach(id => formData.append('todoIds', id.toString())); + + const response = await fetch('/api/todos/bulk', { + method: 'PATCH', + body: formData, + }); + + const result = await response.json(); + + if (response.ok && result.success) { + toast.success('Bulk update completed!'); + router.refresh(); // Refresh to show updated state + } else { + toast.error(result.error || 'Failed to update todos'); + } + } catch (error) { + toast.error('Failed to update todos'); + } setLoading(false); setBulkMode(false); setDirty([]); setDeleted([]); - if (res) { - if (res.error) { - toast.error(res.error); - } else if (res.success) { - toast.success('Bulk update completed!'); - } - } }; return ( @@ -111,7 +131,7 @@ export function Todos({ todos }: { todos: Todo[] }) { deleted.findIndex((t) => t === todo.id) > -1, })} > - {todo.todo} + {todo.content} {bulkMode && (