feat(api): upgrade zod to v4 and implement api docs and client generation
This commit is contained in:
parent
98776aacb2
commit
87dc6162f4
26 changed files with 4827 additions and 419 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -43,5 +43,5 @@ next-env.d.ts
|
||||||
|
|
||||||
# database
|
# database
|
||||||
/prisma/*.db*
|
/prisma/*.db*
|
||||||
src/generated/prisma
|
src/generated/*
|
||||||
data
|
data
|
||||||
|
|
|
@ -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 -----
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
62
exportSwagger.ts
Normal 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
10
orval.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
14
package.json
14
package.json
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
11
src/app/api-doc/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
14
src/app/api-doc/react-swagger.tsx
Normal file
14
src/app/api-doc/react-swagger.tsx
Normal 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;
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
25
src/auth.ts
25
src/auth.ts
|
@ -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',
|
||||||
});
|
});
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
12
src/components/query-provider.tsx
Normal file
12
src/components/query-provider.tsx
Normal 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
38
src/lib/apiHelpers.ts
Normal 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 });
|
||||||
|
}
|
|
@ -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>) {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
53
src/lib/auth/validation.ts
Normal file
53
src/lib/auth/validation.ts
Normal 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'],
|
||||||
|
});
|
60
src/lib/defaultApiResponses.ts
Normal file
60
src/lib/defaultApiResponses.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -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
36
src/lib/swagger.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
|
@ -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'],
|
|
||||||
},
|
|
||||||
);
|
|
|
@ -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).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue