[FEATURE] - Performance Enhancement on List View
Summary
I was feeling pretty good about list view performance, but I recently added a lot of data to some of my records, and now I noticed that even though I am not showing that data in the list view, that it slowed down the load time quite a lot. It seems like all the data from every record is being served to the front end and then processed. It would be nice if when loading list views only required data would load so that it could work faster and more efficiently.
Basic Example
I have a Job model. It has a description field. If the description fields in my dataset only contain a small amount of text, everything works pretty well. If I add a lot of text to the description field, the initial load takes a much longer time. The description field is not even displayed in the list view, so it shouldn't really be effecting the list view load time. If only the specified fields were retrieved from the database and served up, I think this would solve this problem.
Drawbacks
It may be a lot of work to refactor this. I don't know. But that's the only downside I can think of.
Unresolved questions
No response
That's interesting, currently we are supposed to only retrieve the fields listed in the display property in a model's list options. Maybe a bug was introduced in recent versions, we will have a look, thanks for the report
Maybe I'm just wrong, is another possibility.
Was anyone able to validate if my theory is correct?
Indeed, the list view retrieves only displayed fields.
As you can probably see, some displayed columns are relationships and need to fetch more data about the related model.
We are trying to figure out your issue, without finding a way to reproduce it. Even using a huge amount of data, containing huge sized plain text as description
If you still have the issue and have tried all other performance solutions without success, you may want to provide us with your Prisma Schema so that we can try to reproduce your issue and your approximate amount of data
Also, this issue seems to be closely related to this one #342, and we believe they have the same origin
Let us know if you are still struggling and give us more information if you can 🚀
It's really weird. In my staging environment where I have basically no data, the performance is great. In my prod db, I have lots of data and it's pretty slow. Somehow the performance of next-admin as a whole seems to be directly related to the amount of data in the database. I just don't know how to wrap my head around it. We are at the point where we are considering rewriting our entire admin by hand because of the performance issues. It's not just the list view, either, it's also dropdowns for relation fields, saving, and other aspects.
Here's my prisma schema in case that's helpful
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
previewFeatures = ["driverAdapters", "fullTextSearch", "relationJoins", "postgresqlExtensions"]
}
generator jsonSchema {
provider = "prisma-json-schema-generator"
includeRequiredFields = "true"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
extensions = [citext(map: "citext")]
// directUrl = env("DIRECT_DB_URL")
}
model User {
firstName String @db.Citext
lastName String @db.Citext
email String @unique @db.Citext
phoneNumber String?
password String?
roleId String @db.VarChar(30)
createdAt DateTime @default(now())
deletedAt DateTime?
updatedAt DateTime? @default(now()) @updatedAt
employerId String? @db.VarChar(30)
profileId String? @db.VarChar(30)
unionId String? @db.VarChar(30)
id String @id @default(cuid()) @db.VarChar(30)
loginAttemptCount Int? @default(0)
accounts Account[]
authoredJobs Job[] @relation("author")
profile Profile?
employer Employer? @relation(fields: [employerId], references: [id])
role Role @relation(fields: [roleId], references: [id])
union Union? @relation(fields: [unionId], references: [id])
}
model Profile {
gender Gender?
zipCode String?
unionMember Boolean?
shouldReceiveEmails Boolean?
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
username String? @unique @db.Citext
immigrantOrRefugee Boolean @default(false)
employerId String? @db.VarChar(30)
userId String @unique
id String @id @default(cuid()) @db.VarChar(30)
unionId String? @db.VarChar(30)
transgender Boolean?
union Union? @relation(fields: [unionId], references: [id])
user User @relation(fields: [userId], references: [id])
ethnicities Ethnicity[] @relation("ProfileToEthnicity")
savedJobs Job[] @relation("ProfileToSavedJob")
}
model Role {
id String @id @default(cuid()) @db.VarChar(30)
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
featurePermissions FeaturePermission[] @relation("FeaturePermissionToRole")
}
model FeaturePermission {
id String @id @default(cuid()) @db.VarChar(30)
feature Feature
permission Permission
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
url String?
roles Role[] @relation("FeaturePermissionToRole")
}
model Job {
id String @id @default(cuid()) @db.VarChar(30)
title String @db.Citext
previewText String? @db.Citext
description String @db.Citext
datePosted DateTime? @default(now())
validThrough DateTime?
employmentType EmploymentType @default(FULL_TIME)
salaryMin Int?
salaryMax Int?
salaryUnit SalaryUnit? @default(HOUR)
email String?
yearsOfExperience Int?
numberOfOpenings Int?
cityId String @db.VarChar(30)
stateId String?
zipCode String?
commutingRequirement CommutingRequirement?
englishProficiency EnglishProficiency?
unionId String @db.VarChar(30)
employerId String @db.VarChar(30)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
applicationUrl String
applicationDeadline DateTime? @default(dbgenerated("(NOW() + '60 days'::interval)"))
published Boolean @default(false)
slug String? @unique
authorId String? @db.VarChar(30)
minSalaryCentsPerHour Int?
sourceUrl String? @unique
scrapeId String?
author User? @relation("author", fields: [authorId], references: [id])
city City @relation(fields: [cityId], references: [id])
employer Employer @relation(fields: [employerId], references: [id])
state State? @relation(fields: [stateId], references: [id])
union Union @relation(fields: [unionId], references: [id])
benefits Benefit[] @relation("BenefitToJob")
industries Industry[] @relation("IndustryToJob")
tags Tag[] @relation("JobToTag")
savedByProfiles Profile[] @relation("ProfileToSavedJob")
}
model Industry {
id String @id @default(cuid()) @db.VarChar(30)
name String @unique @db.Citext
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employers Employer[] @relation("EmployerToIndustry")
jobs Job[] @relation("IndustryToJob")
}
model Benefit {
id String @id @default(cuid()) @db.VarChar(30)
name String @unique @db.Citext
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employers Employer[] @relation("BenefitToEmployer")
jobs Job[] @relation("BenefitToJob")
unions Union[] @relation("BenefitToUnion")
}
model Union {
id String @id @default(cuid()) @db.VarChar(30)
name String @unique @db.Citext
website String?
logoUrl String?
description String?
email String? @db.Citext
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String? @unique
jobs Job[]
members Profile[]
users User[]
benefits Benefit[] @relation("BenefitToUnion")
employers Employer[] @relation("EmployerToUnion")
}
model Employer {
id String @id @default(cuid()) @db.VarChar(30)
name String @unique
description String? @db.Citext
email String? @db.Citext
phone String?
website String?
logoUrl String?
address String?
cityId String? @db.VarChar(30)
stateId String?
zipCode String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String? @unique
city City? @relation(fields: [cityId], references: [id])
state State? @relation(fields: [stateId], references: [id])
jobs Job[]
users User[]
benefits Benefit[] @relation("BenefitToEmployer")
industries Industry[] @relation("EmployerToIndustry")
unions Union[] @relation("EmployerToUnion")
}
model City {
id String @id @default(cuid()) @db.VarChar(30)
name String @unique @db.Citext
stateId String?
countyId String?
county County? @relation(fields: [countyId], references: [id])
state State? @relation(fields: [stateId], references: [id])
employers Employer[]
jobs Job[]
}
model County {
id String @id @default(cuid()) @db.VarChar(30)
name String @unique @db.Citext
stateId String?
cities City[]
state State? @relation(fields: [stateId], references: [id])
}
model State {
id String @id
name String @unique @db.Citext
cities City[]
counties County[]
employers Employer[]
jobs Job[]
}
model Tag {
name String @unique
id String @id @default(cuid()) @db.VarChar(30)
jobs Job[] @relation("JobToTag")
}
model Resource {
id String @id @default(cuid()) @db.VarChar(30)
title String @unique @db.Citext
description String @db.Citext
url String
sortOrder Int
resourceSectionId String
slug String? @unique @db.Citext
resourceSection ResourceSection @relation(fields: [resourceSectionId], references: [id])
}
model ResourceSection {
id String @id @default(cuid()) @db.VarChar(30)
title String @unique @db.Citext
sortOrder Int
slug String? @unique @db.Citext
resources Resource[]
}
model Account {
provider_account_id String?
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
type AccountType?
provider AccountProvider?
userId String? @db.VarChar(30)
id String @id @default(cuid()) @db.VarChar(30)
user User? @relation(fields: [userId], references: [id])
@@unique([provider, provider_account_id])
}
model Ethnicity {
id String @id @default(cuid()) @db.VarChar(30)
name String @unique @db.Citext
profiles Profile[] @relation("ProfileToEthnicity")
}
model SystemConfiguration {
id String @id @default(cuid()) @db.VarChar(30)
isSystemDown Boolean
}
enum AccountType {
CREDENTIAL
OAUTH
}
enum AccountProvider {
GOOGLE
FACEBOOK
}
enum Feature {
ADMIN_DASHBOARD
EMPLOYER_DASHBOARD
UNION_DASHBOARD
USER_DASHBOARD
}
enum Permission {
CREATE
READ
UPDATE
DELETE
}
enum EmploymentType {
FULL_TIME
PART_TIME
TEMPORARY
ON_CALL
}
enum CommutingRequirement {
ON_SITE
REMOTE
HYBRID
}
enum EnglishProficiency {
BEGINNER
INTERMEDIATE
FLUENT
}
enum Gender {
MALE
FEMALE
NON_BINARY_AND_NON_CONFORMING
DECLINE_TO_STATE
}
enum SalaryUnit {
HOUR
WEEK
MONTH
YEAR
ENTIRE_CONTRACT
NOT_PROVIDED
}
Here's my options.tsx
import { NextAdminOptions } from '@premieroctet/next-admin';
import {
Job,
Benefit,
City,
Employer,
Industry,
Resource,
ResourceSection,
Tag,
Union,
Role,
Profile,
} from '@prisma/client';
export const options: NextAdminOptions = {
basePath: '/admin',
model: {
Job: {
toString: (job) => `${job.title}`,
icon: 'WrenchScrewdriverIcon',
aliases: { updatedAt: 'Last Modified' },
edit: {
display: [
'published',
'title',
'union',
'employer',
'employmentType',
'city',
'tags',
'benefits',
'industries',
'salaryUnit',
'salaryMin',
'salaryMax',
'applicationUrl',
'datePosted',
'applicationDeadline',
'author',
'previewText',
'description',
],
fields: {
description: {
format: 'richtext-html',
},
previewText: {
format: 'richtext-html',
},
salaryMin: {
required: true,
helperText:
'Please enter an integer for number of cents. e.g. for $10 enter 1000',
},
salaryMax: {
helperText:
'Please enter an integer for number of cents. e.g. for $10 enter 1000',
},
salaryUnit: {
required: true,
helperText:
'This is to specify the period of time to earn the amount specified in the salary range fields',
},
employmentType: {
required: true,
},
industries: {
required: true,
},
datePosted: {
required: false,
},
author: {},
},
},
list: {
display: [
'title',
'employer',
'union',
'city',
'published',
'author',
'datePosted',
'updatedAt',
],
defaultSort: {
field: 'updatedAt',
direction: 'desc',
},
search: ['title', 'description', 'employer', 'union'],
filters: [
{
name: 'Draft',
value: {
published: false,
applicationDeadline: { gte: new Date() },
},
},
{
name: 'Published',
value: { published: true },
},
{
name: 'Expired',
value: { applicationDeadline: { lte: new Date() } },
},
],
fields: {
employer: {
formatter: (value: Employer) => {
return value?.name;
},
sortBy: 'name',
},
city: {
formatter: (value: City) => {
return `${value?.name}, ${value.stateId}`;
},
sortBy: 'name',
},
union: {
formatter: (value: Union) => {
return value?.name;
},
sortBy: 'name',
},
published: {
formatter: (value: boolean) => {
return value ? 'Published' : 'Unpublished';
},
},
author: {
formatter: (value: User) => {
return value?.email;
},
},
datePosted: {
formatter: (value: Date | null) => {
if (!value) return '';
const dateObject =
value instanceof Date ? value : new Date(value);
return dateObject.toLocaleString('en-US', {
dateStyle: 'short',
});
},
},
updatedAt: {
formatter: (value: Date | null) => {
if (!value) return '';
const parsed = Date.parse(value.toString());
const dateObject = new Date(parsed);
return dateObject.toLocaleString('en-US', {
dateStyle: 'short',
});
},
},
},
},
},
Tag: {
toString: (tag) => `${tag.name}`,
icon: 'TagIcon',
list: {
display: ['name', 'jobs'],
},
},
Employer: {
toString: (employer) => `${employer.name}`,
icon: 'BuildingOfficeIcon',
list: {
display: ['name', 'updatedAt', 'jobs'],
defaultSort: {
field: 'name',
direction: 'asc',
},
fields: {
updatedAt: {
formatter: (value: Date | null) => {
if (!value) return '';
const parsed = Date.parse(value.toString());
const dateObject = new Date(parsed);
return dateObject.toLocaleString('en-US', {
dateStyle: 'short',
});
},
},
},
},
edit: {
fields: {
description: {
format: 'richtext-html',
},
},
},
},
Resource: {
toString: (resource) => `${resource.title}`,
icon: 'BuildingLibraryIcon',
list: {
display: ['title', 'resourceSection', 'sortOrder'],
defaultSort: {
field: 'resourceSection',
direction: 'desc',
},
fields: {
resourceSection: {
formatter: (value: ResourceSection) => {
return value?.title;
},
sortBy: 'sortOrder',
},
},
// defaultSort: {
// field: 'updatedAt',
// direction: 'desc',
// },
},
edit: {
fields: {
description: {
format: 'richtext-html',
},
},
},
},
ResourceSection: {
toString: (resourceSection) => `${resourceSection.title}`,
icon: 'Square3Stack3DIcon',
list: {
display: ['title', 'sortOrder'],
defaultSort: {
field: 'updatedAt',
direction: 'desc',
},
},
},
Industry: {
toString: (industry) => `${industry.name}`,
icon: 'BriefcaseIcon',
list: {
display: ['name', 'updatedAt'],
defaultSort: {
field: 'updatedAt',
direction: 'desc',
},
fields: {
updatedAt: {
formatter: (value: Date | null) => {
if (!value) return '';
const parsed = Date.parse(value.toString());
const dateObject = new Date(parsed);
return dateObject.toLocaleString('en-US', {
dateStyle: 'short',
});
},
},
},
},
},
Benefit: {
toString: (benefit) => `${benefit.name}`,
icon: 'LifebuoyIcon',
list: {
display: ['name', 'updatedAt', 'jobs'],
defaultSort: {
field: 'updatedAt',
direction: 'desc',
},
fields: {
updatedAt: {
formatter: (value: Date | null) => {
if (!value) return '';
const parsed = Date.parse(value.toString());
const dateObject = new Date(parsed);
return dateObject.toLocaleString('en-US', {
dateStyle: 'short',
});
},
},
},
},
},
City: {
toString: (city) => `${city.name}, ${city.state?.id}`,
icon: 'MapPinIcon',
list: {
display: ['name', 'state'],
defaultSort: {
field: 'name',
direction: 'asc',
},
},
},
Union: {
toString: (union) => `${union.name}`,
icon: 'UserGroupIcon',
list: {
display: ['name'],
defaultSort: {
field: 'name',
direction: 'asc',
},
fields: {
updatedAt: {
formatter: (value: Date | null) => {
if (!value) return '';
const parsed = Date.parse(value.toString());
const dateObject = new Date(parsed);
return dateObject.toLocaleString('en-US', {
dateStyle: 'short',
});
},
},
},
},
edit: {
fields: {
description: {
format: 'richtext-html',
},
},
},
},
User: {
toString: (user) => `${user.email}`,
icon: 'FingerPrintIcon',
list: {
defaultSort: {
field: 'updatedAt',
direction: 'desc',
},
display: [
'firstName',
'lastName',
'email',
'profile',
'role',
'phoneNumber',
],
filters: [
{
name: 'is Admin',
active: false,
value: {
role: {
name: {
equals: 'admin',
},
},
},
},
],
fields: {
profile: {
formatter: (value: Profile) => {
return value.id;
},
},
role: {
formatter: (value: Role) => {
return value.name;
},
},
union: {
formatter: (value: Union) => {
return value.name;
},
},
},
},
edit: {
display: [
'firstName',
'lastName',
'email',
'profile',
'role',
'phoneNumber',
],
fields: {
profile: {
optionFormatter: (value: Profile) => {
return value.id;
},
},
role: {
optionFormatter: (value: Role) => {
return value.name;
},
},
union: {
optionFormatter: (value: Union) => {
return value.name;
},
},
},
},
},
Profile: {
toString: (profile) => `${profile.id}`,
title: 'UserProfile',
icon: 'UserIcon',
list: {
defaultSort: {
field: 'updatedAt',
direction: 'desc',
},
filters: [
{
name: 'is immigrant',
active: false,
value: {
immigrantOrRefugee: {
equals: true,
},
},
},
],
display: [
'user',
'zipCode',
'gender',
'transgender',
'immigrantOrRefugee',
'unionMember',
'union',
'shouldReceiveEmails',
'createdAt',
'updatedAt',
],
fields: {
user: {
formatter: (value: User) => {
return `${value?.firstName} ${value.lastName}`;
},
},
union: {
formatter: (value: Union) => {
return value.name;
},
},
},
},
edit: {
display: [
'user',
'zipCode',
'gender',
'transgender',
'immigrantOrRefugee',
'ethnicities',
'unionMember',
'union',
'shouldReceiveEmails',
],
},
},
Role: {
toString: (role) => `${role.name}`,
icon: 'KeyIcon',
permissions: [],
edit: {
display: ['name', 'description', 'featurePermissions'],
},
},
FeaturePermission: {
toString: (featurePermission) =>
`${featurePermission.feature} - ${featurePermission.permission}`,
icon: 'CogIcon',
permissions: [],
edit: {
display: ['feature', 'permission', 'description', 'url', 'roles'],
},
},
Ethnicity: {
toString: (ethnicity) => `${ethnicity.name}`,
icon: 'GlobeEuropeAfricaIcon',
},
},
sidebar: {
groups: [
{
title: 'Jobs',
models: [
'Job',
'Employer',
'Union',
'Industry',
'Benefit',
'Tag',
'City',
],
},
{
title: 'Resources',
models: ['Resource', 'ResourceSection'],
},
{
title: 'Users',
models: ['User', 'Profile', 'Role', 'FeaturePermission', 'Ethnicity'],
},
],
},
};
here's my next-admin page.tsx
'use server';
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { NextAdmin } from '@premieroctet/next-admin';
import { getPropsFromParams } from '@premieroctet/next-admin/dist/appRouter';
import schema from '@/../prisma/json-schema/json-schema.json';
import { options } from '@/app/admin/options';
import prisma from '@/../server/db/client';
import {
publishJobs,
unpublishJobs,
submitFormAction,
deleteItem,
searchResource,
exportModelAsCsv,
generatePreviewText,
} from '@/actions/nextadmin';
import getServerSession from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route.ts';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { isEmpty } from '@/app/lib/utils';
export default async function AdminPage({
params,
searchParams,
}: {
params: { [key: string]: string[] };
searchParams: { [key: string]: string | string[] | undefined } | undefined;
}) {
const session: any = await auth();
if (isEmpty(session)) {
redirect('/auth/signin');
}
if (
!session?.role?.featurePermissions.find(
(fp) => fp.feature === 'ADMIN_DASHBOARD'
)
) {
redirect('/401');
}
if (options?.model?.Job) {
options.model.Job.actions = [
{
title: 'Publish',
action: publishJobs,
successMessage: 'The selected jobs have been published',
errorMessage: 'Something went wrong',
},
{
title: 'Unpublish',
action: unpublishJobs,
successMessage: 'The selected jobs have been unpublished',
errorMessage: 'Something went wrong',
},
{
title: 'Generate preview text from description',
action: generatePreviewText,
successMessage: 'Preview text has been generated for the selected jobs',
errorMessage: 'Something went wrong',
},
];
}
if (options?.model?.Profile) {
options.model.Profile.actions = [
{
title: 'Export as CSV',
action: exportModelAsCsv,
successMessage:
'Please check your email for a CSV export of all user profiles',
errorMessage: 'Something went wrong',
},
];
}
const props = await getPropsFromParams({
params: params.nextadmin,
searchParams,
deleteAction: deleteItem,
searchPaginatedResourceAction: searchResource,
options,
prisma: prisma as any,
schema,
action: submitFormAction,
});
return (
<>
<div className="flex justify-end p-5">
{/* <div><pre>{JSON.stringify(session, null, 1)}</pre></div> */}
</div>
<NextAdmin
{...props}
user={{
data: {
name:
`${session?.user?.user?.firstName} ${session?.user?.user?.lastName}` ||
session?.email,
},
logoutUrl: '/',
}}
/>
</>
);
}
Here's my actions file:
/* eslint-disable no-await-in-loop */
'use server';
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { ActionParams, ModelName } from '@premieroctet/next-admin';
import {
deleteResourceItems,
submitForm,
searchPaginatedResource,
SearchPaginatedResourceParams,
} from '@premieroctet/next-admin/dist/actions';
import prisma from '@/../server/db/client';
import { options } from '@/app/admin/options';
import { PrismaClient } from '@prisma/client';
import papaparse from 'papaparse';
import { sendEmail } from '@/app/lib/utils';
import { generateJobPreviewText } from '@/app/lib/llm';
export const submitFormAction = async (
params: ActionParams,
formData: FormData
) => {
if (params?.params?.[0] === 'job') {
const session: any = await auth();
formData.set('author', session?.id);
}
return submitForm(
{
...params,
options: { basePath: '/admin' },
prisma: prisma as PrismaClient,
},
formData
);
};
export const deleteItem = async (
model: ModelName,
ids: string[] | number[]
) => {
return deleteResourceItems(prisma as PrismaClient, model, ids);
};
export const searchResource = async (
actionParams: ActionParams,
params: SearchPaginatedResourceParams
) => {
const prisma = new PrismaClient();
return searchPaginatedResource({ ...actionParams, options, prisma }, params);
};
export const publishJobs = async (
model: ModelName,
ids: (string | number)[]
) => {
await prisma.job.updateMany({
where: { id: { in: ids.map((id) => id.toString()) } },
data: { published: true },
});
};
export const unpublishJobs = async (
model: ModelName,
ids: (string | number)[]
) => {
await prisma.job.updateMany({
where: { id: { in: ids.map((id) => id.toString()) } },
data: { published: false },
});
};
export const generatePreviewText = async (
model: ModelName,
ids: (string | number)[]
) => {
const jobs = await prisma.job.findMany({
where: { id: { in: ids.map((id) => id.toString()) } },
});
for (const job of jobs) {
// eslint-disable-next-line no-await-in-loop
const previewText = await generateJobPreviewText(job.description);
await prisma.job.update({
where: { id: job.id },
data: { previewText },
});
}
};
function getCommaSeparatedEthnicities(profile) {
if (!profile || !profile.ethnicities) {
return '';
}
return profile.ethnicities.map((e) => e.name).join(', ');
}
export async function exportModelAsCsv(
model: ModelName,
ids: (string | number)[]
) {
const session: any = await auth();
const profiles = await prisma[model].findMany({
where: {},
include: {
user: true,
ethnicities: true,
},
});
const data = profiles.map(
(profile: {
[x: string]: any;
user: any;
ethnicities: any[];
zipCode: any;
gender: any;
immigrantRefugee: any;
createdAt: any;
}) => {
const { user } = profile;
const parsed = Date.parse(profile.createdAt);
const dateObject = new Date(parsed);
const createdAt = dateObject.toLocaleDateString();
return {
firstName: user.firstName,
lastName: user.lastName,
zipCode: `"${profile.zipCode.toString()}"`,
gender: profile.gender,
ethnicities: profile.ethnicities.map((e) => e.name).join(', '),
immigrantOrRefugee: `${profile.immigrantOrRefugee}`,
createdAt: createdAt,
};
}
);
// Convert JSON to CSV
const parsedData = await papaparse.unparse(data, {
header: true,
});
// Convert CSV to base64 string
const base64Data = Buffer.from(parsedData).toString('base64');
// Send email with base64 encoded attachment
sendEmail({
to: session.email,
subject: `Registered users on Union Hall Jobs`,
html: '<p>Find attached .csv of all registered users to date</p>',
attachments: [
{
filename: `${model}.csv`,
content: base64Data,
contentType: 'text/csv',
encoding: 'base64',
},
],
});
}
Hello
Are you still having the issue with latest version ?
Thank you