Skip to content

Commit

Permalink
Feat: added loading and better downloading for attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
harshithmullapudi committed Dec 8, 2024
1 parent a68746d commit 6a287ae
Show file tree
Hide file tree
Showing 20 changed files with 455 additions and 97 deletions.
41 changes: 40 additions & 1 deletion apps/server/src/modules/attachments/attachments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { SignedURLBody } from '@tegonhq/types';
import { Response } from 'express';
import { SessionContainer } from 'supertokens-node/recipe/session';

import { AuthGuard } from 'modules/auth/auth.guard';
import {
Session as SessionDecorator,
UserId,
Workspace,
} from 'modules/auth/session.decorator';

Expand Down Expand Up @@ -56,14 +58,47 @@ export class AttachmentController {
);
}

@Post('get-signed-url')
@UseGuards(AuthGuard)
async getUploadSignedUrl(
@Body() attachmentBody: SignedURLBody,
@Workspace() workspaceId: string,
@UserId() userId: string,
) {
return await this.attachementService.uploadGenerateSignedURL(
attachmentBody,
userId,
workspaceId,
);
}

@Get('get-signed-url/:attachmentId')
@UseGuards(AuthGuard)
async getFileFromGCSSignedURL(
@Workspace() workspaceId: string,
@Param() attachementRequestParams: AttachmentRequestParams,
) {
try {
return await this.attachementService.getFileFromGCSSignedUrl(
attachementRequestParams,
workspaceId,
);
} catch (error) {
return undefined;
}
}

@Get(':workspaceId/:attachmentId')
@UseGuards(AuthGuard)
async getFileFromGCS(
@Workspace() workspaceId: string,
@Param() attachementRequestParams: AttachmentRequestParams,
@Res() res: Response,
) {
try {
const file = await this.attachementService.getFileFromGCS(
attachementRequestParams,
workspaceId,
);
res.setHeader('Content-Type', file.contentType);
res.send(file.buffer);
Expand All @@ -75,9 +110,13 @@ export class AttachmentController {
@Delete(':workspaceId/:attachmentId')
@UseGuards(AuthGuard)
async deleteAttachment(
@Workspace() workspaceId: string,
@Param() attachementRequestParams: AttachmentRequestParams,
) {
await this.attachementService.deleteAttachment(attachementRequestParams);
await this.attachementService.deleteAttachment(
attachementRequestParams,
workspaceId,
);
return { message: 'Attachment deleted successfully' };
}
}
3 changes: 0 additions & 3 deletions apps/server/src/modules/attachments/attachments.interface.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { IsOptional, IsString } from 'class-validator';

export class AttachmentRequestParams {
@IsString()
workspaceId: string;

@IsString()
attachmentId: string;
}
Expand Down
121 changes: 116 additions & 5 deletions apps/server/src/modules/attachments/attachments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,81 @@ import {
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import { AttachmentResponse, AttachmentStatusEnum } from '@tegonhq/types';
import {
AttachmentResponse,
AttachmentStatusEnum,
SignedURLBody,
} from '@tegonhq/types';
import { PrismaService } from 'nestjs-prisma';

import { LoggerService } from 'modules/logger/logger.service';

import { AttachmentRequestParams, ExternalFile } from './attachments.interface';
@Injectable()
export class AttachmentService {
private storage: Storage;
private bucketName = process.env.GCP_BUCKET_NAME;
private readonly logger: LoggerService = new LoggerService(
'AttachmentService',
);

constructor(private prisma: PrismaService) {
this.storage = new Storage({
keyFilename: process.env.GCP_SERVICE_ACCOUNT_FILE,
});
}

async uploadGenerateSignedURL(
file: SignedURLBody,
userId: string,
workspaceId: string,
) {
const bucket = this.storage.bucket(this.bucketName);
const attachment = await this.prisma.attachment.create({
data: {
fileName: file.fileName,
originalName: file.originalName,
fileType: file.mimetype,
size: file.size,
status: AttachmentStatusEnum.Pending,
fileExt: file.originalName.split('.').pop(),
workspaceId,
...(userId ? { uploadedById: userId } : {}),
},
include: {
workspace: true,
},
});

try {
const [url] = await bucket
.file(`${workspaceId}/${attachment.id}.${attachment.fileExt}`)
.getSignedUrl({
version: 'v4',
action: 'write',
expires: Date.now() + 15 * 60 * 1000, // 15 minutes
contentType: file.contentType,
});

const publicURL = `${process.env.PUBLIC_ATTACHMENT_URL}/v1/attachment/${workspaceId}/${attachment.id}`;

return {
url,
attachment: {
publicURL,
id: attachment.id,
fileType: attachment.fileType,
originalName: attachment.originalName,
size: attachment.size,
},
};
} catch (err) {
this.logger.error(err);

return undefined;
}
}

async uploadAttachment(
files: Express.Multer.File[],
userId: string,
Expand Down Expand Up @@ -109,8 +169,11 @@ export class AttachmentService {
return await Promise.all(attachmentPromises);
}

async getFileFromGCS(attachementRequestParams: AttachmentRequestParams) {
const { attachmentId, workspaceId } = attachementRequestParams;
async getFileFromGCS(
attachementRequestParams: AttachmentRequestParams,
workspaceId: string,
) {
const { attachmentId } = attachementRequestParams;

const attachment = await this.prisma.attachment.findFirst({
where: { id: attachmentId, workspaceId },
Expand Down Expand Up @@ -139,8 +202,56 @@ export class AttachmentService {
};
}

async deleteAttachment(attachementRequestParams: AttachmentRequestParams) {
const { attachmentId, workspaceId } = attachementRequestParams;
async getFileFromGCSSignedUrl(
attachementRequestParams: AttachmentRequestParams,
workspaceId: string,
) {
const { attachmentId } = attachementRequestParams;

const attachment = await this.prisma.attachment.findFirst({
where: { id: attachmentId, workspaceId },
});

if (!attachment) {
throw new BadRequestException(
`No attachment found for this id: ${attachmentId}`,
);
}

const bucket = this.storage.bucket(this.bucketName);
const filePath = `${workspaceId}/${attachment.id}.${attachment.fileExt}`;
const file = bucket.file(filePath);

const [exists] = await file.exists();
if (!exists) {
throw new BadRequestException('File not found');
}

// Get file metadata for size
const [metadata] = await file.getMetadata();

const [signedUrl] = await file.getSignedUrl({
version: 'v4',
action: 'read',
expires: Date.now() + 60 * 60 * 1000, // 1 hour
// Enable range requests and other necessary headers
responseDisposition: 'inline',
responseType: attachment.fileType,
});

return {
signedUrl,
contentType: attachment.fileType,
originalName: attachment.originalName,
size: metadata.size,
};
}

async deleteAttachment(
attachementRequestParams: AttachmentRequestParams,
workspaceId: string,
) {
const { attachmentId } = attachementRequestParams;

const attachment = await this.prisma.attachment.findFirst({
where: { id: attachmentId, workspaceId },
Expand Down
20 changes: 14 additions & 6 deletions apps/server/src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
Put,
Query,
Req,
Res,
Expand Down Expand Up @@ -44,12 +45,16 @@ export class UsersController {
async getUser(
@SessionDecorator() session: SessionContainer,
@Query() userIdParams: { userIds: string },
@Workspace() workspaceId: string,
): Promise<UserWithInvites | PublicUser[]> {
try {
if (userIdParams.userIds && userIdParams.userIds.split(',').length > 0) {
return await this.users.getUsersbyId({
userIds: userIdParams.userIds.split(','),
});
return await this.users.getUsersbyId(
{
userIds: userIdParams.userIds.split(','),
},
workspaceId,
);
}
} catch (e) {}

Expand All @@ -61,8 +66,11 @@ export class UsersController {

@Post()
@UseGuards(AuthGuard)
async getUsersById(@Body() getUsersDto: GetUsersDto): Promise<PublicUser[]> {
return await this.users.getUsersbyId(getUsersDto);
async getUsersById(
@Body() getUsersDto: GetUsersDto,
@Workspace() workspaceId: string,
): Promise<PublicUser[]> {
return await this.users.getUsersbyId(getUsersDto, workspaceId);
}

@Post('impersonate')
Expand Down Expand Up @@ -139,14 +147,14 @@ export class UsersController {
return this.users.authorizeCode(userId, codeBody);
}

@Put()
@UseGuards(AuthGuard)
async updateUser(
@UserId() userId: string,
@Body()
updateUserBody: UpdateUserBody,
): Promise<User> {
const user = await this.users.updateUser(userId, updateUserBody);

return user;
}
}
9 changes: 6 additions & 3 deletions apps/server/src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,17 @@ export class UsersService {
return { ...serializeUser, invites };
}

async getUsersbyId(getUsersDto: GetUsersDto): Promise<PublicUser[]> {
async getUsersbyId(
getUsersDto: GetUsersDto,
workspaceId: string,
): Promise<PublicUser[]> {
const where: Prisma.UserWhereInput = {
id: { in: getUsersDto.userIds },
};

if (getUsersDto.workspaceId) {
if (workspaceId) {
where.usersOnWorkspaces = {
some: { workspaceId: getUsersDto.workspaceId },
some: { workspaceId },
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@tegonhq/types": "workspace:*",
"@tegonhq/ui": "workspace:*",
"@tiptap/core": "^2.3.0",
"@tiptap/extension-mention": "^2.10.3",
"@tiptap/react": "^2.5.4",
"@typeform/embed-react": "^3.17.0",
"ai": "^3.2.37",
Expand Down
8 changes: 8 additions & 0 deletions apps/webapp/src/common/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client';

import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,7 @@ export function ProfileForm() {
});

const onSubmit = (values: UpdateUserParams) => {
updateUser({
...values,
userId: userData.id,
});
updateUser(values);
};

return (
Expand Down
26 changes: 26 additions & 0 deletions apps/webapp/src/services/attachment/get-signed-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type UseQueryResult, useQuery } from 'react-query';

import type { User } from 'common/types';

import { type XHRErrorResponse, ajaxGet } from 'services/utils';

/**
* Query Key for Get user.
*/
export const GetSignedURL = 'getSignedURLQuery';

export function getSignedURL(attachmentId: string) {
return ajaxGet({
url: `/api/v1/attachment/get-signed-url/${attachmentId}`,
});
}

export function useGetSignedURLQuery(
attachmentId: string,
): UseQueryResult<User, XHRErrorResponse> {
return useQuery([GetSignedURL], () => getSignedURL(attachmentId), {
retry: 1,
staleTime: Infinity,
refetchOnWindowFocus: false, // Frequency of Change would be Low
});
}
1 change: 1 addition & 0 deletions apps/webapp/src/services/attachment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './get-signed-url';
Loading

0 comments on commit 6a287ae

Please sign in to comment.