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, stato text,
web boolean, web boolean,
caratteristiche json, caratteristiche json,
url_immagini text[],
homepage boolean, homepage boolean,
url_video text[], url_video text[],
email text, email text,
creato_il timestamp without time zone, creato_il timestamp without time zone,
modificato_il timestamp without time zone, modificato_il timestamp without time zone,
consegna integer, consegna integer,
og_url text,
disponibile_da date, disponibile_da date,
permanenza integer[], 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; 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 -- Name: messages; Type: TABLE; Schema: public; Owner: postgres
-- --
@ -410,18 +424,6 @@ CREATE TABLE public.messages (
ALTER TABLE public.messages OWNER TO postgres; 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 -- Name: ordini; Type: TABLE; Schema: public; Owner: postgres
-- --
@ -526,7 +528,10 @@ CREATE TABLE public.servizio (
permanenza integer, permanenza integer,
"isOkAcconto" boolean DEFAULT false NOT NULL, "isOkAcconto" boolean DEFAULT false NOT NULL,
"isOkSaldo" 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, annunci_id integer NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL, created_at timestamp without time zone DEFAULT now() NOT NULL,
open_contatti_at timestamp without time zone, open_contatti_at timestamp without time zone,
doc_conferma_ref uuid, doc_conferma_ref text,
user_confirmed_at timestamp without time zone, user_confirmed_at timestamp without time zone,
accettato_conferma_at timestamp without time zone, accettato_conferma_at timestamp without time zone,
caparra_causale text, caparra_causale text,
@ -549,12 +554,12 @@ CREATE TABLE public.servizio_annunci (
caparra_importo text, caparra_importo text,
caparra_intestazione text, caparra_intestazione text,
"hasConfermaAdmin" boolean DEFAULT false NOT NULL, "hasConfermaAdmin" boolean DEFAULT false NOT NULL,
doc_contratto_ref uuid, doc_contratto_ref text,
gestionale_id integer, gestionale_id integer,
contratto_decorrenza date, contratto_decorrenza date,
contratto_scadenza date, contratto_scadenza date,
contratto_tipo text, contratto_tipo text,
doc_registrazione_ref uuid, doc_registrazione_ref text,
doc_conferma_added boolean DEFAULT false NOT NULL, doc_conferma_added boolean DEFAULT false NOT NULL,
doc_contratto_added boolean DEFAULT false NOT NULL, doc_contratto_added boolean DEFAULT false NOT NULL,
doc_registrazione_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; 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 -- 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 ( CREATE TABLE public.users_storage (
userid uuid NOT NULL, "userId" uuid NOT NULL,
storageid 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); 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 -- 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); 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 -- 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); 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 -- 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); 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 -- 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); 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 -- 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); 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 -- 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; 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 -- 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 ALTER TABLE ONLY public.servizio
ADD CONSTRAINT servizio_annuncio_conferma_doc FOREIGN KEY (doc_conferma_ref) REFERENCES public.storageindex(id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID; 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 ALTER TABLE ONLY public.servizio
ADD CONSTRAINT servizio_annuncio_contratto_doc FOREIGN KEY (doc_contratto_ref) REFERENCES public.storageindex(id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID; 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 ALTER TABLE ONLY public.servizio
ADD CONSTRAINT servizio_annuncio_registraz_doc FOREIGN KEY (doc_registrazione_ref) REFERENCES public.storageindex(id) ON UPDATE CASCADE ON DELETE SET NULL NOT VALID; 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; 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 -- Name: users_storage userstorage; Type: FK CONSTRAINT; Schema: public; Owner: postgres
-- --
ALTER TABLE ONLY public.users_storage 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 -- 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({ await utils.storage.retrieveUserFileData.invalidate({
userId: userId, userId: userId,
}); });
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
toast.success(t.allegati.allegato_rimosso, { toast.success(t.allegati.allegato_rimosso, {
icon: "🗑️", icon: "🗑️",
id: context?.toastId, id: context?.toastId,

View file

@ -1,18 +1,14 @@
import { import {
CircleCheck, CircleCheck,
CircleUser,
FileBadge, FileBadge,
FileText, FileText,
Handshake, Handshake,
Navigation,
PackageCheck, PackageCheck,
Phone,
Pointer, Pointer,
TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
Credenza, Credenza,
@ -26,7 +22,6 @@ import {
CredenzaTrigger, CredenzaTrigger,
} from "~/components/custom_ui/credenza"; } from "~/components/custom_ui/credenza";
import LoadingButton from "~/components/custom_ui/loading-button"; import LoadingButton from "~/components/custom_ui/loading-button";
import { LoadingPage } from "~/components/loading";
import { Button, type ButtonConfigs } from "~/components/ui/button"; import { Button, type ButtonConfigs } from "~/components/ui/button";
import { import {
Tooltip, Tooltip,
@ -39,7 +34,7 @@ import { useTranslation } from "~/providers/I18nProvider";
import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider"; import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider";
import type { AnnunciId } from "~/schemas/public/Annunci"; import type { AnnunciId } from "~/schemas/public/Annunci";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { WhatsAppIcon2 } from "../svgs"; import { ContattiProprietarioModal } from "./modale_contatti";
export const Interactions = () => { export const Interactions = () => {
const { servizio, servizioId } = useServizio(); const { servizio, servizioId } = useServizio();
@ -406,225 +401,3 @@ const ConfermaLavorazioneModal = () => {
</Credenza> </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 { api } from "~/utils/api";
import { import {
AnnunciCompatibili, AnnunciCompatibili,
AnnunciInConferma,
AnnunciSelezionati, AnnunciSelezionati,
} from "./servizio_annunci_accordions"; } from "./servizio_annunci_accordions";
@ -255,12 +254,22 @@ export const ServizioContent = () => {
))} ))}
</div> </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"> <div className="flex flex-col items-center gap-4 sm:flex-row">
<AnnunciInConferma annunci={annunciInConferma} />
<AnnunciSelezionati annunci={annunciNotInConferma} /> <AnnunciSelezionati annunci={annunciNotInConferma} />
<AnnunciCompatibili /> <AnnunciCompatibili />
</div> </div>
</div>
</> </>
} }
header={ 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 <DialogServizio
title="Annunci selezionati" title="Annunci selezionati"
trigger={ trigger={
<div className="relative"> <div className="relative flex w-full sm:w-fit">
<Button className="text-lg" size="xl" variant="orange"> <Button
className="w-full text-lg sm:w-fit"
size="xl"
variant="orange"
>
Annunci selezionati Annunci selezionati
</Button> </Button>
{annunci.length > 0 && ( {annunci.length > 0 && (
@ -159,8 +163,8 @@ export const AnnunciCompatibili = () => {
<DialogServizio <DialogServizio
title="Annunci compatibili" title="Annunci compatibili"
trigger={ trigger={
<div className="relative"> <div className="relative flex w-full sm:w-fit">
<Button className="bg-purple-500 text-lg" size="xl"> <Button className="w-full bg-purple-500 text-lg sm:w-fit" size="xl">
Annunci compatibili Annunci compatibili
</Button> </Button>
{data && data.length > 0 && ( {data && data.length > 0 && (

View file

@ -108,14 +108,7 @@ const Row = ({
</div> </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"> <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 /> <ServizioContent />
</ServizioProvider>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</ServizioProvider> </ServizioProvider>

View file

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

View file

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

View file

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

View file

@ -627,6 +627,45 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
"servizio.isOkAcconto", "servizio.isOkAcconto",
"servizio.isOkSaldo", "servizio.isOkSaldo",
"servizio.isOkConsulenza", "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( jsonArrayFrom(
eb eb
.selectFrom("servizio_annunci") .selectFrom("servizio_annunci")

View file

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

View file

@ -18,29 +18,12 @@ services:
interval: 1s interval: 1s
timeout: 5s timeout: 5s
retries: 10 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: storage:
image: "ghcr.io/marcopedone/go_fs:latest" image: "ghcr.io/marcopedone/go_fs:latest"
ports: expose:
- "8080:8080" - "8080"
restart: unless-stopped
pull_policy: "always"
environment: environment:
PORT: 8080 PORT: 8080
AUTH_TOKEN: ${STORAGE_TOKEN} AUTH_TOKEN: ${STORAGE_TOKEN}
@ -106,10 +89,8 @@ services:
- images_volume:/app/images - images_volume:/app/images
- storage_volume:/app/storage - storage_volume:/app/storage
environment: environment:
STORAGE_STRATEGY: "s3" STORAGE_URL: http://storage:8080
MINIO_URL: minio:9000 STORAGE_TOKEN: ${STORAGE_TOKEN}
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}