mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
upload org icon
This commit is contained in:
@@ -85,6 +85,7 @@ export async function updateOrganisation(
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
iconURL?: string | null;
|
||||||
statuses?: Record<string, string>;
|
statuses?: Record<string, string>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const main = async () => {
|
|||||||
"/organisation/by-id": withCors(withAuth(routes.organisationById)),
|
"/organisation/by-id": withCors(withAuth(routes.organisationById)),
|
||||||
"/organisation/update": withCors(withAuth(withCSRF(routes.organisationUpdate))),
|
"/organisation/update": withCors(withAuth(withCSRF(routes.organisationUpdate))),
|
||||||
"/organisation/delete": withCors(withAuth(withCSRF(routes.organisationDelete))),
|
"/organisation/delete": withCors(withAuth(withCSRF(routes.organisationDelete))),
|
||||||
|
"/organisation/upload-icon": withCors(withAuth(withCSRF(routes.organisationUploadIcon))),
|
||||||
"/organisation/add-member": withCors(withAuth(withCSRF(routes.organisationAddMember))),
|
"/organisation/add-member": withCors(withAuth(withCSRF(routes.organisationAddMember))),
|
||||||
"/organisation/members": withCors(withAuth(routes.organisationMembers)),
|
"/organisation/members": withCors(withAuth(routes.organisationMembers)),
|
||||||
"/organisation/remove-member": withCors(withAuth(withCSRF(routes.organisationRemoveMember))),
|
"/organisation/remove-member": withCors(withAuth(withCSRF(routes.organisationRemoveMember))),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import organisationMembers from "./organisation/members";
|
|||||||
import organisationRemoveMember from "./organisation/remove-member";
|
import organisationRemoveMember from "./organisation/remove-member";
|
||||||
import organisationUpdate from "./organisation/update";
|
import organisationUpdate from "./organisation/update";
|
||||||
import organisationUpdateMemberRole from "./organisation/update-member-role";
|
import organisationUpdateMemberRole from "./organisation/update-member-role";
|
||||||
|
import organisationUploadIcon from "./organisation/upload-icon";
|
||||||
import projectsAll from "./project/all";
|
import projectsAll from "./project/all";
|
||||||
import projectsByCreator from "./project/by-creator";
|
import projectsByCreator from "./project/by-creator";
|
||||||
import projectsByOrganisation from "./project/by-organisation";
|
import projectsByOrganisation from "./project/by-organisation";
|
||||||
@@ -66,6 +67,7 @@ export const routes = {
|
|||||||
organisationMembers,
|
organisationMembers,
|
||||||
organisationRemoveMember,
|
organisationRemoveMember,
|
||||||
organisationUpdateMemberRole,
|
organisationUpdateMemberRole,
|
||||||
|
organisationUploadIcon,
|
||||||
|
|
||||||
organisationsByUser,
|
organisationsByUser,
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default async function organisationUpdate(req: AuthedRequest) {
|
|||||||
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
|
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
|
||||||
if ("error" in parsed) return parsed.error;
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
const { id, name, description, slug, statuses } = parsed.data;
|
const { id, name, description, slug, iconURL, statuses } = parsed.data;
|
||||||
|
|
||||||
const existingOrganisation = await getOrganisationById(id);
|
const existingOrganisation = await getOrganisationById(id);
|
||||||
if (!existingOrganisation) {
|
if (!existingOrganisation) {
|
||||||
@@ -22,9 +22,9 @@ export default async function organisationUpdate(req: AuthedRequest) {
|
|||||||
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
|
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name && !description && !slug && !statuses) {
|
if (!name && !description && !slug && !statuses && iconURL === undefined) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"at least one of name, description, slug, or statuses must be provided",
|
"at least one of name, description, slug, iconURL, or statuses must be provided",
|
||||||
"NO_UPDATES",
|
"NO_UPDATES",
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
@@ -34,6 +34,7 @@ export default async function organisationUpdate(req: AuthedRequest) {
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
slug,
|
slug,
|
||||||
|
iconURL,
|
||||||
statuses,
|
statuses,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
96
packages/backend/src/routes/organisation/upload-icon.ts
Normal file
96
packages/backend/src/routes/organisation/upload-icon.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
|
import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries";
|
||||||
|
import { s3Client, s3Endpoint, s3PublicUrl } from "../../s3";
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
|
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||||
|
const TARGET_SIZE = 256;
|
||||||
|
|
||||||
|
export default async function uploadOrganisationIcon(req: AuthedRequest) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("method not allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const organisationIdStr = formData.get("organisationId") as string | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return new Response("file is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organisationIdStr) {
|
||||||
|
return new Response("organisationId is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisationId = Number.parseInt(organisationIdStr, 10);
|
||||||
|
if (Number.isNaN(organisationId) || organisationId <= 0) {
|
||||||
|
return new Response("organisationId must be a positive integer", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisation = await getOrganisationById(organisationId);
|
||||||
|
if (!organisation) {
|
||||||
|
return new Response("organisation not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await getOrganisationMemberRole(organisationId, req.userId);
|
||||||
|
if (!member) {
|
||||||
|
return new Response("you are not a member of this organisation", { status: 403 });
|
||||||
|
}
|
||||||
|
if (member.role !== "owner" && member.role !== "admin") {
|
||||||
|
return new Response("only owners and admins can upload organisation icons", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return new Response("file size exceeds 5MB limit", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return new Response("invalid file type. Allowed types: png, jpg, jpeg, webp, gif", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGIF = file.type === "image/gif";
|
||||||
|
const outputExtension = isGIF ? "gif" : "png";
|
||||||
|
const outputMimeType = isGIF ? "image/gif" : "image/png";
|
||||||
|
|
||||||
|
let resizedBuffer: Buffer;
|
||||||
|
try {
|
||||||
|
const inputBuffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
if (isGIF) {
|
||||||
|
resizedBuffer = await sharp(inputBuffer, { animated: true })
|
||||||
|
.resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" })
|
||||||
|
.gif()
|
||||||
|
.toBuffer();
|
||||||
|
} else {
|
||||||
|
resizedBuffer = await sharp(inputBuffer)
|
||||||
|
.resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("failed to resize image:", error);
|
||||||
|
return new Response("failed to process image", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = randomUUID();
|
||||||
|
const key = `org-icons/${uuid}.${outputExtension}`;
|
||||||
|
const publicUrlBase = s3PublicUrl || s3Endpoint;
|
||||||
|
const publicUrl = `${publicUrlBase}/${key}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s3File = s3Client.file(key);
|
||||||
|
await s3File.write(resizedBuffer, { type: outputMimeType });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("failed to upload to S3:", error);
|
||||||
|
return new Response("failed to upload image", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateOrganisation(organisationId, { iconURL: publicUrl });
|
||||||
|
|
||||||
|
return Response.json({ iconURL: publicUrl });
|
||||||
|
}
|
||||||
92
packages/frontend/src/components/upload-org-icon.tsx
Normal file
92
packages/frontend/src/components/upload-org-icon.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import OrgIcon from "@/components/org-icon";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Icon from "@/components/ui/icon";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useUploadOrganisationIcon } from "@/lib/query/hooks";
|
||||||
|
import { parseError } from "@/lib/server";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function UploadOrgIcon({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
iconURL,
|
||||||
|
organisationId,
|
||||||
|
onIconUploaded,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
iconURL?: string | null;
|
||||||
|
organisationId: number;
|
||||||
|
onIconUploaded: (iconURL: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const uploadIcon = useUploadOrganisationIcon();
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await uploadIcon.mutateAsync({ file, organisationId });
|
||||||
|
onIconUploaded(url);
|
||||||
|
setUploading(false);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<img src={url} alt="Organisation icon" className="size-32" />
|
||||||
|
Organisation icon uploaded successfully
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
dismissible: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = parseError(err as Error);
|
||||||
|
setError(message);
|
||||||
|
setUploading(false);
|
||||||
|
|
||||||
|
toast.error(`Error uploading icon: ${message}`, {
|
||||||
|
dismissible: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col items-center gap-4", className)}>
|
||||||
|
<Button
|
||||||
|
variant="dummy"
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onMouseOver={() => setShowEdit(true)}
|
||||||
|
onMouseOut={() => setShowEdit(false)}
|
||||||
|
className="size-24 rounded-lg border-1 p-0 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<OrgIcon name={name} slug={slug} iconURL={iconURL} size={24} textClass="text-4xl" />
|
||||||
|
|
||||||
|
{!uploading && showEdit && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||||
|
<Icon icon="edit" className="size-6 text-white drop-shadow-md" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{error && <Label className="text-destructive text-sm">{error}</Label>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,3 +99,15 @@ export function useUpdateOrganisationMemberRole() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUploadOrganisationIcon() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<string, Error, { file: File; organisationId: number }>({
|
||||||
|
mutationKey: ["organisations", "upload-icon"],
|
||||||
|
mutationFn: ({ file, organisationId }) => organisation.uploadIcon(file, organisationId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
42
packages/frontend/src/lib/server/organisation/uploadIcon.ts
Normal file
42
packages/frontend/src/lib/server/organisation/uploadIcon.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||||
|
import { getErrorMessage } from "..";
|
||||||
|
|
||||||
|
export async function uploadIcon(file: File, organisationId: number): Promise<string> {
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
|
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
throw new Error("File size exceeds 5MB limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("organisationId", organisationId.toString());
|
||||||
|
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||||
|
|
||||||
|
const res = await fetch(`${getServerURL()}/organisation/upload-icon`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = await getErrorMessage(res, `Failed to upload icon (${res.status})`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.iconURL) {
|
||||||
|
return data.iconURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to upload icon");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user