feat: implement image and video reconciliation, update pricing options, and enhance media removal functionality

This commit is contained in:
Marco Pedone 2026-03-02 15:35:23 +01:00
parent 74320248e0
commit c31a68ef6d
15 changed files with 263 additions and 70 deletions

View file

@ -305,7 +305,7 @@ func CodiciToProcessImages(annunci *typesdefs.AnnunciXML) ([]string, error) {
return nil, err
}
dbImagesMap := make(map[string][]imgRef) // Convert slice to map for faster lookup
dbImagesMap := make(map[string][]imgRef)
for _, ref := range db_images {
dbImagesMap[ref.Codice] = append(dbImagesMap[ref.Codice], imgRef{
Og_url: ref.Og_url,
@ -318,7 +318,8 @@ func CodiciToProcessImages(annunci *typesdefs.AnnunciXML) ([]string, error) {
for _, annuncio := range annunci.Annuncio {
if annuncio.Codice != nil && annuncio.Foto != nil {
refFoto, existsInDb := dbImagesMap[*annuncio.Codice]
// Check if codice exists in DB
refFotos, existsInDb := dbImagesMap[*annuncio.Codice]
if !existsInDb {
codiciToProcess = append(codiciToProcess, *annuncio.Codice)
continue
@ -326,8 +327,10 @@ func CodiciToProcessImages(annunci *typesdefs.AnnunciXML) ([]string, error) {
needProcess := false
for _, fotoUrl := range *annuncio.Foto {
found := false
for _, ref := range refFoto {
// Check if the fotoUrl exists in the DB refs
for _, ref := range refFotos {
if fotoUrl == ref.Og_url {
// check if the corresponding img and thumb are in the bucket
if bucketIdsMap[ref.Img] && bucketIdsMap[ref.Thumb] {
found = true
break
@ -374,3 +377,42 @@ func ForceClearImages(codici []string) error {
}
return nil
}
// Removes stranded images (in storage but not in DB)
func ReconcileImages() error {
currentBucketState, err := ListBucket("images")
if err != nil {
return fmt.Errorf("failed to list bucket: %w", err)
}
//make a list of bucket ids
bucketIdsMap := make(map[string]bool)
for _, item := range currentBucketState {
bucketIdsMap[item.ID] = true
}
db_images, err := queries.GetImagesFromDB()
if err != nil {
return fmt.Errorf("failed to get images from DB: %w", err)
}
knownStorageIds := make(map[string]bool)
for _, ref := range db_images {
knownStorageIds[ref.Img] = true
knownStorageIds[ref.Thumb] = true
}
// reconcile: delete storage files not referenced in DB
var strandedIds []string
for _, item := range currentBucketState {
if !knownStorageIds[item.ID] {
strandedIds = append(strandedIds, item.ID)
}
}
if len(strandedIds) > 0 {
log.Printf("Reconciliation: found %d stranded files, deleting...", len(strandedIds))
if err := DeleteIdsFromStorage(strandedIds); err != nil {
log.Printf("Warning: failed to delete stranded files: %v", err) // non-fatal
}
} else {
log.Println("Reconciliation: no stranded files found.")
}
return nil
}

View file

@ -204,6 +204,7 @@ func (m *MiogestHandler) ParseAnnunci(codFilter string, imgPool *[]typesdefs.Med
image_codes_to_process = append(image_codes_to_process, codFilter)
}
}
err = ReconcileImages()
err = ForceClearImages(image_codes_to_process)
if err != nil {
log.Fatalf("Failed to clear images for codice %s: %v", codFilter, err.Error())
@ -237,6 +238,7 @@ func (m *MiogestHandler) ParseAnnunci(codFilter string, imgPool *[]typesdefs.Med
video_codes_to_process = append(video_codes_to_process, codFilter)
}
}
err = ReconcileVideos()
err = ForceClearVideos(video_codes_to_process)
if err != nil {
log.Fatalf("Failed to clear videos for codice %s: %v", codFilter, err.Error())

View file

@ -201,8 +201,8 @@ type AnnuncioXML struct {
} `xml:"Clienti"`
Cap *string `xml:"Cap"`
Video *[]string `xml:"Video"`
AMMedias *[]struct {
AMVideo *struct {
AMMedias *struct {
AMVideo []struct {
Url *string `xml:"Url"`
} `xml:"AMVideo"`
} `xml:"AMMedias"`

View file

@ -27,10 +27,10 @@ func AddToVideoProcessing(tmp *typesdefs.AnnuncioParsed, annuncio *typesdefs.Ann
miogestVideos := []string{}
if annuncio.AMMedias != nil {
for _, media := range *annuncio.AMMedias {
if media.AMVideo != nil && media.AMVideo.Url != nil {
if strings.Contains(*media.AMVideo.Url, "video.miogest") {
miogestVideos = append(miogestVideos, *media.AMVideo.Url)
for _, media := range annuncio.AMMedias.AMVideo {
if media.Url != nil {
if strings.Contains(*media.Url, "video.miogest") {
miogestVideos = append(miogestVideos, *media.Url)
}
}
@ -347,12 +347,12 @@ func CodiciToProcessVideos(annunci *typesdefs.AnnunciXML) ([]string, error) {
var codiciToProcess []string
for _, annuncio := range annunci.Annuncio {
if annuncio.Codice != nil && annuncio.AMMedias != nil && len(*annuncio.AMMedias) > 0 {
if annuncio.Codice != nil && annuncio.AMMedias != nil {
mediaUrls := []string{}
for _, media := range *annuncio.AMMedias {
if media.AMVideo != nil && media.AMVideo.Url != nil {
if strings.Contains(*media.AMVideo.Url, "video.miogest") {
mediaUrls = append(mediaUrls, *media.AMVideo.Url)
for _, media := range annuncio.AMMedias.AMVideo {
if media.Url != nil {
if strings.Contains(*media.Url, "video.miogest") {
mediaUrls = append(mediaUrls, *media.Url)
}
}
}
@ -367,17 +367,17 @@ func CodiciToProcessVideos(annunci *typesdefs.AnnunciXML) ([]string, error) {
}
needProcess := false
for _, media := range *annuncio.AMMedias {
if media.AMVideo == nil || media.AMVideo.Url == nil {
for _, media := range annuncio.AMMedias.AMVideo {
if media.Url == nil {
continue
}
if !strings.Contains(*media.AMVideo.Url, "video.miogest") {
if !strings.Contains(*media.Url, "video.miogest") {
continue
}
found := false
for _, ref := range refVideos {
if *media.AMVideo.Url == ref.Og_url {
if *media.Url == ref.Og_url {
if bucketIdsMap[ref.Video] && bucketIdsMap[ref.Thumb] {
found = true
break
@ -425,3 +425,36 @@ func ForceClearVideos(codici []string) error {
}
return nil
}
// ReconcileVideos removes stranded videos (in storage but not in DB)
func ReconcileVideos() error {
currentBucketState, err := ListBucket("videos")
if err != nil {
return fmt.Errorf("failed to list bucket: %w", err)
}
//make a list of bucket ids
bucketIdsMap := make(map[string]bool)
for _, item := range currentBucketState {
bucketIdsMap[item.ID] = true
}
db_videos, err := queries.GetVideosFromDB()
if err != nil {
return fmt.Errorf("failed to get videos from DB: %w", err)
}
var strandedIds []string
for _, ref := range db_videos {
if !bucketIdsMap[ref.Video] {
strandedIds = append(strandedIds, ref.Video)
}
if !bucketIdsMap[ref.Thumb] {
strandedIds = append(strandedIds, ref.Thumb)
}
}
if len(strandedIds) > 0 {
err := DeleteIdsFromStorage(strandedIds)
if err != nil {
return fmt.Errorf("failed to delete stranded videos from storage: %w", err)
}
}
return nil
}

View file

@ -33,7 +33,6 @@ export const PricingChoice = () => {
downPayment: prezziario.stabile.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
return (
<div className="flex flex-col items-center justify-center gap-4">
<h2 className="font-semibold text-3xl">Scegli il tuo servizio</h2>
@ -75,6 +74,7 @@ export const PricingChoice = () => {
opzioni={[
t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2,
t.pricing_cmp.stabile_option3,
]}
pricingData={pricingStabile}
tipo={TipologiaPosizioneEnum.Stabile}
@ -103,6 +103,7 @@ export const PricingComponent = ({
className,
title,
tipo,
optionsBtnClassName,
}: {
pricingData: PricingData;
opzioni: string[];
@ -111,6 +112,7 @@ export const PricingComponent = ({
statico?: boolean;
className?: string;
tipo: TipologiaPosizioneEnum;
optionsBtnClassName?: string;
}) => {
const { t } = useTranslation();
const [Value, setValue] = useState<number>(0);
@ -213,6 +215,7 @@ export const PricingComponent = ({
className={cn(
"h-9 xs:h-10 rounded-md border-none px-3 xs:px-4 xs:py-2",
idx === Value ? "fade-in-0 animate-in" : "",
optionsBtnClassName,
)}
key={opzione}
onClick={() => {

View file

@ -8,11 +8,12 @@ import {
RefreshCcw,
RotateCw,
TriangleAlert,
X,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import toast from "react-hot-toast";
import z from "zod";
import { CarouselAnnuncio } from "~/components/annuncio_card";
import { Confirm } from "~/components/confirm";
import {
Form,
@ -41,8 +42,10 @@ import {
} from "~/components/ui/popover";
import { Switch } from "~/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { VideoPlayer } from "~/components/videoPlayer";
import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
import { NullableStringOnChange } from "~/lib/form_utils";
import { getStorageUrl } from "~/lib/storage_utils";
import { cn } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import { StatusBadge } from "~/pages/area-riservata/admin/edit-annuncio/[id]";
@ -109,14 +112,6 @@ export const AnnuncioEditForm = ({ data }: { data: AnnunciWithMedia }) => {
titolo_it: z.string().nullable(),
unita_condominio: z.number().nullable(),
web: z.boolean().nullable(),
// MEDIA
//url_immagini: z.string().array().nullable(),
//url_video: z.string().array().nullable(),
//og_url: z.string().nullable(),
//creato_il: z.date().nullable(),
//modificato_il: z.date().nullable(),
});
type FormValues = z.infer<typeof schema>;
@ -160,6 +155,14 @@ export const AnnuncioEditForm = ({ data }: { data: AnnunciWithMedia }) => {
},
});
const { mutateAsync: removeMedia } =
api.annunci.removeAnnuncioMedia.useMutation({
onSettled: async () => {
await revalidate({ cod: data.codice });
toast.success("Media rimosso con successo");
},
});
const defaultValues: FormValues = {
...data,
mq: Number(data.mq) || null,
@ -535,19 +538,6 @@ export const AnnuncioEditForm = ({ data }: { data: AnnunciWithMedia }) => {
/>
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>Imagini Annuncio</CardTitle>
</CardHeader>
<CardContent>
<CarouselAnnuncio
immagini={data.images}
single
updated_at={data.media_updated_at}
videos={data.videos}
/>
</CardContent>
</Card>
</div>
</div>
<Card>
@ -1843,7 +1833,80 @@ export const AnnuncioEditForm = ({ data }: { data: AnnunciWithMedia }) => {
<CardTitle>Media</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row"></div>
<div className="flex w-full flex-wrap justify-between gap-4">
{data.images.map((img) => (
<div className="relative" key={img.img}>
<Image
alt={img.img}
className="aspect-square size-52 rounded-md bg-[#e6e9ec] object-cover"
height={400}
key={img.img}
src={getStorageUrl({
storageId: img.img,
params: {
cacheKey: data.media_updated_at?.toISOString(),
media: "image",
},
})}
width={800}
/>
<Confirm
description="Sei sicuro di voler rimuovere questo media? Questa azione è irreversibile fino al prossimo aggiornamento."
onConfirm={async () =>
removeMedia({ type: "img", id: img.img })
}
title="Rimuovi media"
>
<Button
className="absolute top-2 right-2"
size="sm"
type="button"
variant="destructive"
>
<X className="size-4" />
</Button>
</Confirm>
</div>
))}
{data.videos.map((video) => (
<div className="relative" key={video.video}>
<VideoPlayer
className="aspect-square size-52 object-cover"
coverImage={getStorageUrl({
storageId: video.thumb,
params: {
cacheKey: data.media_updated_at?.toISOString(),
media: "image",
},
})}
key={`videoplayer-${video.video}`}
videoSrc={getStorageUrl({
storageId: video.video,
params: {
cacheKey: data.media_updated_at?.toISOString(),
media: "video",
},
})}
/>
<Confirm
description="Sei sicuro di voler rimuovere questo media? Questa azione è irreversibile fino al prossimo aggiornamento."
onConfirm={async () =>
removeMedia({ type: "video", id: video.video })
}
title="Rimuovi media"
>
<Button
className="absolute top-2 right-2"
size="sm"
type="button"
variant="destructive"
>
<X className="size-4" />
</Button>
</Confirm>
</div>
))}
</div>
</CardContent>
</Card>
</div>

View file

@ -133,12 +133,7 @@ export const FormPrezzo = ({
<FormMessage />
</div>
<FormControl>
<Input
placeholder=""
{...field}
disabled={initialValues != null}
id="idprezziario"
/>
<Input placeholder="" {...field} id="idprezziario" />
</FormControl>
</FormItem>
)}

View file

@ -1187,7 +1187,8 @@ The Stable Rent service, ideal for those with demonstrable work references and f
stabile_footer:
"Types of stable rent: Libero (4+4 years), Subsidized (3+2 years) or commercial (6+6 years)",
stabile_option1: "Up to 500 €/month",
stabile_option2: "Over 500 €/month",
stabile_option2: "Up to 700 €/month",
stabile_option3: "Over 700 €/month",
stabile_select: "Select the rent:",
titolo: "Pay based on your search",
txt_tipo_acquisto: "For the purchase of any type of property",

View file

@ -1183,8 +1183,9 @@ export const it: LangDict = {
saldi_stabile: ["Fino a 500 €/mese", "Superiore a 500 €/mese"],
stabile_footer:
"Tipologie di affitto stabile: libero (4+4 anni), agevolato (3+2 anni) o commerciale (6+6 anni)",
stabile_option1: "Fino a 500 €/mese",
stabile_option2: "Superiore a 500 €/mese",
stabile_option1: "Fino a 500€/mese",
stabile_option2: "Fino a 700€/mese",
stabile_option3: "Oltre 700€/mese",
stabile_select: "Seleziona il canone:",
titolo: "Paga in base alla tua ricerca",
txt_tipo_acquisto: "Per l'acquisto di qualsiasi tipologia di immobile",

View file

@ -185,6 +185,7 @@ export type LangDict = {
stabile_select: string;
stabile_option1: string;
stabile_option2: string;
stabile_option3: string;
stabile_footer: string;
breve_select: string;
breve_option1: string;

View file

@ -307,7 +307,11 @@ const Pricing = ({
t.pricing_cmp.breve_option4,
t.pricing_cmp.breve_option5,
]
: [t.pricing_cmp.stabile_option1, t.pricing_cmp.stabile_option2];
: [
t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2,
t.pricing_cmp.stabile_option3,
];
const style =
tipologia === TipologiaPosizioneEnum.Transitorio
@ -456,7 +460,7 @@ const CardInfos = ({ data }: { data: Annunci }) => {
<CardContent className="flex flex-col gap-4 p-4 py-0">
<div className="grid grid-cols-2 gap-3 font-semibold">
<div className="relative">
<div className="rounded-md bg-primary-foreground p-2 text-center text-primary">
<div className="flex h-10 items-center justify-center rounded-md bg-primary-foreground text-center text-primary">
{data.prezzo && (data.prezzo / 1e2).toFixed(2).replace(".", ",")}{" "}
{t.annunci.euro_mese}
</div>
@ -480,7 +484,7 @@ const CardInfos = ({ data }: { data: Annunci }) => {
<div className="relative">
<div
className={cn(
"rounded-md bg-primary-foreground p-2 text-center text-primary outline-2",
"h-10 rounded-md bg-primary-foreground p-2 text-center text-primary outline-2",
data.tipo === "Transitorio" && "outline-transitorio",
data.tipo === "Stabile" && "outline-stabile",
)}
@ -501,27 +505,31 @@ const CardInfos = ({ data }: { data: Annunci }) => {
<div className="grid grid-cols-2 gap-3">
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
<BedDouble />
{(() => {
if (!data.numero_camere) return "N/A";
if (data.numero_camere > 1)
return `${data.numero_camere} ${t.card.camere2}`;
if (data.numero_camere === 1)
return `${data.numero_camere} ${t.card.camere1}`;
})()}
<span className="line-clamp-1 break-all">
{(() => {
if (!data.numero_camere) return "N/A";
if (data.numero_camere > 1)
return `${data.numero_camere} ${t.card.camere2}`;
if (data.numero_camere === 1)
return `${data.numero_camere} ${t.card.camere1}`;
})()}
</span>
</div>
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
<Bath />
<span>
<span className="line-clamp-1 break-all">
{data.numero_bagni || 0} {t.annunci.bagni}
</span>
</div>
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
<Armchair />
{data.caratteristiche?.Scheda_Arredi || ""}
<span className="line-clamp-1 break-all">
{data.caratteristiche?.Scheda_Arredi || ""}
</span>
</div>
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
<Car />
<span>
<span className="line-clamp-1 break-all">
{data.numero_postiauto || 0} {t.annunci.posti_auto}
</span>
</div>
@ -532,7 +540,7 @@ const CardInfos = ({ data }: { data: Annunci }) => {
<div className="grid grid-cols-2 gap-3">
<div className="flex justify-center gap-2 rounded-md bg-primary-foreground p-2 text-primary">
<Ruler />
<span>
<span className="line-clamp-1 break-all">
{data.mq ? Number(data.mq).toLocaleString("it-IT") : "--"} m
<sup>2</sup>
</span>
@ -542,7 +550,7 @@ const CardInfos = ({ data }: { data: Annunci }) => {
{data.piano !== null && data.piano !== "" && (
<>
<ArrowUp01 />
<span>
<span className="line-clamp-1 break-all">
{(() => {
if (data.piano === "0") return t.annunci.p_terra;
if (data.piano === "0.5") return t.annunci.p_rialzato;

View file

@ -102,6 +102,7 @@ const Prezzi: NextPage = () => {
opzioni={[
t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2,
t.pricing_cmp.stabile_option3,
]}
pricingData={pricingStabile}
statico

View file

@ -19,6 +19,9 @@ import {
getOnline_AnnuncioIdCodice_AnnunciHandler,
getOptions_AnnunciHandler,
getProprietarioDataHandler,
type ImgToRemove,
removeAnnuncioMedia,
type VideoToRemove,
} from "~/server/controllers/annunci.controller";
import { db } from "~/server/db";
import { NewMail } from "~/server/services/mailer";
@ -142,4 +145,11 @@ export const annunciRouter = createTRPCRouter({
}
return { status: "ok", timestamp: Date.now() };
}),
removeAnnuncioMedia: adminProcedure
.input(z.custom<ImgToRemove | VideoToRemove>())
.mutation(async ({ input }) => {
return await removeAnnuncioMedia({
...input,
});
}),
});

View file

@ -495,3 +495,30 @@ export const getProprietarioDataHandler = async ({
});
}
};
export type ImgToRemove = {
type: "img";
id: ImagesRefs["img"];
};
export type VideoToRemove = {
type: "video";
id: VideosRefs["video"];
};
export const removeAnnuncioMedia = async ({
id,
type,
}: ImgToRemove | VideoToRemove) => {
try {
if (type === "img") {
await db.deleteFrom("images_refs").where("img", "=", id).execute();
}
if (type === "video") {
await db.deleteFrom("videos_refs").where("video", "=", id).execute();
}
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore rimozione media: ${(e as Error).message}`,
});
}
};

View file

@ -461,7 +461,11 @@ export const setupSaldoServizio = async ({
}
saldo = await qry.where("idprezziario", "=", mpp).executeTakeFirst();
} else if (servizio.tipologia === TipologiaPosizioneEnum.Stabile) {
if (servizio.budget >= 500) {
if (servizio.budget >= 700) {
saldo = await qry
.where("idprezziario", "=", "SALDOCA700+" as PrezziarioIdprezziario)
.executeTakeFirst();
} else if (servizio.budget >= 500) {
saldo = await qry
.where("idprezziario", "=", "SALDOCA500+" as PrezziarioIdprezziario)
@ -588,9 +592,11 @@ export const getPacksPerServizio = async (
}
case TipologiaPosizioneEnum.Stabile: {
const pack = await getPrezziarioByIdHandler({
id: (servizio.budget >= 500
? "SALDOCA500+"
: "SALDOCA500") as PrezziarioIdprezziario,
id: (servizio.budget >= 700
? "SALDOCA700+"
: servizio.budget >= 500
? "SALDOCA500+"
: "SALDOCA500") as PrezziarioIdprezziario,
});
if (!pack) {
return { success: false, message: "Pack non trovato" };