feat: refactor error handling and response structure in authentication and payment processes
This commit is contained in:
parent
a03326f036
commit
2676dc61ed
13 changed files with 391 additions and 257 deletions
|
|
@ -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":
|
||||
if (!data.ok) {
|
||||
switch (data.error._tag) {
|
||||
case "NotFound":
|
||||
setUserNotFound(true);
|
||||
toast.error(data.message);
|
||||
toast.error(`${t.auth.login.fail}\n${data.error.message}`);
|
||||
return;
|
||||
case "fail":
|
||||
toast.error(data.message);
|
||||
case "Fail":
|
||||
toast.error(`${t.auth.login.fail}\n${data.error.message}`);
|
||||
return;
|
||||
case "success":
|
||||
default:
|
||||
assertNever(data.error);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
const [userNotFound, setUserNotFound] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; message: string };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, InviteErrors>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -68,16 +64,20 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
|
|||
verification,
|
||||
token,
|
||||
}: InviteTokenProps) => {
|
||||
switch (verification.status) {
|
||||
case "valid":
|
||||
return <ValidInvitePage email={verification.email} token={token} />;
|
||||
case "invalid":
|
||||
return <InvalidInvitePage reason={verification.reason} />;
|
||||
case "already_user":
|
||||
if (!verification.ok) {
|
||||
switch (verification.error._tag) {
|
||||
case "AlreadyUser":
|
||||
return <AlreadyUserPage />;
|
||||
case "Expired":
|
||||
case "TokenNotFound":
|
||||
return <InvalidInvitePage reason={verification.error._tag} />;
|
||||
case "InternalError":
|
||||
return <Status500 />;
|
||||
default:
|
||||
return <InvalidInvitePage reason="not_found" />;
|
||||
assertNever(verification.error);
|
||||
}
|
||||
}
|
||||
return <ValidInvitePage email={verification.value} token={token} />;
|
||||
};
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<PreparedPaymentData>(),
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
message: z.string(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.output(zResult<PreparedPaymentData, PreparePaymentError>())
|
||||
.query(async ({ input }) => {
|
||||
if (input.type === OrderTypeEnum.Rinnovo) {
|
||||
return {
|
||||
success: false,
|
||||
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<PreparedPaymentData>(),
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
message: z.string(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.output(zResult<PreparedPaymentData, PreparePaymentError>())
|
||||
.query(async ({ input }) => {
|
||||
return await preparePayment({
|
||||
type: OrderTypeEnum.Rinnovo,
|
||||
|
|
|
|||
|
|
@ -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<Result<AnnuncioData>> => {
|
||||
}): Promise<Result<AnnuncioData, getAnnuncioDataError>> => {
|
||||
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 } };
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Result<null, LoginErrors>> => {
|
||||
try {
|
||||
checkCtx({ req, res });
|
||||
|
||||
|
|
@ -86,17 +91,23 @@ export const logIn = async ({
|
|||
|
||||
if (!user) {
|
||||
return {
|
||||
status: "not-found" as const,
|
||||
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,
|
||||
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,
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "Fail",
|
||||
message: "Password errata, riprova.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +153,6 @@ export const logIn = async ({
|
|||
"refreshToken",
|
||||
);
|
||||
|
||||
try {
|
||||
await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, {
|
||||
...TOKEN_CONFIG.COOKIE_OPTIONS,
|
||||
expires: add(new Date(), {
|
||||
|
|
@ -157,19 +170,12 @@ export const logIn = async ({
|
|||
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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
| {
|
||||
status: true;
|
||||
invite: UserInvites;
|
||||
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" } };
|
||||
}
|
||||
| {
|
||||
status: false;
|
||||
reason: "not_found" | "expired";
|
||||
}
|
||||
|
||||
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 =
|
||||
| {
|
||||
_tag: "UserNotFound";
|
||||
message: string;
|
||||
}
|
||||
| TokenVerErrors;
|
||||
export const InviteAcceptance = async (
|
||||
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(
|
||||
token: string,
|
||||
): Promise<VerifyInviteResult> {
|
||||
): Promise<Result<UserInvites, TokenVerErrors>> {
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<Result<PreparedPaymentData, PreparePaymentError>> => {
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "InvalidInput",
|
||||
message:
|
||||
"Il servizio non ha un pacchetto di consulenza, è necessario inserirlo prima di procedere",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
|
|
|
|||
15
apps/infoalloggi/src/utils/result.ts
Normal file
15
apps/infoalloggi/src/utils/result.ts
Normal 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>(),
|
||||
}),
|
||||
]);
|
||||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue