Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,7 @@ SKIP_SETUP=false
SHOW_REMIX_DEVTOOLS=false
IMAGE_DIR="./data"

SENTRY_DSN="xxx"
SENTRY_DSN="xxx"

AZURE_BLOB_KEY=xxx
AZURE_BLOB_NAME=xxx
2 changes: 2 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ jobs:
PWA_PUBLIC_KEY: "xxx"
PWA_PRIVATE_KEY: "xxx"
IMAGE_DIR: "/data"
AZURE_BLOB_KEY: "xxx"
AZURE_BLOB_NAME: "xxx"

steps:
- uses: actions/checkout@v6
Expand Down
9 changes: 7 additions & 2 deletions app/.server/db/gataEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ export const insertCloudinaryImage = async (eventId: number, values: typeof clou
await db.insert(cloudinaryImage).values(values);
await db.insert(eventCloudinaryImages).values({ eventId, cloudId: values.cloudId });
};
export const insertAzureBlob = async (eventId: number, values: (typeof cloudinaryImage.$inferInsert)[]) => {
await db.insert(cloudinaryImage).values(values);
await db.insert(eventCloudinaryImages).values(values.map((file) => ({ eventId, cloudId: file.cloudId })));
Comment on lines +176 to +177
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The insertAzureBlob function does not handle potential database constraint violations or conflicts. If a cloudId already exists in the database (from a previous upload or duplicate UUID), the insert will fail without proper error handling. Consider using an "INSERT OR IGNORE" or "INSERT OR REPLACE" strategy, or adding explicit error handling for duplicate key violations.

Suggested change
await db.insert(cloudinaryImage).values(values);
await db.insert(eventCloudinaryImages).values(values.map((file) => ({ eventId, cloudId: file.cloudId })));
await db
.insert(cloudinaryImage)
.values(values)
.onConflictDoNothing({ target: cloudinaryImage.cloudId });
await db
.insert(eventCloudinaryImages)
.values(values.map((file) => ({ eventId, cloudId: file.cloudId })))
.onConflictDoNothing({ target: [eventCloudinaryImages.eventId, eventCloudinaryImages.cloudId] });

Copilot uses AI. Check for mistakes.
};

export const getEventCloudinaryImages = async (eventId: number) => {
return await db
Expand All @@ -180,15 +184,16 @@ export const getEventCloudinaryImages = async (eventId: number) => {
cloudUrl: cloudinaryImage.cloudUrl,
width: cloudinaryImage.width,
height: cloudinaryImage.height,
type: cloudinaryImage.type,
})
.from(eventCloudinaryImages)
.innerJoin(cloudinaryImage, eq(cloudinaryImage.cloudId, eventCloudinaryImages.cloudId))
.orderBy(desc(sql`${eventCloudinaryImages}.rowid`))
.where(eq(eventCloudinaryImages.eventId, eventId));
.where(and(eq(eventCloudinaryImages.eventId, eventId), eq(cloudinaryImage.isDeleted, false)));
};

export const deleteEventCloudinaryImage = async (cloudId: string) => {
await db.delete(cloudinaryImage).where(eq(cloudinaryImage.cloudId, cloudId));
await db.update(cloudinaryImage).set({ isDeleted: true }).where(eq(cloudinaryImage.cloudId, cloudId));
};
Comment on lines 195 to 197
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delete functionality has been changed from hard delete to soft delete (setting isDeleted to true), but there's no mechanism to actually remove the blob from Azure storage. This means deleted images will remain in blob storage indefinitely, consuming storage space and incurring costs. Consider implementing a cleanup job or providing a way to permanently delete blobs from Azure storage when images are soft-deleted.

Copilot uses AI. Check for mistakes.

export const getNumberOfImages = async (eventId: number) => {
Expand Down
40 changes: 35 additions & 5 deletions app/.server/db/report.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { desc, eq, sql } from "drizzle-orm";

import { db } from "db/config.server";
import { gataReport, reportFile } from "db/schema";
import { gataReport, oldReportFiles, cloudinaryImage, reportFiles } from "db/schema";
import { ReportType } from "~/types/GataReport.type";
import type { ReportSchema } from "~/utils/formSchema";

import type { User } from "./user";
import { deleteImage } from "../services/cloudinaryService";

export const getReportsSimple = async (reportType: ReportType) => {
return await db
Expand Down Expand Up @@ -90,14 +89,13 @@ export const updateReport = async (reportId: string, values: ReportSchema, logge
};

export const deleteReport = async (reportId: string) => {
const reportFiles = await db.select().from(reportFile).where(eq(reportFile.reportId, reportId));
const reportFiles = await db.select().from(oldReportFiles).where(eq(oldReportFiles.reportId, reportId));
await Promise.all(
reportFiles.map(async (file) => {
if (!file.cloudId) {
throw new Error("No cloud id!");
}
await deleteImage(file.cloudId);
await db.delete(reportFile).where(eq(reportFile.id, file.id));
await db.delete(oldReportFiles).where(eq(oldReportFiles.id, file.id));
})
);
await db.delete(gataReport).where(eq(gataReport.id, reportId));
Expand All @@ -113,3 +111,35 @@ export const updateReportContent = async (reportId: string, content: string, log
})
.where(eq(gataReport.id, reportId));
};

export const getAllReportsWithOldFiles = async () => {
const reports = await db
.select({
id: gataReport.id,
content: gataReport.content,
})
.from(gataReport);

const reportsWithFiles = await Promise.all(
reports.map(async (report) => {
const files = await db.select().from(oldReportFiles).where(eq(oldReportFiles.reportId, report.id));
return { ...report, oldFiles: files };
})
);

return reportsWithFiles.filter((report) => report.oldFiles.length > 0);
};

export const batchInsertCloudImages = async (images: Array<typeof cloudinaryImage.$inferInsert>) => {
if (images.length === 0) return;
await db.insert(cloudinaryImage).values(images);
};

export const batchInsertReportFiles = async (reportFileRecords: Array<{ reportId: string; fileId: string }>) => {
if (reportFileRecords.length === 0) return;
await db.insert(reportFiles).values(reportFileRecords);
};

export const updateReportContentOnly = async (reportId: string, content: string) => {
await db.update(gataReport).set({ content }).where(eq(gataReport.id, reportId));
};
9 changes: 5 additions & 4 deletions app/.server/db/reportFile.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { eq } from "drizzle-orm";

import { db } from "db/config.server";
import { reportFile } from "db/schema";
import { oldReportFiles, reportFiles, cloudinaryImage } from "db/schema";

export const insertReportFile = async (values: Omit<typeof reportFile.$inferInsert, "id">) => {
return await db.insert(reportFile).values(values).returning();
export const insertReportFile = async (reportId: string, file: Omit<typeof cloudinaryImage.$inferInsert, "id">) => {
await db.insert(cloudinaryImage).values(file).returning();
await db.insert(reportFiles).values({ fileId: file.cloudId, reportId });
};

export const getReportFile = async (fileId: string) => {
const [result] = await db.select().from(reportFile).where(eq(reportFile.id, fileId));
const [result] = await db.select().from(oldReportFiles).where(eq(oldReportFiles.id, fileId));
return result;
};
26 changes: 0 additions & 26 deletions app/.server/services/cloudinaryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,6 @@ cloudinary.v2.config({
api_secret: process.env.CLOUDINARY_API_SECRET,
});

export const uploadImage = (data: string, folder: string) => {
return new Promise<UploadApiResponse>((resolve, reject) => {
void cloudinary.v2.uploader.upload(data, { folder }, (error, result) => {
if (error) {
console.error(error);
reject(error as Error);
}
if (result) {
resolve(result);
}
});
});
};

export const generateZip = (folderName: string, zipName: string) => {
const zipUrl = cloudinary.v2.utils.download_zip_url({
resource_type: "all",
Expand All @@ -33,18 +19,6 @@ export const generateZip = (folderName: string, zipName: string) => {
return zipUrl;
};

Comment on lines 19 to 21
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deleteImage function was removed from the Cloudinary service, but no equivalent deletion functionality was added for Azure Blob Storage. When images stored in Azure are "deleted" (soft-deleted in the database), the actual blobs remain in Azure storage. This creates an inconsistency where Cloudinary images were actually deleted from the cloud provider, but Azure blobs are not. Consider implementing Azure blob deletion functionality or documenting this intentional change in behavior.

Copilot uses AI. Check for mistakes.
export const deleteImage = (publicId: string) => {
return new Promise((resolve, reject) => {
void cloudinary.v2.uploader.destroy(publicId, (error, result) => {
if (error) {
reject(error as Error);
} else {
resolve(result);
}
});
});
};

export function uploadImageToCloudinary(data: ArrayBuffer, folder: string) {
const buffer = Buffer.from(data);
const uploadPromise = new Promise<UploadApiResponse>((resolve, reject) => {
Expand Down
21 changes: 0 additions & 21 deletions app/.server/services/localImageService.ts

This file was deleted.

5 changes: 1 addition & 4 deletions app/components/AvatarUser.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { Image } from "@unpic/react";

import type { User } from "~/.server/db/user";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { cn } from "~/utils";
Expand Down Expand Up @@ -42,8 +40,7 @@ export const AvatarUserButton = ({ user, className }: Props) => {

<Dialog ref={dialogRef} className="backdrop:bg-black/80">
<DialogCloseButton onClose={close} />
<Image
unstyled
<img
className={cn("max-h-screen object-contain")}
src={user?.picture || ""}
alt=""
Expand Down
15 changes: 3 additions & 12 deletions app/components/CloudImage.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { Image } from "@unpic/react";
import { Video } from "lucide-react";

import type { CloudinaryImage } from "db/schema";
import { getIsVideo } from "~/utils/cloudinaryUtils";
import { getIsVideo } from "~/utils/file.utils";

type Props = {
cloudImage: CloudinaryImage;
};

export const CloudImage = ({ cloudImage }: Props) => {
if (getIsVideo(cloudImage.cloudUrl)) {
if (getIsVideo(cloudImage)) {
return (
<div className="relative h-full object-cover">
<video className="h-full rounded shadow" loop muted playsInline preload="metadata">
Expand All @@ -22,14 +21,6 @@ export const CloudImage = ({ cloudImage }: Props) => {
);
}
return (
<Image
className="rounded shadow h-full object-cover"
src={cloudImage.cloudUrl}
alt=""
height={300}
unstyled
background="auto"
width={200}
/>
<img className="rounded shadow h-full object-cover" src={cloudImage.cloudUrl} alt="" height={300} width={200} />
);
};
14 changes: 6 additions & 8 deletions app/components/CloudImageFullscreen.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Image } from "@unpic/react";
import { ChevronLeft, ChevronRight, Loader2, X } from "lucide-react";
import { useEffect, useState } from "react";

import type { CloudinaryImage } from "db/schema";
import { Button } from "~/components/ui/button";
import { cn } from "~/utils";
import { getIsVideo } from "~/utils/cloudinaryUtils";
import { useDialog } from "~/utils/dialogUtils";
import { getIsVideo } from "~/utils/file.utils";

import { Typography } from "./ui/typography";

Expand All @@ -20,6 +19,7 @@ type Props = {

export const CloudImageFullscreen = ({
cloudImage: { cloudUrl, width, height },
cloudImage,
onNext,
onPrevious,
onClose,
Expand All @@ -29,12 +29,12 @@ export const CloudImageFullscreen = ({
const [isLoaded, setIsLoaded] = useState(false);

useEffect(() => {
if (getIsVideo(cloudUrl)) {
if (getIsVideo(cloudImage)) {
setIsLoaded(true);
} else {
setIsLoaded(false);
}
}, [cloudUrl]);
}, [cloudImage]);

return (
<>
Expand Down Expand Up @@ -92,7 +92,7 @@ export const CloudImageFullscreen = ({
>
<Loader2 className="animate-spin size-12" />
</Typography>
{getIsVideo(cloudUrl) ? (
{getIsVideo(cloudImage) ? (
<video
className={cn("max-h-screen object-contain max-w-full", isLoaded ? "opacity-100" : "opacity-30")}
controls
Expand All @@ -105,13 +105,11 @@ export const CloudImageFullscreen = ({
<track default kind="captions" />
</video>
) : (
<Image
<img
loading="eager"
unstyled
className={cn("max-h-screen object-contain", isLoaded ? "opacity-100" : "opacity-30")}
fetchPriority="high"
src={cloudUrl}
background="auto"
alt=""
width={width}
height={height}
Expand Down
10 changes: 2 additions & 8 deletions app/components/ResponsiveAppBar/UserForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@ type Props = {
setCrop: (crop: Point) => void;
zoom: number;
setZoom: (zoom: number) => void;
area: Area | undefined;
setArea: (area: Area) => void;
picture: string | undefined;
setPicture: (value: string | undefined) => void;
};

export const UserForm = ({ user, area, crop, picture, setCrop, setPicture, setZoom, zoom, setArea }: Props) => {
export const UserForm = ({ user, crop, picture, setCrop, setPicture, setZoom, zoom, setArea }: Props) => {
return (
<>
<FormItem name="name">
Expand Down Expand Up @@ -65,16 +64,11 @@ export const UserForm = ({ user, area, crop, picture, setCrop, setPicture, setZo
cropShape="round"
maxZoom={10}
zoomSpeed={0.3}
onCropAreaChange={(area, areaPx) => setArea(areaPx)}
onCropComplete={(area, areaPx) => setArea(areaPx)}
/>
</div>
) : null}

<input hidden readOnly value={area?.x ?? 0} name="pictureCropX" />
<input hidden readOnly value={area?.y ?? 0} name="pictureCropY" />
<input hidden readOnly value={area?.width ?? 0} name="pictureCropWidth" />
<input hidden readOnly value={area?.height ?? 0} name="pictureCropHeight" />

<FormItem name="emailSubscription" className="border p-2 flex justify-between items-center rounded">
<div>
<FormLabel className="flex gap-2 items-center">
Expand Down
Loading