From 2676dc61edd96546243de1944911d67b07cf7642 Mon Sep 17 00:00:00 2001 From: Marco Pedone Date: Fri, 17 Apr 2026 17:15:13 +0200 Subject: [PATCH] feat: refactor error handling and response structure in authentication and payment processes --- apps/infoalloggi/src/forms/FormLogin.tsx | 36 ++-- apps/infoalloggi/src/lib/result.ts | 3 - apps/infoalloggi/src/pages/annuncio/[cod].tsx | 21 ++- .../src/pages/auth/accetta-invito/[token].tsx | 76 ++++++--- .../src/pages/servizio/pagamento.tsx | 14 +- .../src/server/api/routers/invite.ts | 72 +------- .../src/server/api/routers/pagamenti.ts | 35 +--- .../server/controllers/annunci.controller.ts | 23 +-- .../src/server/controllers/auth.controller.ts | 74 +++++---- .../server/controllers/invites.controller.ts | 118 +++++++++++-- .../controllers/pagamenti.controller.ts | 157 +++++++++++------- apps/infoalloggi/src/utils/result.ts | 15 ++ apps/infoalloggi/src/utils/utils.ts | 4 + 13 files changed, 391 insertions(+), 257 deletions(-) delete mode 100644 apps/infoalloggi/src/lib/result.ts create mode 100644 apps/infoalloggi/src/utils/result.ts diff --git a/apps/infoalloggi/src/forms/FormLogin.tsx b/apps/infoalloggi/src/forms/FormLogin.tsx index abe2751..1432df3 100644 --- a/apps/infoalloggi/src/forms/FormLogin.tsx +++ b/apps/infoalloggi/src/forms/FormLogin.tsx @@ -19,6 +19,7 @@ import { env } from "~/env"; import { useZodForm } from "~/lib/zodForm"; import { useTranslation } from "~/providers/I18nProvider"; import { api } from "~/utils/api"; +import { assertNever } from "~/utils/utils"; export const FormLogin = () => { const router = useRouter(); @@ -46,26 +47,29 @@ export const FormLogin = () => { const utils = api.useUtils(); const { mutate, isPending } = api.auth.loginUser.useMutation({ - onError: ({ message }) => { - toast.error(`${t.auth.login.fail}\n${message}`); + onError: (error) => { + toast.error(`${t.auth.login.fail}\n${error.message}`); }, onSuccess: async (data) => { // FLOWS: login | 3 | Login Result Handling - switch (data.status) { - case "not-found": - setUserNotFound(true); - toast.error(data.message); - return; - case "fail": - toast.error(data.message); - return; - case "success": - await utils.auth.getSession.invalidate(); - toast.success(t.auth.login.success); - await router.push(redirect ? (redirect as string) : "/"); - //window.location.replace(redirect ? (redirect as string) : "/"); - return; + if (!data.ok) { + switch (data.error._tag) { + case "NotFound": + setUserNotFound(true); + toast.error(`${t.auth.login.fail}\n${data.error.message}`); + return; + case "Fail": + toast.error(`${t.auth.login.fail}\n${data.error.message}`); + return; + default: + assertNever(data.error); + } } + + await utils.auth.getSession.invalidate(); + toast.success(t.auth.login.success); + await router.push(redirect ? (redirect as string) : "/"); + return; }, }); const [userNotFound, setUserNotFound] = useState(false); diff --git a/apps/infoalloggi/src/lib/result.ts b/apps/infoalloggi/src/lib/result.ts deleted file mode 100644 index ae3a847..0000000 --- a/apps/infoalloggi/src/lib/result.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type Result = - | { success: true; data: T } - | { success: false; message: string }; diff --git a/apps/infoalloggi/src/pages/annuncio/[cod].tsx b/apps/infoalloggi/src/pages/annuncio/[cod].tsx index 17fa982..f182737 100644 --- a/apps/infoalloggi/src/pages/annuncio/[cod].tsx +++ b/apps/infoalloggi/src/pages/annuncio/[cod].tsx @@ -58,7 +58,7 @@ import { useMediaQuery } from "~/hooks/use-media-query"; import { handleConsegna } from "~/lib/annuncio_details"; import { replaceWithBr } from "~/lib/newlineToBr"; import { getStorageUrl } from "~/lib/storage_utils"; -import { cn } from "~/lib/utils"; +import { cn, redirectTo500 } from "~/lib/utils"; import { AnnuncioContext, useAnnuncio } from "~/providers/AnnuncioProvider"; import { useTranslation } from "~/providers/I18nProvider"; import { buildRicercaUrl } from "~/providers/RicercaProvider"; @@ -176,8 +176,21 @@ export async function getStaticProps( }, }; } - const annuncio = await ssg.annunci.getAnnuncioData.fetch({ cod: cod }); - if (!annuncio.success || annuncio.data.stato === "Sospeso") { + const result = await ssg.annunci.getAnnuncioData.fetch({ cod: cod }); + + if (!result.ok) { + if (result.error._tag === "InternalError") { + return redirectTo500; + } + + return { + redirect: { + destination: "/annuncio-non-trovato", + permanent: false, + }, + }; + } + if (result.value.stato === "Sospeso") { return { redirect: { destination: "/annuncio-non-trovato", @@ -191,7 +204,7 @@ export async function getStaticProps( }); return { props: { - data: annuncio.data, + data: result.value, //cod, flag, //meta, diff --git a/apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx b/apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx index 6de35cb..f2482c7 100644 --- a/apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx +++ b/apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx @@ -23,6 +23,7 @@ import { FormMessage, } from "~/components/custom_ui/form"; import LoadingButton from "~/components/custom_ui/loading-button"; +import { Status500 } from "~/components/status-page"; import { PasswordSVG } from "~/components/svgs"; import { AlertDialog, @@ -38,27 +39,22 @@ import { import { Button } from "~/components/ui/button"; import Input from "~/components/ui/input"; import { usePassword } from "~/hooks/usePassword"; -import { cn } from "~/lib/utils"; +import { cn, redirectTo500 } from "~/lib/utils"; import { useZodForm } from "~/lib/zodForm"; import type { NextPageWithLayout } from "~/pages/_app"; import { useTranslation } from "~/providers/I18nProvider"; +import type { + InviteErrors, + TokenVerErrors, +} from "~/server/controllers/invites.controller"; import { generateSSGHelper } from "~/server/utils/ssgHelper"; import { api } from "~/utils/api"; +import type { Result } from "~/utils/result"; +import { assertNever } from "~/utils/utils"; type InviteTokenProps = { token: string; - verification: - | { - status: "valid"; - email: string; - } - | { - status: "invalid"; - reason: "not_found" | "expired"; - } - | { - status: "already_user"; - }; + verification: Result; }; /** @@ -68,16 +64,20 @@ const AcceptInvitePage: NextPageWithLayout = ({ verification, token, }: InviteTokenProps) => { - switch (verification.status) { - case "valid": - return ; - case "invalid": - return ; - case "already_user": - return ; - default: - return ; + if (!verification.ok) { + switch (verification.error._tag) { + case "AlreadyUser": + return ; + case "Expired": + case "TokenNotFound": + return ; + case "InternalError": + return ; + default: + assertNever(verification.error); + } } + return ; }; const ValidInvitePage = ({ token, @@ -131,10 +131,28 @@ const ValidInvitePage = ({ const { mutate: acceptInvite, isPending } = api.invite.acceptInvite.useMutation({ - onSuccess: async () => { - toast.success("Account creato! Effettua il login."); + onSuccess: async (result) => { + if (!result.ok) { + switch (result.error._tag) { + case "UserNotFound": + toast.error(result.error.message); + return; + case "TokenNotFound": + case "Expired": + toast.error( + "Il token di invito non è più valido. Richiedi un nuovo invito.", + ); + window.location.reload(); + return; + + default: + assertNever(result.error); + } + } + toast.success( + "Successo! La tua password è stata impostata. Reindirizzamento in corso...", + ); await router.push("/area-riservata/dashboard"); - //await router.push("/login?redirect=/area-riservata/dashboard"); }, onError: (error) => { toast.error(error.message); @@ -246,9 +264,9 @@ const AlreadyUserPage = () => { ); }; -const InvalidInvitePage = ({ reason }: { reason: "not_found" | "expired" }) => { +const InvalidInvitePage = ({ reason }: { reason: TokenVerErrors["_tag"] }) => { const message = - reason === "expired" + reason === "Expired" ? "L'invito è scaduto, è passato troppo tempo dall'invio." : "Non è stato trovato alcun invito valido per questo collegamento."; @@ -354,6 +372,10 @@ export const getServerSideProps = (async (context) => { const verification = await helper.invite.verifyInvite.fetch({ token, email }); + if (!verification.ok && verification.error._tag === "InternalError") { + return redirectTo500; + } + return { props: { verification, diff --git a/apps/infoalloggi/src/pages/servizio/pagamento.tsx b/apps/infoalloggi/src/pages/servizio/pagamento.tsx index 5698716..34db31a 100644 --- a/apps/infoalloggi/src/pages/servizio/pagamento.tsx +++ b/apps/infoalloggi/src/pages/servizio/pagamento.tsx @@ -149,14 +149,15 @@ export const getServerSideProps = (async (context) => { const result = await helper.trpc.pagamenti.preparePaymentRinnovi.fetch({ rinnovoId: parsedRinnovoId.data, }); - if (!result.success) { - console.error("Error preparing payment", result.message); + if (!result.ok) { + // error type non usato prienamente per ora, logghiamo tutto con messaggio + console.error("Error preparing payment", result.error.message); return redirectTo500; } return { props: { stripeDisabled: flag || false, - data: result.data, + data: result.value, saldoPreview: null, }, }; @@ -176,8 +177,9 @@ export const getServerSideProps = (async (context) => { type, servizioId: parsedServizioId.data, }); - if (!result.success) { - console.error("Error preparing payment", result.message); + if (!result.ok) { + // error type non usato prienamente per ora, logghiamo tutto con messaggio + console.error("Error preparing payment", result.error.message); return redirectTo500; } let saldoPreview: Prezziario | null = null; @@ -193,7 +195,7 @@ export const getServerSideProps = (async (context) => { return { props: { stripeDisabled: flag || false, - data: result.data, + data: result.value, saldoPreview, }, }; diff --git a/apps/infoalloggi/src/server/api/routers/invite.ts b/apps/infoalloggi/src/server/api/routers/invite.ts index 784d718..e40a40f 100644 --- a/apps/infoalloggi/src/server/api/routers/invite.ts +++ b/apps/infoalloggi/src/server/api/routers/invite.ts @@ -6,16 +6,13 @@ import { createTRPCRouter, publicProcedure, } from "~/server/api/trpc"; -import { swapAuth } from "~/server/controllers/auth.controller"; import { - consumeInviteToken, createInviteToken, + InviteAcceptance, + InviteVerification, sendInvitoEmail, - verifyInviteToken, } from "~/server/controllers/invites.controller"; -import { editAccountHandler } from "~/server/controllers/user.controller"; import { db } from "~/server/db"; -import { generateSalt, hashPassword } from "~/server/services/password.service"; import { findUser_byEmail, getUser } from "~/server/services/user.service"; import { zUserId } from "~/server/utils/zod_types"; @@ -90,31 +87,8 @@ export const inviteRouter = createTRPCRouter({ // Verify invite token (public - user clicking the link) verifyInvite: publicProcedure .input(z.object({ token: z.string(), email: z.string().nullable() })) - .output( - z.discriminatedUnion("status", [ - z.object({ status: z.literal("valid"), email: z.email() }), - z.object({ - status: z.literal("invalid"), - reason: z.enum(["not_found", "expired"]), - }), - z.object({ status: z.literal("already_user") }), - ]), - ) .query(async ({ input }) => { - if (input.email) { - const existingUser = await findUser_byEmail({ email: input.email }); - if (existingUser?.usedInvite) { - return { status: "already_user" }; - } - } - - const ver = await verifyInviteToken(input.token); - - if (!ver.status) { - return { status: "invalid", reason: ver.reason }; - } - - return { status: "valid", email: ver.invite.email }; + return await InviteVerification(input.token, input.email); }), // Accept invite and set password @@ -127,39 +101,11 @@ export const inviteRouter = createTRPCRouter({ }), ) .mutation(async ({ input, ctx }) => { - const user = await findUser_byEmail({ email: input.email }); - if (!user) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `Accept Invite: Utente non trovato con email: ${input.email}`, - }); - } - const ver = await verifyInviteToken(input.token); - - if (!ver.status) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `Accept Invite: Invito non valido o scaduto per email: ${input.email}`, - }); - } - - // Create user with password - const salt = generateSalt(); - const hashedPassword = await hashPassword(input.password, salt); - - await editAccountHandler({ - id: user.id, - data: { - password: hashedPassword, - salt, - usedInvite: true, - }, - }); - - // Mark invite as used - await consumeInviteToken(input.token); - await swapAuth({ ctx, userId: user.id }); - - return { success: true }; + return await InviteAcceptance( + ctx, + input.token, + input.email, + input.password, + ); }), }); diff --git a/apps/infoalloggi/src/server/api/routers/pagamenti.ts b/apps/infoalloggi/src/server/api/routers/pagamenti.ts index 87d5d29..00d3c2f 100644 --- a/apps/infoalloggi/src/server/api/routers/pagamenti.ts +++ b/apps/infoalloggi/src/server/api/routers/pagamenti.ts @@ -6,6 +6,7 @@ import { getPagamentoDataForCheckout, getRicevutaData, type PreparedPaymentData, + type PreparePaymentError, preparePayment, previewSaldo, } from "~/server/controllers/pagamenti.controller"; @@ -13,6 +14,7 @@ import { import { db } from "~/server/db"; import { TypstGenerate } from "~/server/services/typst.service"; import { zOrdineId, zRinnovoId, zServizioId } from "~/server/utils/zod_types"; +import { zResult } from "~/utils/result"; // Create a new ratelimiter, that allows 10 requests per 10 seconds /* const ratelimit = new RateLimiterHandler({ @@ -47,23 +49,15 @@ export const pagamentiRouter = createTRPCRouter({ type: z.enum(OrderTypeEnum), }), ) - .output( - z.discriminatedUnion("success", [ - z.object({ - success: z.literal(true), - data: z.custom(), - }), - z.object({ - success: z.literal(false), - message: z.string(), - }), - ]), - ) + .output(zResult()) .query(async ({ input }) => { if (input.type === OrderTypeEnum.Rinnovo) { return { - success: false, - message: "Use preparePaymentRinnovi for Rinnovo payments", + ok: false, + error: { + _tag: "InvalidInput", + message: "Use preparePaymentRinnovi for Rinnovo payments", + }, }; } return await preparePayment({ @@ -86,18 +80,7 @@ export const pagamentiRouter = createTRPCRouter({ rinnovoId: zRinnovoId, }), ) - .output( - z.discriminatedUnion("success", [ - z.object({ - success: z.literal(true), - data: z.custom(), - }), - z.object({ - success: z.literal(false), - message: z.string(), - }), - ]), - ) + .output(zResult()) .query(async ({ input }) => { return await preparePayment({ type: OrderTypeEnum.Rinnovo, diff --git a/apps/infoalloggi/src/server/controllers/annunci.controller.ts b/apps/infoalloggi/src/server/controllers/annunci.controller.ts index 3b72d18..03adfe0 100644 --- a/apps/infoalloggi/src/server/controllers/annunci.controller.ts +++ b/apps/infoalloggi/src/server/controllers/annunci.controller.ts @@ -1,7 +1,6 @@ import { TRPCError } from "@trpc/server"; import { env } from "~/env"; import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils"; -import type { Result } from "~/lib/result"; import { getStorageUrl } from "~/lib/storage_utils"; import type { Annunci, @@ -14,6 +13,7 @@ import type { VideosRefs } from "~/schemas/public/VideosRefs"; import { db } from "~/server/db"; import type { AnnuncioTemplateData } from "~/server/services/typst.service"; import { withImages, withVideos } from "~/utils/kysely-helper"; +import type { Result } from "~/utils/result"; import { revalidate } from "../utils/revalidationHelper"; // const ratelimit = new RateLimiterHandler({ @@ -112,11 +112,16 @@ export type AnnuncioData = Pick< ogUrl: string; ogImage: string; }; + +type getAnnuncioDataError = + | { _tag: "NoAnnuncio" } + | { _tag: "MalformedAnnuncio" } + | { _tag: "InternalError"; cause: unknown }; export const getAnnuncioData = async ({ cod, }: { cod: string; -}): Promise> => { +}): Promise> => { try { const annuncio = await db .selectFrom("annunci") @@ -157,11 +162,11 @@ export const getAnnuncioData = async ({ .where("codice", "=", cod) .executeTakeFirst(); if (!annuncio) { - return { success: false, message: "Annuncio non trovato" }; + return { ok: false, error: { _tag: "NoAnnuncio" } }; } const { tipo, media_updated_at, ...rest } = annuncio; if (tipo === null) { - return { success: false, message: "Annuncio non trovato" }; + return { ok: false, error: { _tag: "MalformedAnnuncio" } }; } const ogImage = annuncio.images && annuncio.images.length > 0 && annuncio.images[0] @@ -175,8 +180,8 @@ export const getAnnuncioData = async ({ }) : `${env.BASE_URL}/og.jpg`; return { - success: true, - data: { + ok: true, + value: { ...rest, media_updated_at: media_updated_at?.toISOString() || null, tipo, @@ -185,11 +190,7 @@ export const getAnnuncioData = async ({ }, }; } catch (e) { - throw new TRPCError({ - cause: (e as Error).cause, - code: "INTERNAL_SERVER_ERROR", - message: `Errore query getAnnunciByCod: ${(e as Error).message}`, - }); + return { ok: false, error: { _tag: "InternalError", cause: e } }; } }; diff --git a/apps/infoalloggi/src/server/controllers/auth.controller.ts b/apps/infoalloggi/src/server/controllers/auth.controller.ts index d400b95..3c6a380 100644 --- a/apps/infoalloggi/src/server/controllers/auth.controller.ts +++ b/apps/infoalloggi/src/server/controllers/auth.controller.ts @@ -19,6 +19,7 @@ import { findUser_byId_MINI, } from "~/server/services/user.service"; import { checkCtx } from "~/server/utils/ctxChecker"; +import type { Result } from "~/utils/result"; import { TOKEN_CONFIG } from "../auth/configs"; export const createUserAdmin = async ({ @@ -68,6 +69,10 @@ export const createUserAdmin = async ({ return { status: "success" as const }; }; +type LoginErrors = + | { _tag: "NotFound"; message: string } + | { _tag: "Fail"; message: string }; + // FLOWS: login | 2 | Login function export const logIn = async ({ email, @@ -77,7 +82,7 @@ export const logIn = async ({ email: string; password: string; ctx: Context; -}) => { +}): Promise> => { try { checkCtx({ req, res }); @@ -86,17 +91,23 @@ export const logIn = async ({ if (!user) { return { - status: "not-found" as const, - message: - "Questo utente non esiste. Devi avere un account per effettuare il login.", + ok: false, + error: { + _tag: "NotFound", + message: + "Questo utente non esiste. Devi avere un account per effettuare il login.", + }, }; } //Check if user is blocked or unverified if (user.isBlocked) { return { - status: "fail" as const, - message: "Errore in Login, non è possibile effettuare il login.", + ok: false, + error: { + _tag: "Fail", + message: "Errore in Login, non è possibile effettuare il login.", + }, }; } @@ -112,8 +123,11 @@ export const logIn = async ({ if (!passworIsMatch) { return { - status: "fail" as const, - message: "Password errata, riprova.", + ok: false, + error: { + _tag: "Fail", + message: "Password errata, riprova.", + }, }; } } @@ -139,37 +153,29 @@ export const logIn = async ({ "refreshToken", ); - try { - await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, { - ...TOKEN_CONFIG.COOKIE_OPTIONS, - expires: add(new Date(), { - hours: TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_EXPIRY, - }), - req, - res, - }); - await setCookie(TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, refreshToken, { - ...TOKEN_CONFIG.COOKIE_OPTIONS, - expires: add(new Date(), { - days: TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_EXPIRY, - }), - req, - res, - }); + await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, { + ...TOKEN_CONFIG.COOKIE_OPTIONS, + expires: add(new Date(), { + hours: TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_EXPIRY, + }), + req, + res, + }); + await setCookie(TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, refreshToken, { + ...TOKEN_CONFIG.COOKIE_OPTIONS, + expires: add(new Date(), { + days: TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_EXPIRY, + }), + req, + res, + }); - return { status: "success" as const }; - } catch (cookieError) { - console.error("Error setting cookies:", cookieError); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Errore durante l'impostazione dei cookies: ${(cookieError as Error).message}`, - }); - } + return { ok: true, value: null }; } catch (e) { console.error("Login error:", e); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: `Errore login: ${(e as Error).message}`, + message: `Errore durante il login: ${(e as Error).message}`, }); } }; diff --git a/apps/infoalloggi/src/server/controllers/invites.controller.ts b/apps/infoalloggi/src/server/controllers/invites.controller.ts index afcef70..47916de 100644 --- a/apps/infoalloggi/src/server/controllers/invites.controller.ts +++ b/apps/infoalloggi/src/server/controllers/invites.controller.ts @@ -2,7 +2,13 @@ import crypto from "node:crypto"; import { TRPCError } from "@trpc/server"; import type { UserInvites } from "~/schemas/public/UserInvites"; import type { UsersId } from "~/schemas/public/Users"; +import type { Context } from "~/server/api/trpc"; +import { swapAuth } from "~/server/controllers/auth.controller"; +import { editAccountHandler } from "~/server/controllers/user.controller"; import { db } from "~/server/db"; +import { generateSalt, hashPassword } from "~/server/services/password.service"; +import { findUser_byEmail } from "~/server/services/user.service"; +import type { Result } from "~/utils/result"; import { NewMail } from "../services/mailer"; const INVITE_EXPIRY_HOURS = 48; @@ -31,19 +37,109 @@ export async function createInviteToken(email: string) { } return token; } +export type InviteErrors = + | { _tag: "AlreadyUser" } + | { _tag: "InternalError"; message: string } + | TokenVerErrors; -type VerifyInviteResult = +export const InviteVerification = async ( + token: string, + email: string | null, +): Promise> => { + try { + if (email) { + const existingUser = await findUser_byEmail({ email }); + if (existingUser?.usedInvite) { + return { ok: false, error: { _tag: "AlreadyUser" } }; + } + } + + const ver = await verifyInviteToken(token); + + if (!ver.ok) { + return { + ok: false, + error: ver.error, + }; + } + + return { ok: true, value: ver.value.email }; + } catch (e) { + console.error("Error verifying invite token:", e); + return { + ok: false, + error: { + _tag: "InternalError", + message: `Errore durante la verifica del token di invito.: ${(e as Error).message}`, + }, + }; + } +}; +type InviteAcceptanceErrors = | { - status: true; - invite: UserInvites; + _tag: "UserNotFound"; + message: string; } - | { - status: false; - reason: "not_found" | "expired"; - }; + | TokenVerErrors; +export const InviteAcceptance = async ( + ctx: Context, + token: string, + email: string, + password: string, +): Promise> => { + try { + const user = await findUser_byEmail({ email }); + if (!user) { + return { + ok: false, + error: { + _tag: "UserNotFound", + message: `Accept Invite: Utente non trovato con email: ${email}`, + }, + }; + } + + const ver = await verifyInviteToken(token); + + if (!ver.ok) { + return { + ok: false, + error: ver.error, + }; + } + + // Create user with password + const salt = generateSalt(); + const hashedPassword = await hashPassword(password, salt); + + await editAccountHandler({ + id: user.id, + data: { + password: hashedPassword, + salt, + usedInvite: true, + }, + }); + + // Mark invite as used + await consumeInviteToken(token); + await swapAuth({ ctx, userId: user.id }); + + return { ok: true, value: ver.value }; + } catch (e) { + console.error("Error verifying invite token:", e); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Errore durante l'accettazione del token di invito.: ${(e as Error).message}`, + }); + } +}; + +export type TokenVerErrors = { _tag: "TokenNotFound" } | { _tag: "Expired" }; + export async function verifyInviteToken( token: string, -): Promise { +): Promise> { try { const invite = await db .selectFrom("user_invites") @@ -52,13 +148,13 @@ export async function verifyInviteToken( //.where("expires_at", ">", new Date()) .executeTakeFirst(); if (!invite) { - return { status: false, reason: "not_found" }; + return { ok: false, error: { _tag: "TokenNotFound" } }; } if (invite.expires_at < new Date()) { - return { status: false, reason: "expired" }; + return { ok: false, error: { _tag: "Expired" } }; } - return { status: true, invite }; + return { ok: true, value: invite }; } catch (e) { console.error("Error verifying invite token:", e); throw new TRPCError({ diff --git a/apps/infoalloggi/src/server/controllers/pagamenti.controller.ts b/apps/infoalloggi/src/server/controllers/pagamenti.controller.ts index 108c6a2..2ce7ed1 100644 --- a/apps/infoalloggi/src/server/controllers/pagamenti.controller.ts +++ b/apps/infoalloggi/src/server/controllers/pagamenti.controller.ts @@ -13,6 +13,7 @@ import type { ServizioServizioId } from "~/schemas/public/Servizio"; import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum"; import { getComuni, getNazioni } from "~/server/controllers/catasto.controller"; import type { RicevutaProps } from "~/server/services/typst.service"; +import type { Result } from "~/utils/result"; import { db } from "../db"; import { createOrdine } from "../services/ordini.service"; import { @@ -40,12 +41,14 @@ type preparePaymentInput = type: OrderTypeEnum.Rinnovo; }; +export type PreparePaymentError = + | { _tag: "PrezziarioNotFound"; message: string } + | { _tag: "AlreadyPaid"; message: string } + | { _tag: "InvalidInput"; message: string } + | { _tag: "InternalError"; message: string }; export const preparePayment = async ( props: preparePaymentInput, -): Promise< - | { success: true; data: PreparedPaymentData } - | { success: false; message: string } -> => { +): Promise> => { try { switch (props.type) { case OrderTypeEnum.Acconto: @@ -68,8 +71,11 @@ export const preparePayment = async ( const acconto = await AccontoSolver(servizio.tipologia); if (!acconto.success) { return { - success: false, - message: `Acconto non trovato per il servizio: ${acconto.message}`, + ok: false, + error: { + _tag: "PrezziarioNotFound", + message: `Acconto non trovato per il servizio: ${acconto.message}`, + }, }; } @@ -82,8 +88,8 @@ export const preparePayment = async ( if (preExistingAcconto) { // acconto già creato, procedere con questo return { - success: true, - data: { + ok: true, + value: { ordine_id: preExistingAcconto.ordine_id, packid: preExistingAcconto.packid, amount_cent: preExistingAcconto.amount_cent, @@ -105,8 +111,8 @@ export const preparePayment = async ( userid: servizio.user_id, }); return { - success: true, - data: { + ok: true, + value: { ordine_id: ordine.ordine_id, packid: acconto.pack.idprezziario, amount_cent: acconto.pack.prezzo_cent, @@ -119,17 +125,23 @@ export const preparePayment = async ( }; } return { - success: false, - message: - "Il servizio ha già un acconto attivo, non è possibile preparare un nuovo pagamento", + ok: false, + error: { + _tag: "AlreadyPaid", + message: + "Il servizio ha già un acconto attivo, non è possibile preparare un nuovo pagamento", + }, }; } case OrderTypeEnum.Saldo: { if (!servizio.isOkAcconto) { return { - success: false, - message: - "Non è possibile procedere con il pagamento del saldo prima di aver attivato l'acconto", + ok: false, + error: { + _tag: "InvalidInput", + message: + "Non è possibile procedere con il pagamento del saldo prima di aver attivato l'acconto", + }, }; } if (!servizio.isOkSaldo) { @@ -141,8 +153,11 @@ export const preparePayment = async ( }); if (!saldo.success) { return { - success: false, - message: `Saldo non trovato per il servizio: ${saldo.message}`, + ok: false, + error: { + _tag: "PrezziarioNotFound", + message: `Saldo non trovato per il servizio: ${saldo.message}`, + }, }; } @@ -152,8 +167,8 @@ export const preparePayment = async ( if (preExistingSaldo) { // saldo già creato, procedere con questo return { - success: true, - data: { + ok: true, + value: { ordine_id: preExistingSaldo.ordine_id, packid: preExistingSaldo.packid, amount_cent: preExistingSaldo.amount_cent, @@ -174,8 +189,8 @@ export const preparePayment = async ( userid: servizio.user_id, }); return { - success: true, - data: { + ok: true, + value: { ordine_id: ordine.ordine_id, packid: saldo.pack.idprezziario, amount_cent: saldo.pack.prezzo_cent, @@ -187,18 +202,24 @@ export const preparePayment = async ( }; } return { - success: false, - message: - "Il servizio ha già un saldo attivo, non è possibile preparare un nuovo pagamento", + ok: false, + error: { + _tag: "AlreadyPaid", + message: + "Il servizio ha già un saldo attivo, non è possibile preparare un nuovo pagamento", + }, }; } case OrderTypeEnum.Consulenza: { if (!servizio.isOkAcconto || !servizio.isOkSaldo) { return { - success: false, - message: - "Non è possibile procedere con il pagamento della consulenza prima di aver attivato acconto e saldo", + ok: false, + error: { + _tag: "InvalidInput", + message: + "Non è possibile procedere con il pagamento della consulenza prima di aver attivato acconto e saldo", + }, }; } if (!servizio.isOkConsulenza) { @@ -212,14 +233,17 @@ export const preparePayment = async ( ); if (!consulenzaPack) { return { - success: false, - message: `Pack della consulenza non trovato: ${existingConsulenza.packid}`, + ok: false, + error: { + _tag: "PrezziarioNotFound", + message: `Pack della consulenza non trovato: ${existingConsulenza.packid}`, + }, }; } return { - success: true, - data: { + ok: true, + value: { ordine_id: existingConsulenza.ordine_id, packid: existingConsulenza.packid, amount_cent: existingConsulenza.amount_cent, @@ -233,16 +257,22 @@ export const preparePayment = async ( } return { - success: false, - message: - "Il servizio non ha un pacchetto di consulenza, è necessario inserirlo prima di procedere", + ok: false, + error: { + _tag: "InvalidInput", + message: + "Il servizio non ha un pacchetto di consulenza, è necessario inserirlo prima di procedere", + }, }; } return { - success: false, - message: - "Il servizio ha già una consulenza attiva, non è possibile preparare un nuovo pagamento", + ok: false, + error: { + _tag: "AlreadyPaid", + message: + "Il servizio ha già una consulenza attiva, non è possibile preparare un nuovo pagamento", + }, }; } case OrderTypeEnum.Altro: { @@ -256,14 +286,17 @@ export const preparePayment = async ( ); if (!altroPack) { return { - success: false, - message: `Pack dell'altro non trovato: ${existingAltro.packid}`, + ok: false, + error: { + _tag: "PrezziarioNotFound", + message: `Pack dell'altro non trovato: ${existingAltro.packid}`, + }, }; } return { - success: true, - data: { + ok: true, + value: { ordine_id: existingAltro.ordine_id, packid: existingAltro.packid, amount_cent: existingAltro.amount_cent, @@ -276,9 +309,12 @@ export const preparePayment = async ( } return { - success: false, - message: - "La preparazione del pagamento per questa tipologia non è supportata", + ok: false, + error: { + _tag: "InvalidInput", + message: + "La preparazione del pagamento per questa tipologia non è supportata", + }, }; } default: @@ -300,9 +336,12 @@ export const preparePayment = async ( const alreadyPaid = ordiniRinnovo.some((ordine) => ordine.isActive); if (alreadyPaid) { return { - success: false, - message: - "Il rinnovo ha già un ordine attivo, non è possibile preparare un nuovo pagamento", + ok: false, + error: { + _tag: "AlreadyPaid", + message: + "Il rinnovo ha già un ordine attivo, non è possibile preparare un nuovo pagamento", + }, }; } @@ -314,14 +353,17 @@ export const preparePayment = async ( ); if (!rinnovoPack) { return { - success: false, - message: `Pack del rinnovo non trovato: ${unusedOrdine.packid}`, + ok: false, + error: { + _tag: "PrezziarioNotFound", + message: `Pack del rinnovo non trovato: ${unusedOrdine.packid}`, + }, }; } return { - success: true, - data: { + ok: true, + value: { ordine_id: unusedOrdine.ordine_id, packid: unusedOrdine.packid, amount_cent: unusedOrdine.amount_cent, @@ -343,8 +385,11 @@ export const preparePayment = async ( const pack = await RinnovoSolver(rinnovo.decorrenza, rinnovo.scadenza); if (!pack.success) { return { - success: false, - message: `Pack non trovato per il rinnovo: ${pack.message}`, + ok: false, + error: { + _tag: "PrezziarioNotFound", + message: `Pack non trovato per il rinnovo: ${pack.message}`, + }, }; } @@ -356,8 +401,8 @@ export const preparePayment = async ( userid: rinnovo.userId, }); return { - success: true, - data: { + ok: true, + value: { ordine_id: ordine.ordine_id, packid: pack.pack.idprezziario, amount_cent: pack.pack.prezzo_cent, diff --git a/apps/infoalloggi/src/utils/result.ts b/apps/infoalloggi/src/utils/result.ts new file mode 100644 index 0000000..417cf81 --- /dev/null +++ b/apps/infoalloggi/src/utils/result.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +export const zResult = () => + z.discriminatedUnion("ok", [ + z.object({ + ok: z.literal(true), + value: z.custom(), + }), + z.object({ + ok: z.literal(false), + error: z.custom(), + }), + ]); diff --git a/apps/infoalloggi/src/utils/utils.ts b/apps/infoalloggi/src/utils/utils.ts index 195af0c..7a0974e 100644 --- a/apps/infoalloggi/src/utils/utils.ts +++ b/apps/infoalloggi/src/utils/utils.ts @@ -31,3 +31,7 @@ export function titleCase(str: string | null | undefined): string { } export const startsWithVowel = (s: string) => /^[aeiouàèéìòù]/i.test(s); + +export function assertNever(x: never): never { + throw new Error(`Unexpected: ${JSON.stringify(x)}`); +}