feat: refactor error handling and response structure in authentication and payment processes

This commit is contained in:
Marco Pedone 2026-04-17 17:15:13 +02:00
parent a03326f036
commit 2676dc61ed
13 changed files with 391 additions and 257 deletions

View file

@ -19,6 +19,7 @@ import { env } from "~/env";
import { useZodForm } from "~/lib/zodForm"; import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { assertNever } from "~/utils/utils";
export const FormLogin = () => { export const FormLogin = () => {
const router = useRouter(); const router = useRouter();
@ -46,26 +47,29 @@ export const FormLogin = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate, isPending } = api.auth.loginUser.useMutation({ const { mutate, isPending } = api.auth.loginUser.useMutation({
onError: ({ message }) => { onError: (error) => {
toast.error(`${t.auth.login.fail}\n${message}`); toast.error(`${t.auth.login.fail}\n${error.message}`);
}, },
onSuccess: async (data) => { onSuccess: async (data) => {
// FLOWS: login | 3 | Login Result Handling // FLOWS: login | 3 | Login Result Handling
switch (data.status) { if (!data.ok) {
case "not-found": switch (data.error._tag) {
setUserNotFound(true); case "NotFound":
toast.error(data.message); setUserNotFound(true);
return; toast.error(`${t.auth.login.fail}\n${data.error.message}`);
case "fail": return;
toast.error(data.message); case "Fail":
return; toast.error(`${t.auth.login.fail}\n${data.error.message}`);
case "success": return;
await utils.auth.getSession.invalidate(); default:
toast.success(t.auth.login.success); assertNever(data.error);
await router.push(redirect ? (redirect as string) : "/"); }
//window.location.replace(redirect ? (redirect as string) : "/");
return;
} }
await utils.auth.getSession.invalidate();
toast.success(t.auth.login.success);
await router.push(redirect ? (redirect as string) : "/");
return;
}, },
}); });
const [userNotFound, setUserNotFound] = useState(false); const [userNotFound, setUserNotFound] = useState(false);

View file

@ -1,3 +0,0 @@
export type Result<T> =
| { success: true; data: T }
| { success: false; message: string };

View file

@ -58,7 +58,7 @@ import { useMediaQuery } from "~/hooks/use-media-query";
import { handleConsegna } from "~/lib/annuncio_details"; import { handleConsegna } from "~/lib/annuncio_details";
import { replaceWithBr } from "~/lib/newlineToBr"; import { replaceWithBr } from "~/lib/newlineToBr";
import { getStorageUrl } from "~/lib/storage_utils"; import { getStorageUrl } from "~/lib/storage_utils";
import { cn } from "~/lib/utils"; import { cn, redirectTo500 } from "~/lib/utils";
import { AnnuncioContext, useAnnuncio } from "~/providers/AnnuncioProvider"; import { AnnuncioContext, useAnnuncio } from "~/providers/AnnuncioProvider";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import { buildRicercaUrl } from "~/providers/RicercaProvider"; import { buildRicercaUrl } from "~/providers/RicercaProvider";
@ -176,8 +176,21 @@ export async function getStaticProps(
}, },
}; };
} }
const annuncio = await ssg.annunci.getAnnuncioData.fetch({ cod: cod }); const result = await ssg.annunci.getAnnuncioData.fetch({ cod: cod });
if (!annuncio.success || annuncio.data.stato === "Sospeso") {
if (!result.ok) {
if (result.error._tag === "InternalError") {
return redirectTo500;
}
return {
redirect: {
destination: "/annuncio-non-trovato",
permanent: false,
},
};
}
if (result.value.stato === "Sospeso") {
return { return {
redirect: { redirect: {
destination: "/annuncio-non-trovato", destination: "/annuncio-non-trovato",
@ -191,7 +204,7 @@ export async function getStaticProps(
}); });
return { return {
props: { props: {
data: annuncio.data, data: result.value,
//cod, //cod,
flag, flag,
//meta, //meta,

View file

@ -23,6 +23,7 @@ import {
FormMessage, FormMessage,
} from "~/components/custom_ui/form"; } from "~/components/custom_ui/form";
import LoadingButton from "~/components/custom_ui/loading-button"; import LoadingButton from "~/components/custom_ui/loading-button";
import { Status500 } from "~/components/status-page";
import { PasswordSVG } from "~/components/svgs"; import { PasswordSVG } from "~/components/svgs";
import { import {
AlertDialog, AlertDialog,
@ -38,27 +39,22 @@ import {
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import Input from "~/components/ui/input"; import Input from "~/components/ui/input";
import { usePassword } from "~/hooks/usePassword"; import { usePassword } from "~/hooks/usePassword";
import { cn } from "~/lib/utils"; import { cn, redirectTo500 } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm"; import { useZodForm } from "~/lib/zodForm";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import type {
InviteErrors,
TokenVerErrors,
} from "~/server/controllers/invites.controller";
import { generateSSGHelper } from "~/server/utils/ssgHelper"; import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import type { Result } from "~/utils/result";
import { assertNever } from "~/utils/utils";
type InviteTokenProps = { type InviteTokenProps = {
token: string; token: string;
verification: verification: Result<string, InviteErrors>;
| {
status: "valid";
email: string;
}
| {
status: "invalid";
reason: "not_found" | "expired";
}
| {
status: "already_user";
};
}; };
/** /**
@ -68,16 +64,20 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
verification, verification,
token, token,
}: InviteTokenProps) => { }: InviteTokenProps) => {
switch (verification.status) { if (!verification.ok) {
case "valid": switch (verification.error._tag) {
return <ValidInvitePage email={verification.email} token={token} />; case "AlreadyUser":
case "invalid": return <AlreadyUserPage />;
return <InvalidInvitePage reason={verification.reason} />; case "Expired":
case "already_user": case "TokenNotFound":
return <AlreadyUserPage />; return <InvalidInvitePage reason={verification.error._tag} />;
default: case "InternalError":
return <InvalidInvitePage reason="not_found" />; return <Status500 />;
default:
assertNever(verification.error);
}
} }
return <ValidInvitePage email={verification.value} token={token} />;
}; };
const ValidInvitePage = ({ const ValidInvitePage = ({
token, token,
@ -131,10 +131,28 @@ const ValidInvitePage = ({
const { mutate: acceptInvite, isPending } = const { mutate: acceptInvite, isPending } =
api.invite.acceptInvite.useMutation({ api.invite.acceptInvite.useMutation({
onSuccess: async () => { onSuccess: async (result) => {
toast.success("Account creato! Effettua il login."); 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("/area-riservata/dashboard");
//await router.push("/login?redirect=/area-riservata/dashboard");
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message); 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 = const message =
reason === "expired" reason === "Expired"
? "L'invito è scaduto, è passato troppo tempo dall'invio." ? "L'invito è scaduto, è passato troppo tempo dall'invio."
: "Non è stato trovato alcun invito valido per questo collegamento."; : "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 }); const verification = await helper.invite.verifyInvite.fetch({ token, email });
if (!verification.ok && verification.error._tag === "InternalError") {
return redirectTo500;
}
return { return {
props: { props: {
verification, verification,

View file

@ -149,14 +149,15 @@ export const getServerSideProps = (async (context) => {
const result = await helper.trpc.pagamenti.preparePaymentRinnovi.fetch({ const result = await helper.trpc.pagamenti.preparePaymentRinnovi.fetch({
rinnovoId: parsedRinnovoId.data, rinnovoId: parsedRinnovoId.data,
}); });
if (!result.success) { if (!result.ok) {
console.error("Error preparing payment", result.message); // error type non usato prienamente per ora, logghiamo tutto con messaggio
console.error("Error preparing payment", result.error.message);
return redirectTo500; return redirectTo500;
} }
return { return {
props: { props: {
stripeDisabled: flag || false, stripeDisabled: flag || false,
data: result.data, data: result.value,
saldoPreview: null, saldoPreview: null,
}, },
}; };
@ -176,8 +177,9 @@ export const getServerSideProps = (async (context) => {
type, type,
servizioId: parsedServizioId.data, servizioId: parsedServizioId.data,
}); });
if (!result.success) { if (!result.ok) {
console.error("Error preparing payment", result.message); // error type non usato prienamente per ora, logghiamo tutto con messaggio
console.error("Error preparing payment", result.error.message);
return redirectTo500; return redirectTo500;
} }
let saldoPreview: Prezziario | null = null; let saldoPreview: Prezziario | null = null;
@ -193,7 +195,7 @@ export const getServerSideProps = (async (context) => {
return { return {
props: { props: {
stripeDisabled: flag || false, stripeDisabled: flag || false,
data: result.data, data: result.value,
saldoPreview, saldoPreview,
}, },
}; };

View file

@ -6,16 +6,13 @@ import {
createTRPCRouter, createTRPCRouter,
publicProcedure, publicProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { swapAuth } from "~/server/controllers/auth.controller";
import { import {
consumeInviteToken,
createInviteToken, createInviteToken,
InviteAcceptance,
InviteVerification,
sendInvitoEmail, sendInvitoEmail,
verifyInviteToken,
} from "~/server/controllers/invites.controller"; } from "~/server/controllers/invites.controller";
import { editAccountHandler } from "~/server/controllers/user.controller";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { generateSalt, hashPassword } from "~/server/services/password.service";
import { findUser_byEmail, getUser } from "~/server/services/user.service"; import { findUser_byEmail, getUser } from "~/server/services/user.service";
import { zUserId } from "~/server/utils/zod_types"; import { zUserId } from "~/server/utils/zod_types";
@ -90,31 +87,8 @@ export const inviteRouter = createTRPCRouter({
// Verify invite token (public - user clicking the link) // Verify invite token (public - user clicking the link)
verifyInvite: publicProcedure verifyInvite: publicProcedure
.input(z.object({ token: z.string(), email: z.string().nullable() })) .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 }) => { .query(async ({ input }) => {
if (input.email) { return await InviteVerification(input.token, 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 };
}), }),
// Accept invite and set password // Accept invite and set password
@ -127,39 +101,11 @@ export const inviteRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const user = await findUser_byEmail({ email: input.email }); return await InviteAcceptance(
if (!user) { ctx,
throw new TRPCError({ input.token,
code: "NOT_FOUND", input.email,
message: `Accept Invite: Utente non trovato con email: ${input.email}`, input.password,
}); );
}
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 };
}), }),
}); });

View file

@ -6,6 +6,7 @@ import {
getPagamentoDataForCheckout, getPagamentoDataForCheckout,
getRicevutaData, getRicevutaData,
type PreparedPaymentData, type PreparedPaymentData,
type PreparePaymentError,
preparePayment, preparePayment,
previewSaldo, previewSaldo,
} from "~/server/controllers/pagamenti.controller"; } from "~/server/controllers/pagamenti.controller";
@ -13,6 +14,7 @@ import {
import { db } from "~/server/db"; import { db } from "~/server/db";
import { TypstGenerate } from "~/server/services/typst.service"; import { TypstGenerate } from "~/server/services/typst.service";
import { zOrdineId, zRinnovoId, zServizioId } from "~/server/utils/zod_types"; 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 // Create a new ratelimiter, that allows 10 requests per 10 seconds
/* /*
const ratelimit = new RateLimiterHandler({ const ratelimit = new RateLimiterHandler({
@ -47,23 +49,15 @@ export const pagamentiRouter = createTRPCRouter({
type: z.enum(OrderTypeEnum), type: z.enum(OrderTypeEnum),
}), }),
) )
.output( .output(zResult<PreparedPaymentData, PreparePaymentError>())
z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
data: z.custom<PreparedPaymentData>(),
}),
z.object({
success: z.literal(false),
message: z.string(),
}),
]),
)
.query(async ({ input }) => { .query(async ({ input }) => {
if (input.type === OrderTypeEnum.Rinnovo) { if (input.type === OrderTypeEnum.Rinnovo) {
return { return {
success: false, ok: false,
message: "Use preparePaymentRinnovi for Rinnovo payments", error: {
_tag: "InvalidInput",
message: "Use preparePaymentRinnovi for Rinnovo payments",
},
}; };
} }
return await preparePayment({ return await preparePayment({
@ -86,18 +80,7 @@ export const pagamentiRouter = createTRPCRouter({
rinnovoId: zRinnovoId, rinnovoId: zRinnovoId,
}), }),
) )
.output( .output(zResult<PreparedPaymentData, PreparePaymentError>())
z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
data: z.custom<PreparedPaymentData>(),
}),
z.object({
success: z.literal(false),
message: z.string(),
}),
]),
)
.query(async ({ input }) => { .query(async ({ input }) => {
return await preparePayment({ return await preparePayment({
type: OrderTypeEnum.Rinnovo, type: OrderTypeEnum.Rinnovo,

View file

@ -1,7 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { env } from "~/env"; import { env } from "~/env";
import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils"; import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
import type { Result } from "~/lib/result";
import { getStorageUrl } from "~/lib/storage_utils"; import { getStorageUrl } from "~/lib/storage_utils";
import type { import type {
Annunci, Annunci,
@ -14,6 +13,7 @@ import type { VideosRefs } from "~/schemas/public/VideosRefs";
import { db } from "~/server/db"; import { db } from "~/server/db";
import type { AnnuncioTemplateData } from "~/server/services/typst.service"; import type { AnnuncioTemplateData } from "~/server/services/typst.service";
import { withImages, withVideos } from "~/utils/kysely-helper"; import { withImages, withVideos } from "~/utils/kysely-helper";
import type { Result } from "~/utils/result";
import { revalidate } from "../utils/revalidationHelper"; import { revalidate } from "../utils/revalidationHelper";
// const ratelimit = new RateLimiterHandler({ // const ratelimit = new RateLimiterHandler({
@ -112,11 +112,16 @@ export type AnnuncioData = Pick<
ogUrl: string; ogUrl: string;
ogImage: string; ogImage: string;
}; };
type getAnnuncioDataError =
| { _tag: "NoAnnuncio" }
| { _tag: "MalformedAnnuncio" }
| { _tag: "InternalError"; cause: unknown };
export const getAnnuncioData = async ({ export const getAnnuncioData = async ({
cod, cod,
}: { }: {
cod: string; cod: string;
}): Promise<Result<AnnuncioData>> => { }): Promise<Result<AnnuncioData, getAnnuncioDataError>> => {
try { try {
const annuncio = await db const annuncio = await db
.selectFrom("annunci") .selectFrom("annunci")
@ -157,11 +162,11 @@ export const getAnnuncioData = async ({
.where("codice", "=", cod) .where("codice", "=", cod)
.executeTakeFirst(); .executeTakeFirst();
if (!annuncio) { if (!annuncio) {
return { success: false, message: "Annuncio non trovato" }; return { ok: false, error: { _tag: "NoAnnuncio" } };
} }
const { tipo, media_updated_at, ...rest } = annuncio; const { tipo, media_updated_at, ...rest } = annuncio;
if (tipo === null) { if (tipo === null) {
return { success: false, message: "Annuncio non trovato" }; return { ok: false, error: { _tag: "MalformedAnnuncio" } };
} }
const ogImage = const ogImage =
annuncio.images && annuncio.images.length > 0 && annuncio.images[0] annuncio.images && annuncio.images.length > 0 && annuncio.images[0]
@ -175,8 +180,8 @@ export const getAnnuncioData = async ({
}) })
: `${env.BASE_URL}/og.jpg`; : `${env.BASE_URL}/og.jpg`;
return { return {
success: true, ok: true,
data: { value: {
...rest, ...rest,
media_updated_at: media_updated_at?.toISOString() || null, media_updated_at: media_updated_at?.toISOString() || null,
tipo, tipo,
@ -185,11 +190,7 @@ export const getAnnuncioData = async ({
}, },
}; };
} catch (e) { } catch (e) {
throw new TRPCError({ return { ok: false, error: { _tag: "InternalError", cause: e } };
cause: (e as Error).cause,
code: "INTERNAL_SERVER_ERROR",
message: `Errore query getAnnunciByCod: ${(e as Error).message}`,
});
} }
}; };

View file

@ -19,6 +19,7 @@ import {
findUser_byId_MINI, findUser_byId_MINI,
} from "~/server/services/user.service"; } from "~/server/services/user.service";
import { checkCtx } from "~/server/utils/ctxChecker"; import { checkCtx } from "~/server/utils/ctxChecker";
import type { Result } from "~/utils/result";
import { TOKEN_CONFIG } from "../auth/configs"; import { TOKEN_CONFIG } from "../auth/configs";
export const createUserAdmin = async ({ export const createUserAdmin = async ({
@ -68,6 +69,10 @@ export const createUserAdmin = async ({
return { status: "success" as const }; return { status: "success" as const };
}; };
type LoginErrors =
| { _tag: "NotFound"; message: string }
| { _tag: "Fail"; message: string };
// FLOWS: login | 2 | Login function // FLOWS: login | 2 | Login function
export const logIn = async ({ export const logIn = async ({
email, email,
@ -77,7 +82,7 @@ export const logIn = async ({
email: string; email: string;
password: string; password: string;
ctx: Context; ctx: Context;
}) => { }): Promise<Result<null, LoginErrors>> => {
try { try {
checkCtx({ req, res }); checkCtx({ req, res });
@ -86,17 +91,23 @@ export const logIn = async ({
if (!user) { if (!user) {
return { return {
status: "not-found" as const, ok: false,
message: error: {
"Questo utente non esiste. Devi avere un account per effettuare il login.", _tag: "NotFound",
message:
"Questo utente non esiste. Devi avere un account per effettuare il login.",
},
}; };
} }
//Check if user is blocked or unverified //Check if user is blocked or unverified
if (user.isBlocked) { if (user.isBlocked) {
return { return {
status: "fail" as const, ok: false,
message: "Errore in Login, non è possibile effettuare il login.", error: {
_tag: "Fail",
message: "Errore in Login, non è possibile effettuare il login.",
},
}; };
} }
@ -112,8 +123,11 @@ export const logIn = async ({
if (!passworIsMatch) { if (!passworIsMatch) {
return { return {
status: "fail" as const, ok: false,
message: "Password errata, riprova.", error: {
_tag: "Fail",
message: "Password errata, riprova.",
},
}; };
} }
} }
@ -139,37 +153,29 @@ export const logIn = async ({
"refreshToken", "refreshToken",
); );
try { await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, {
await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, { ...TOKEN_CONFIG.COOKIE_OPTIONS,
...TOKEN_CONFIG.COOKIE_OPTIONS, expires: add(new Date(), {
expires: add(new Date(), { hours: TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_EXPIRY,
hours: TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_EXPIRY, }),
}), req,
req, res,
res, });
}); await setCookie(TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
await setCookie(TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, refreshToken, { ...TOKEN_CONFIG.COOKIE_OPTIONS,
...TOKEN_CONFIG.COOKIE_OPTIONS, expires: add(new Date(), {
expires: add(new Date(), { days: TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_EXPIRY,
days: TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_EXPIRY, }),
}), req,
req, res,
res, });
});
return { status: "success" as const }; return { ok: true, value: null };
} 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}`,
});
}
} catch (e) { } catch (e) {
console.error("Login error:", e); console.error("Login error:", e);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: `Errore login: ${(e as Error).message}`, message: `Errore durante il login: ${(e as Error).message}`,
}); });
} }
}; };

View file

@ -2,7 +2,13 @@ import crypto from "node:crypto";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { UserInvites } from "~/schemas/public/UserInvites"; import type { UserInvites } from "~/schemas/public/UserInvites";
import type { UsersId } from "~/schemas/public/Users"; 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 { 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"; import { NewMail } from "../services/mailer";
const INVITE_EXPIRY_HOURS = 48; const INVITE_EXPIRY_HOURS = 48;
@ -31,19 +37,109 @@ export async function createInviteToken(email: string) {
} }
return token; return token;
} }
export type InviteErrors =
| { _tag: "AlreadyUser" }
| { _tag: "InternalError"; message: string }
| TokenVerErrors;
type VerifyInviteResult = export const InviteVerification = async (
token: string,
email: string | null,
): Promise<Result<string, InviteErrors>> => {
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; _tag: "UserNotFound";
invite: UserInvites; message: string;
} }
| { | TokenVerErrors;
status: false; export const InviteAcceptance = async (
reason: "not_found" | "expired"; ctx: Context,
}; token: string,
email: string,
password: string,
): Promise<Result<UserInvites, InviteAcceptanceErrors>> => {
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( export async function verifyInviteToken(
token: string, token: string,
): Promise<VerifyInviteResult> { ): Promise<Result<UserInvites, TokenVerErrors>> {
try { try {
const invite = await db const invite = await db
.selectFrom("user_invites") .selectFrom("user_invites")
@ -52,13 +148,13 @@ export async function verifyInviteToken(
//.where("expires_at", ">", new Date()) //.where("expires_at", ">", new Date())
.executeTakeFirst(); .executeTakeFirst();
if (!invite) { if (!invite) {
return { status: false, reason: "not_found" }; return { ok: false, error: { _tag: "TokenNotFound" } };
} }
if (invite.expires_at < new Date()) { 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) { } catch (e) {
console.error("Error verifying invite token:", e); console.error("Error verifying invite token:", e);
throw new TRPCError({ throw new TRPCError({

View file

@ -13,6 +13,7 @@ import type { ServizioServizioId } from "~/schemas/public/Servizio";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum"; import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { getComuni, getNazioni } from "~/server/controllers/catasto.controller"; import { getComuni, getNazioni } from "~/server/controllers/catasto.controller";
import type { RicevutaProps } from "~/server/services/typst.service"; import type { RicevutaProps } from "~/server/services/typst.service";
import type { Result } from "~/utils/result";
import { db } from "../db"; import { db } from "../db";
import { createOrdine } from "../services/ordini.service"; import { createOrdine } from "../services/ordini.service";
import { import {
@ -40,12 +41,14 @@ type preparePaymentInput =
type: OrderTypeEnum.Rinnovo; 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 ( export const preparePayment = async (
props: preparePaymentInput, props: preparePaymentInput,
): Promise< ): Promise<Result<PreparedPaymentData, PreparePaymentError>> => {
| { success: true; data: PreparedPaymentData }
| { success: false; message: string }
> => {
try { try {
switch (props.type) { switch (props.type) {
case OrderTypeEnum.Acconto: case OrderTypeEnum.Acconto:
@ -68,8 +71,11 @@ export const preparePayment = async (
const acconto = await AccontoSolver(servizio.tipologia); const acconto = await AccontoSolver(servizio.tipologia);
if (!acconto.success) { if (!acconto.success) {
return { return {
success: false, ok: false,
message: `Acconto non trovato per il servizio: ${acconto.message}`, error: {
_tag: "PrezziarioNotFound",
message: `Acconto non trovato per il servizio: ${acconto.message}`,
},
}; };
} }
@ -82,8 +88,8 @@ export const preparePayment = async (
if (preExistingAcconto) { if (preExistingAcconto) {
// acconto già creato, procedere con questo // acconto già creato, procedere con questo
return { return {
success: true, ok: true,
data: { value: {
ordine_id: preExistingAcconto.ordine_id, ordine_id: preExistingAcconto.ordine_id,
packid: preExistingAcconto.packid, packid: preExistingAcconto.packid,
amount_cent: preExistingAcconto.amount_cent, amount_cent: preExistingAcconto.amount_cent,
@ -105,8 +111,8 @@ export const preparePayment = async (
userid: servizio.user_id, userid: servizio.user_id,
}); });
return { return {
success: true, ok: true,
data: { value: {
ordine_id: ordine.ordine_id, ordine_id: ordine.ordine_id,
packid: acconto.pack.idprezziario, packid: acconto.pack.idprezziario,
amount_cent: acconto.pack.prezzo_cent, amount_cent: acconto.pack.prezzo_cent,
@ -119,17 +125,23 @@ export const preparePayment = async (
}; };
} }
return { return {
success: false, ok: false,
message: error: {
"Il servizio ha già un acconto attivo, non è possibile preparare un nuovo pagamento", _tag: "AlreadyPaid",
message:
"Il servizio ha già un acconto attivo, non è possibile preparare un nuovo pagamento",
},
}; };
} }
case OrderTypeEnum.Saldo: { case OrderTypeEnum.Saldo: {
if (!servizio.isOkAcconto) { if (!servizio.isOkAcconto) {
return { return {
success: false, ok: false,
message: error: {
"Non è possibile procedere con il pagamento del saldo prima di aver attivato l'acconto", _tag: "InvalidInput",
message:
"Non è possibile procedere con il pagamento del saldo prima di aver attivato l'acconto",
},
}; };
} }
if (!servizio.isOkSaldo) { if (!servizio.isOkSaldo) {
@ -141,8 +153,11 @@ export const preparePayment = async (
}); });
if (!saldo.success) { if (!saldo.success) {
return { return {
success: false, ok: false,
message: `Saldo non trovato per il servizio: ${saldo.message}`, error: {
_tag: "PrezziarioNotFound",
message: `Saldo non trovato per il servizio: ${saldo.message}`,
},
}; };
} }
@ -152,8 +167,8 @@ export const preparePayment = async (
if (preExistingSaldo) { if (preExistingSaldo) {
// saldo già creato, procedere con questo // saldo già creato, procedere con questo
return { return {
success: true, ok: true,
data: { value: {
ordine_id: preExistingSaldo.ordine_id, ordine_id: preExistingSaldo.ordine_id,
packid: preExistingSaldo.packid, packid: preExistingSaldo.packid,
amount_cent: preExistingSaldo.amount_cent, amount_cent: preExistingSaldo.amount_cent,
@ -174,8 +189,8 @@ export const preparePayment = async (
userid: servizio.user_id, userid: servizio.user_id,
}); });
return { return {
success: true, ok: true,
data: { value: {
ordine_id: ordine.ordine_id, ordine_id: ordine.ordine_id,
packid: saldo.pack.idprezziario, packid: saldo.pack.idprezziario,
amount_cent: saldo.pack.prezzo_cent, amount_cent: saldo.pack.prezzo_cent,
@ -187,18 +202,24 @@ export const preparePayment = async (
}; };
} }
return { return {
success: false, ok: false,
message: error: {
"Il servizio ha già un saldo attivo, non è possibile preparare un nuovo pagamento", _tag: "AlreadyPaid",
message:
"Il servizio ha già un saldo attivo, non è possibile preparare un nuovo pagamento",
},
}; };
} }
case OrderTypeEnum.Consulenza: { case OrderTypeEnum.Consulenza: {
if (!servizio.isOkAcconto || !servizio.isOkSaldo) { if (!servizio.isOkAcconto || !servizio.isOkSaldo) {
return { return {
success: false, ok: false,
message: error: {
"Non è possibile procedere con il pagamento della consulenza prima di aver attivato acconto e saldo", _tag: "InvalidInput",
message:
"Non è possibile procedere con il pagamento della consulenza prima di aver attivato acconto e saldo",
},
}; };
} }
if (!servizio.isOkConsulenza) { if (!servizio.isOkConsulenza) {
@ -212,14 +233,17 @@ export const preparePayment = async (
); );
if (!consulenzaPack) { if (!consulenzaPack) {
return { return {
success: false, ok: false,
message: `Pack della consulenza non trovato: ${existingConsulenza.packid}`, error: {
_tag: "PrezziarioNotFound",
message: `Pack della consulenza non trovato: ${existingConsulenza.packid}`,
},
}; };
} }
return { return {
success: true, ok: true,
data: { value: {
ordine_id: existingConsulenza.ordine_id, ordine_id: existingConsulenza.ordine_id,
packid: existingConsulenza.packid, packid: existingConsulenza.packid,
amount_cent: existingConsulenza.amount_cent, amount_cent: existingConsulenza.amount_cent,
@ -233,16 +257,22 @@ export const preparePayment = async (
} }
return { return {
success: false, ok: false,
message: error: {
"Il servizio non ha un pacchetto di consulenza, è necessario inserirlo prima di procedere", _tag: "InvalidInput",
message:
"Il servizio non ha un pacchetto di consulenza, è necessario inserirlo prima di procedere",
},
}; };
} }
return { return {
success: false, ok: false,
message: error: {
"Il servizio ha già una consulenza attiva, non è possibile preparare un nuovo pagamento", _tag: "AlreadyPaid",
message:
"Il servizio ha già una consulenza attiva, non è possibile preparare un nuovo pagamento",
},
}; };
} }
case OrderTypeEnum.Altro: { case OrderTypeEnum.Altro: {
@ -256,14 +286,17 @@ export const preparePayment = async (
); );
if (!altroPack) { if (!altroPack) {
return { return {
success: false, ok: false,
message: `Pack dell'altro non trovato: ${existingAltro.packid}`, error: {
_tag: "PrezziarioNotFound",
message: `Pack dell'altro non trovato: ${existingAltro.packid}`,
},
}; };
} }
return { return {
success: true, ok: true,
data: { value: {
ordine_id: existingAltro.ordine_id, ordine_id: existingAltro.ordine_id,
packid: existingAltro.packid, packid: existingAltro.packid,
amount_cent: existingAltro.amount_cent, amount_cent: existingAltro.amount_cent,
@ -276,9 +309,12 @@ export const preparePayment = async (
} }
return { return {
success: false, ok: false,
message: error: {
"La preparazione del pagamento per questa tipologia non è supportata", _tag: "InvalidInput",
message:
"La preparazione del pagamento per questa tipologia non è supportata",
},
}; };
} }
default: default:
@ -300,9 +336,12 @@ export const preparePayment = async (
const alreadyPaid = ordiniRinnovo.some((ordine) => ordine.isActive); const alreadyPaid = ordiniRinnovo.some((ordine) => ordine.isActive);
if (alreadyPaid) { if (alreadyPaid) {
return { return {
success: false, ok: false,
message: error: {
"Il rinnovo ha già un ordine attivo, non è possibile preparare un nuovo pagamento", _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) { if (!rinnovoPack) {
return { return {
success: false, ok: false,
message: `Pack del rinnovo non trovato: ${unusedOrdine.packid}`, error: {
_tag: "PrezziarioNotFound",
message: `Pack del rinnovo non trovato: ${unusedOrdine.packid}`,
},
}; };
} }
return { return {
success: true, ok: true,
data: { value: {
ordine_id: unusedOrdine.ordine_id, ordine_id: unusedOrdine.ordine_id,
packid: unusedOrdine.packid, packid: unusedOrdine.packid,
amount_cent: unusedOrdine.amount_cent, amount_cent: unusedOrdine.amount_cent,
@ -343,8 +385,11 @@ export const preparePayment = async (
const pack = await RinnovoSolver(rinnovo.decorrenza, rinnovo.scadenza); const pack = await RinnovoSolver(rinnovo.decorrenza, rinnovo.scadenza);
if (!pack.success) { if (!pack.success) {
return { return {
success: false, ok: false,
message: `Pack non trovato per il rinnovo: ${pack.message}`, error: {
_tag: "PrezziarioNotFound",
message: `Pack non trovato per il rinnovo: ${pack.message}`,
},
}; };
} }
@ -356,8 +401,8 @@ export const preparePayment = async (
userid: rinnovo.userId, userid: rinnovo.userId,
}); });
return { return {
success: true, ok: true,
data: { value: {
ordine_id: ordine.ordine_id, ordine_id: ordine.ordine_id,
packid: pack.pack.idprezziario, packid: pack.pack.idprezziario,
amount_cent: pack.pack.prezzo_cent, amount_cent: pack.pack.prezzo_cent,

View file

@ -0,0 +1,15 @@
import z from "zod";
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export const zResult = <T, E>() =>
z.discriminatedUnion("ok", [
z.object({
ok: z.literal(true),
value: z.custom<T>(),
}),
z.object({
ok: z.literal(false),
error: z.custom<E>(),
}),
]);

View file

@ -31,3 +31,7 @@ export function titleCase(str: string | null | undefined): string {
} }
export const startsWithVowel = (s: string) => /^[aeiouàèéìòù]/i.test(s); export const startsWithVowel = (s: string) => /^[aeiouàèéìòù]/i.test(s);
export function assertNever(x: never): never {
throw new Error(`Unexpected: ${JSON.stringify(x)}`);
}