feat(api): upgrade zod to v4 and implement api docs and client generation

This commit is contained in:
Dominik 2025-06-20 13:23:52 +02:00
parent 98776aacb2
commit 87dc6162f4
Signed by: dominik
GPG key ID: 06A4003FC5049644
26 changed files with 4827 additions and 419 deletions

2
.gitignore vendored
View file

@ -43,5 +43,5 @@ next-env.d.ts
# database # database
/prisma/*.db* /prisma/*.db*
src/generated/prisma src/generated/*
data data

View file

@ -16,6 +16,8 @@ RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN yarn prisma:generate RUN yarn prisma:generate
RUN yarn swagger:generate
RUN yarn orval:generate
RUN yarn build RUN yarn build
# ----- Runner ----- # ----- Runner -----

View file

@ -13,6 +13,7 @@ services:
- .env.local - .env.local
volumes: volumes:
- ./data:/data - ./data:/data
- ./src/generated:/app/src/generated
develop: develop:
watch: watch:
- action: sync - action: sync
@ -20,8 +21,12 @@ services:
target: /app/src target: /app/src
ignore: ignore:
- node_modules/ - node_modules/
- generated/
- action: rebuild - action: rebuild
path: package.json path: package.json
- action: sync+restart - action: sync+restart
path: prisma path: prisma
target: /app/prisma target: /app/prisma
- action: sync+restart
path: ./src/app/api
target: /app/src/app/api

View file

@ -7,4 +7,7 @@ if [ -d "prisma" ]; then
yarn prisma:db:push yarn prisma:db:push
fi fi
yarn swagger:generate
yarn orval:generate
exec yarn dev exec yarn dev

62
exportSwagger.ts Normal file
View file

@ -0,0 +1,62 @@
import { registry } from '@/lib/swagger';
import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import fs from 'fs';
import path from 'path';
function recursiveFileSearch(dir: string, fileList: string[] = []): string[] {
const files = fs.readdirSync(dir);
files.forEach((file) => {
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
recursiveFileSearch(filePath, fileList);
} else if (file.match(/swagger\.ts$/)) {
fileList.push(filePath);
}
});
return fileList;
}
async function exportSwagger() {
const filesToImport = recursiveFileSearch(
path.join(process.cwd(), 'src', 'app', 'api'),
);
await Promise.all(
filesToImport.map((file) => {
return import(file)
.then((module) => {
if (module.default) {
module.default(registry);
}
})
.catch((error) => {
console.error(`Error importing ${file}:`, error);
});
}),
);
await import('./src/app/api/validation');
const generator = new OpenApiGeneratorV3(registry.definitions);
const spec = generator.generateDocument({
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'MeetUP',
description: 'API documentation for MeetUP application',
},
});
const outputPath = path.join(
process.cwd(),
'src',
'generated',
'swagger.json',
);
fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2), 'utf8');
console.log(`Swagger JSON generated at ${outputPath}`);
}
exportSwagger().catch((error) => {
console.error('Error exporting Swagger:', error);
});

10
orval.config.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
meetup: {
input: './src/generated/swagger.json',
output: {
mode: 'tags-split',
target: './src/generated/api/meetup.ts',
client: 'react-query',
},
},
};

View file

@ -13,9 +13,12 @@
"prisma:studio": "dotenv -e .env.local -- prisma studio", "prisma:studio": "dotenv -e .env.local -- prisma studio",
"prisma:db:push": "dotenv -e .env.local -- prisma db push", "prisma:db:push": "dotenv -e .env.local -- prisma db push",
"prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset", "prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset",
"dev_container": "docker compose -f docker-compose.dev.yml up --watch --build" "dev_container": "docker compose -f docker-compose.dev.yml up --watch --build",
"swagger:generate": "ts-node -r tsconfig-paths/register exportSwagger.ts",
"orval:generate": "orval"
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^8.0.0-beta.4",
"@auth/prisma-adapter": "^2.9.1", "@auth/prisma-adapter": "^2.9.1",
"@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2",
@ -33,16 +36,18 @@
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.11",
"@tanstack/react-query": "^5.80.7",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "15.3.4", "next": "15.4.0-canary.85",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.56.4", "react-hook-form": "^7.56.4",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"zod": "^3.25.60" "zod": "^3.25.60"
}, },
@ -52,14 +57,19 @@
"@types/node": "22.15.32", "@types/node": "22.15.32",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@types/swagger-ui-react": "5",
"@types/webpack-env": "1.18.8",
"dotenv-cli": "8.0.0", "dotenv-cli": "8.0.0",
"eslint": "9.29.0", "eslint": "9.29.0",
"eslint-config-next": "15.3.4", "eslint-config-next": "15.3.4",
"eslint-config-prettier": "10.1.5", "eslint-config-prettier": "10.1.5",
"orval": "7.10.0",
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.5.3", "prettier": "3.5.3",
"prisma": "6.9.0", "prisma": "6.9.0",
"tailwindcss": "4.1.10", "tailwindcss": "4.1.10",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"tw-animate-css": "1.3.4", "tw-animate-css": "1.3.4",
"typescript": "5.8.3" "typescript": "5.8.3"
}, },

View file

@ -158,8 +158,8 @@ model Friendship {
requested_at DateTime @default(now()) requested_at DateTime @default(now())
accepted_at DateTime? accepted_at DateTime?
user1 User @relation("FriendshipUser1", fields: [user_id_1], references: [id]) user1 User @relation("FriendshipUser1", fields: [user_id_1], references: [id], onDelete: Cascade)
user2 User @relation("FriendshipUser2", fields: [user_id_2], references: [id]) user2 User @relation("FriendshipUser2", fields: [user_id_2], references: [id], onDelete: Cascade)
@@id([user_id_1, user_id_2]) @@id([user_id_1, user_id_2])
@@index([user_id_2, status], name: "idx_friendships_user2_status") @@index([user_id_2, status], name: "idx_friendships_user2_status")
@ -187,8 +187,8 @@ model GroupMember {
role group_member_role @default(MEMBER) role group_member_role @default(MEMBER)
added_at DateTime @default(now()) added_at DateTime @default(now())
group Group @relation(fields: [group_id], references: [id]) group Group @relation(fields: [group_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([group_id, user_id]) @@id([group_id, user_id])
@@index([user_id]) @@index([user_id])
@ -207,7 +207,7 @@ model BlockedSlot {
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id, start_time, end_time]) @@index([user_id, start_time, end_time])
@@index([user_id, is_recurring]) @@index([user_id, is_recurring])
@ -241,8 +241,8 @@ model MeetingParticipant {
status participant_status @default(PENDING) status participant_status @default(PENDING)
added_at DateTime @default(now()) added_at DateTime @default(now())
meeting Meeting @relation(fields: [meeting_id], references: [id]) meeting Meeting @relation(fields: [meeting_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([meeting_id, user_id]) @@id([meeting_id, user_id])
@@index([user_id, status], name: "idx_participants_user_status") @@index([user_id, status], name: "idx_participants_user_status")
@ -259,7 +259,7 @@ model Notification {
is_read Boolean @default(false) is_read Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id, is_read, created_at], name: "idx_notifications_user_read_time") @@index([user_id, is_read, created_at], name: "idx_notifications_user_read_time")
@@map("notifications") @@map("notifications")
@ -271,7 +271,7 @@ model UserNotificationPreference {
email_enabled Boolean @default(false) email_enabled Boolean @default(false)
updated_at DateTime @default(now()) updated_at DateTime @default(now())
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([user_id, notification_type]) @@id([user_id, notification_type])
@@map("user_notification_preferences") @@map("user_notification_preferences")
@ -292,7 +292,7 @@ model EmailQueue {
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([status, scheduled_at], name: "idx_email_queue_pending_jobs") @@index([status, scheduled_at], name: "idx_email_queue_pending_jobs")
@@index([user_id, created_at], name: "idx_email_queue_user_history") @@index([user_id, created_at], name: "idx_email_queue_user_history")
@ -308,7 +308,7 @@ model CalendarExportToken {
created_at DateTime @default(now()) created_at DateTime @default(now())
last_accessed_at DateTime? last_accessed_at DateTime?
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id]) @@index([user_id])
@@map("calendar_export_tokens") @@map("calendar_export_tokens")
@ -327,7 +327,7 @@ model CalendarSubscription {
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
externalEvents ExternalEvent[] externalEvents ExternalEvent[]
@@index([user_id, is_enabled]) @@index([user_id, is_enabled])
@ -350,7 +350,7 @@ model ExternalEvent {
show_as_free Boolean @default(false) show_as_free Boolean @default(false)
last_fetched_at DateTime @default(now()) last_fetched_at DateTime @default(now())
subscription CalendarSubscription @relation(fields: [subscription_id], references: [id]) subscription CalendarSubscription @relation(fields: [subscription_id], references: [id], onDelete: Cascade)
@@unique([subscription_id, ical_uid], name: "uq_external_event_sub_uid") @@unique([subscription_id, ical_uid], name: "uq_external_event_sub_uid")
@@index([subscription_id, start_time, end_time]) @@index([subscription_id, start_time, end_time])

11
src/app/api-doc/page.tsx Normal file
View file

@ -0,0 +1,11 @@
import { getApiDocs } from '@/lib/swagger';
import ReactSwagger from './react-swagger';
export default async function IndexPage() {
const spec = await getApiDocs();
return (
<section className='container'>
<ReactSwagger spec={spec} />
</section>
);
}

View file

@ -0,0 +1,14 @@
'use client';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
type Props = {
spec: object;
};
function ReactSwagger({ spec }: Props) {
return <SwaggerUI spec={spec} />;
}
export default ReactSwagger;

View file

@ -364,3 +364,8 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* Fix for swagger ui readability */
body:has(.swagger-ui) {
@apply bg-white text-black;
}

View file

@ -2,6 +2,7 @@ import { ThemeProvider } from '@/components/wrappers/theme-provider';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import { QueryProvider } from '@/components/query-provider';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'MeetUp', title: 'MeetUp',
@ -55,7 +56,7 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
{children} <QueryProvider>{children}</QueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View file

@ -8,9 +8,8 @@ import Authentik from 'next-auth/providers/authentik';
import { PrismaAdapter } from '@auth/prisma-adapter'; import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
import { loginSchema } from './lib/validation/user'; import { loginSchema } from '@/lib/auth/validation';
import { ZodError } from 'zod/v4';
import { ZodError } from 'zod';
class InvalidLoginError extends CredentialsSignin { class InvalidLoginError extends CredentialsSignin {
constructor(code: string) { constructor(code: string) {
@ -25,7 +24,11 @@ const providers: Provider[] = [
Credentials({ Credentials({
credentials: { password: { label: 'Password', type: 'password' } }, credentials: { password: { label: 'Password', type: 'password' } },
async authorize(c) { async authorize(c) {
if (process.env.NODE_ENV === 'development' && c.password === 'password') if (
process.env.NODE_ENV === 'development' &&
process.env.DISABLE_AUTH_TEST_USER !== 'true' &&
c.password === 'password'
)
return { return {
id: 'test', id: 'test',
name: 'Test User', name: 'Test User',
@ -37,7 +40,7 @@ const providers: Provider[] = [
const { email, password } = await loginSchema.parseAsync(c); const { email, password } = await loginSchema.parseAsync(c);
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { email }, where: { OR: [{ email }, { name: email }] },
include: { accounts: true }, include: { accounts: true },
}); });
@ -113,6 +116,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
authorized({ auth }) { authorized({ auth }) {
return !!auth?.user; return !!auth?.user;
}, },
session: async ({ session, token }) => {
if (session?.user) {
session.user.id = token.sub as string;
}
return session;
},
jwt: async ({ user, token }) => {
if (user) {
token.uid = user.id;
}
return token;
},
}, },
debug: process.env.NODE_ENV === 'development', debug: process.env.NODE_ENV === 'development',
}); });

View file

@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
import LabeledInput from '@/components/custom-ui/labeled-input'; import LabeledInput from '@/components/custom-ui/labeled-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import useZodForm from '@/lib/hooks/useZodForm'; import useZodForm from '@/lib/hooks/useZodForm';
import { loginSchema, registerSchema } from '@/lib/validation/user'; import { loginSchema, registerSchema } from '@/lib/auth/validation';
import { loginAction } from '@/lib/auth/login'; import { loginAction } from '@/lib/auth/login';
import { registerAction } from '@/lib/auth/register'; import { registerAction } from '@/lib/auth/register';

View file

@ -0,0 +1,12 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as React from 'react';
const queryClient = new QueryClient();
export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

38
src/lib/apiHelpers.ts Normal file
View file

@ -0,0 +1,38 @@
import { NextAuthRequest } from 'next-auth';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import { NextResponse } from 'next/server';
extendZodWithOpenApi(zod);
export function userAuthenticated(req: NextAuthRequest) {
if (!req.auth || !req.auth.user || !req.auth.user.id)
return {
continue: false,
response: { success: false, message: 'Not authenticated' },
metadata: { status: 401 },
} as const;
return { continue: true, user: req.auth.user } as const;
}
export function returnZodTypeCheckedResponse<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Schema extends zod.ZodType<any, any, any>,
>(
expectedType: Schema,
response: zod.input<Schema>,
metadata?: { status: number },
): NextResponse {
const result = expectedType.safeParse(response);
if (!result.success) {
return NextResponse.json(
{
success: false,
message: 'Invalid response format',
errors: result.error.issues,
},
{ status: 500 },
);
}
return NextResponse.json(result.data, { status: metadata?.status || 200 });
}

View file

@ -1,7 +1,7 @@
'use server'; 'use server';
import { z } from 'zod'; import { z } from 'zod/v4';
import { loginSchema } from '@/lib/validation/user'; import { loginSchema } from './validation';
import { signIn } from '@/auth'; import { signIn } from '@/auth';
export async function loginAction(data: z.infer<typeof loginSchema>) { export async function loginAction(data: z.infer<typeof loginSchema>) {

View file

@ -1,46 +1,24 @@
'use server'; 'use server';
import type { z } from 'zod'; import type { z } from 'zod/v4';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { registerSchema } from '@/lib/validation/user'; import { registerServerSchema } from './validation';
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
export async function registerAction(data: z.infer<typeof registerSchema>) { export async function registerAction(
data: z.infer<typeof registerServerSchema>,
) {
try { try {
const result = await registerSchema.safeParseAsync(data); const result = await registerServerSchema.safeParseAsync(data);
if (!result.success) { if (!result.success) {
return { return {
error: result.error.errors[0].message, error: result.error.issues[0].message,
}; };
} }
const { email, password, firstName, lastName, username } = result.data; const { email, password, firstName, lastName, username } = result.data;
const user = await prisma.user.findUnique({
where: {
email,
},
});
if (user) {
return {
error: 'User already exist with this email',
};
}
const existingUsername = await prisma.user.findUnique({
where: {
name: username,
},
});
if (existingUsername) {
return {
error: 'Username already exists',
};
}
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {

View file

@ -0,0 +1,53 @@
import zod from 'zod/v4';
import {
emailSchema,
firstNameSchema,
lastNameSchema,
newUserEmailServerSchema,
newUserNameServerSchema,
passwordSchema,
userNameSchema,
} from '@/app/api/user/validation';
// ----------------------------------------
//
// Login Validation
//
// ----------------------------------------
export const loginSchema = zod.object({
email: emailSchema.or(userNameSchema),
password: zod.string().min(1, 'Password is required'),
});
// ----------------------------------------
//
// Register Validation
//
// ----------------------------------------
export const registerServerSchema = zod
.object({
firstName: firstNameSchema,
lastName: lastNameSchema,
email: newUserEmailServerSchema,
password: passwordSchema,
confirmPassword: passwordSchema,
username: newUserNameServerSchema,
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export const registerSchema = zod
.object({
firstName: firstNameSchema,
lastName: lastNameSchema,
email: emailSchema,
password: passwordSchema,
confirmPassword: passwordSchema,
username: userNameSchema,
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});

View file

@ -0,0 +1,60 @@
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
export const invalidRequestDataResponse = {
400: {
description: 'Invalid request data',
content: {
'application/json': {
schema: ZodErrorResponseSchema,
},
},
},
};
export const notAuthenticatedResponse = {
401: {
description: 'Not authenticated',
content: {
'application/json': {
schema: ErrorResponseSchema,
example: {
success: false,
message: 'Not authenticated',
},
},
},
},
};
export const userNotFoundResponse = {
404: {
description: 'User not found',
content: {
'application/json': {
schema: ErrorResponseSchema,
example: {
success: false,
message: 'User not found',
},
},
},
},
};
export const serverReturnedDataValidationErrorResponse = {
500: {
description: 'Server returned data validation error',
content: {
'application/json': {
schema: ZodErrorResponseSchema,
example: {
success: false,
message: 'Server returned data validation error',
},
},
},
},
};

View file

@ -1,13 +1,14 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod/v4';
export default function useZodForm< export default function useZodForm<
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
Schema extends z.ZodType<any, any, any>, Schema extends z.ZodType<any, any, any>,
Values extends z.infer<Schema>, Values extends z.infer<Schema>,
>(schema: Schema, defaultValues?: Values) { >(schema: Schema, defaultValues?: Values) {
return useForm<Values>({ // eslint-disable-next-line @typescript-eslint/no-explicit-any
return useForm<z.input<typeof schema>, any, z.output<typeof schema>>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues, defaultValues,
}); });

36
src/lib/swagger.ts Normal file
View file

@ -0,0 +1,36 @@
import {
OpenAPIRegistry,
OpenApiGeneratorV3,
} from '@asteasolutions/zod-to-openapi';
export const registry = new OpenAPIRegistry();
export const getApiDocs = async () => {
const swaggerFiles = require.context('../app', true, /swagger\.ts$/);
swaggerFiles
.keys()
.sort((a, b) => b.length - a.length)
.forEach((file) => {
console.log(`Registering Swagger file: ${file}`);
swaggerFiles(file).default?.(registry);
});
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@/app/api/validation');
try {
const generator = new OpenApiGeneratorV3(registry.definitions);
const spec = generator.generateDocument({
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'MeetUP',
description: 'API documentation for MeetUP application',
},
});
return spec;
} catch (error) {
console.error('Error generating API docs:', error);
throw new Error('Failed to generate API documentation');
}
};

View file

@ -1,67 +0,0 @@
import zod from 'zod';
export const loginSchema = zod.object({
email: zod
.string()
.email('Invalid email address')
.min(3, 'Email is required')
.or(
zod
.string()
.min(3, 'Username is required')
.max(32, 'Username must be at most 32 characters long')
.regex(
/^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores',
),
),
password: zod.string().min(1, 'Password is required'),
});
export const registerSchema = zod
.object({
firstName: zod
.string()
.min(1, 'First name is required')
.max(32, 'First name must be at most 32 characters long'),
lastName: zod
.string()
.min(1, 'Last name is required')
.max(32, 'Last name must be at most 32 characters long'),
email: zod
.string()
.email('Invalid email address')
.min(3, 'Email is required'),
password: zod
.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long'),
confirmPassword: zod
.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long'),
username: zod
.string()
.min(3, 'Username is required')
.max(32, 'Username must be at most 32 characters long')
.regex(
/^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores',
),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
.refine(
(data) =>
!data.password.includes(data.firstName) &&
!data.password.includes(data.lastName) &&
!data.password.includes(data.email) &&
!data.password.includes(data.username),
{
message:
'Password cannot contain your first name, last name, email, or username',
path: ['password'],
},
);

View file

@ -2,6 +2,6 @@ export { auth as middleware } from '@/auth';
export const config = { export const config = {
matcher: [ matcher: [
'/((?!api|_next/static|_next/image|site\.webmanifest|web-app-manifest-(?:192x192|512x512)\.png|favicon(?:-(?:dark|light))?\.(?:png|svg|ico)|fonts).*)', '/((?!api|_next/static|api-doc|_next/image|site\.webmanifest|web-app-manifest-(?:192x192|512x512)\.png|apple-touch-icon.png|favicon(?:-(?:dark|light))?\.(?:png|svg|ico)|fonts).*)',
], ],
}; };

View file

@ -22,6 +22,11 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

4744
yarn.lock

File diff suppressed because it is too large Load diff