Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better error handling & reporting #111

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 28 additions & 25 deletions src/app/actions/auth.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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<LoginActionState> {
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 ?? "/" };
}
108 changes: 76 additions & 32 deletions src/app/actions/movie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeleteMovieRoleState> {
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"));
Expand All @@ -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<AddMovieRoleState> {
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<UpdateMovieRoleState> {
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<DeleteMovieState> {
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 };
}

/**
Expand Down
17 changes: 12 additions & 5 deletions src/app/actions/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeletePersonState> {
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 };
}
2 changes: 2 additions & 0 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
21 changes: 19 additions & 2 deletions src/components/button-delete-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand Down
Loading