diff --git a/config/eslint/next.js b/config/eslint/next.js index 8d61b8a5da..d26374ed99 100644 --- a/config/eslint/next.js +++ b/config/eslint/next.js @@ -6,6 +6,11 @@ const config = { "@typescript-eslint/require-await": "off", "no-restricted-syntax": [ "error", + { + selector: + "CallExpression[callee.name='defineServerAction'] > :nth-child(1):not(FunctionExpression[id.name][async=true])", + message: "You can only pass named, async functions into defineServerAction", + }, { selector: "TryStatement > .block CallExpression[callee.name='redirect']", message: diff --git a/core/app/c/[communitySlug]/members/[[...add]]/MemberInviteForm.tsx b/core/app/c/[communitySlug]/members/[[...add]]/MemberInviteForm.tsx index 27aa29ae9c..219da6270f 100644 --- a/core/app/c/[communitySlug]/members/[[...add]]/MemberInviteForm.tsx +++ b/core/app/c/[communitySlug]/members/[[...add]]/MemberInviteForm.tsx @@ -25,6 +25,7 @@ import { Loader2, Mail, UserPlus } from "ui/icon"; import { Input } from "ui/input"; import { toast } from "ui/use-toast"; +import { didSucceed, useServerAction } from "~/lib/serverActions"; import * as actions from "./actions"; import { MemberFormState } from "./AddMember"; import { memberInviteFormSchema } from "./memberInviteFormSchema"; @@ -38,6 +39,8 @@ export const MemberInviteForm = ({ state: MemberFormState; email?: string; }) => { + const runCreateUserWithMembership = useServerAction(actions.createUserWithMembership); + const runAddMember = useServerAction(actions.addMember); const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -71,7 +74,7 @@ export const MemberInviteForm = ({ return; } - const { error } = await actions.createUserWithMembership({ + const result = await runCreateUserWithMembership({ email: data.email, firstName: data.firstName!, lastName: data.lastName!, @@ -79,46 +82,32 @@ export const MemberInviteForm = ({ canAdmin: Boolean(data.canAdmin), }); - if (error) { + if (didSucceed(result)) { toast({ - title: "Error", - description: error, - variant: "destructive", + title: "Success", + description: "User successfully invited", }); - return; + closeForm(); } - toast({ - title: "Success", - description: "User successfully invited", - }); - closeForm(); - return; } - const result = await actions.addMember({ + const result = await runAddMember({ user: state.user, canAdmin: data.canAdmin, community, }); - if ("error" in result) { + if (didSucceed(result)) { toast({ - title: "Error", - description: `Failed to add member. ${result.error}`, - variant: "destructive", + title: "Success", + description: "Member added successfully", }); - return; - } - - toast({ - title: "Success", - description: "Member added successfully", - }); - // navigate away from the add page to the normal member page - closeForm(); + // navigate away from the add page to the normal member page + closeForm(); + } } const debouncedEmailCheck = useDebouncedCallback(async (email: string) => { diff --git a/core/app/c/[communitySlug]/members/[[...add]]/RemoveMemberButton.tsx b/core/app/c/[communitySlug]/members/[[...add]]/RemoveMemberButton.tsx index a8701f98bb..01abb52747 100644 --- a/core/app/c/[communitySlug]/members/[[...add]]/RemoveMemberButton.tsx +++ b/core/app/c/[communitySlug]/members/[[...add]]/RemoveMemberButton.tsx @@ -1,6 +1,6 @@ "use client"; -import type { Community, Member, User } from "@prisma/client"; +import type { Community } from "@prisma/client"; import { AlertDialog, @@ -16,6 +16,7 @@ import { Trash } from "ui/icon"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "ui/tooltip"; import { toast } from "ui/use-toast"; +import { didSucceed, useServerAction } from "~/lib/serverActions"; import * as actions from "./actions"; import { TableMember } from "./getMemberTableColumns"; @@ -26,6 +27,7 @@ export const RemoveMemberButton = ({ member: TableMember; community: Community; }) => { + const runRemoveMember = useServerAction(actions.removeMember); return ( @@ -56,20 +58,14 @@ export const RemoveMemberButton = ({ + {url &&

URL: {url}

} + + ); +} +``` diff --git a/core/lib/serverActions.ts b/core/lib/serverActions.ts new file mode 100644 index 0000000000..4bdd9ca8f6 --- /dev/null +++ b/core/lib/serverActions.ts @@ -0,0 +1,57 @@ +import { useCallback } from "react"; +import { captureException } from "@sentry/nextjs"; + +import { toast } from "ui/use-toast"; + +export type ClientException = { + isClientException: true; + error: string; + title?: string; + id?: string; +}; + +type ClientExceptionOptions = Omit & { cause?: unknown }; + +export function makeClientException(options: ClientExceptionOptions): ClientException; +export function makeClientException(message: string, id?: string): ClientException; +export function makeClientException( + message: string | ClientExceptionOptions, + id?: string +): ClientException { + if (typeof message === "object") { + if ("cause" in message) { + const { cause, ...messageWithoutCause } = message; + const id = captureException(cause); + return { ...messageWithoutCause, isClientException: true, id }; + } + return { ...message, isClientException: true }; + } + return { isClientException: true, error: message, id }; +} + +export const isClientException = (error: unknown): error is ClientException => + typeof error === "object" && error !== null && "isClientException" in error; + +export const isClientExceptionOptions = (error: unknown): error is ClientExceptionOptions => + typeof error === "object" && error !== null && "error" in error; + +export function useServerAction(action: (...args: T) => Promise) { + const runServerAction = useCallback( + async function runServerAction(...args: T) { + const result = await action(...args); + if (isClientException(result)) { + toast({ + title: result.title ?? "Error", + variant: "destructive", + description: `${result.error}${result.id ? ` (Error ID: ${result.id})` : ""}`, + }); + } + return result; + }, + [action, toast] + ); + return runServerAction; +} + +export const didSucceed = (result: T): result is Exclude => + !isClientException(result); diff --git a/core/package.json b/core/package.json index 59aa710528..e05d8e7aef 100644 --- a/core/package.json +++ b/core/package.json @@ -39,7 +39,7 @@ "@hookform/resolvers": "^3.3.1", "@opentelemetry/auto-instrumentations-node": "^0.41.1", "@prisma/client": "^5.2.0", - "@sentry/nextjs": "^7.102.0", + "@sentry/nextjs": "^7.106.1", "@stoplight/elements": "^7.12.2", "@supabase/supabase-js": "^2.33.2", "@t3-oss/env-nextjs": "^0.9.2", diff --git a/core/sentry.client.config.ts b/core/sentry.client.config.ts index 3c0a6fd73b..423ce310a3 100644 --- a/core/sentry.client.config.ts +++ b/core/sentry.client.config.ts @@ -24,7 +24,7 @@ if (env.NODE_ENV === "production") { // You can remove this option if you're not planning to use the Sentry Session Replay feature: integrations: [ - new Sentry.Replay({ + Sentry.replayIntegration({ // Additional Replay configuration goes in here, for example: maskAllText: true, blockAllMedia: true, diff --git a/integrations/evaluations/app/actions/evaluate/actions.ts b/integrations/evaluations/app/actions/evaluate/actions.ts index c95c90dd18..c57ae733c9 100644 --- a/integrations/evaluations/app/actions/evaluate/actions.ts +++ b/integrations/evaluations/app/actions/evaluate/actions.ts @@ -1,6 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { captureException, withServerActionInstrumentation } from "@sentry/nextjs"; import { PubValues } from "@pubpub/sdk"; import { expect } from "utils"; @@ -16,57 +18,75 @@ import { cookie } from "~/lib/request"; import { assertHasAccepted } from "~/lib/types"; export const submit = async (instanceId: string, submissionPubId: string, values: PubValues) => { - try { - const submissionPub = await client.getPub(instanceId, submissionPubId); - const user = JSON.parse(expect(cookie("user"))); - const instanceConfig = expect( - await getInstanceConfig(instanceId), - "Instance not configured" - ); - const instanceState = (await getInstanceState(instanceId, submissionPubId)) ?? {}; - if (instanceConfig === undefined) { - return { error: "Instance not configured" }; + return withServerActionInstrumentation( + "evaluate/submit", + { + headers: headers(), + }, + async () => { + try { + const submissionPub = await client.getPub(instanceId, submissionPubId); + const user = JSON.parse(expect(cookie("user"))); + const instanceConfig = expect( + await getInstanceConfig(instanceId), + "Instance not configured" + ); + const instanceState = (await getInstanceState(instanceId, submissionPubId)) ?? {}; + if (instanceConfig === undefined) { + return { error: "Instance not configured" }; + } + let evaluator = expect( + instanceState[user.id], + `User was not invited to evaluate pub ${submissionPubId}` + ); + assertHasAccepted(evaluator); + const evaluationPub = await client.createPub(instanceId, { + pubTypeId: instanceConfig.pubTypeId, + parentId: submissionPubId, + values: values, + }); + evaluator = instanceState[user.id] = { + ...evaluator, + status: "received", + evaluatedAt: new Date().toString(), + evaluationPubId: evaluationPub.id, + }; + await setInstanceState(instanceId, submissionPubId, instanceState); + // Unschedule no-submit notification email for manager. + await unscheduleNoSubmitNotificationEmail(instanceId, submissionPubId, evaluator); + // unschedule dealine reminder emails. + await unscheduleAllDeadlineReminderEmails(instanceId, submissionPubId, evaluator); + // Immediately send submitted notification email. + await sendSubmittedNotificationEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator, + submissionPub.assignee + ); + revalidatePath("/"); + return { success: true }; + } catch (error) { + captureException(error); + return { error: error.message }; + } } - let evaluator = expect( - instanceState[user.id], - `User was not invited to evaluate pub ${submissionPubId}` - ); - assertHasAccepted(evaluator); - const evaluationPub = await client.createPub(instanceId, { - pubTypeId: instanceConfig.pubTypeId, - parentId: submissionPubId, - values: values, - }); - evaluator = instanceState[user.id] = { - ...evaluator, - status: "received", - evaluatedAt: new Date().toString(), - evaluationPubId: evaluationPub.id, - }; - await setInstanceState(instanceId, submissionPubId, instanceState); - // Unschedule no-submit notification email for manager. - await unscheduleNoSubmitNotificationEmail(instanceId, submissionPubId, evaluator); - // unschedule dealine reminder emails. - await unscheduleAllDeadlineReminderEmails(instanceId, submissionPubId, evaluator); - // Immediately send submitted notification email. - await sendSubmittedNotificationEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator, - submissionPub.assignee - ); - revalidatePath("/"); - return { success: true }; - } catch (error) { - return { error: error.message }; - } + ); }; export const upload = async (instanceId: string, pubId: string, fileName: string) => { - try { - return await client.generateSignedAssetUploadUrl(instanceId, pubId, fileName); - } catch (error) { - return { error: error.message }; - } + return withServerActionInstrumentation( + "evaluate/upload", + { + headers: headers(), + }, + async () => { + try { + return await client.generateSignedAssetUploadUrl(instanceId, pubId, fileName); + } catch (error) { + captureException(error); + return { error: error.message }; + } + } + ); }; diff --git a/integrations/evaluations/app/actions/manage/actions.ts b/integrations/evaluations/app/actions/manage/actions.ts index a1d1a5c74b..68d3204758 100644 --- a/integrations/evaluations/app/actions/manage/actions.ts +++ b/integrations/evaluations/app/actions/manage/actions.ts @@ -1,6 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { captureException, withServerActionInstrumentation } from "@sentry/nextjs"; import { SuggestedMembersQuery } from "@pubpub/sdk"; import { expect } from "utils"; @@ -30,129 +32,156 @@ export const save = async ( evaluators: InviteFormEvaluator[], send: boolean ) => { - try { - const submissionPub = await client.getPub(instanceId, submissionPubId); - const user = JSON.parse(expect(cookie("user"))); - const instanceConfig = expect( - await getInstanceConfig(instanceId), - "Instance not configured" - ); - const instanceState = (await getInstanceState(instanceId, submissionPubId)) ?? {}; - const evaluatorUserIds = new Set(); - for (let i = 0; i < evaluators.length; i++) { - let evaluator = evaluators[i]; - // Invited evaluators are read-only and already persisted, so we can skip - // them. - if (isInvited(evaluator)) { - // We still need to add the userId to the set so we can check for - // duplicates. We don't need to check, however, because the invite - // should already be unique. - evaluatorUserIds.add(evaluator.userId); - continue; - } - // Find or create a PubPub user for the evaluator. - const evaluatorUser = await client.getOrCreateUser(instanceId, evaluator); - // If the user has already been saved or invited, halt and notify the - // user. - if (evaluatorUserIds.has(evaluatorUser.id)) { - const { firstName, lastName } = evaluator; - const name = `${firstName}${lastName ? ` ${lastName}` : ""}`; - return { - error: `${name} was added more than once.`, - }; - } - // Update the evaluator to reflect that they have been persisted. - evaluator = { - ...evaluator, - userId: evaluatorUser.id, - firstName: evaluatorUser.firstName, - lastName: evaluatorUser.lastName ?? undefined, - status: "saved", - }; - // If the user intends to invite selected evaluators, send an email to - // the evaluator with the invite link. - if (send && evaluator.selected) { - // Update the evaluator to reflect that they have been invited. - evaluator = { - ...evaluator, - status: "invited", - invitedAt: new Date().toString(), - invitedBy: user.id, - }; - // Immediately send the invite email. - await sendInviteEmail(instanceId, submissionPubId, evaluator); - // Scehdule a reminder email to person who was invited to evaluate. - await scheduleInvitationReminderEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator - ); - // Schedule no-reply notification email to person who invited the - // evaluator. - await scheduleNoReplyNotificationEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator, - submissionPub.assignee + return withServerActionInstrumentation( + "manage/save", + { + headers: headers(), + }, + async () => { + try { + const submissionPub = await client.getPub(instanceId, submissionPubId); + const user = JSON.parse(expect(cookie("user"))); + const instanceConfig = expect( + await getInstanceConfig(instanceId), + "Instance not configured" ); + const instanceState = (await getInstanceState(instanceId, submissionPubId)) ?? {}; + const evaluatorUserIds = new Set(); + for (let i = 0; i < evaluators.length; i++) { + let evaluator = evaluators[i]; + // Invited evaluators are read-only and already persisted, so we can skip + // them. + if (isInvited(evaluator)) { + // We still need to add the userId to the set so we can check for + // duplicates. We don't need to check, however, because the invite + // should already be unique. + evaluatorUserIds.add(evaluator.userId); + continue; + } + // Find or create a PubPub user for the evaluator. + const evaluatorUser = await client.getOrCreateUser(instanceId, evaluator); + // If the user has already been saved or invited, halt and notify the + // user. + if (evaluatorUserIds.has(evaluatorUser.id)) { + const { firstName, lastName } = evaluator; + const name = `${firstName}${lastName ? ` ${lastName}` : ""}`; + return { + error: `${name} was added more than once.`, + }; + } + // Update the evaluator to reflect that they have been persisted. + evaluator = { + ...evaluator, + userId: evaluatorUser.id, + firstName: evaluatorUser.firstName, + lastName: evaluatorUser.lastName ?? undefined, + status: "saved", + }; + // If the user intends to invite selected evaluators, send an email to + // the evaluator with the invite link. + if (send && evaluator.selected) { + // Update the evaluator to reflect that they have been invited. + evaluator = { + ...evaluator, + status: "invited", + invitedAt: new Date().toString(), + invitedBy: user.id, + }; + // Immediately send the invite email. + await sendInviteEmail(instanceId, submissionPubId, evaluator); + // Scehdule a reminder email to person who was invited to evaluate. + await scheduleInvitationReminderEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator + ); + // Schedule no-reply notification email to person who invited the + // evaluator. + await scheduleNoReplyNotificationEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator, + submissionPub.assignee + ); + } + // Remove the form's selected property from the evaluator before + // persisting. + const { selected, ...rest } = evaluator; + instanceState[evaluator.userId] = rest; + // Add the user id to the set so we can check for duplicates. + evaluatorUserIds.add(evaluatorUser.id); + } + // Persist the updated instance state. + await setInstanceState(instanceId, submissionPubId, instanceState); + // Reload the page to reflect the changes. + revalidatePath("/"); + return { success: true }; + } catch (error) { + captureException(error); + return { error: error.message }; } - // Remove the form's selected property from the evaluator before - // persisting. - const { selected, ...rest } = evaluator; - instanceState[evaluator.userId] = rest; - // Add the user id to the set so we can check for duplicates. - evaluatorUserIds.add(evaluatorUser.id); } - // Persist the updated instance state. - await setInstanceState(instanceId, submissionPubId, instanceState); - // Reload the page to reflect the changes. - revalidatePath("/"); - return { success: true }; - } catch (error) { - return { error: error.message }; - } + ); }; export const suggest = async (instanceId: string, query: SuggestedMembersQuery) => { - try { - const users = await client.getSuggestedMembers(instanceId, query); - return users; - } catch (error) { - return { error: error.message }; - } + return withServerActionInstrumentation( + "manage/suggest", + { + headers: headers(), + }, + async () => { + try { + const users = await client.getSuggestedMembers(instanceId, query); + return users; + } catch (error) { + captureException(error); + return { error: error.message }; + } + } + ); }; export const remove = async (instanceId: string, pubId: string, userId: string) => { - try { - const instanceConfig = expect( - await getInstanceConfig(instanceId), - "Instance not configured" - ); - const instanceState = await getInstanceState(instanceId, pubId); - const pub = await client.getPub(instanceId, pubId); - const evaluation = pub.children.find( - (child) => child.values[instanceConfig.evaluatorFieldSlug] === userId - ); + return withServerActionInstrumentation( + "manage/remove", + { + headers: headers(), + }, + async () => { + try { + const instanceConfig = expect( + await getInstanceConfig(instanceId), + "Instance not configured" + ); + const instanceState = await getInstanceState(instanceId, pubId); + const pub = await client.getPub(instanceId, pubId); + const evaluation = pub.children.find( + (child) => child.values[instanceConfig.evaluatorFieldSlug] === userId + ); - if (evaluation !== undefined) { - await client.deletePub(instanceId, evaluation.id); - } - if (instanceState !== undefined) { - let evaluator = expect( - instanceState[userId], - `User was not invited to evaluate pub ${pubId}` - ); - assertHasAccepted(evaluator); - await unscheduleAllDeadlineReminderEmails(instanceId, pubId, evaluator); - await unscheduleAllManagerEmails(instanceId, pubId, evaluator); - delete instanceState[userId]; - await setInstanceState(instanceId, pubId, instanceState); - } + if (evaluation !== undefined) { + await client.deletePub(instanceId, evaluation.id); + } + if (instanceState !== undefined) { + let evaluator = expect( + instanceState[userId], + `User was not invited to evaluate pub ${pubId}` + ); + assertHasAccepted(evaluator); + await unscheduleAllDeadlineReminderEmails(instanceId, pubId, evaluator); + await unscheduleAllManagerEmails(instanceId, pubId, evaluator); + delete instanceState[userId]; + await setInstanceState(instanceId, pubId, instanceState); + } - return { success: true }; - } catch (error) { - return { error: error.message }; - } + return { success: true }; + } catch (error) { + captureException(error); + return { error: error.message }; + } + } + ); }; diff --git a/integrations/evaluations/app/actions/respond/actions.ts b/integrations/evaluations/app/actions/respond/actions.ts index e4668d8218..d59cafe129 100644 --- a/integrations/evaluations/app/actions/respond/actions.ts +++ b/integrations/evaluations/app/actions/respond/actions.ts @@ -1,6 +1,9 @@ "use server"; +import { isRedirectError } from "next/dist/client/components/redirect"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { captureException, withServerActionInstrumentation } from "@sentry/nextjs"; import { expect } from "utils"; @@ -32,141 +35,180 @@ export const accept = async ( instanceId: string, submissionPubId: string ): Promise => { - const redirectParams = `?token=${cookie( - "token" - )}&instanceId=${instanceId}&pubId=${submissionPubId}`; - try { - const submissionPub = await client.getPub(instanceId, submissionPubId); - const user = JSON.parse(expect(cookie("user"))); - const instanceConfig = expect( - await getInstanceConfig(instanceId), - "Instance not configured" - ); - const instanceState = (await getInstanceState(instanceId, submissionPubId)) ?? {}; - let evaluator = expect( - instanceState[user.id], - `User was not invited to evaluate pub ${submissionPubId}` - ); - // Accepting again is a no-op. - if (evaluator.status === "accepted" || evaluator.status === "received") { - redirect(`/actions/respond/accepted${redirectParams}`); - } - // Assert the user is invited to evaluate this pub. - assertIsInvited(evaluator); - // Update the evaluator's status to accepted and add recored the time of - // acceptance. - evaluator = instanceState[user.id] = { - ...evaluator, - status: "accepted", - acceptedAt: new Date().toString(), - }; - const deadline = calculateDeadline(instanceConfig, new Date(evaluator.acceptedAt)); - evaluator.deadline = deadline; - await setInstanceState(instanceId, submissionPubId, instanceState); - // Unschedule reminder email to evaluator. - await unscheduleInvitationReminderEmail(instanceId, submissionPubId, evaluator); - // Unschedule no-reply notification email to community manager. - await unscheduleNoReplyNotificationEmail(instanceId, submissionPubId, evaluator); - // Immediately send accepted notification email to community manager. - await sendAcceptedNotificationEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator, - submissionPub.assignee - ); - // Immediately send accepted email to evaluator. - await sendAcceptedEmail(instanceId, instanceConfig, submissionPubId, evaluator); - // Schedule no-submit notification email to community manager. - await scheduleNoSubmitNotificationEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator, - submissionPub.assignee - ); + return withServerActionInstrumentation( + "respond/accept", + { + headers: headers(), + }, + async () => { + try { + const submissionPub = await client.getPub(instanceId, submissionPubId); + const user = JSON.parse(expect(cookie("user"))); + const instanceConfig = expect( + await getInstanceConfig(instanceId), + "Instance not configured" + ); + const instanceState = (await getInstanceState(instanceId, submissionPubId)) ?? {}; + let evaluator = expect( + instanceState[user.id], + `User was not invited to evaluate pub ${submissionPubId}` + ); + const hasAlreadyAccepted = + evaluator.status === "accepted" || evaluator.status === "received"; + // Accepting more than one time is a no-op. + if (!hasAlreadyAccepted) { + // Assert the user is invited to evaluate this pub. + assertIsInvited(evaluator); + // Update the evaluator's status to accepted and add recored the time of + // acceptance. + evaluator = instanceState[user.id] = { + ...evaluator, + status: "accepted", + acceptedAt: new Date().toString(), + }; + const deadline = calculateDeadline( + instanceConfig, + new Date(evaluator.acceptedAt) + ); + evaluator.deadline = deadline; + await setInstanceState(instanceId, submissionPubId, instanceState); + // Unschedule reminder email to evaluator. + await unscheduleInvitationReminderEmail(instanceId, submissionPubId, evaluator); + // Unschedule no-reply notification email to community manager. + await unscheduleNoReplyNotificationEmail( + instanceId, + submissionPubId, + evaluator + ); + // Immediately send accepted notification email to community manager. + await sendAcceptedNotificationEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator, + submissionPub.assignee + ); + // Immediately send accepted email to evaluator. + await sendAcceptedEmail(instanceId, instanceConfig, submissionPubId, evaluator); + // Schedule no-submit notification email to community manager. + await scheduleNoSubmitNotificationEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator, + submissionPub.assignee + ); - // schedule prompt evaluation email to evaluator. - await schedulePromptEvalBonusReminderEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator - ); - //schedule final prompt eval email to evaluator - await scheduleFinalPromptEvalBonusReminderEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator - ); - //schedule eval reminder email to evaluator - await scheduleEvaluationReminderEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator - ); - //schedule final eval reminder email to evaluator - await scheduleFinalEvaluationReminderEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator - ); - //schedule follow up to final eval reminder email to evaluator - await scheduleFollowUpToFinalEvaluationReminderEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator - ); - // schedule no-submit notification email to evalutaor - await sendNoticeOfNoSubmitEmail(instanceId, instanceConfig, submissionPubId, evaluator); - } catch (error) { - return { error: error.message }; - } - redirect(`/actions/respond/accepted${redirectParams}`); + // schedule prompt evaluation email to evaluator. + await schedulePromptEvalBonusReminderEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator + ); + //schedule final prompt eval email to evaluator + await scheduleFinalPromptEvalBonusReminderEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator + ); + //schedule eval reminder email to evaluator + await scheduleEvaluationReminderEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator + ); + //schedule final eval reminder email to evaluator + await scheduleFinalEvaluationReminderEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator + ); + //schedule follow up to final eval reminder email to evaluator + await scheduleFollowUpToFinalEvaluationReminderEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator + ); + // schedule no-submit notification email to evalutaor + await sendNoticeOfNoSubmitEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator + ); + } + } catch (error) { + captureException(error); + return { error: error.message }; + } + redirect( + `/actions/respond/accepted?token=${cookie( + "token" + )}&instanceId=${instanceId}&pubId=${submissionPubId}` + ); + } + ); }; export const decline = async ( instanceId: string, submissionPubId: string ): Promise => { - const redirectParams = `?token=${cookie( - "token" - )}&instanceId=${instanceId}&pubId=${submissionPubId}`; - try { - const submissionPub = await client.getPub(instanceId, submissionPubId); - const user = JSON.parse(expect(cookie("user"))); - const instanceConfig = expect( - await getInstanceConfig(instanceId), - "Instance not configured" - ); - const instanceState = (await getInstanceState(instanceId, submissionPubId)) ?? {}; - let evaluator = expect(instanceState[user.id], "User was not invited to evaluate this pub"); - // Declining again is a no-op. - if (evaluator.status !== "declined") { - // Assert the user is invited to evaluate this pub. - assertIsInvited(evaluator); - // Update the evaluator's status to declined. - evaluator = instanceState[user.id] = { ...evaluator, status: "declined" }; - await setInstanceState(instanceId, submissionPubId, instanceState); - // Unschedule reminder email. - await unscheduleInvitationReminderEmail(instanceId, submissionPubId, evaluator); - // Unschedule no-reply notification email. - await unscheduleNoReplyNotificationEmail(instanceId, submissionPubId, evaluator); - // Immediately send declined notification email. - await sendDeclinedNotificationEmail( - instanceId, - instanceConfig, - submissionPubId, - evaluator, - submissionPub.assignee - ); + return withServerActionInstrumentation( + "evaluations/respond/decline", + { + headers: headers(), + }, + async () => { + const redirectParams = `?token=${cookie( + "token" + )}&instanceId=${instanceId}&pubId=${submissionPubId}`; + try { + const submissionPub = await client.getPub(instanceId, submissionPubId); + const user = JSON.parse(expect(cookie("user"))); + const instanceConfig = expect( + await getInstanceConfig(instanceId), + "Instance not configured" + ); + const instanceState = (await getInstanceState(instanceId, submissionPubId)) ?? {}; + let evaluator = expect( + instanceState[user.id], + "User was not invited to evaluate this pub" + ); + // Declining again is a no-op. + if (evaluator.status !== "declined") { + // Assert the user is invited to evaluate this pub. + assertIsInvited(evaluator); + // Update the evaluator's status to declined. + evaluator = instanceState[user.id] = { ...evaluator, status: "declined" }; + await setInstanceState(instanceId, submissionPubId, instanceState); + // Unschedule reminder email. + await unscheduleInvitationReminderEmail(instanceId, submissionPubId, evaluator); + // Unschedule no-reply notification email. + await unscheduleNoReplyNotificationEmail( + instanceId, + submissionPubId, + evaluator + ); + // Immediately send declined notification email. + await sendDeclinedNotificationEmail( + instanceId, + instanceConfig, + submissionPubId, + evaluator, + submissionPub.assignee + ); + } + } catch (error) { + captureException(error); + return { error: error.message }; + } + redirect(`/actions/respond/declined${redirectParams}`); } - } catch (error) { - return { error: error.message }; - } - redirect(`/actions/respond/declined${redirectParams}`); + ); }; diff --git a/integrations/evaluations/app/configure/actions.ts b/integrations/evaluations/app/configure/actions.ts index 14d15eccc1..a4f99afefe 100644 --- a/integrations/evaluations/app/configure/actions.ts +++ b/integrations/evaluations/app/configure/actions.ts @@ -1,12 +1,24 @@ "use server"; +import { headers } from "next/headers"; +import { captureException, withServerActionInstrumentation } from "@sentry/nextjs"; + import { setInstanceConfig } from "~/lib/instance"; import { InstanceConfig } from "~/lib/types"; export const configure = (instanceId: string, instanceConfig: InstanceConfig) => { - try { - return setInstanceConfig(instanceId, instanceConfig); - } catch (error) { - return { error: error.message }; - } + return withServerActionInstrumentation( + "configure", + { + headers: headers(), + }, + async () => { + try { + return setInstanceConfig(instanceId, instanceConfig); + } catch (error) { + captureException(error); + return { error: error.message }; + } + } + ); }; diff --git a/integrations/evaluations/app/global-error.tsx b/integrations/evaluations/app/global-error.tsx new file mode 100644 index 0000000000..3d36a71758 --- /dev/null +++ b/integrations/evaluations/app/global-error.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; +import NextError from "next/error"; +import * as Sentry from "@sentry/nextjs"; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + + + + ); +} diff --git a/integrations/evaluations/package.json b/integrations/evaluations/package.json index e92f916270..fb65dbb719 100644 --- a/integrations/evaluations/package.json +++ b/integrations/evaluations/package.json @@ -16,7 +16,7 @@ "dependencies": { "@hookform/resolvers": "^3.3.1", "@pubpub/sdk": "workspace:*", - "@sentry/nextjs": "^7.102.0", + "@sentry/nextjs": "^7.106.1", "@t3-oss/env-nextjs": "^0.9.2", "@ts-react/form": "^1.8.3", "ajv": "^8.12.0", diff --git a/integrations/submissions/app/actions/submit/actions.ts b/integrations/submissions/app/actions/submit/actions.ts index e47a24a03d..bca314c65f 100644 --- a/integrations/submissions/app/actions/submit/actions.ts +++ b/integrations/submissions/app/actions/submit/actions.ts @@ -1,5 +1,8 @@ "use server"; +import { headers } from "next/headers"; +import { captureException, withServerActionInstrumentation } from "@sentry/nextjs"; + import { PubValues } from "@pubpub/sdk"; import { getInstanceConfig } from "~/lib/instance"; @@ -7,20 +10,29 @@ import { makePubFromDoi, makePubFromTitle, makePubFromUrl } from "~/lib/metadata import { client } from "~/lib/pubpub"; export const submit = async (instanceId: string, values: PubValues, assigneeId: string) => { - try { - const instance = await getInstanceConfig(instanceId); - if (instance === undefined) { - return { error: "Instance not configured" }; + return withServerActionInstrumentation( + "submit", + { + headers: headers(), + }, + async () => { + try { + const instance = await getInstanceConfig(instanceId); + if (instance === undefined) { + return { error: "Instance not configured" }; + } + const pub = await client.createPub(instanceId, { + assigneeId, + values, + pubTypeId: instance.pubTypeId, + }); + return pub; + } catch (error) { + captureException(error); + return { error: error.message }; + } } - const pub = await client.createPub(instanceId, { - assigneeId, - values, - pubTypeId: instance.pubTypeId, - }); - return pub; - } catch (error) { - return { error: error.message }; - } + ); }; const metadataResolvers = { @@ -33,16 +45,25 @@ export const resolveMetadata = async ( identifierName: string, identifierValue: string ): Promise | { error: string }> => { - const resolve = metadataResolvers[identifierName]; - try { - if (resolve !== undefined) { - const pub = await resolve(identifierValue); - if (pub !== null) { - return pub; + return withServerActionInstrumentation( + "resolveMetadata", + { + headers: headers(), + }, + async () => { + const resolve = metadataResolvers[identifierName]; + try { + if (resolve !== undefined) { + const pub = await resolve(identifierValue); + if (pub !== null) { + return pub; + } + } + } catch (error) { + captureException(error); + return { error: "There was an error resolving metadata." }; } + return { error: "No metdata found." }; } - } catch (error) { - return { error: "There was an error resolving metadata." }; - } - return { error: "No metdata found." }; + ); }; diff --git a/integrations/submissions/app/configure/actions.ts b/integrations/submissions/app/configure/actions.ts index 555b2e5fb4..055d798c5f 100644 --- a/integrations/submissions/app/configure/actions.ts +++ b/integrations/submissions/app/configure/actions.ts @@ -1,12 +1,24 @@ "use server"; +import { headers } from "next/headers"; +import { captureException, withServerActionInstrumentation } from "@sentry/nextjs"; + import { setInstanceConfig } from "~/lib/instance"; export const configure = async (instanceId: string, pubTypeId: string) => { - try { - await setInstanceConfig(instanceId, { pubTypeId }); - return { success: true }; - } catch (error) { - return { error: error.message }; - } + return withServerActionInstrumentation( + "configure", + { + headers: headers(), + }, + async () => { + try { + await setInstanceConfig(instanceId, { pubTypeId }); + return { success: true }; + } catch (error) { + captureException(error); + return { error: error.message }; + } + } + ); }; diff --git a/integrations/submissions/app/global-error.tsx b/integrations/submissions/app/global-error.tsx new file mode 100644 index 0000000000..3d36a71758 --- /dev/null +++ b/integrations/submissions/app/global-error.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; +import NextError from "next/error"; +import * as Sentry from "@sentry/nextjs"; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + + + + ); +} diff --git a/integrations/submissions/package.json b/integrations/submissions/package.json index 6c1d24d4ba..985018e4ff 100644 --- a/integrations/submissions/package.json +++ b/integrations/submissions/package.json @@ -16,7 +16,7 @@ "dependencies": { "@hookform/resolvers": "^3.3.1", "@pubpub/sdk": "workspace:*", - "@sentry/nextjs": "^7.102.0", + "@sentry/nextjs": "^7.106.1", "@t3-oss/env-nextjs": "^0.9.2", "clsx": "^2.0.0", "contracts": "workspace:*", diff --git a/packages/ui/src/data-table.tsx b/packages/ui/src/data-table.tsx index 120ebda914..97ea03cad1 100644 --- a/packages/ui/src/data-table.tsx +++ b/packages/ui/src/data-table.tsx @@ -1,3 +1,5 @@ +import type { Column, Table } from "@tanstack/react-table"; + import * as React from "react"; import { ArrowDownIcon, @@ -9,7 +11,6 @@ import { DoubleArrowRightIcon, EyeNoneIcon, } from "@radix-ui/react-icons"; -import { Column, Table } from "@tanstack/react-table"; import { cn } from "utils"; diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx index 7adeaeb9ed..bcc6f5ac84 100644 --- a/packages/ui/src/form.tsx +++ b/packages/ui/src/form.tsx @@ -1,16 +1,11 @@ "use client"; +import type * as LabelPrimitive from "@radix-ui/react-label"; +import type { ControllerProps, FieldPath, FieldValues } from "react-hook-form"; + import * as React from "react"; -import * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; -import { - Controller, - ControllerProps, - FieldPath, - FieldValues, - FormProvider, - useFormContext, -} from "react-hook-form"; +import { Controller, FormProvider, useFormContext } from "react-hook-form"; import { cn } from "utils"; diff --git a/packages/ui/src/pagination.tsx b/packages/ui/src/pagination.tsx index ae54d7fc2c..e7445da923 100644 --- a/packages/ui/src/pagination.tsx +++ b/packages/ui/src/pagination.tsx @@ -4,7 +4,8 @@ import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui import { cn } from "utils"; -import { ButtonProps, buttonVariants } from "./button"; +import type { ButtonProps } from "./button"; +import { buttonVariants } from "./button"; const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (