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
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ import { IsHexColor, IsOptional, IsString, IsUrl } from 'class-validator';
import { IsImageUrl } from '../../shared/validators/image.validator';

export class UpdateBrandingDetailsDto {
@IsUrl({
require_protocol: true,
protocols: ['https'],
})
@IsImageUrl({
message: 'Logo must be a valid image URL with one of the following extensions: jpg, jpeg, png, gif, svg',
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';

export class UploadUrlResponse {
export class GetSignedUrlResponseDto {
@ApiProperty()
signedUrl: string;
@ApiProperty()
path: string;
@ApiProperty()
additionalHeaders?: Record<string, string>;
}
17 changes: 12 additions & 5 deletions apps/api/src/app/storage/storage.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { GetSignedUrlCommand } from './usecases/get-signed-url/get-signed-url.co
import { UserSession } from '../shared/framework/user.decorator';
import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
import { UploadUrlResponse } from './dtos/upload-url-response.dto';
import { GetSignedUrlResponseDto } from './dtos/get-signed-url-response.dto';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';

Expand All @@ -18,19 +18,26 @@ import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.de
export class StorageController {
constructor(private getSignedUrlUsecase: GetSignedUrl) {}

@Get('/upload-url')
@Get('/signed-url')
@ApiOperation({
summary: 'Get upload url',
summary: 'Get signed url for uploading or reading a file',
})
@ApiResponse(UploadUrlResponse)
@ApiResponse(GetSignedUrlResponseDto)
@ExternalApiAccessible()
async signedUrl(@UserSession() user: IJwtPayload, @Query('extension') extension: string): Promise<UploadUrlResponse> {
async signedUrl(
@UserSession() user: IJwtPayload,
@Query('operation') operation: 'read' | 'write',
@Query('extension') extension?: string,
@Query('imagePath') imagePath?: string
): Promise<GetSignedUrlResponseDto> {
return await this.getSignedUrlUsecase.execute(
GetSignedUrlCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
extension,
operation,
imagePath,
})
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,12 @@ import { EnvironmentWithUserCommand } from '../../../shared/commands/project.com
export class GetSignedUrlCommand extends EnvironmentWithUserCommand {
@IsString()
@IsIn(['jpg', 'png', 'jpeg'])
extension: string;
extension?: string;

@IsString()
@IsIn(['read', 'write'])
operation: 'read' | 'write';

@IsString()
imagePath?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import * as hat from 'hat';
import { StorageService } from '@novu/application-generic';

import { UploadUrlResponse } from '../../dtos/upload-url-response.dto';
import { GetSignedUrlResponseDto } from '../../dtos/get-signed-url-response.dto';
import { GetSignedUrlCommand } from './get-signed-url.command';

const mimeTypes = {
Expand All @@ -14,11 +14,22 @@ const mimeTypes = {
export class GetSignedUrl {
constructor(private storageService: StorageService) {}

async execute(command: GetSignedUrlCommand): Promise<UploadUrlResponse> {
const path = `${command.organizationId}/${command.environmentId}/${hat()}.${command.extension}`;
async execute(command: GetSignedUrlCommand): Promise<GetSignedUrlResponseDto> {
const path =
command.operation === 'read'
? (command.imagePath as string)
: `${command.organizationId}/${command.environmentId}/${hat()}.${command.extension}`;

const response = await this.storageService.getSignedUrl(path, mimeTypes[command.extension]);
const { signedUrl, additionalHeaders } = await this.storageService.getSignedUrl(
path,
command.operation === 'write' ? mimeTypes[command.extension as keyof typeof mimeTypes] : 'image/png',
command.operation
);

return response;
return {
signedUrl,
path,
additionalHeaders,
};
}
}
2 changes: 1 addition & 1 deletion apps/api/src/app/subscribers/subscribers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ export class SubscribersController {
@UseGuards(UserAuthGuard)
@Post('/:subscriberId/messages/markAs')
@ApiOperation({
summary: 'Mark a subscriber feed message as seen',
summary: 'Mark a subscriber feed message as seen/unseen/read/unread',
})
@ApiResponse(MessageResponseDto, 201, true)
async markMessageAs(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SubscriberRepository,
MemberRepository,
} from '@novu/dal';
import { AnalyticsService } from '@novu/application-generic';
import { AnalyticsService, buildFeedKey, InvalidateCacheService } from '@novu/application-generic';

import { UpdateMessageActionsCommand } from './update-message-actions.command';

Expand All @@ -15,13 +15,20 @@ import { ApiException } from '../../../shared/exceptions/api.exception';
@Injectable()
export class UpdateMessageActions {
constructor(
private invalidateCache: InvalidateCacheService,
private messageRepository: MessageRepository,
private subscriberRepository: SubscriberRepository,
private analyticsService: AnalyticsService,
private memberRepository: MemberRepository
) {}

async execute(command: UpdateMessageActionsCommand): Promise<MessageEntity> {
await this.invalidateCache.invalidateQuery({
key: buildFeedKey().invalidate({
subscriberId: command.subscriberId,
_environmentId: command.environmentId,
}),
});
const foundMessage = await this.messageRepository.findOne({
_environmentId: command.environmentId,
_id: command.messageId,
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/api/storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { api } from './api.client';

export function getSignedUrl(extension: string) {
return api.get(`/v1/storage/upload-url?extension=${extension}`);
export function getSignedUrl({
extension,
operation,
imagePath,
}: {
extension: string;
operation: 'read' | 'write';
imagePath?: string;
}) {
if (imagePath) {
return api.get(`/v1/storage/signed-url?extension=${extension}&operation=${operation}&imagePath=${imagePath}`);
}

return api.get(`/v1/storage/signed-url?extension=${extension}&operation=${operation}&imagePath=""`);
}
74 changes: 62 additions & 12 deletions apps/web/src/pages/brand/tabs/BrandingForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Flex, Grid, Group, Input, LoadingOverlay, Stack, UnstyledButton, useMan
import { Dropzone } from '@mantine/dropzone';
import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import styled from '@emotion/styled';
import { useOutletContext } from 'react-router-dom';
Expand All @@ -19,24 +19,55 @@ const mimeTypes = {
'image/png': 'png',
};

export function BrandingForm() {
export const BrandingForm = () => {
const [imagePath, setImagePath] = useState<string | undefined>(undefined);
const { currentOrganization: organization } = useOutletContext<{
currentOrganization: IOrganizationEntity | undefined;
}>();

const theme = useMantineTheme();

const { mutateAsync: getSignedUrlAction } = useMutation<
{ signedUrl: string; path: string; additionalHeaders: object },
IResponseError,
string
{ extension: string; operation: 'read' | 'write'; imagePath?: string }
>(getSignedUrl);

const getLogoUrl = useCallback(
async (logo: string | undefined) => {
if (!logo) {
return '';
} else if (logo.includes('https')) {
return logo;
}

try {
const logoParts = logo.split('.');

const { signedUrl: signedUrlToRead } = await getSignedUrlAction({
extension: logoParts[logoParts.length - 1],
operation: 'read',
imagePath: logo,
});

return signedUrlToRead;
} catch (e) {
console.error('Error getting logo URL:', e);

return '';
}
},
[getSignedUrlAction]
);

const { setValue, handleSubmit, control } = useForm({
defaultValues: {
fontFamily: organization?.branding?.fontFamily || 'inherit',
color: organization?.branding?.color || '#f47373',
image: organization?.branding?.logo || '',
image: '',
file: '',
},
});
const theme = useMantineTheme();

const { mutateAsync: updateBrandingSettingsMutation, isLoading: isUpdateBrandingLoading } = useMutation<
{ logo: string; path: string },
Expand All @@ -46,13 +77,17 @@ export function BrandingForm() {

useEffect(() => {
if (organization) {
organization?.branding?.logo ? setValue('image', organization.branding.logo) : setValue('image', '');
getLogoUrl(organization?.branding?.logo).then((url) => {
setValue('image', url);

return url;
});
organization?.branding?.color ? setValue('color', organization?.branding?.color) : setValue('color', '#f47373');
organization?.branding?.fontFamily
? setValue('fontFamily', organization?.branding?.fontFamily)
: setValue('fontFamily', 'inherit');
}
}, [organization, setValue]);
}, [organization, setValue, getLogoUrl]);

function removeFile() {
setValue('file', '');
Expand All @@ -63,13 +98,21 @@ export function BrandingForm() {
const file = files[0];
if (!file) return;

const { signedUrl, path, additionalHeaders } = await getSignedUrlAction(mimeTypes[file.type]);
const {
signedUrl: signedUrlToUpload,
additionalHeaders,
path: imagePathToUpload,
} = await getSignedUrlAction({
extension: mimeTypes[file.type],
operation: 'write',
});

const contentTypeHeaders = {
'Content-Type': file.type,
};

const mergedHeaders = Object.assign({}, contentTypeHeaders, additionalHeaders || {});
await axios.put(signedUrl, file, {
await axios.put(signedUrlToUpload, file, {
headers: mergedHeaders,
transformRequest: [
(data, headers) => {
Expand All @@ -83,15 +126,22 @@ export function BrandingForm() {
],
});

setValue('image', path);
const { signedUrl: signedUrlToRead, path: imagePathToRead } = await getSignedUrlAction({
extension: mimeTypes[file.type],
operation: 'read',
imagePath: imagePathToUpload,
});

setValue('image', signedUrlToRead);
setImagePath(imagePathToRead);
}

const dropzoneRef = useRef<() => void>(null);

async function saveBrandsForm({ color, fontFamily, image }) {
const brandData = {
color,
logo: image || null,
logo: imagePath,
fontFamily,
};

Expand Down Expand Up @@ -240,7 +290,7 @@ export function BrandingForm() {
</form>
</Stack>
);
}
};

const DropzoneButton: any = styled(UnstyledButton)`
color: ${colors.B70};
Expand Down
2 changes: 2 additions & 0 deletions libs/dal/src/repositories/message/message.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ export class MessageRepository extends BaseRepository<MessageDBModel, MessageEnt

if (mark.read != null) {
requestQuery.read = mark.read;
// if read is true, then seen should be true
requestQuery.seen = true;
requestQuery.lastReadDate = new Date();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export interface IFilePath {
export abstract class StorageService {
abstract getSignedUrl(
key: string,
contentType: string
contentType: string,
operation?: 'read' | 'write'
): Promise<{
signedUrl: string;
path: string;
Expand Down Expand Up @@ -55,6 +56,10 @@ export class S3StorageService implements StorageService {
region: process.env.S3_REGION,
endpoint: process.env.S3_LOCAL_STACK || undefined,
forcePathStyle: true,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});

async uploadFile(
Expand Down Expand Up @@ -102,14 +107,24 @@ export class S3StorageService implements StorageService {
await this.s3.send(command);
}

async getSignedUrl(key: string, contentType: string) {
const command = new PutObjectCommand({
Key: key,
Bucket: process.env.S3_BUCKET_NAME,
ACL: 'public-read',
ContentType: contentType,
});

async getSignedUrl(
key: string,
contentType: string,
operation: 'read' | 'write'
) {
let command;
if (operation === 'read') {
command = new GetObjectCommand({
Key: key,
Bucket: process.env.S3_BUCKET_NAME,
});
} else {
command = new PutObjectCommand({
Key: key,
Bucket: process.env.S3_BUCKET_NAME,
ContentType: contentType,
});
}
const signedUrl = await getSignedUrl(this.s3, command, { expiresIn: 3600 });
const parsedUrl = new URL(signedUrl);
const path = process.env.CDN_URL
Expand Down
Loading