diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 2065a77..2b566a0 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,8 @@ { - "recommendations": [ - "esbenp.prettier-vscode", - "streetsidesoftware.code-spell-checker", - "ms-playwright.playwright" - ] + "recommendations": [ + "esbenp.prettier-vscode", + "streetsidesoftware.code-spell-checker", + "ms-playwright.playwright", + "prisma.prisma" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index d1e7269..74c68e3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,5 +30,9 @@ }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "[prisma]": { + "editor.defaultFormatter": "Prisma.prisma" + }, + "typescript.preferences.importModuleSpecifier": "non-relative" } diff --git a/playwright.config.ts b/playwright.config.ts index edb93a5..b766db8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,42 +11,42 @@ import { defineConfig, devices } from "@playwright/test"; */ export default defineConfig({ - testDir: "./tests", - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: 1, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [["list", { printSteps: true }], ["html"]], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "on-first-retry", - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: "chromium", - grepInvert: /(Mobile)/, - use: { - ...devices["Desktop Chrome"], - }, + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env["CI"], + /* Retry on CI only */ + retries: process.env["CI"] ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [["list", { printSteps: true }], ["html"]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", }, - // Mobile devices - { - name: "android", - grep: /(Mobile)/, - use: { - ...devices["Pixel 5"], - }, - }, - ], + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + grepInvert: /(Mobile)/, + use: { + ...devices["Desktop Chrome"], + }, + }, + + // Mobile devices + { + name: "android", + grep: /(Mobile)/, + use: { + ...devices["Pixel 5"], + }, + }, + ], - /* No webserver config, webserver is started within the tests */ + /* No webserver config, webserver is started within the tests */ }); diff --git a/prisma/migrations/20250306125830_add_surveydate_to_survey/migration.sql b/prisma/migrations/20250306125830_add_surveydate_to_survey/migration.sql new file mode 100644 index 0000000..ae72d30 --- /dev/null +++ b/prisma/migrations/20250306125830_add_surveydate_to_survey/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - Added the required column `surveyDate` to the `Survey` table without a default value. This is not possible if the table is not empty. + +*/ + +-- Add the surveyDate column as nullable +ALTER TABLE "Survey" ADD COLUMN "surveyDate" DATE NULL; + +-- Update the 2024 survey to have a surveyDate +UPDATE "Survey" SET "surveyDate" = '2024-01-01' WHERE "surveyDate" IS NULL; + +-- Alter the surveyDate column to be non-nullable +ALTER TABLE "Survey" ALTER COLUMN "surveyDate" SET NOT NULL; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 079e5cb..5b4eaa7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model Survey { id String @id @default(cuid()) surveyName String @unique questions Question[] + surveyDate DateTime @db.Date } model Role { diff --git a/prisma/seed.js b/prisma/seed.js index 71a0740..f96697d 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,8 +1,8 @@ -import { PrismaClient } from "@prisma/client"; +import { PrismaDbClient } from "~/prisma"; import fs from "fs"; import csv from "csv-parser"; -const prisma = new PrismaClient(); +const prisma = new PrismaDbClient(); /** * Parses a CSV file to extract roles, questions, and their mappings. @@ -93,6 +93,7 @@ async function main() { where: { surveyName: "Info Support Tech Survey - 2024" }, create: { surveyName: "Info Support Tech Survey - 2024", + surveyDate: new Date(2024, 0, 1), }, update: {}, }); diff --git a/src/app/find-the-expert/profile-page/page.tsx b/src/app/find-the-expert/profile-page/page.tsx index 8f3d796..779b5d1 100644 --- a/src/app/find-the-expert/profile-page/page.tsx +++ b/src/app/find-the-expert/profile-page/page.tsx @@ -1,32 +1,31 @@ import type { Metadata } from "next"; -import type { Prisma } from "@prisma/client"; import React, { Suspense } from "react"; import ButtonSkeleton from "~/components/loading/button-loader"; import ProfilePageSearch from "~/components/ui/profile-page-search"; -import { db } from "~/server/db"; +import { prismaClient } from "~/server/db"; import { DataTable } from "~/components/data-tables/data-table"; import type { ColumnDef } from "@tanstack/react-table"; -import communicationMethodToIcon from "~/components/ui/CommunicationMethodToIcon"; +import communicationMethodToIcon from "~/components/ui/communication-method-to-icon"; import ProfileRadarChart from "~/components/profile-radar-chart"; +import type { ProfilePageUserData } from "~/server/db/prisma-client/user"; export const metadata: Metadata = { title: "Find the expert", }; -const ContentSection = async ({ name }: { name: string }) => { - const users = await db.user.findMany({ - select: userSelect, - }); - - const selectedUser = users.find((user) => user.name === name); +const ContentSection = async ({ userId }: { userId?: string }) => { + const users = await prismaClient.users.getUsers(); + const selectedUser = userId + ? await prismaClient.users.getProfilePageUserById(userId) + : null; return ( <> }> - + }> - {name ? ( + {selectedUser !== null ? ( ) : (

@@ -38,49 +37,6 @@ const ContentSection = async ({ name }: { name: string }) => { ); }; -const userSelect = { - name: true, - id: true, - questionResults: { - orderBy: { - answer: { - option: "asc", - }, - }, - select: { - answer: { - select: { - option: true, - }, - }, - question: { - select: { - questionText: true, - survey: { - select: { - surveyName: true, - }, - }, - roles: { - select: { - role: true, - }, - }, - }, - }, - }, - }, - communicationPreferences: { - select: { - methods: true, - }, - }, -} satisfies Prisma.UserSelect; - -export type UserData = Prisma.UserGetPayload<{ - select: typeof userSelect; -}>; - const optionWeights: Record = { 0: 5, 1: 3, @@ -88,7 +44,7 @@ const optionWeights: Record = { 3: 0, }; -const ProfilePage = async ({ user }: { user?: UserData }) => { +const ProfilePage = async ({ user }: { user?: ProfilePageUserData }) => { if (!user) { return (

No user found

@@ -118,7 +74,7 @@ const ProfilePage = async ({ user }: { user?: UserData }) => { [] as { role: string; sum: number }[], ); - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: "question.questionText", header: "Name", @@ -164,7 +120,7 @@ const ProfilePage = async ({ user }: { user?: UserData }) => { ); }; const ProfilePageWrapper = async (context: { - searchParams: Promise<{ name: string }>; + searchParams: Promise<{ userId: string }>; }) => { return (
@@ -177,7 +133,7 @@ const ProfilePageWrapper = async (context: { Tech Survey - Profile page

- + ); }; diff --git a/src/app/find-the-expert/tech-page/page.tsx b/src/app/find-the-expert/tech-page/page.tsx index 9ea7990..8a1b327 100644 --- a/src/app/find-the-expert/tech-page/page.tsx +++ b/src/app/find-the-expert/tech-page/page.tsx @@ -2,10 +2,10 @@ import type { Metadata } from "next"; import { Suspense } from "react"; import ButtonSkeleton from "~/components/loading/button-loader"; import ShowDataTable from "~/components/data-tables/show-data-table"; -import { retrieveAnswersByRole } from "~/utils/data-manipulation"; -import { getRoles } from "~/utils/role-utils"; -import { db } from "~/server/db"; +import { sortRoles } from "~/utils/role-utils"; +import { prismaClient } from "~/server/db"; import ShowTechSearchWrapper from "~/components/ui/show-tech-search-wrapper"; +import { getAnswerDataByRole } from "~/utils/data-manipulation"; export const metadata: Metadata = { title: "Find the expert", @@ -31,8 +31,8 @@ const ContentSection = ({ ); const FindTheExpertSearch = async () => { - const availableRoles = await getRoles()(); - const availableUnits = await db.businessUnit.findMany(); + const availableRoles = sortRoles(await prismaClient.roles.getAll()); + const availableUnits = await prismaClient.businessUnits.getAll(); return ( { + const results = await prismaClient.questionResults.getQuestionResultsByRole( + role ?? null, + tech ?? null, + unit ?? null, + ); const { dataByRoleAndQuestion, aggregatedDataByRole } = - await retrieveAnswersByRole({ - role, - questionText: tech, - unit, - }); + getAnswerDataByRole(results); return ( { const session = await auth(); const [roles, businessUnits] = await Promise.all([ - db.role.findMany(), - db.businessUnit.findMany(), + prismaClient.roles.getAll(), + prismaClient.businessUnits.getAll(), ]); return ( diff --git a/src/app/result/page.tsx b/src/app/result/page.tsx index 4c15ad1..0347abd 100644 --- a/src/app/result/page.tsx +++ b/src/app/result/page.tsx @@ -4,15 +4,15 @@ import { type Role, type TransformedData, } from "~/models/types"; -import { db } from "~/server/db"; +import { prismaClient } from "~/server/db"; -import type { BusinessUnit, Prisma } from "@prisma/client"; +import type { BusinessUnit } from "~/prisma"; import { type Metadata } from "next"; import ButtonSkeleton from "~/components/loading/button-loader"; import LegendSkeleton from "~/components/loading/results-loader"; import SearchAnonymized from "~/components/ui/search-anonymized"; -import { getRoles } from "~/utils/role-utils"; import ShowResults from "~/components/show-results"; +import { sortRoles } from "~/utils/role-utils"; export const metadata: Metadata = { title: "Results", @@ -35,8 +35,8 @@ const Results = async (context: { }> @@ -44,8 +44,8 @@ const Results = async (context: { }; export async function AnonymousRoleSearch() { - const availableRoles = await getRoles()(); - const availableUnits = await db.businessUnit.findMany(); + const availableRoles = sortRoles(await prismaClient.roles.getAll()); + const availableUnits = await prismaClient.businessUnits.getAll(); const def: Role = { id: "", @@ -68,70 +68,16 @@ export async function AnonymousRoleSearch() { ); } -const FetchQuestionResults = async ({ - role, - unit, -}: { - role: string; - unit: string; -}) => { - // If both role and unit are undefined, return an empty array - if (!role && !unit) return []; - - // Base include object reused in all queries - const includeConfig = { - question: { - include: { - roles: true, - }, - }, - }; - - // Dynamically build the where conditions - const whereConditions: Prisma.QuestionResultWhereInput = {}; - - if (role) { - whereConditions.question = { - roles: { - some: { - role: { - equals: role, - mode: "insensitive", - }, - }, - }, - }; - } - - if (unit) { - whereConditions.user = { - businessUnit: { - unit: { - equals: unit, - mode: "insensitive", - }, - }, - }; - } - - return db.questionResult.findMany({ - where: whereConditions, - include: includeConfig, - }); -}; - const ShowResultsWrapper = async ({ - role, - unit, + roleId, + unitId, }: { - role: string; - unit: string; + roleId: string | null; + unitId: string | null; }) => { - const userAnswersForRole: QuestionResult[] = await FetchQuestionResults({ - role, - unit, - }); - const answerOptions = await db.answerOption.findMany(); + const userAnswersForRole: QuestionResult[] = + await prismaClient.questionResults.getResultPageData(roleId, unitId); + const answerOptions = await prismaClient.answerOptions.getAll(); const transformedData: TransformedData = {}; @@ -139,9 +85,9 @@ const ShowResultsWrapper = async ({ const questionText = question?.questionText ?? ""; const roles = question?.roles ?? []; - if (role != undefined) { + if (roleId !== null) { roles.forEach(({ role: roleName = "" }) => { - if (roleName && questionText && roleName == role) { + if (roleName && questionText && roleName === roleId) { transformedData[roleName] ??= {}; transformedData[roleName][questionText] ??= {}; @@ -154,14 +100,14 @@ const ShowResultsWrapper = async ({ ] ?? 0) + 1; } }); - } else if (unit != undefined) { - transformedData[unit] ??= {}; - transformedData[unit][questionText] ??= {}; + } else if (unitId !== null) { + transformedData[unitId] ??= {}; + transformedData[unitId][questionText] ??= {}; const answerString = answerOptions.find(({ id }) => id === answerId)?.option ?? ""; - transformedData[unit][questionText][answerString] = - (transformedData[unit][questionText][answerString] ?? 0) + 1; + transformedData[unitId][questionText][answerString] = + (transformedData[unitId][questionText][answerString] ?? 0) + 1; } }); diff --git a/src/app/thank-you/page.tsx b/src/app/thank-you/page.tsx index 22598fd..20aa01f 100644 --- a/src/app/thank-you/page.tsx +++ b/src/app/thank-you/page.tsx @@ -1,11 +1,7 @@ -import { db } from "~/server/db"; +import { prismaClient } from "~/server/db"; import PdfDownloadButton from "~/components/download-pdf"; import React, { Suspense } from "react"; -import { - type QuestionResult, - type Question, - type AnswerOption, -} from "~/models/types"; +import { type QuestionResult, type Question } from "~/models/types"; import { type Metadata } from "next"; import ButtonSkeleton from "~/components/loading/button-loader"; @@ -18,34 +14,20 @@ export const metadata: Metadata = { const ThankYou = async () => { const session = (await auth())!; const userAnswersForRole: QuestionResult[] = - await db.questionResult.findMany({ - where: { - userId: session.user.id, - }, - include: { - question: { - include: { - roles: true, - }, - }, - }, - }); + await prismaClient.questionResults.getRecentQuestionResultsWithRolesByUserId( + session.user.id, + ); - const answerOptions: AnswerOption[] = await db.answerOption.findMany(); + const answerOptions = await prismaClient.answerOptions.getAll(); - const userSelectedRoles = await db.user.findUnique({ - where: { - id: session.user.id, - }, - include: { - roles: true, - }, - }); + const selectedRoles = await prismaClient.users.getRolesForUser( + session.user.id, + ); // Update the userAnswersForRole object such that a question only includes the roles of the selected roles of the user. for (const userAnswer of userAnswersForRole) { userAnswer.question.roles = userAnswer.question.roles?.filter((role) => - userSelectedRoles?.roles.some( + selectedRoles?.roles.some( (selectedRole) => selectedRole.id === role.id, ), ); diff --git a/src/auth.ts b/src/auth.ts index b0866a7..60d599c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,10 +1,9 @@ -import type { Adapter } from "@auth/core/adapters"; import MicrosoftEntraID from "@auth/core/providers/microsoft-entra-id"; -import { PrismaAdapter } from "@auth/prisma-adapter"; import NextAuth, { type DefaultSession } from "next-auth"; -import { db } from "~/server/db"; -import { env } from "./env"; +import { env } from "~/env"; +import { prismaClient } from "~/server/db"; +import type { IPrismaAdapterService } from "~/server/db/prisma-client"; /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` @@ -51,7 +50,9 @@ export const { auth, handlers, signIn, signOut } = NextAuth({ session: { strategy: "jwt", }, - adapter: PrismaAdapter(db) as Adapter, + adapter: ( + prismaClient as unknown as IPrismaAdapterService + ).toPrismaAdapter(), // We trust Microsoft Entra ID to have securely verified the email address associated with the account // so we allow linking accounts with the same email address. // Automatic account linking on sign in is not secure between arbitrary providers, so if you are using arbitrary providers, this should be set to `false`. diff --git a/src/components/additional-buttons-homepage.tsx b/src/components/additional-buttons-homepage.tsx index 3a4ad26..f0bd4fc 100644 --- a/src/components/additional-buttons-homepage.tsx +++ b/src/components/additional-buttons-homepage.tsx @@ -1,10 +1,10 @@ "use server"; -import { ArrowRight, ArrowRightDarkModeFriendly } from "./svg"; -import { Button } from "./ui/button"; +import { HomepageFindTheExpertButton } from "~/components/homepage-find-the-expert-button"; import Link from "next/link"; -import { HomepageFindTheExpertButton } from "./homepage-find-the-expert-button"; import { auth } from "~/auth"; +import { Button } from "~/components/ui/button"; +import { ArrowRight, ArrowRightDarkModeFriendly } from "~/components/svg"; const Buttons = async () => { const session = await auth(); diff --git a/src/components/data-tables/data-table.tsx b/src/components/data-tables/data-table.tsx index d3a7a00..8f8eb74 100644 --- a/src/components/data-tables/data-table.tsx +++ b/src/components/data-tables/data-table.tsx @@ -16,9 +16,9 @@ import { TableHeader, TableRow, } from "~/components/ui/table"; -import { idToTextMap } from "~/utils/optionMapping"; +import { idToTextMap } from "~/utils/option-mapping"; import { DataTablePagination } from "~/components/data-tables/data-table-pagination"; -import communicationMethodToIcon from "~/components/ui/CommunicationMethodToIcon"; +import communicationMethodToIcon from "~/components/ui/communication-method-to-icon"; interface DataTableProps { columns: ColumnDef[]; diff --git a/src/components/download-pdf.tsx b/src/components/download-pdf.tsx index 63b8ce6..78af54d 100644 --- a/src/components/download-pdf.tsx +++ b/src/components/download-pdf.tsx @@ -2,14 +2,14 @@ import React from "react"; import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer"; -import { idToTextMap } from "~/utils/optionMapping"; +import { idToTextMap } from "~/utils/option-mapping"; import { type AnswerOption, type PdfTransformedData } from "~/models/types"; import dynamic from "next/dynamic"; import { Button } from "~/components/ui/button"; import Link from "next/link"; -import { ArrowLeftDarkModeFriendly, Download } from "./svg"; import type { Session } from "next-auth"; +import { ArrowLeftDarkModeFriendly, Download } from "~/components/svg"; const PDFDownloadLink = dynamic( () => import("~/components/pdf-download-link").then((mod) => mod.default), diff --git a/src/components/github-link.tsx b/src/components/github-link.tsx index 10cb9ce..3ea1b33 100644 --- a/src/components/github-link.tsx +++ b/src/components/github-link.tsx @@ -1,7 +1,7 @@ "use client"; -import { GithubLogo } from "./svg"; -import { Button } from "./ui/button"; +import { GithubLogo } from "~/components/svg"; +import { Button } from "~/components/ui/button"; const GitHubLink = () => { return ( diff --git a/src/components/home-link.tsx b/src/components/home-link.tsx index 4d773af..1fef08b 100644 --- a/src/components/home-link.tsx +++ b/src/components/home-link.tsx @@ -1,9 +1,9 @@ "use client"; import Link from "next/link"; -import { Button } from "./ui/button"; -import { ArrowLeftDarkModeFriendly } from "./svg"; import { usePathname } from "next/navigation"; +import { ArrowLeftDarkModeFriendly } from "~/components/svg"; +import { Button } from "~/components/ui/button"; export const HomeLink = () => { const currentPathName = usePathname(); diff --git a/src/components/homepage-find-the-expert-button.tsx b/src/components/homepage-find-the-expert-button.tsx index 1435ef5..f669347 100644 --- a/src/components/homepage-find-the-expert-button.tsx +++ b/src/components/homepage-find-the-expert-button.tsx @@ -1,9 +1,9 @@ "use client"; import { api } from "~/trpc/react"; -import { ArrowRightDarkModeFriendly } from "./svg"; -import { Button } from "./ui/button"; import Link from "next/link"; +import { Button } from "~/components/ui/button"; +import { ArrowRightDarkModeFriendly } from "~/components/svg"; export const HomepageFindTheExpertButton = () => { const { mutate: logUsageMetric } = @@ -14,7 +14,7 @@ export const HomepageFindTheExpertButton = () => { }; return ( - +