diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index c3f4448..3533b89 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -1,9 +1,15 @@ "use server"; import { userService } from "@/services/user.service"; +import * as Sentry from "@sentry/nextjs"; import { Provider } from "@supabase/supabase-js"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +export interface LoginActionState { + message: null | string; + redirect?: string; +} + /** * Logs out the current user. */ @@ -18,39 +24,36 @@ export async function logoutAction() { * Logs in the user using the specified provider. * @param currentState The current state of the form. * @param formData The form data containing the provider. + * @returns The result of the login action if it failed. Otherwise, the user is redirected. */ export async function loginAction( - currentState: null | void, + currentState: LoginActionState | null, formData: FormData, -) { +): Promise { const provider = formData.get("provider") as Provider; // For safety, we only allow supported providers. if (!["discord"].includes(provider)) { - console.error(`Unsupported provider: ${provider}`); - redirect("/error"); + Sentry.captureException( + new Error(`Unsupported login provider: ${provider}`), + ); + + return { message: "Unsupported login provider." }; + } + + const { data, error } = await userService.loginWithProvider(provider); + + if (error) { + Sentry.captureException(error); + + return { message: "An error occurred while logging in." }; } - let data: { - provider: Provider; - url: string; - } | null = null; - - try { - data = await userService.loginWithProvider(provider); - - revalidatePath("/", "layout"); - if (data.url) { - revalidatePath(data.url, "layout"); - } - } catch (error) { - console.error(`loginWithProvider error`, error); - redirect("/error"); - } finally { - if (!data) { - redirect("/"); - } - - redirect(data.url); + revalidatePath("/", "layout"); + + if (data.url) { + revalidatePath(data.url, "layout"); } + + return { message: null, redirect: data.url ?? "/" }; } diff --git a/src/app/actions/movie.ts b/src/app/actions/movie.ts index 419018d..1c0109a 100644 --- a/src/app/actions/movie.ts +++ b/src/app/actions/movie.ts @@ -14,17 +14,23 @@ import { userService } from "@/services/user.service"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +export interface DeleteMovieRoleState { + message: string; + success?: boolean; +} + /** * Delete a role from a movie. * @param previousState - Unused. * @param formData - Form data containing the movie ID and role ID. + * @returns An object containing a message in case of an error. */ export async function deleteMovieRoleAction( - previousState: null | void, + previousState: DeleteMovieRoleState | null, formData: FormData, -) { +): Promise { if (!formData.get("movie_id")) { - throw new Error("No movie ID provided"); + return { message: "No movie ID provided" }; } const movie_id = Number(formData.get("movie_id")); @@ -33,115 +39,153 @@ export async function deleteMovieRoleAction( const isLoggedIn = await userService.refreshUser(); if (!isLoggedIn) { - throw new Error("User not authenticated"); + return { message: "User not authenticated" }; } const movie = await movieService.getMovie(movie_id); if (!movie) { - throw new Error("Movie not found"); + return { message: "Movie not found" }; } - await movieService.deleteMovieRole(movie_id, role_id); + const { error, message } = await movieService.deleteMovieRole( + movie_id, + role_id, + ); + + if (error) { + return { message }; + } // Revalidate the movie page in case the roles were updated revalidatePath(`/movie/${movie_id}`, "page"); revalidatePath(`/movie/${movie_id}/edit/cast`, "page"); - redirect(`/movie/${movie_id}/edit/cast`); + + return { message: "Role deleted", success: true }; +} + +export interface AddMovieRoleState { + message: string; + success?: boolean; } /** * Add a role to a movie. * @param movie_id - The ID of the movie to add the role to. * @param formData - Form data containing the person ID. + * @returns An object containing a message in case of an error. */ export async function addMovieRoleAction( movie_id: number, formData: MovieRoleAddFormSchema, -) { +): Promise { if (!movie_id) { - throw new Error("No movie ID provided"); + return { message: "No movie ID provided" }; } const isLoggedIn = await userService.refreshUser(); if (!isLoggedIn) { - throw new Error("User not authenticated"); + return { message: "User not authenticated" }; } - try { - const movie = await movieService.getMovie(movie_id); - if (!movie) { - throw new Error("Movie not found"); - } - } catch (error) { - console.error(error); - throw new Error("Error fetching movie"); + const movie = await movieService.getMovie(movie_id); + if (!movie) { + return { message: "Movie not found" }; } - await movieService.addMovieRole(movie_id, formData.person_id); + const result = await movieService.addMovieRole(movie_id, formData.person_id); + + if (result?.error) { + return { message: result.message }; + } // Revalidate the movie page in case the roles were updated revalidatePath(`/movie/${movie_id}`, "page"); revalidatePath(`/movie/${movie_id}/edit/cast`, "page"); - redirect(`/movie/${movie_id}/edit/cast`); + + return { message: "Role added", success: true }; +} + +export interface UpdateMovieRoleState { + message: string; + success?: boolean; } /** * Update a movie in the database. * @param formData - Form data containing the movie details. + * @returns An object containing a message in case of an error. */ export async function updateMovieAction( formData: MovieEditFormSchema, -) { +): Promise { if (!formData.id) { - throw new Error("No movie ID provided"); + return { message: "No movie ID provided" }; } const isLoggedIn = await userService.refreshUser(); if (!isLoggedIn) { - throw new Error("User not authenticated"); + return { message: "User not authenticated" }; } const movie = await movieService.getMovie(formData.id); if (!movie) { - throw new Error("Movie not found"); + return { message: "Movie not found" }; } - await movieService.updateMovie(fromMovieEditForm(formData)); + const { error, message } = await movieService.updateMovie( + fromMovieEditForm(formData), + ); + + if (error) { + return { message }; + } // Revalidate the homepage in case the movie updated was on the homepage revalidatePath("/", "page"); - redirect(`/movie/${movie.id}`); + + return { message: "Movie updated", success: true }; +} + +export interface DeleteMovieState { + message: string; + success?: boolean; } /** * Delete a movie from the database. * @param previousState - Unused. * @param formData - Form data containing the movie ID. + * @returns An object containing a message in case of an error. */ export async function deleteMovieAction( - previousState: null | void, + previousState: DeleteMovieState | null, formData: FormData, -) { +): Promise { const id = Number(formData.get("item_id")); if (!id) { - throw new Error("No movie ID provided"); + return { message: "No movie ID provided" }; } const isLoggedIn = await userService.refreshUser(); if (!isLoggedIn) { - throw new Error("User not authenticated"); + return { message: "User not authenticated" }; } // Delete the movie from the database - await movieService.deleteMovie(id); + const { error, message } = await movieService.deleteMovie(id); + + if (error) { + return { message }; + } // Revalidate the homepage in case the movie deleted was on the homepage revalidatePath("/", "page"); - redirect("/"); + + return { message: "Movie deleted", success: true }; } /** diff --git a/src/app/actions/person.ts b/src/app/actions/person.ts index e758e72..99c837b 100644 --- a/src/app/actions/person.ts +++ b/src/app/actions/person.ts @@ -40,30 +40,37 @@ export async function updatePersonAction( redirect(`/person/${person.id}`); } +export interface DeletePersonState { + message: string; + success?: boolean; +} + /** * Delete a person from the database. * @param previousState - Unused. * @param formData - Form data containing the person ID. + * @returns An object containing a message in case of an error. */ export async function deletePersonAction( - previousState: null | void, + previousState: DeletePersonState | null, formData: FormData, -) { +): Promise { const id = Number(formData.get("item_id")); if (!id) { - throw new Error("No person ID provided"); + return { message: "No person ID provided" }; } const isLoggedIn = await userService.refreshUser(); if (!isLoggedIn) { - throw new Error("User not authenticated"); + return { message: "User not authenticated" }; } await personService.deletePerson(id); // Revalidate the homepage in case the movie deleted was on the homepage revalidatePath("/", "page"); - redirect("/"); + + return { message: "Person deleted", success: true }; } diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 6f5e5ac..c4247b0 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -18,6 +18,8 @@ export default function GlobalError({ useEffect(() => { // If we are in development mode, don't send the error to Sentry. if (process.env.NODE_ENV === 'development') { + console.error(error); + return; } diff --git a/src/components/button-delete-item.tsx b/src/components/button-delete-item.tsx index 98cffae..dd11314 100644 --- a/src/components/button-delete-item.tsx +++ b/src/components/button-delete-item.tsx @@ -18,7 +18,10 @@ import { MovieDto } from '@/data/movie.dto'; import { PersonDto } from '@/data/person.dto'; import { SeriesDto } from '@/data/series.dto'; import { StudioDto } from '@/data/studio.dto'; -import { useActionState, useState } from 'react'; +import { redirect } from 'next/navigation'; +import { useActionState, useEffect, useState } from 'react'; + +import { useToast } from './ui/use-toast'; /** * Button to delete a movie. @@ -32,10 +35,24 @@ export default function ButtonDeleteItem({ item: LabelDto | MovieDto | PersonDto | SeriesDto | StudioDto; }>) { const [isOpen, setIsOpen] = useState(false); + const { toast } = useToast(); const action = isMovie(item) ? deleteMovieAction : deletePersonAction; - const [, deleteAction, isDeletePending] = useActionState(action, null); + const [state, deleteAction, isDeletePending] = useActionState(action, null); + + useEffect(() => { + toast({ + description: state?.message, + title: state?.success ? 'Success' : 'Error', + variant: state?.success ? 'success' : 'destructive', + }); + + if (state?.success) { + redirect('/'); + } + }, [state, toast]); + // TODO: Deleting labels, series, and studios is not supported yet. if (['label', 'series', 'studio'].includes(item?._type)) { return null; } diff --git a/src/components/button-delete-role.tsx b/src/components/button-delete-role.tsx index a7d706c..31fbff6 100644 --- a/src/components/button-delete-role.tsx +++ b/src/components/button-delete-role.tsx @@ -14,7 +14,10 @@ import { import { MovieDto } from '@/data/movie.dto'; import { RoleDto } from '@/data/role.dto'; import IconTrash from '~icons/mdi/trash-can-outline.jsx'; -import { useActionState, useState } from 'react'; +import { redirect } from 'next/navigation'; +import { useActionState, useEffect, useState } from 'react'; + +import { useToast } from './ui/use-toast'; /** * Button to delete a role. @@ -28,12 +31,27 @@ export default function ButtonDeleteRole({ role, }: Readonly<{ movie: MovieDto; role: RoleDto }>) { const [isOpen, setIsOpen] = useState(false); + const { toast } = useToast(); - const [, deleteAction, isDeletePending] = useActionState( + const [state, deleteAction, isDeletePending] = useActionState( deleteMovieRoleAction, null, ); + useEffect(() => { + if (state?.success) { + redirect(`/movie/${movie.id}/edit/cast`); + } + + if (state?.message) { + toast({ + description: state.message, + title: state.success ? 'Success' : 'Error', + variant: state.success ? 'success' : 'destructive', + }); + } + }, [state, movie, toast]); + return ( diff --git a/src/components/form-cast-edit.tsx b/src/components/form-cast-edit.tsx index 98650e2..68b4b42 100644 --- a/src/components/form-cast-edit.tsx +++ b/src/components/form-cast-edit.tsx @@ -13,10 +13,13 @@ import { import { MovieRoleAddFormSchema } from '@/core/utils/validation/movie-update'; import { MovieDto } from '@/data/movie.dto'; import { personService } from '@/services/person.service'; +import { redirect } from 'next/navigation'; import { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { useAsync } from 'react-use'; +import { useToast } from './ui/use-toast'; + /** * Form to edit the cast of a movie. * @param properties - The properties to render the form. @@ -29,6 +32,7 @@ export function FormCastEdit({ movie: MovieDto; }>) { const [personSearchValue, setPersonSearchValue] = useState(''); + const { toast } = useToast(); const { loading: isPersonLoading, value: persons } = useAsync( async () => await personService.searchPersonByName(personSearchValue ?? ''), @@ -54,7 +58,17 @@ export function FormCastEdit({ }); } - await addMovieRoleAction(movie.id, data); + const { message, success } = await addMovieRoleAction(movie.id, data); + + toast({ + description: message, + title: success ? 'Success' : 'Error adding role', + variant: success ? 'success' : 'destructive', + }); + + if (success) { + redirect(`/movie/${movie.id}/edit/cast`); + } } }; diff --git a/src/components/form-movie-edit.tsx b/src/components/form-movie-edit.tsx index 8f33192..9325929 100644 --- a/src/components/form-movie-edit.tsx +++ b/src/components/form-movie-edit.tsx @@ -28,10 +28,13 @@ import { seriesService } from '@/services/series.service'; import { studioService } from '@/services/studio.service'; import { zodResolver } from '@hookform/resolvers/zod'; import { DateTime } from 'luxon'; +import { redirect } from 'next/navigation'; import { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { useAsync } from 'react-use'; +import { useToast } from './ui/use-toast'; + /** * Form to edit a movie. * @param properties - The properties to render the form. @@ -43,6 +46,8 @@ export function FormMovieEdit({ }: Readonly<{ movie: MovieDto; }>) { + const { toast } = useToast(); + const [studioSearchValue, setStudioSearchValue] = useState( movie?.studio?.display_name, ); @@ -86,7 +91,17 @@ export function FormMovieEdit({ }); const onSubmit: SubmitHandler = async (data) => { - await updateMovieAction(data); + const result = await updateMovieAction(data); + + toast({ + description: result.message, + title: result.success ? 'Success' : 'Error', + variant: result.success ? 'success' : 'destructive', + }); + + if (result.success) { + redirect(`/movie/${movie.id}`); + } }; return ( diff --git a/src/components/login-button.tsx b/src/components/login-button.tsx index 4d59ccf..c77e772 100644 --- a/src/components/login-button.tsx +++ b/src/components/login-button.tsx @@ -3,7 +3,9 @@ import type { Provider } from '@supabase/supabase-js'; import { loginAction } from '@/app/actions/auth'; import { Button } from '@/components/ui/button'; -import { useActionState } from 'react'; +import { useToast } from '@/components/ui/use-toast'; +import { redirect } from 'next/navigation'; +import { useActionState, useEffect } from 'react'; /** * A button that logs in the user using the specified provider. @@ -19,7 +21,23 @@ export default function LoginButton({ nextUrl?: string; provider: Provider; }) { - const [, action, isProcessing] = useActionState(loginAction, null); + const [state, action, isProcessing] = useActionState(loginAction, null); + const { toast } = useToast(); + + useEffect(() => { + if (state?.redirect) { + redirect(state.redirect); + } + + if (state?.message) { + toast({ + description: state.message, + title: 'Error', + variant: 'destructive', + }); + } + }, [state, toast]); + return (
diff --git a/src/infrastructure/database/repositories/movie.repository.ts b/src/infrastructure/database/repositories/movie.repository.ts index 67a5f03..8f7a4ee 100644 --- a/src/infrastructure/database/repositories/movie.repository.ts +++ b/src/infrastructure/database/repositories/movie.repository.ts @@ -131,12 +131,11 @@ export async function updateMovie( delete movie.series; delete movie.studio; - await client + return await client .from("movies") .update(movie) .eq("id", movie.id) - .single() - .throwOnError(); + .single(); } /** @@ -149,11 +148,10 @@ export async function updateMovie( export async function deleteMovie(movie_id: number) { const client = await createSupabaseClient(); - await client + return await client .from("movies") .delete() - .eq("id", movie_id) - .throwOnError(); + .eq("id", movie_id); } /** @@ -300,6 +298,7 @@ export async function addMovieRole( * Delete a role from a movie for a person. * @param movie_id ID of the movie to delete the role from. * @param role_id ID of the person to delete the role for. + * @returns The deleted role. */ export async function deleteMovieRole( movie_id: number, @@ -307,12 +306,11 @@ export async function deleteMovieRole( ) { const client = await createSupabaseClient(); - await client + return await client .from("roles") .delete() .eq("movie_id", movie_id) - .eq("id", role_id) - .throwOnError(); + .eq("id", role_id); } /** diff --git a/src/infrastructure/database/repositories/user.repository.ts b/src/infrastructure/database/repositories/user.repository.ts index 2cef8dd..aa0f596 100644 --- a/src/infrastructure/database/repositories/user.repository.ts +++ b/src/infrastructure/database/repositories/user.repository.ts @@ -78,18 +78,12 @@ export async function loginWithProvider(provider: Provider) { : "http://localhost:3000"; const client = await createSupabaseClient(); - const { data, error } = await client.auth.signInWithOAuth({ + return await client.auth.signInWithOAuth({ options: { redirectTo: `${defaultUrl}/auth/callback`, }, provider: provider, }); - - if (error) { - throw error; - } - - return data; } /** diff --git a/src/services/movie.service.ts b/src/services/movie.service.ts index d798a0c..7134a80 100644 --- a/src/services/movie.service.ts +++ b/src/services/movie.service.ts @@ -19,6 +19,7 @@ import { } from "@/infrastructure/database/repositories/movie.repository"; import { cloudflareService } from "./cloudflare.service"; +import { ServiceResponse } from "./types"; export const movieService = { async addMovieImage( @@ -74,18 +75,22 @@ export const movieService = { } }, async addMovieRole(movie_id: number, role_id: number) { - await addMovieRole(movie_id, role_id); + try { + await addMovieRole(movie_id, role_id); + } catch (error) { + return { error, message: "Error adding role" }; + } }, async createMovie(movie: MovieDto) { const createdMovie = await createMovie(fromBaseMovieDto(movie)); return toBaseMovieDto(createdMovie); }, - async deleteMovie(movie_id: number) { + async deleteMovie(movie_id: number): Promise { const movie = await this.getMovie(movie_id); if (!movie) { - throw new Error("Movie not found"); + return { message: "Movie not found" }; } try { @@ -100,10 +105,23 @@ export const movieService = { throw new Error("Error deleting images"); } - await deleteMovie(movie_id); + const { error } = await deleteMovie(movie_id); + + if (error) { + return { error, message: "Error deleting movie" }; + } + + return { message: "Movie deleted" }; }, - async deleteMovieRole(movie_id: number, role_id: number) { - await deleteMovieRole(movie_id, role_id); + async deleteMovieRole( + movie_id: number, + role_id: number, + ): Promise { + const { error } = await deleteMovieRole(movie_id, role_id); + + return error + ? { error, message: "Error deleting role" } + : { message: "Role deleted" }; }, async getMovie(movie_id: number): Promise { const movie = await getMovieById(movie_id); @@ -134,11 +152,17 @@ export const movieService = { return movies?.map((movie) => toMovieDto(movie)) ?? []; }, - async updateMovie(movie: MovieDto) { + async updateMovie( + movie: MovieDto, + ): Promise<{ movie?: MovieDto } & ServiceResponse> { if (!movie.id) { throw new Error("No movie ID provided"); } - await updateMovie(fromMovieDto(movie)); + const { data, error } = await updateMovie(fromMovieDto(movie)); + + return error + ? { error, message: "Error updating movie" } + : { message: "Movie updated", movie: toMovieDto(data) }; }, }; diff --git a/src/services/person.service.ts b/src/services/person.service.ts index 5f7e908..b0ad0e8 100644 --- a/src/services/person.service.ts +++ b/src/services/person.service.ts @@ -2,7 +2,7 @@ import { fromBasePersonDto } from "@/data/base.dto"; /** * Service to handle movie related operations. */ -import { PersonDto, toPersonDto } from "@/data/person.dto"; +import { toPersonDto } from "@/data/person.dto"; import { addImage } from "@/infrastructure/database/repositories/image.repository"; import { addPersonImage, @@ -16,6 +16,7 @@ import { } from "@/infrastructure/database/repositories/person.repository"; import { cloudflareService } from "./cloudflare.service"; +import { ServiceResponse } from "./types"; export const personService = { async addPersonImage(image: File, person_id: number, type: "profile") { @@ -51,11 +52,11 @@ export const personService = { await updatePerson(updatedPerson); } }, - async deletePerson(person_id: number) { + async deletePerson(person_id: number): Promise { const person = await this.getPerson(person_id); if (!person) { - throw new Error("Person not found"); + return { error: true, message: "Person not found" }; } try { @@ -71,6 +72,8 @@ export const personService = { } await deletePerson(person_id); + + return { message: "Person deleted" }; }, async getPaginatedPersons(page = 1, perPage = 25, options?: { orderBy?: string; @@ -82,9 +85,18 @@ export const personService = { return persons?.map((person) => toPersonDto(person)) ?? []; }, async getPerson(person_id: number) { - const person = await getPersonById(person_id); - - return toPersonDto(person); + try { + const person = await getPersonById(person_id); + + return { + person: toPersonDto(person), + }; + } catch (error) { + return { + error, + message: "Person not found", + }; + } }, async getPersonMoviesCount(person_id: number) { const count = await getPersonRolesCount(person_id); diff --git a/src/services/types.ts b/src/services/types.ts new file mode 100644 index 0000000..422dd4d --- /dev/null +++ b/src/services/types.ts @@ -0,0 +1,4 @@ +export interface ServiceResponse { + error?: Error; + message: string; +} diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 4ee0f21..3faf498 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -10,7 +10,7 @@ import { logoutUser, refreshUser, } from "@/infrastructure/database/repositories/user.repository"; -import { Provider, User } from "@supabase/supabase-js"; +import { OAuthResponse, Provider, User } from "@supabase/supabase-js"; export const userService = { async exchangeCodeForSession(code: string): Promise { @@ -41,10 +41,7 @@ export const userService = { return toUserDto(user); }, - async loginWithProvider(provider: Provider): Promise<{ - provider: Provider; - url: string; - }> { + async loginWithProvider(provider: Provider): Promise { return await loginWithProvider(provider); }, async logout(): Promise {