Skip to content

Commit

Permalink
improvement of a newsletter (#350)
Browse files Browse the repository at this point in the history
* add: select roles

* Add: role selector to specify which users the newsletter will be sent to

* implement: send to self button for newsletter

* add: DOMPurify sanitize for html content from BlockNoteContent component

---------

Co-authored-by: David Hordiienko <[email protected]>
  • Loading branch information
flipvh and LemonardoD authored Jan 23, 2025
1 parent 5ee0383 commit c504ed0
Show file tree
Hide file tree
Showing 15 changed files with 275 additions and 100 deletions.
18 changes: 11 additions & 7 deletions backend/src/lib/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ export const emailSender = {
return;
}

await sendgrid.send({
to: env.SEND_ALL_TO_EMAIL || to,
replyTo: replyTo ? replyTo : config.supportEmail,
from: config.notificationsEmail,
subject,
html,
});
try {
await sendgrid.send({
to: env.SEND_ALL_TO_EMAIL || to,
replyTo: replyTo ? replyTo : config.supportEmail,
from: config.notificationsEmail,
subject: subject || `${config.name} message.`,
html,
});
} catch (err) {
console.warn('Failed to send email. \n', err);
}
},
};
32 changes: 31 additions & 1 deletion backend/src/modules/me/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@ import { transformDatabaseUserWithCount } from '../users/helpers/transform-datab
import meRoutesConfig from './routes';

import { config } from 'config';
import { render } from 'jsx-email';
import { membershipSelect, membershipsTable } from '#/db/schema/memberships';
import { oauthAccountsTable } from '#/db/schema/oauth-accounts';
import { passkeysTable } from '#/db/schema/passkeys';
import { getUserBy } from '#/db/util';
import { type MenuSection, entityIdFields, entityTables, menuSections } from '#/entity-config';
import { getContextUser, getMemberships } from '#/lib/context';
import { resolveEntity } from '#/lib/entity';
import { emailSender } from '#/lib/mailer';
import { sendSSEToUsers } from '#/lib/sse';
import type { MenuItem, UserMenu } from '#/types/common';
import { updateBlocknoteHTML } from '#/utils/blocknote';
import { NewsletterEmail } from '../../../emails/newsletter';
import { env } from '../../../env';
import { getPreparedSessions } from './helpers/get-sessions';
import type { MenuItem, UserMenu } from './schema';

const app = new CustomHono();

Expand Down Expand Up @@ -244,6 +250,30 @@ const meRoutes = app

return ctx.json({ success: true }, 200);
})
/*
* Send newsletter to current user (self)
*/
.openapi(meRoutesConfig.sendNewsletterEmailToSelf, async (ctx) => {
const user = getContextUser();
const { subject, content } = ctx.req.valid('json');

const unsafeUser = await getUserBy('id', user.id, 'unsafe');

// generating email html
const emailHtml = await render(
NewsletterEmail({
userLanguage: user.language,
subject,
content: updateBlocknoteHTML(content),
unsubscribeLink: `${config.backendUrl}/unsubscribe?token=${unsafeUser?.unsubscribeToken}`,
orgName: 'Organization',
}),
);

await emailSender.send(env.SEND_ALL_TO_EMAIL ?? user.email, subject, emailHtml, user.email);

return ctx.json({ success: true }, 200);
})
/*
* Delete current user (self) entity membership
*/
Expand Down
34 changes: 32 additions & 2 deletions backend/src/modules/me/routes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createRouteConfig } from '#/lib/route-config';
import { isAuthenticated } from '#/middlewares/guard';
import { isAuthenticated, systemGuard } from '#/middlewares/guard';
import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithoutDataSchema } from '#/utils/schema/common-responses';
import { idsQuerySchema } from '#/utils/schema/common-schemas';
import { updateUserBodySchema, userSchema } from '../users/schema';
import { leaveEntityQuerySchema, meUserSchema, signUpInfo, userMenuSchema } from './schema';
import { leaveEntityQuerySchema, meUserSchema, newsletterToSelfSchema, signUpInfo, userMenuSchema } from './schema';

class MeRoutesConfig {
public getSelf = createRouteConfig({
Expand Down Expand Up @@ -77,6 +77,36 @@ class MeRoutesConfig {
},
});

public sendNewsletterEmailToSelf = createRouteConfig({
method: 'post',
path: '/send-newsletter',
guard: [isAuthenticated, systemGuard],
tags: ['me'],
summary: 'Newsletter for self',
description: 'Sends to self, a newsletter.',
request: {
body: {
required: true,
content: {
'application/json': {
schema: newsletterToSelfSchema,
},
},
},
},
responses: {
200: {
description: 'News letter sended',
content: {
'application/json': {
schema: successWithoutDataSchema,
},
},
},
...errorResponses,
},
});

public getUserMenu = createRouteConfig({
method: 'get',
path: '/menu',
Expand Down
10 changes: 6 additions & 4 deletions backend/src/modules/me/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { config } from 'config';
import { type MenuSectionName, menuSections } from '#/entity-config';
import { contextEntityTypeSchema, idOrSlugSchema, idSchema, imageUrlSchema, nameSchema, slugSchema } from '#/utils/schema/common-schemas';
import { membershipInfoSchema } from '../memberships/schema';
import { sendNewsletterBodySchema } from '../organizations/schema';
import { userSchema } from '../users/schema';

export const sessionSchema = z.object({
Expand Down Expand Up @@ -42,8 +43,6 @@ export const menuItemSchema = z.object({
organizationId: membershipInfoSchema.shape.organizationId.optional(),
});

export type MenuItem = z.infer<typeof menuItemSchema>;

export const menuItemsSchema = z.array(
z.object({
...menuItemSchema.shape,
Expand All @@ -62,9 +61,12 @@ export const userMenuSchema = z.object(
),
);

export type UserMenu = z.infer<typeof userMenuSchema>;

export const leaveEntityQuerySchema = z.object({
idOrSlug: idOrSlugSchema,
entityType: contextEntityTypeSchema,
});

export const newsletterToSelfSchema = sendNewsletterBodySchema.omit({
organizationIds: true,
roles: true,
});
97 changes: 41 additions & 56 deletions backend/src/modules/organizations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { config } from 'config';
import { render } from 'jsx-email';
import { tokensTable } from '#/db/schema/tokens';
import { usersTable } from '#/db/schema/users';
import { getUserBy } from '#/db/util';
import { getContextUser, getMemberships } from '#/lib/context';
import { type ErrorType, createError, errorResponse } from '#/lib/errors';
import { emailSender } from '#/lib/mailer';
Expand All @@ -21,7 +20,6 @@ import { getOrderColumn } from '#/utils/order-column';
import { splitByAllowance } from '#/utils/split-by-allowance';
import { prepareStringForILikeFilter } from '#/utils/sql';
import { NewsletterEmail } from '../../../emails/newsletter';
import { env } from '../../../env';
import { checkSlugAvailable } from '../general/helpers/check-slug';
import { insertMembership } from '../memberships/helpers/insert-membership';
import organizationRoutesConfig from './routes';
Expand Down Expand Up @@ -254,69 +252,56 @@ const organizationsRoutes = app
*/
.openapi(organizationRoutesConfig.sendNewsletterEmail, async (ctx) => {
const user = getContextUser();
const { organizationIds, subject, content } = ctx.req.valid('json');
const { organizationIds, subject, content, roles } = ctx.req.valid('json');

// For test purposes
if (env.NODE_ENV === 'development') {
const unsafeUser = await getUserBy('id', user.id, 'unsafe');
const unsubscribeLink = unsafeUser ? `${config.backendUrl}/unsubscribe?token=${unsafeUser.unsubscribeToken}` : '';
// Get members
const organizationsMembersEmails = await db
.select({
membershipId: membershipsTable.userId,
email: usersTable.email,
unsubscribeToken: usersTable.unsubscribeToken,
newsletter: usersTable.newsletter,
language: usersTable.language,
})
.from(membershipsTable)
.innerJoin(usersTable, and(eq(usersTable.id, membershipsTable.userId)))
// eq(usersTable.emailVerified, true) // maybe add for only confirmed emails
.where(
and(
eq(membershipsTable.type, 'organization'),
inArray(membershipsTable.organizationId, organizationIds),
inArray(membershipsTable.role, roles),
),
);

if (!organizationsMembersEmails.length) return errorResponse(ctx, 404, 'There is no members in organizations', 'warn', 'organization');

if (organizationsMembersEmails.length === 1 && user.email === organizationsMembersEmails[0].email)
return errorResponse(ctx, 400, 'Only receiver is sender', 'warn', 'organization');

// Generate email HTML
for (const member of organizationsMembersEmails) {
if (!member.newsletter) continue;
const [organization] = await db
.select({
name: organizationsTable.name,
})
.from(organizationsTable)
.innerJoin(membershipsTable, and(eq(membershipsTable.userId, member.membershipId)))
.where(eq(organizationsTable.id, membershipsTable.organizationId));
const unsubscribeLink = `${config.backendUrl}/unsubscribe?token=${member.unsubscribeToken}`;

// generating email html
const emailHtml = await render(
NewsletterEmail({
userLanguage: user.language,
userLanguage: member.language,
subject,
content: user.newsletter ? updateBlocknoteHTML(content) : 'You`ve unsubscribed from news letters',
content: updateBlocknoteHTML(content),
unsubscribeLink,
orgName: 'SOME NAME',
orgName: organization?.name ?? 'Organization',
}),
);

emailSender.send(env.SEND_ALL_TO_EMAIL ?? user.email, subject, emailHtml);
} else {
// Get members
const organizationsMembersEmails = await db
.select({
membershipId: membershipsTable.userId,
email: usersTable.email,
unsubscribeToken: usersTable.unsubscribeToken,
newsletter: usersTable.newsletter,
language: usersTable.language,
})
.from(membershipsTable)
.innerJoin(usersTable, and(eq(usersTable.id, membershipsTable.userId)))
// eq(usersTable.emailVerified, true) // maybe add for only confirmed emails
.where(and(eq(membershipsTable.type, 'organization'), inArray(membershipsTable.organizationId, organizationIds)));

if (!organizationsMembersEmails.length) return errorResponse(ctx, 404, 'not_found', 'warn', 'organization');

if (organizationsMembersEmails.length === 1 && user.email === organizationsMembersEmails[0].email)
return errorResponse(ctx, 400, 'Only receiver is sender', 'warn', 'organization');

for (const member of organizationsMembersEmails) {
if (!member.newsletter) continue;
const [organization] = await db
.select({
name: organizationsTable.name,
})
.from(organizationsTable)
.innerJoin(membershipsTable, and(eq(membershipsTable.userId, member.membershipId)))
.where(eq(organizationsTable.id, membershipsTable.organizationId));
const unsubscribeLink = `${config.backendUrl}/unsubscribe?token=${member.unsubscribeToken}`;

// generating email html
const emailHtml = await render(
NewsletterEmail({
userLanguage: member.language,
subject,
content: updateBlocknoteHTML(content),
unsubscribeLink,
orgName: organization?.name ?? 'Organization',
}),
);

emailSender.send(member.email, subject, emailHtml, user.email);
}
emailSender.send(member.email, subject, emailHtml, user.email);
}

return ctx.json({ success: true }, 200);
Expand Down
2 changes: 2 additions & 0 deletions backend/src/modules/organizations/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';

import { config } from 'config';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { organizationsTable } from '#/db/schema/organizations';
import {
Expand Down Expand Up @@ -47,6 +48,7 @@ export const createOrganizationBodySchema = z.object({

export const sendNewsletterBodySchema = z.object({
organizationIds: z.array(z.string()),
roles: z.array(z.enum(config.rolesByType.entityRoles)),
subject: z.string(),
content: z.string(),
});
Expand Down
4 changes: 4 additions & 0 deletions backend/src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { config } from 'config';
import type { Schema } from 'hono';

import type { entityIdFields } from '#/entity-config';
import type { menuItemSchema, userMenuSchema } from '#/modules/me/schema';
import type { failWithErrorSchema } from '#/utils/schema/common-schemas';
import type { Env } from './app';

Expand Down Expand Up @@ -33,5 +34,8 @@ export type NonEmptyArray<T> = readonly [T, ...T[]];

export type ErrorResponse = z.infer<typeof failWithErrorSchema>;

export type MenuItem = z.infer<typeof menuItemSchema>;
export type UserMenu = z.infer<typeof userMenuSchema>;

// biome-ignore lint/complexity/noBannedTypes: <explanation>
export class CustomHono<E extends Env = Env, S extends Schema = {}, BasePath extends string = '/'> extends OpenAPIHono<E, S, BasePath> {}
59 changes: 36 additions & 23 deletions frontend/src/modules/common/form-fields/blocknote-content.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import DOMPurify from 'dompurify';
import { Suspense } from 'react';
import type { Control } from 'react-hook-form';

Expand Down Expand Up @@ -25,29 +26,41 @@ const BlockNoteContent = ({ blocknoteId, control, label, name, required, disable
<FormField
control={control}
name={name}
render={({ field: { onChange, value } }) => (
<FormItem name={name} aria-disabled={disabled}>
<FormLabel>
{label}
{required && <span className="ml-1 opacity-50">*</span>}
</FormLabel>
<FormControl>
<Suspense>
<BlockNote
id={blocknoteId}
defaultValue={value}
onChange={onChange}
updateData={onChange}
className="min-h-20 pl-10 pr-6 p-3 border rounded-md"
allowedFileBlockTypes={['image', 'file']}
allowedBlockTypes={['emoji', 'heading', 'paragraph', 'codeBlock']}
filePanel={(props) => <UppyFilePanel {...props} />}
/>
</Suspense>
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field: { onChange, value } }) => {
const sanitizedOnChange = (value: string) => {
const config = {
ADD_ATTR: ['colwidth', 'style'], // Allow 'colwidth' and 'style' attributes in the sanitized HTML
};

//Sanitized BlockNote content
const cleanContent = DOMPurify.sanitize(value, config);
onChange(cleanContent);
};

return (
<FormItem name={name} aria-disabled={disabled}>
<FormLabel>
{label}
{required && <span className="ml-1 opacity-50">*</span>}
</FormLabel>
<FormControl>
<Suspense>
<BlockNote
id={blocknoteId}
defaultValue={value}
onChange={sanitizedOnChange}
updateData={sanitizedOnChange}
className="min-h-20 pl-10 pr-6 p-3 border rounded-md"
allowedFileBlockTypes={['image', 'file']}
allowedBlockTypes={['emoji', 'heading', 'paragraph', 'codeBlock']}
filePanel={(props) => <UppyFilePanel {...props} />}
/>
</Suspense>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
};
Expand Down
Loading

0 comments on commit c504ed0

Please sign in to comment.