Refactor service document handling and contact modal

- Removed temp_tokens data from SQL initialization.
- Updated AllegatiComp to invalidate additional service announcements on file removal.
- Simplified imports in interactions.tsx and removed unused components.
- Enhanced ServizioContent layout for better display of announcements.
- Adjusted button styles in servizio_annunci_accordions for responsiveness.
- Streamlined ricerca-table by removing unnecessary ServizioProvider wrapping.
- Modified upload modal to support new upload callback structure.
- Added new document reference fields to Servizio schema.
- Introduced UsersStorageUserStorageId type for better type safety.
- Updated servizio.controller to include document references in service announcements.
- Changed storage.controller to return user_storage_id on conflict.
- Updated docker-compose to streamline storage service configuration.
- Created migration script for new foreign key constraints on document references.
- Implemented ContattiProprietarioModal to manage document uploads and contact unlocking.
This commit is contained in:
Marco Pedone 2025-10-29 09:39:06 +01:00
parent f3c4f231a9
commit bd5a35a172
15 changed files with 582 additions and 403 deletions

View file

@ -146,17 +146,16 @@ CREATE TABLE public.annunci (
stato text,
web boolean,
caratteristiche json,
url_immagini text[],
homepage boolean,
url_video text[],
email text,
creato_il timestamp without time zone,
modificato_il timestamp without time zone,
consegna integer,
og_url text,
disponibile_da date,
permanenza integer[],
persone text[]
persone text[],
updated_at timestamp without time zone
);
@ -394,6 +393,21 @@ CREATE TABLE public.flags (
ALTER TABLE public.flags OWNER TO postgres;
--
-- Name: images_refs; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.images_refs (
codice text NOT NULL,
ordine integer NOT NULL,
img text NOT NULL,
thumb text NOT NULL,
og_url text NOT NULL
);
ALTER TABLE public.images_refs OWNER TO postgres;
--
-- Name: messages; Type: TABLE; Schema: public; Owner: postgres
--
@ -410,18 +424,6 @@ CREATE TABLE public.messages (
ALTER TABLE public.messages OWNER TO postgres;
--
-- Name: miogest_images_ref; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.miogest_images_ref (
codice text NOT NULL,
foto text[]
);
ALTER TABLE public.miogest_images_ref OWNER TO postgres;
--
-- Name: ordini; Type: TABLE; Schema: public; Owner: postgres
--
@ -526,7 +528,10 @@ CREATE TABLE public.servizio (
permanenza integer,
"isOkAcconto" boolean DEFAULT false NOT NULL,
"isOkSaldo" boolean DEFAULT false NOT NULL,
"isOkConsulenza" boolean DEFAULT false NOT NULL
"isOkConsulenza" boolean DEFAULT false NOT NULL,
doc_personale_fronte_ref uuid,
doc_personale_retro_ref uuid,
doc_motivazione_ref uuid
);
@ -541,7 +546,7 @@ CREATE TABLE public.servizio_annunci (
annunci_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL,
open_contatti_at timestamp without time zone,
doc_conferma_ref uuid,
doc_conferma_ref text,
user_confirmed_at timestamp without time zone,
accettato_conferma_at timestamp without time zone,
caparra_causale text,
@ -549,12 +554,12 @@ CREATE TABLE public.servizio_annunci (
caparra_importo text,
caparra_intestazione text,
"hasConfermaAdmin" boolean DEFAULT false NOT NULL,
doc_contratto_ref uuid,
doc_contratto_ref text,
gestionale_id integer,
contratto_decorrenza date,
contratto_scadenza date,
contratto_tipo text,
doc_registrazione_ref uuid,
doc_registrazione_ref text,
doc_conferma_added boolean DEFAULT false NOT NULL,
doc_contratto_added boolean DEFAULT false NOT NULL,
doc_registrazione_added boolean DEFAULT false NOT NULL
@ -575,34 +580,6 @@ CREATE TABLE public.servizio_interessi (
ALTER TABLE public.servizio_interessi OWNER TO postgres;
--
-- Name: storageindex; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.storageindex (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL,
filename text,
ext text,
from_admin boolean DEFAULT false NOT NULL,
expires_at timestamp without time zone
);
ALTER TABLE public.storageindex OWNER TO postgres;
--
-- Name: temp_tokens; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.temp_tokens (
token uuid DEFAULT gen_random_uuid() NOT NULL,
elapse timestamp without time zone DEFAULT (now() + '00:00:30'::interval) NOT NULL
);
ALTER TABLE public.temp_tokens OWNER TO postgres;
--
-- Name: testi_e_stringhe; Type: TABLE; Schema: public; Owner: postgres
--
@ -677,8 +654,10 @@ ALTER TABLE public.users_anagrafica OWNER TO postgres;
--
CREATE TABLE public.users_storage (
userid uuid NOT NULL,
storageid uuid NOT NULL
"userId" uuid NOT NULL,
"storageId" text NOT NULL,
from_admin boolean DEFAULT false NOT NULL,
user_storage_id uuid DEFAULT gen_random_uuid() NOT NULL
);
@ -791,6 +770,14 @@ ALTER TABLE ONLY public.event_queue
ADD CONSTRAINT event_queue_pkey PRIMARY KEY (event_id);
--
-- Name: images_refs img_cod_order; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.images_refs
ADD CONSTRAINT img_cod_order UNIQUE (codice, ordine);
--
-- Name: messages messages_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
@ -799,14 +786,6 @@ ALTER TABLE ONLY public.messages
ADD CONSTRAINT messages_pkey PRIMARY KEY (messageid);
--
-- Name: miogest_images_ref miogest_images_ref_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.miogest_images_ref
ADD CONSTRAINT miogest_images_ref_pkey PRIMARY KEY (codice);
--
-- Name: ordini ordini2_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
@ -847,30 +826,6 @@ ALTER TABLE ONLY public.flags
ADD CONSTRAINT sitesettings_pkey PRIMARY KEY (id);
--
-- Name: users_storage sotrageuser; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.users_storage
ADD CONSTRAINT sotrageuser UNIQUE (userid, storageid);
--
-- Name: storageindex storageindex_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.storageindex
ADD CONSTRAINT storageindex_pkey PRIMARY KEY (id);
--
-- Name: temp_tokens temp_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.temp_tokens
ADD CONSTRAINT temp_tokens_pkey PRIMARY KEY (token);
--
-- Name: testi_e_stringhe testi_e_stringhe_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
@ -895,6 +850,14 @@ ALTER TABLE ONLY public.servizio_annunci
ADD CONSTRAINT uniq_annuncio_servizio UNIQUE (annunci_id, servizio_id);
--
-- Name: users_storage unique_user_storage; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.users_storage
ADD CONSTRAINT unique_user_storage UNIQUE ("userId", "storageId");
--
-- Name: servizio_interessi uniqui_interest; Type: CONSTRAINT; Schema: public; Owner: postgres
--
@ -919,6 +882,14 @@ ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- Name: users_storage users_storage_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.users_storage
ADD CONSTRAINT users_storage_pkey PRIMARY KEY (user_storage_id);
--
-- Name: users_anagrafica usersanagrafica2_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
@ -935,13 +906,6 @@ ALTER TABLE ONLY public.users_anagrafica
ADD CONSTRAINT usserunique2 UNIQUE (userid);
--
-- Name: annunci_immagini_index; Type: INDEX; Schema: public; Owner: postgres
--
CREATE INDEX annunci_immagini_index ON public.annunci USING gin (url_immagini);
--
-- Name: ratelimiter_key_index; Type: INDEX; Schema: public; Owner: postgres
--
@ -957,6 +921,14 @@ ALTER TABLE ONLY public.chats_etichette
ADD CONSTRAINT chat_etichetta_fkey FOREIGN KEY (chatid) REFERENCES public.chats(chatid) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: images_refs cod_image; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.images_refs
ADD CONSTRAINT cod_image FOREIGN KEY (codice) REFERENCES public.annunci(codice);
--
-- Name: emails email_user; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
@ -1070,27 +1042,27 @@ ALTER TABLE ONLY public.servizio_annunci
--
-- Name: servizio_annunci servizio_annuncio_conferma_doc; Type: FK CONSTRAINT; Schema: public; Owner: postgres
-- Name: servizio servizio_doc_fronte; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.servizio_annunci
ADD CONSTRAINT servizio_annuncio_conferma_doc FOREIGN KEY (doc_conferma_ref) REFERENCES public.storageindex(id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID;
ALTER TABLE ONLY public.servizio
ADD CONSTRAINT servizio_doc_fronte FOREIGN KEY (doc_personale_fronte_ref) REFERENCES public.users_storage(user_storage_id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID;
--
-- Name: servizio_annunci servizio_annuncio_contratto_doc; Type: FK CONSTRAINT; Schema: public; Owner: postgres
-- Name: servizio servizio_doc_motivazione; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.servizio_annunci
ADD CONSTRAINT servizio_annuncio_contratto_doc FOREIGN KEY (doc_contratto_ref) REFERENCES public.storageindex(id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID;
ALTER TABLE ONLY public.servizio
ADD CONSTRAINT servizio_doc_motivazione FOREIGN KEY (doc_motivazione_ref) REFERENCES public.users_storage(user_storage_id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID;
--
-- Name: servizio_annunci servizio_annuncio_registraz_doc; Type: FK CONSTRAINT; Schema: public; Owner: postgres
-- Name: servizio servizio_doc_retro; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.servizio_annunci
ADD CONSTRAINT servizio_annuncio_registraz_doc FOREIGN KEY (doc_registrazione_ref) REFERENCES public.storageindex(id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID;
ALTER TABLE ONLY public.servizio
ADD CONSTRAINT servizio_doc_retro FOREIGN KEY (doc_personale_retro_ref) REFERENCES public.users_storage(user_storage_id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID;
--
@ -1117,20 +1089,12 @@ ALTER TABLE ONLY public.users_anagrafica
ADD CONSTRAINT useridfkeyanagrafica2 FOREIGN KEY (userid) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE NOT VALID;
--
-- Name: users_storage users_storage_storageid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.users_storage
ADD CONSTRAINT users_storage_storageid_fkey FOREIGN KEY (storageid) REFERENCES public.storageindex(id) ON UPDATE CASCADE ON DELETE CASCADE NOT VALID;
--
-- Name: users_storage userstorage; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.users_storage
ADD CONSTRAINT userstorage FOREIGN KEY (userid) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE;
ADD CONSTRAINT userstorage FOREIGN KEY ("userId") REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE;
--

View file

@ -87,17 +87,6 @@ SALDOCA500+ 38700 CONDIZIONI_CERCHI t Saldo Cerco Stabile (500+) Stable 500+ Sal
\.
--
-- Data for Name: temp_tokens; Type: TABLE DATA; Schema: public; Owner: postgres
--
COPY public.temp_tokens (token, elapse) FROM stdin;
c256c17d-3299-47a4-b3ef-640aa9688b38 2025-08-22 13:01:41.20164
4bd62a5f-aa6d-403b-a1bb-c0e11e382adc 2025-08-22 13:01:48.897271
5d069230-9572-4b26-aa06-2964f3ca79a2 2030-02-19 10:25:33.046587
\.
--
-- Data for Name: testi_e_stringhe; Type: TABLE DATA; Schema: public; Owner: postgres
--

View file

@ -0,0 +1,20 @@
ALTER TABLE
IF EXISTS public.servizio
ADD
CONSTRAINT servizio_doc_fronte FOREIGN KEY (doc_personale_fronte_ref) REFERENCES public.users_storage ("storageId") MATCH SIMPLE ON UPDATE CASCADE ON DELETE
SET
NULL NOT VALID;
ALTER TABLE
IF EXISTS public.servizio
ADD
CONSTRAINT servizio_doc_retro FOREIGN KEY (doc_personale_retro_ref) REFERENCES public.users_storage ("storageId") MATCH SIMPLE ON UPDATE CASCADE ON DELETE
SET
NULL NOT VALID;
ALTER TABLE
IF EXISTS public.servizio
ADD
CONSTRAINT servizio_doc_motivazione FOREIGN KEY (doc_motivazione_ref) REFERENCES public.users_storage ("storageId") MATCH SIMPLE ON UPDATE CASCADE ON DELETE
SET
NULL NOT VALID;

View file

@ -66,6 +66,8 @@ export const AllegatiComp = ({
await utils.storage.retrieveUserFileData.invalidate({
userId: userId,
});
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
toast.success(t.allegati.allegato_rimosso, {
icon: "🗑️",
id: context?.toastId,

View file

@ -1,18 +1,14 @@
import {
CircleCheck,
CircleUser,
FileBadge,
FileText,
Handshake,
Navigation,
PackageCheck,
Phone,
Pointer,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import {
Credenza,
@ -26,7 +22,6 @@ import {
CredenzaTrigger,
} from "~/components/custom_ui/credenza";
import LoadingButton from "~/components/custom_ui/loading-button";
import { LoadingPage } from "~/components/loading";
import { Button, type ButtonConfigs } from "~/components/ui/button";
import {
Tooltip,
@ -39,7 +34,7 @@ import { useTranslation } from "~/providers/I18nProvider";
import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider";
import type { AnnunciId } from "~/schemas/public/Annunci";
import { api } from "~/utils/api";
import { WhatsAppIcon2 } from "../svgs";
import { ContattiProprietarioModal } from "./modale_contatti";
export const Interactions = () => {
const { servizio, servizioId } = useServizio();
@ -406,225 +401,3 @@ const ConfermaLavorazioneModal = () => {
</Credenza>
);
};
const ContattiProprietarioModal = () => {
const { servizioId, userId } = useServizio();
const { data, annuncioId } = useServizioAnnuncio();
const { t } = useTranslation();
const [showContacts, setShowContacts] = useState<boolean>(
data.open_contatti_at != null,
);
const utils = api.useUtils();
const { mutate } = api.servizio.unlockServizioContatti.useMutation({
onError: (err) => {
toast.error(err.message, {
id: "unlock-contacts",
});
},
onMutate: () => {
toast.loading("Caricamento ...", {
id: "unlock-contacts",
});
},
onSuccess: async (data) => {
if (!data) {
toast.error("Limite di sblocchi raggiunto", {
id: "unlock-contacts",
});
return;
}
toast.success("Successo", {
id: "unlock-contacts",
});
await utils.servizio.getAllServizioAnnunci.invalidate({
userId,
});
setShowContacts(true);
},
});
const d = new Date();
const tooEarly = d.getHours() < 9;
const tooLate = d.getHours() >= 20;
return (
<Credenza>
<CredenzaTrigger asChild>
{showContacts ? (
<Button
aria-label="Vedi contatti proprietario"
className="flex w-fit items-center gap-2"
variant="success"
>
<CircleUser className="size-7" />
Vedi i contatti
</Button>
) : (
<Button
aria-label="Contatta proprietario"
className="flex w-fit items-center gap-2"
variant="warning"
>
<CircleUser className="size-7" />
Ottieni i contatti
</Button>
)}
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]">
<CredenzaHeader>
<CredenzaTitle>{t.contatto.title}</CredenzaTitle>
<CredenzaDescription className="sr-only">
{t.contatto.descrizione}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
{showContacts ? (
<ContattiProprietarioData />
) : (
<div className="space-y-5">
<p>{t.contatto.disclaimer}</p>
<p>{t.contatto.disclaimer2}</p>
<p className="text-muted-foreground text-xs">
{t.contatto.disclaimer3}
</p>
{(tooEarly || tooLate) && (
<div className="flex items-center gap-2 rounded-md bg-yellow-200 p-2">
<TriangleAlert className="size-8" />
{/*TODO testare */}
<p>
Il proprietario riceverà la notifica alle ore 9.00{" "}
{tooLate && "di domani"}
</p>
</div>
)}
</div>
)}
</CredenzaBody>
<CredenzaFooter>
{!showContacts && (
<Button
aria-label="Contatta proprietario"
onClick={() =>
mutate({
annuncioId,
servizioId,
})
}
variant="success"
>
{t.accetta}
</Button>
)}
<CredenzaClose asChild>
<Button aria-label="Chiudi modal credenza" variant="outline">
{t.chiudi}
</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
};
const ContattiProprietarioData = () => {
const { servizioId } = useServizio();
const { annuncioId } = useServizioAnnuncio();
const { data, isLoading } = api.annunci.getProprietarioData.useQuery({
annuncioId,
servizioId,
});
const [navigationUrl, setNavigationUrl] = useState<string | undefined>(
undefined,
);
const { t } = useTranslation();
useEffect(() => {
if (!data || !data.propData) return;
if (typeof window === "undefined") return;
if (
navigator.userAgent.toUpperCase().includes("MAC") ||
navigator.userAgent.toUpperCase().includes("IPAD") ||
navigator.userAgent.toUpperCase().includes("IPHONE") ||
navigator.userAgent.toUpperCase().includes("IOS") ||
navigator.userAgent.toUpperCase().includes("IPOD")
) {
setNavigationUrl(
`maps://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination=${data.propData.lat},${data.propData.lon}`,
);
} else {
setNavigationUrl(
`https://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination=${data.propData.lat},${data.propData.lon}`,
);
}
}, [data]);
if (isLoading) {
return (
<div className="flex justify-center">
<LoadingPage />
</div>
);
}
if (!data || !data.propData) {
return <p>{t.contatto.error}</p>;
}
return (
<div className="flex flex-col gap-2">
<h3 className="font-semibold">{t.contatto.propietario}:</h3>
<p>
{t.contatto.nome}: {data.propData.locatore}
</p>
<a
className="flex items-center gap-2"
href={`tel:${data.propData.numero}`}
>
<Phone />:
<span className="text-blue-600 underline">{data.propData.numero}</span>
<Link href={`https://wa.me/${data.propData.numero}`} target="_blank">
<Button
className="flex items-center gap-2"
size="sm"
variant="success"
>
<WhatsAppIcon2 className="size-4 fill-white" />
WhatsApp
</Button>
</Link>
</a>
<br />
<h3 className="font-semibold">{t.contatto.immobile}:</h3>
<p>
{t.contatto.indirizzo}: {data.propData.indirizzo},{" "}
{data.propData.civico} {data.propData.comune} ({data.propData.provincia}
)
</p>
<a
className="flex w-fit items-center gap-2 rounded-md border border-border px-3 py-2"
href={navigationUrl}
rel="noreferrer"
target="_blank"
>
<Navigation /> <span>{t.contatto.apri_nav}</span>
</a>
<br />
<p>
{t.contatto.sbloccato_il}:{" "}
{data.opened_at.toLocaleString("it", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
<div className="flex items-center gap-2 rounded-md bg-red-500 p-2 text-white">
<TriangleAlert className="size-8" />
<div>
<p>{t.contatto.contatta}</p>
<p className="font-semibold">{t.contatto.orari}</p>
</div>
</div>
</div>
);
};

View file

@ -24,7 +24,6 @@ import {
import { api } from "~/utils/api";
import {
AnnunciCompatibili,
AnnunciInConferma,
AnnunciSelezionati,
} from "./servizio_annunci_accordions";
@ -255,12 +254,22 @@ export const ServizioContent = () => {
))}
</div>
)}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 overflow-y-auto">
{annunciInConferma.map((data) => {
return (
<ServizioAnnuncioProvider data={data} key={data.id}>
<AnnuncioCard className="border-2 border-green-500" />
</ServizioAnnuncioProvider>
);
})}
</div>
<div className="flex items-center justify-between">
<AnnunciInConferma annunci={annunciInConferma} />
<div className="flex flex-col items-center gap-4 sm:flex-row">
<AnnunciSelezionati annunci={annunciNotInConferma} />
<AnnunciCompatibili />
</div>
</div>
</>
}
header={

View file

@ -0,0 +1,378 @@
import {
CircleCheck,
CircleUser,
Navigation,
Phone,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider";
import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
import { api } from "~/utils/api";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "../custom_ui/credenza";
import { LoadingPage } from "../loading";
import { WhatsAppIcon2 } from "../svgs";
import { Button } from "../ui/button";
import { type UploadCallback, UploadModal } from "../upload_modal";
export const ContattiProprietarioModal = () => {
const { servizio } = useServizio();
const { data } = useServizioAnnuncio();
const { t } = useTranslation();
return (
<Credenza>
<CredenzaTrigger asChild>
{data.open_contatti_at != null ? (
<Button
aria-label="Vedi contatti proprietario"
className="flex w-fit items-center gap-2"
variant="success"
>
<CircleUser className="size-7" />
Vedi i contatti
</Button>
) : (
<Button
aria-label="Contatta proprietario"
className="flex w-fit items-center gap-2"
variant="warning"
>
<CircleUser className="size-7" />
Ottieni i contatti
</Button>
)}
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]">
<CredenzaHeader>
<CredenzaTitle>{t.contatto.title}</CredenzaTitle>
<CredenzaDescription className="sr-only">
{t.contatto.descrizione}
</CredenzaDescription>
</CredenzaHeader>
{servizio.doc_personale_fronte == null ||
servizio.doc_personale_retro == null ? (
<DocCheckpoint />
// biome-ignore lint/style/noNestedTernary: <this is fine>
) : data.open_contatti_at == null ? (
<PreUnlock />
) : (
<PostUnlock />
)}
</CredenzaContent>
</Credenza>
);
};
const DocComponent = ({
docInfo,
cb,
}: {
docInfo: {
storageId: string;
ref: UsersStorageUserStorageId | null;
} | null;
cb: UploadCallback;
}) => {
const { isAdmin, userId } = useServizio();
if (!docInfo?.storageId) {
return <UploadModal cb_onUpload={cb} isAdmin={isAdmin} userId={userId} />;
}
return (
<div className="flex items-center gap-2 text-green-500">
<CircleCheck className="size-5" />
<span>Documento caricato</span>
</div>
);
};
const DocCheckpoint = () => {
const { t } = useTranslation();
const { userId, servizio } = useServizio();
const utils = api.useUtils();
const { mutate } = api.servizio.updateServizio.useMutation({
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({
userId,
});
await utils.storage.retrieveUserFileData.invalidate({ userId });
await utils.servizio.getServizio.invalidate({
servizioId: servizio.servizio_id,
});
},
onError: (err) => {
toast.error(`Errore aggiornamento servizio: ${err.message}`);
},
});
return (
<>
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
<div className="space-y-5">
<p>Devi ancora caricare un tuo documento</p>
<div className="flex items-center gap-2">
Fronte documento:{" "}
<DocComponent
cb={({ userStorageIds }) =>
userStorageIds.length > 0 &&
mutate({
servizioId: servizio.servizio_id,
data: {
doc_personale_fronte_ref: userStorageIds[0],
},
})
}
docInfo={servizio.doc_personale_fronte}
/>
</div>
<div className="flex items-center gap-2">
Retro documento:{" "}
<DocComponent
cb={({ userStorageIds }) =>
userStorageIds.length > 0 &&
mutate({
servizioId: servizio.servizio_id,
data: {
doc_personale_retro_ref: userStorageIds[0],
},
})
}
docInfo={servizio.doc_personale_retro}
/>
</div>
<br />
<br />
<p className="text-muted-foreground text-sm">
I tuoi dati saranno conservati solamente per la durata del servizio,
in conformità con la nostra{" "}
<Link className="underline" href="/privacy-policy" target="_blank">
Informativa sulla Privacy
</Link>
</p>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button aria-label="Chiudi modal credenza" variant="outline">
{t.chiudi}
</Button>
</CredenzaClose>
</CredenzaFooter>
</>
);
};
const PreUnlock = () => {
const { userId, servizioId } = useServizio();
const { annuncioId } = useServizioAnnuncio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate } = api.servizio.unlockServizioContatti.useMutation({
onError: (err) => {
toast.error(err.message, {
id: "unlock-contacts",
});
},
onMutate: () => {
toast.loading("Caricamento ...", {
id: "unlock-contacts",
});
},
onSuccess: async (data) => {
if (!data) {
toast.error("Limite di sblocchi raggiunto", {
id: "unlock-contacts",
});
return;
}
toast.success("Successo", {
id: "unlock-contacts",
});
await utils.servizio.getAllServizioAnnunci.invalidate({
userId,
});
},
});
const d = new Date();
const tooEarly = d.getHours() < 9;
const tooLate = d.getHours() >= 20;
return (
<>
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
<div className="space-y-5">
<p>{t.contatto.disclaimer}</p>
<p>{t.contatto.disclaimer2}</p>
<p className="text-muted-foreground text-xs">
{t.contatto.disclaimer3}
</p>
{(tooEarly || tooLate) && (
<div className="flex items-center gap-2 rounded-md bg-yellow-200 p-2">
<TriangleAlert className="size-8" />
{/*TODO testare */}
<p>
Il proprietario riceverà la notifica alle ore 9.00{" "}
{tooLate && "di domani"}
</p>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<Button
aria-label="Contatta proprietario"
onClick={() =>
mutate({
annuncioId,
servizioId,
})
}
variant="success"
>
{t.accetta}
</Button>
<CredenzaClose asChild>
<Button aria-label="Chiudi modal credenza" variant="outline">
{t.chiudi}
</Button>
</CredenzaClose>
</CredenzaFooter>
</>
);
};
function getNavigationUrl(lat: string, lon: string): string {
if (typeof window !== "undefined") {
const isIOS =
navigator.userAgent.toUpperCase().includes("MAC") ||
navigator.userAgent.toUpperCase().includes("IPAD") ||
navigator.userAgent.toUpperCase().includes("IPHONE") ||
navigator.userAgent.toUpperCase().includes("IOS") ||
navigator.userAgent.toUpperCase().includes("IPOD");
return `${isIOS ? "maps" : "https"}://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination=${lat},${lon}`;
}
return `https://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination=${lat},${lon}`;
}
const PostUnlock = () => {
const { servizioId } = useServizio();
const { annuncioId } = useServizioAnnuncio();
const { data, isLoading } = api.annunci.getProprietarioData.useQuery({
annuncioId,
servizioId,
});
const { t } = useTranslation();
// Memoize navigation URL
const navigationUrl = useMemo(() => {
if (!data?.propData || !data.propData.lat || !data.propData.lon)
return undefined;
return getNavigationUrl(data.propData.lat, data.propData.lon);
}, [data?.propData]);
if (isLoading) {
return (
<div className="flex justify-center">
<LoadingPage />
</div>
);
}
if (!data || !data.propData) {
return <p>{t.contatto.error}</p>;
}
return (
<>
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
<div className="flex flex-col gap-2">
<h3 className="font-semibold">{t.contatto.propietario}:</h3>
<p>
{t.contatto.nome}: {data.propData.locatore}
</p>
<div className="flex items-center gap-2">
<a
className="flex items-center gap-2"
href={`tel:${data.propData.numero}`}
>
<Phone />:
<span className="text-blue-600 underline">
{data.propData.numero}
</span>
</a>
<Link
href={`https://wa.me/${data.propData.numero}`}
target="_blank"
>
<Button
className="flex items-center gap-2"
size="sm"
variant="success"
>
<WhatsAppIcon2 className="size-4 fill-white" />
WhatsApp
</Button>
</Link>
</div>
<br />
<h3 className="font-semibold">{t.contatto.immobile}:</h3>
<p>
{t.contatto.indirizzo}: {data.propData.indirizzo},{" "}
{data.propData.civico} {data.propData.comune} (
{data.propData.provincia})
</p>
<a
className="flex w-fit items-center gap-2 rounded-md border border-border px-3 py-2"
href={navigationUrl}
rel="noreferrer"
target="_blank"
>
<Navigation /> <span>{t.contatto.apri_nav}</span>
</a>
<br />
<p>
{t.contatto.sbloccato_il}:{" "}
{data.opened_at.toLocaleString("it", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
<div className="flex items-center gap-2 rounded-md bg-red-500 p-2 text-white">
<TriangleAlert className="size-8" />
<div>
<p>{t.contatto.contatta}</p>
<p className="font-semibold">{t.contatto.orari}</p>
</div>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button aria-label="Chiudi modal credenza" variant="outline">
{t.chiudi}
</Button>
</CredenzaClose>
</CredenzaFooter>
</>
);
};

View file

@ -104,8 +104,12 @@ export const AnnunciSelezionati = ({
<DialogServizio
title="Annunci selezionati"
trigger={
<div className="relative">
<Button className="text-lg" size="xl" variant="orange">
<div className="relative flex w-full sm:w-fit">
<Button
className="w-full text-lg sm:w-fit"
size="xl"
variant="orange"
>
Annunci selezionati
</Button>
{annunci.length > 0 && (
@ -159,8 +163,8 @@ export const AnnunciCompatibili = () => {
<DialogServizio
title="Annunci compatibili"
trigger={
<div className="relative">
<Button className="bg-purple-500 text-lg" size="xl">
<div className="relative flex w-full sm:w-fit">
<Button className="w-full bg-purple-500 text-lg sm:w-fit" size="xl">
Annunci compatibili
</Button>
{data && data.length > 0 && (

View file

@ -108,14 +108,7 @@ const Row = ({
</div>
<CollapsibleContent className="mt-2 overflow-hidden transition-all duration-300 ease-in-out data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<ServizioProvider
isAdmin={isAdmin}
servizio={servizio}
servizioId={servizio.servizio_id}
userId={userId}
>
<ServizioContent />
</ServizioProvider>
</CollapsibleContent>
</Collapsible>
</ServizioProvider>

View file

@ -25,10 +25,16 @@ import { Button } from "~/components/ui/button";
import { uploadHandler } from "~/hooks/storageClienSideHooks";
import { useTranslation } from "~/providers/I18nProvider";
import type { UsersId } from "~/schemas/public/Users";
import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
import { api } from "~/utils/api";
export type UploadCallback = (args: {
storageIds: string[];
userStorageIds: UsersStorageUserStorageId[];
}) => void;
type UploadModalProps = {
cb_onUpload?: () => void;
cb_onUpload?: UploadCallback;
isAdmin?: boolean;
userId?: UsersId;
};
@ -69,7 +75,7 @@ export const UploadModal = ({
};
type UploadComponentProps = {
cb_onUpload?: () => void;
cb_onUpload?: UploadCallback;
isAdmin?: boolean;
userId?: UsersId;
maxMB?: number;
@ -90,7 +96,10 @@ export const UploadComponent = ({
const { mutateAsync: setUserStorage } =
api.storage.addUserStorageEntry.useMutation({
onMutate: () => {
toast(t.allegati.allegato_caricato, { icon: "📤" });
toast(t.allegati.allegato_caricato, {
icon: "📤",
toasterId: "uploading-toast",
});
},
onSettled: async () => {
await utils.storage.getAll.invalidate();
@ -160,6 +169,8 @@ export const UploadComponent = ({
if (files.length === 0) {
return;
}
let storageIds: string[] = [];
const userStorageIds: UsersStorageUserStorageId[] = [];
try {
const from_admin = isAdmin ?? true;
setIsUploading(true);
@ -168,14 +179,24 @@ export const UploadComponent = ({
files,
from_admin,
});
if (!status) {
toast.error(t.allegati.errore_caricamento, {
icon: "❌",
toasterId: "uploading-toast",
});
setIsUploading(false);
return;
}
storageIds = attachments;
if (userId) {
for (const storageId of attachments) {
await setUserStorage({
const res = await setUserStorage({
storageId,
userId,
from_admin,
});
userStorageIds.push(res.user_storage_id);
}
}
@ -193,16 +214,9 @@ export const UploadComponent = ({
}, randomDelay / 1); // Adjust interval based on random delay
});
if (!status) {
toast.error(t.allegati.errore_caricamento, {
icon: "❌",
});
setIsUploading(false);
return;
}
toast.success(t.allegati.success_caricamento, {
icon: "✅",
toasterId: "uploading-toast",
});
} catch (error) {
setIsUploading(false);
@ -211,11 +225,12 @@ export const UploadComponent = ({
error instanceof Error
? error.message
: t.allegati.errore_sconosciuto,
{ icon: "❌", toasterId: "uploading-toast" },
);
} finally {
setIsUploading(false);
setFiles([]);
cb_onUpload?.();
cb_onUpload?.({ storageIds, userStorageIds });
await utils.storage.getAll.invalidate();
}
},

View file

@ -3,6 +3,7 @@
import type { UsersId } from './Users';
import type { default as TipologiaPosizioneEnum } from './TipologiaPosizioneEnum';
import type { UsersStorageUserStorageId } from './UsersStorage';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.servizio */
@ -59,6 +60,12 @@ export default interface ServizioTable {
isOkSaldo: ColumnType<boolean, boolean | undefined, boolean>;
isOkConsulenza: ColumnType<boolean, boolean | undefined, boolean>;
doc_personale_fronte_ref: ColumnType<UsersStorageUserStorageId | null, UsersStorageUserStorageId | null, UsersStorageUserStorageId | null>;
doc_personale_retro_ref: ColumnType<UsersStorageUserStorageId | null, UsersStorageUserStorageId | null, UsersStorageUserStorageId | null>;
doc_motivazione_ref: ColumnType<UsersStorageUserStorageId | null, UsersStorageUserStorageId | null, UsersStorageUserStorageId | null>;
}
export type Servizio = Selectable<ServizioTable>;

View file

@ -4,8 +4,13 @@
import type { UsersId } from './Users';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.users_storage */
export type UsersStorageUserStorageId = string & { __brand: 'public.users_storage' };
/** Represents the table public.users_storage */
export default interface UsersStorageTable {
user_storage_id: ColumnType<UsersStorageUserStorageId, UsersStorageUserStorageId | undefined, UsersStorageUserStorageId>;
userId: ColumnType<UsersId, UsersId, UsersId>;
storageId: ColumnType<string, string, string>;

View file

@ -627,6 +627,45 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
"servizio.isOkAcconto",
"servizio.isOkSaldo",
"servizio.isOkConsulenza",
jsonObjectFrom(
eb
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"servizio.doc_personale_fronte_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"servizio.doc_personale_fronte_ref",
),
).as("doc_personale_fronte"),
jsonObjectFrom(
eb
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"servizio.doc_personale_retro_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"servizio.doc_personale_retro_ref",
),
).as("doc_personale_retro"),
jsonObjectFrom(
eb
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"servizio.doc_motivazione_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"servizio.doc_motivazione_ref",
),
).as("doc_motivazione"),
jsonArrayFrom(
eb
.selectFrom("servizio_annunci")

View file

@ -114,8 +114,8 @@ export const addUserStorage = async (data: NewUsersStorage) => {
.insertInto("users_storage")
.values(data)
.onConflict((oc) => oc.columns(["userId", "storageId"]).doNothing())
.returningAll()
.execute();
.returning("user_storage_id")
.executeTakeFirstOrThrow();
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",

View file

@ -18,29 +18,12 @@ services:
interval: 1s
timeout: 5s
retries: 10
# minio:
# image: minio/minio
# expose:
# - "9000"
# - "9001"
# volumes:
# - minio_storage:/data
# networks:
# - dokploy-network
# environment:
# MINIO_ROOT_USER: ${MINIO_ROOT_USER}
# MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
# command: server --console-address ":9001" /data
# healthcheck:
# test: [ "CMD", "curl", "-f", "http://minio:9000/minio/health/live" ]
# interval: 10s
# timeout: 10s
# retries: 5
storage:
image: "ghcr.io/marcopedone/go_fs:latest"
ports:
- "8080:8080"
expose:
- "8080"
restart: unless-stopped
pull_policy: "always"
environment:
PORT: 8080
AUTH_TOKEN: ${STORAGE_TOKEN}
@ -106,10 +89,8 @@ services:
- images_volume:/app/images
- storage_volume:/app/storage
environment:
STORAGE_STRATEGY: "s3"
MINIO_URL: minio:9000
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
STORAGE_URL: http://storage:8080
STORAGE_TOKEN: ${STORAGE_TOKEN}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}