diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx
index 4289fe154..625d8ec18 100644
--- a/src/features/account/AccountProfileForm.tsx
+++ b/src/features/account/AccountProfileForm.tsx
@@ -18,7 +18,7 @@ import {
FormFieldsAccountProfile,
zFormFieldsAccountProfile,
} from '@/features/account/schemas';
-import { useAvatarFetch, useAvatarUpload } from '@/features/account/service';
+import { useFetchFile, useUploadFileMutation } from '@/files/client';
import {
AVAILABLE_LANGUAGES,
DEFAULT_LANGUAGE_KEY,
@@ -32,8 +32,9 @@ export const AccountProfileForm = () => {
staleTime: Infinity,
});
- const accountAvatar = useAvatarFetch(account.data?.image ?? '');
- const uploadFile = useAvatarUpload();
+ const accountAvatar = useFetchFile(account.data?.image);
+
+ const uploadAvatar = useUploadFileMutation();
const updateAccount = trpc.account.update.useMutation({
onSuccess: async () => {
@@ -65,13 +66,13 @@ export const AccountProfileForm = () => {
image,
...values
}) => {
- let fileUrl = account.data?.image;
try {
- if (image?.file) {
- const uploadResponse = await uploadFile.mutateAsync(image?.file);
- fileUrl = uploadResponse.fileUrl;
- }
- updateAccount.mutate({ ...values, image: fileUrl });
+ updateAccount.mutate({
+ ...values,
+ image: image?.file
+ ? await uploadAvatar.mutateAsync(image.file)
+ : account.data?.image,
+ });
} catch {
form.setError('image', {
message: t('account:profile.feedbacks.uploadError.title'),
@@ -126,7 +127,7 @@ export const AccountProfileForm = () => {
diff --git a/src/features/account/schemas.ts b/src/features/account/schemas.ts
index c1cdc0cfa..1d343a22a 100644
--- a/src/features/account/schemas.ts
+++ b/src/features/account/schemas.ts
@@ -43,8 +43,6 @@ export const zFormFieldsAccountProfile = () =>
name: true,
language: true,
})
- .merge(
- z.object({
- image: zFieldUploadValue(['image']).optional(),
- })
- );
+ .extend({
+ image: zFieldUploadValue(['image']).optional(),
+ });
diff --git a/src/features/account/service.ts b/src/features/account/service.ts
deleted file mode 100644
index b38b8459a..000000000
--- a/src/features/account/service.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { useMutation, useQuery } from '@tanstack/react-query';
-
-import { trpc } from '@/lib/trpc/client';
-
-import { fetchFile, uploadFile } from '../../files/utils';
-
-export const useAvatarFetch = (url: string) => {
- return useQuery({
- queryKey: ['account', url],
- queryFn: () => fetchFile(url, ['name']),
- enabled: !!url,
- });
-};
-
-export const useAvatarUpload = () => {
- const getPresignedUrl = trpc.account.uploadAvatarPresignedUrl.useMutation();
- return useMutation({
- mutationFn: (file: File) => uploadFile(getPresignedUrl.mutateAsync, file),
- });
-};
diff --git a/src/files/client.ts b/src/files/client.ts
new file mode 100644
index 000000000..eba8d0d16
--- /dev/null
+++ b/src/files/client.ts
@@ -0,0 +1,82 @@
+import { useMutation, useQuery } from '@tanstack/react-query';
+
+import { trpc } from '@/lib/trpc/client';
+
+/**
+ * Fetches a file from the specified URL and returns file information.
+ * Designed to be used as a `queryFn` in a `useQuery`.
+ *
+ * @param url The URL from which the file should be fetched.
+ * @param [metadata] The metadata of the file you want to retrieve.
+ * @returns A Promise that resolves to an object containing information about the file.
+ *
+ * @example
+ * // Usage with Tanstack Query's useQuery:
+ * const fileQuery = useQuery({
+ queryKey: ['fileKey', url],
+ queryFn: () => fetchFile(url, ['name']),
+ enabled: !!url,
+ });
+ */
+export const fetchFile = async (url: string, metadata?: string[]) => {
+ const fileResponse = await fetch(url, {
+ cache: 'no-cache',
+ });
+ if (!fileResponse.ok) {
+ throw new Error('Could not fetch the file');
+ }
+
+ const lastModifiedDateHeader = fileResponse.headers.get('Last-Modified');
+ const defaultFileData = {
+ fileUrl: url,
+ size: fileResponse.headers.get('Content-Length') ?? undefined,
+ type: fileResponse.headers.get('Content-Type') ?? undefined,
+ lastModifiedDate: lastModifiedDateHeader
+ ? new Date(lastModifiedDateHeader)
+ : new Date(),
+ };
+
+ if (!metadata) {
+ return defaultFileData;
+ }
+
+ return metadata.reduce((file, metadataKey) => {
+ return {
+ ...file,
+ [metadataKey]: fileResponse.headers.get(`x-amz-meta-${metadataKey}`),
+ };
+ }, defaultFileData);
+};
+
+export const useUploadFileMutation = (
+ params: {
+ getMetadata?: (file: File) => Record;
+ } = {}
+) => {
+ const uploadPresignedUrl = trpc.files.uploadPresignedUrl.useMutation();
+ return useMutation({
+ mutationFn: async (file: File) => {
+ const presignedUrlOutput = await uploadPresignedUrl.mutateAsync({
+ metadata: {
+ name: file.name,
+ ...params.getMetadata?.(file),
+ },
+ });
+ await fetch(presignedUrlOutput.signedUrl, {
+ method: 'PUT',
+ headers: { 'Content-Type': file.type },
+ body: file,
+ });
+ // TODO ERRORS
+ return presignedUrlOutput.futureFileUrl;
+ },
+ });
+};
+
+export const useFetchFile = (url?: string | null) => {
+ return useQuery({
+ queryKey: ['file', url],
+ queryFn: () => (url ? fetchFile(url, ['name']) : undefined),
+ enabled: !!url,
+ });
+};
diff --git a/src/files/schemas.ts b/src/files/schemas.ts
index 53a4bb068..f9ae90012 100644
--- a/src/files/schemas.ts
+++ b/src/files/schemas.ts
@@ -32,15 +32,6 @@ export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) =>
}),
}
);
-
-export type UploadSignedUrlInput = z.infer<
- ReturnType
->;
-export const zUploadSignedUrlInput = () =>
- z.object({
- metadata: z.string().optional(),
- });
-
export type UploadSignedUrlOutput = z.infer<
ReturnType
>;
diff --git a/src/files/utils.ts b/src/files/utils.ts
index 3cfcd4854..b6fccb26f 100644
--- a/src/files/utils.ts
+++ b/src/files/utils.ts
@@ -1,102 +1,5 @@
-import { UseMutateAsyncFunction } from '@tanstack/react-query';
-import { stringify } from 'superjson';
-
import { env } from '@/env.mjs';
-import { UploadSignedUrlInput } from './schemas';
-
-/**
- * Fetches a file from the specified URL and returns file information.
- * Designed to be used as a `queryFn` in a `useQuery`.
- *
- * @param url The URL from which the file should be fetched.
- * @param [metadata] The metadata of the file you want to retrieve.
- * @returns A Promise that resolves to an object containing information about the file.
- *
- * @example
- * // Usage with Tanstack Query's useQuery:
- * const fileQuery = useQuery({
- queryKey: ['fileKey', url],
- queryFn: () => fetchFile(url, ['name']),
- enabled: !!url,
- });
- */
-export const fetchFile = async (url: string, metadata?: string[]) => {
- const fileResponse = await fetch(url, {
- cache: 'no-cache',
- });
- if (!fileResponse.ok) {
- throw new Error('Could not fetch the file');
- }
-
- const lastModifiedDateHeader = fileResponse.headers.get('Last-Modified');
- const defaultFileData = {
- fileUrl: url,
- size: fileResponse.headers.get('Content-Length') ?? undefined,
- type: fileResponse.headers.get('Content-Type') ?? undefined,
- lastModifiedDate: lastModifiedDateHeader
- ? new Date(lastModifiedDateHeader)
- : new Date(),
- };
-
- if (!metadata) {
- return defaultFileData;
- }
-
- return metadata.reduce((file, metadataKey) => {
- return {
- ...file,
- [metadataKey]: fileResponse.headers.get(`x-amz-meta-${metadataKey}`),
- };
- }, defaultFileData);
-};
-
-/**
- * Asynchronously uploads a file to a server using a presigned URL.
- * Designed to be used as a `mutationFn` in a `useMutation`.
- *
- * @param getPresignedUrl
- * - An asyncMutation that is used to obtain the presigned URL and the future URL where the file will be accessible.
- *
- * @param file - The file object to upload.
- * @param metadata - Optional metadata for the file, which will be sent to the server when generating the presigned URL.
- *
- * @returns A promise that resolves to an object containing the URL of the uploaded file,
- *
- * @example
- * // Usage with Tanstack Query's useMutation:
- * const getPresignedUrl = trpc.routeToGetPresignedUrl.useMutation();
- const fileUpload = useMutation({
- mutationFn: (file: File) => uploadFile(getPresignedUrl.mutateAsync, file),
- });
- */
-export const uploadFile = async (
- getPresignedUrl: UseMutateAsyncFunction<
- { signedUrl: string; futureFileUrl: string },
- unknown,
- UploadSignedUrlInput
- >,
- file: File,
- metadata: Record = {}
-) => {
- const { signedUrl, futureFileUrl } = await getPresignedUrl({
- metadata: stringify({
- name: file.name,
- ...metadata,
- }),
- });
-
- await fetch(signedUrl, {
- method: 'PUT',
- headers: { 'Content-Type': file.type },
- body: file,
- });
-
- return {
- fileUrl: futureFileUrl,
- } as const;
-};
-
export const isFileUrlValidBucket = async (url: string) => {
return url.startsWith(env.S3_BUCKET_PUBLIC_URL);
};
diff --git a/src/server/router.ts b/src/server/router.ts
index 3ce18c905..3b560f373 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -1,6 +1,7 @@
import { createTRPCRouter } from '@/server/config/trpc';
import { accountRouter } from '@/server/routers/account';
import { authRouter } from '@/server/routers/auth';
+import { filesRouter } from '@/server/routers/files';
import { oauthRouter } from '@/server/routers/oauth';
import { repositoriesRouter } from '@/server/routers/repositories';
import { usersRouter } from '@/server/routers/users';
@@ -16,6 +17,7 @@ export const appRouter = createTRPCRouter({
oauth: oauthRouter,
repositories: repositoriesRouter,
users: usersRouter,
+ files: filesRouter,
});
// export type definition of API
diff --git a/src/server/routers/account.tsx b/src/server/routers/account.tsx
index 7caaf02fd..1e90554cf 100644
--- a/src/server/routers/account.tsx
+++ b/src/server/routers/account.tsx
@@ -1,20 +1,17 @@
import { TRPCError } from '@trpc/server';
import dayjs from 'dayjs';
import { randomUUID } from 'node:crypto';
-import { parse } from 'superjson';
import { z } from 'zod';
import EmailDeleteAccountCode from '@/emails/templates/delete-account-code';
import EmailUpdateAlreadyUsed from '@/emails/templates/email-update-already-used';
import EmailUpdateCode from '@/emails/templates/email-update-code';
-import { env } from '@/env.mjs';
import {
zUserAccount,
zUserAccountWithEmail,
} from '@/features/account/schemas';
import { zVerificationCodeValidate } from '@/features/auth/schemas';
import { VALIDATION_TOKEN_EXPIRATION_IN_MINUTES } from '@/features/auth/utils';
-import { zUploadSignedUrlInput, zUploadSignedUrlOutput } from '@/files/schemas';
import { isFileUrlValidBucket } from '@/files/utils';
import i18n from '@/lib/i18n/server';
import {
@@ -24,7 +21,6 @@ import {
} from '@/server/config/auth';
import { sendEmail } from '@/server/config/email';
import { ExtendedTRPCError } from '@/server/config/errors';
-import { getS3UploadSignedUrl } from '@/server/config/s3';
import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc';
export const accountRouter = createTRPCRouter({
@@ -306,22 +302,4 @@ export const accountRouter = createTRPCRouter({
return user;
}),
- uploadAvatarPresignedUrl: protectedProcedure()
- .meta({
- openapi: {
- method: 'GET',
- path: '/accounts/avatar-upload-presigned-url',
- tags: ['accounts', 'files'],
- protect: true,
- },
- })
- .input(zUploadSignedUrlInput())
- .output(zUploadSignedUrlOutput())
- .mutation(async ({ ctx, input }) => {
- return await getS3UploadSignedUrl({
- key: ctx.user.id,
- host: env.S3_BUCKET_PUBLIC_URL,
- metadata: parse(input?.metadata ?? ''),
- });
- }),
});
diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts
new file mode 100644
index 000000000..5845067b3
--- /dev/null
+++ b/src/server/routers/files.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+
+import { env } from '@/env.mjs';
+import { zUploadSignedUrlOutput } from '@/files/schemas';
+import { getS3UploadSignedUrl } from '@/server/config/s3';
+import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc';
+
+export const filesRouter = createTRPCRouter({
+ uploadPresignedUrl: protectedProcedure()
+ .meta({
+ openapi: {
+ method: 'GET',
+ path: '/files/upload-presigned-url',
+ tags: ['files'],
+ protect: true,
+ },
+ })
+ .input(
+ z
+ .object({
+ metadata: z.record(z.string(), z.string()),
+ })
+ .optional()
+ )
+ .output(zUploadSignedUrlOutput())
+ .mutation(async ({ input, ctx }) => {
+ return await getS3UploadSignedUrl({
+ key: ctx.user.id, // FIX ME
+ host: env.S3_BUCKET_PUBLIC_URL,
+ metadata: input?.metadata,
+ });
+ }),
+});