premieroctet / next-admin

▲ Full-featured admin for Next.js and Prisma
https://next-admin.js.org
MIT License
278 stars 11 forks source link

[FEATURE] - Performance Enhancement on List View #343

Open ogoldberg opened 3 months ago

ogoldberg commented 3 months ago

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

foyarash commented 2 months ago

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

ogoldberg commented 2 months ago

Maybe I'm just wrong, is another possibility.

ogoldberg commented 2 months ago

Was anyone able to validate if my theory is correct?

cregourd commented 2 months ago

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 🚀

ogoldberg commented 1 month ago

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
}
ogoldberg commented 1 month ago

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',
      },
    ],
  });
}
foyarash commented 2 days ago

Hello

Are you still having the issue with latest version ?

Thank you