diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 50a3829..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "server ⚡️", - "type": "npm", - "script": "dev", - "runOptions": { - "runOn": "folderOpen" - } - }, - { - "label": "database 📊", - "type": "shell", - "command": "prisma studio", - "runOptions": { - "runOn": "folderOpen" - } - } - ] -} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..2b69885 Binary files /dev/null and b/app/favicon.ico differ diff --git a/package.json b/package.json index 7b26d95..0592aed 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.445.0", "next": "^15.1.0", + "next-safe-action": "^7.10.2", "next-themes": "^0.4.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d0453e..6c15777 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: next: specifier: ^15.1.0 version: 15.1.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next-safe-action: + specifier: ^7.10.2 + version: 7.10.2(next@15.1.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1) next-themes: specifier: ^0.4.1 version: 0.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -2484,6 +2487,27 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next-safe-action@7.10.2: + resolution: {integrity: sha512-Tpho8EolopnbJFYNuRuA1sXvUxndGuWhk17HbrPl701uuNOA+fs9zQx4vWCjkGWRvfZap67HqKOBueX7/x2Tug==} + engines: {node: '>=18.17'} + peerDependencies: + '@sinclair/typebox': '>= 0.33.3' + next: '>= 14.0.0' + react: '>= 18.2.0' + react-dom: '>= 18.2.0' + valibot: '>= 0.36.0' + yup: '>= 1.0.0' + zod: '>= 3.0.0' + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + valibot: + optional: true + yup: + optional: true + zod: + optional: true + next-themes@0.4.1: resolution: {integrity: sha512-xD3cn42n0f1DFCAOlxJlrGPog+WdhWHObgJ+LTM7J5Bff/uBuq4vn/okSSao7puz7yBLcrELLOQ7F1/9hwycyQ==} peerDependencies: @@ -5662,6 +5686,14 @@ snapshots: neo-async@2.6.2: {} + next-safe-action@7.10.2(next@15.1.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1): + dependencies: + next: 15.1.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + zod: 3.24.1 + next-themes@0.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 diff --git a/prisma/migrations/20250128204801_update_state_limit/migration.sql b/prisma/migrations/20250128204801_update_state_limit/migration.sql new file mode 100644 index 0000000..e1f8e40 --- /dev/null +++ b/prisma/migrations/20250128204801_update_state_limit/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Customer" ALTER COLUMN "state" SET DATA TYPE VARCHAR(3); diff --git a/prisma/migrations/20250201203147_update_phone_field/migration.sql b/prisma/migrations/20250201203147_update_phone_field/migration.sql new file mode 100644 index 0000000..4b78dd8 --- /dev/null +++ b/prisma/migrations/20250201203147_update_phone_field/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "Customer_phone_key"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef83556..e648d07 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,11 +11,11 @@ model Customer { firstName String lastName String email String @unique - phone String @unique + phone String address1 String address2 String? city String - state String @db.VarChar(2) + state String @db.VarChar(3) zip String @db.VarChar(10) notes String? active Boolean @default(true) diff --git a/src/features/customer/helpers/customer.codex.ts b/src/features/customer/helpers/customer.codex.ts new file mode 100644 index 0000000..a118993 --- /dev/null +++ b/src/features/customer/helpers/customer.codex.ts @@ -0,0 +1,20 @@ +import { CustomerFields } from '@/features/customer/helpers/customer.type'; + +export const customerCodex = { + toPayload(input: CustomerFields): CustomerFields { + return { + ...(input.id === 0 ? {} : { id: input.id }), + active: input.active, + firstName: input.firstName, + lastName: input.lastName, + address1: input.address1, + city: input.city, + state: input.state, + zip: input.zip, + phone: input.phone, + email: input.email, + notes: input.notes?.trim() ?? undefined, + address2: input.address2?.trim() ?? undefined, + }; + }, +}; diff --git a/src/features/customer/services/customer.data.ts b/src/features/customer/helpers/customer.data.ts similarity index 100% rename from src/features/customer/services/customer.data.ts rename to src/features/customer/helpers/customer.data.ts diff --git a/src/features/customer/types/customer.schema.ts b/src/features/customer/helpers/customer.type.ts similarity index 89% rename from src/features/customer/types/customer.schema.ts rename to src/features/customer/helpers/customer.type.ts index c0e3d21..b918534 100644 --- a/src/features/customer/types/customer.schema.ts +++ b/src/features/customer/helpers/customer.type.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; export const CustomerSchema = z.object({ + id: z.number().optional(), firstName: z.string().min(1, 'First name is required'), lastName: z.string().min(1, 'Last name is required'), address1: z.string().min(1, 'Address is required'), @@ -9,7 +10,7 @@ export const CustomerSchema = z.object({ state: z.string().length(3, 'State must be exactly 3 characters'), email: z.string().email('Invalid email address'), zip: z.string().regex(/^\d{5}$/, 'Invalid Zip code. Use 5 digits'), - phone: z.string().regex(/^\d{10}$/, 'Invalid phone number. Use 10 digits'), + phone: z.string(), notes: z.string().optional(), active: z.boolean().default(true), }); diff --git a/src/features/customer/services/customer.action.ts b/src/features/customer/services/customer.action.ts new file mode 100644 index 0000000..9d08ab1 --- /dev/null +++ b/src/features/customer/services/customer.action.ts @@ -0,0 +1,33 @@ +'use server'; + +import { customerCodex } from '@/features/customer/helpers/customer.codex'; +import { CustomerSchema } from '@/features/customer/helpers/customer.type'; +import { createCustomer, updateCustomer } from '@/features/customer/services/customer.query'; +import { actionClient } from '@/lib/safe-action'; +import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server'; +import { flattenValidationErrors } from 'next-safe-action'; +import { redirect } from 'next/navigation'; + +export const saveCustomerAction = actionClient + .metadata({ actionName: 'createCustomer' }) + .schema(CustomerSchema, { + handleValidationErrorsShape: async (ve) => flattenValidationErrors(ve).fieldErrors, + }) + .action(async ({ parsedInput }) => { + const { isAuthenticated } = getKindeServerSession(); + const isAuth = await isAuthenticated(); + + if (!isAuth) { + redirect('/login'); + } + + const payload = customerCodex.toPayload(parsedInput); + + if (payload.id) { + const result = await updateCustomer(payload); + return { data: result, message: `Customer ID #${result.id} updated successfully` }; + } + + const result = await createCustomer(payload); + return { data: result, message: `Customer ${result.id} created successfully` }; + }); diff --git a/src/lib/safe-action.ts b/src/lib/safe-action.ts new file mode 100644 index 0000000..4dbb3e5 --- /dev/null +++ b/src/lib/safe-action.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/nextjs'; +import { createSafeActionClient } from 'next-safe-action'; +import { z } from 'zod'; + +export const actionClient = createSafeActionClient({ + defineMetadataSchema() { + return z.object({ + actionName: z.string(), + }); + }, + handleServerError(error, utils) { + const { clientInput, metadata } = utils; + + Sentry.captureException(error, (scope) => { + scope.clear(); + scope.setContext('serverError', { message: error.message }); + scope.setContext('metadata', { actionName: metadata.actionName }); + scope.setContext('clientInput', { clientInput }); + return scope; + }); + + // We don't want to leak any sensitive data + if (error.constructor.name === 'DatabaseError') { + return 'Database Error: Your data did not save. Support will be notified.'; + } + + console.error('Action error:', error.message); + return error.message; + }, +}); diff --git a/user-stories.md b/user-stories.md index 4a17349..debd118 100644 --- a/user-stories.md +++ b/user-stories.md @@ -15,8 +15,8 @@ 13. [X] Users can have Employee, Manager, or Admin permissions 14. [ ] All users can create and view tickets 15. [ ] All users can create, edit and view customers -16. [ ] Employees can only edit their assigned tickets -17. [ ] Managers and Admins can view, edit, and delete all tickets +16. [X] Employees can only edit their assigned tickets +17. [X] Managers and Admins can view, edit, and complete all tickets 18. [ ] Desktop mode is most important but the app should be usable on tablet devices as well. 19. [X] Light / Dark mode option requested by employees 20. [X] Expects quick support if anything goes wrong with the app