From 9be4b41ffc56bd1110f9c32a47a0533b9947535b Mon Sep 17 00:00:00 2001 From: Alexey Kasperovich Date: Wed, 11 Feb 2026 18:48:29 +0300 Subject: [PATCH 01/22] feat: routes auto register, codegen web api client, schemas --- template/apps/api/package.json | 2 - template/apps/api/src/app.ts | 11 +- .../apps/api/src/migrator/migrations/1.ts | 2 +- .../src/resources/account/account.routes.ts | 32 - .../src/resources/account}/account.schema.ts | 4 +- .../account/actions/forgot-password.ts | 73 -- .../api/src/resources/account/actions/get.ts | 13 - .../src/resources/account/actions/google.ts | 52 -- .../resources/account/actions/resend-email.ts | 61 -- .../account/actions/reset-password.ts | 43 -- .../src/resources/account/actions/sign-in.ts | 59 -- .../src/resources/account/actions/sign-out.ts | 13 - .../src/resources/account/actions/sign-up.ts | 63 -- .../src/resources/account/actions/update.ts | 57 -- .../resources/account/actions/verify-email.ts | 73 -- .../account/actions/verify-reset-token.ts | 45 -- .../account/endpoints/forgot-password.ts | 78 ++ .../src/resources/account/endpoints/get.ts | 14 + .../account/endpoints/google-callback.ts | 32 + .../src/resources/account/endpoints/google.ts | 30 + .../account/endpoints/resend-email.ts | 68 ++ .../account/endpoints/reset-password.ts | 50 ++ .../resources/account/endpoints/sign-in.ts | 66 ++ .../resources/account/endpoints/sign-out.ts | 15 + .../resources/account/endpoints/sign-up.ts | 69 ++ .../src/resources/account/endpoints/update.ts | 62 ++ .../account/endpoints/verify-email.ts | 76 ++ .../account/endpoints/verify-reset-token.ts | 46 ++ .../apps/api/src/resources/account/index.ts | 3 +- .../api/src/resources/base.schema.ts} | 40 +- .../api/src/resources/token/token.schema.ts | 16 + .../api/src/resources/token/token.service.ts | 2 +- .../api/src/resources/user/actions/list.ts | 75 -- .../api/src/resources/user/actions/remove.ts | 28 - .../api/src/resources/user/actions/update.ts | 44 -- template/apps/api/src/resources/user/index.ts | 6 - .../api/src/resources/user/user.routes.ts | 17 - .../api/src/resources/users/endpoints/list.ts | 77 ++ .../src/resources/users/endpoints/remove.ts | 24 + .../src/resources/users/endpoints/update.ts | 39 + .../apps/api/src/resources/users/index.ts | 5 + .../resources/{user => users}/user.handler.ts | 0 .../api/src/resources/users}/user.schema.ts | 17 +- .../resources/{user => users}/user.service.ts | 2 +- template/apps/api/src/routes/admin.routes.ts | 12 - template/apps/api/src/routes/index.ts | 19 +- .../middlewares/admin-auth.middleware.ts | 17 - .../apps/api/src/routes/middlewares/index.ts | 2 + .../routes/middlewares/is-admin.middleware.ts | 22 + .../middlewares/is-public.middleware.ts | 5 + .../route-error-handler.middleware.ts | 2 +- .../try-to-attach-user.middleware.ts | 2 +- .../apps/api/src/routes/private.routes.ts | 14 - template/apps/api/src/routes/public.routes.ts | 15 - template/apps/api/src/routes/routes.ts | 63 ++ template/apps/api/src/routes/types.ts | 108 +++ .../api/src/services/auth/auth.service.ts | 2 +- .../api/src/services/google/google.service.ts | 2 +- template/apps/api/src/types.ts | 64 +- .../src/utils/get-resource-endpoints.util.ts | 38 + .../apps/api/src/utils/get-resources.util.ts | 23 + template/apps/api/src/utils/index.ts | 5 +- template/apps/api/src/utils/routes.util.ts | 17 - template/apps/web/package.json | 4 +- template/apps/web/src/hooks/index.ts | 1 + template/apps/web/src/hooks/use-api.hook.ts | 89 +++ .../Header/components/MenuToggle/index.tsx | 6 +- .../Header/components/UserMenu/index.tsx | 14 +- .../PageConfig/MainLayout/Header/index.tsx | 6 +- .../_app/PageConfig/MainLayout/index.tsx | 6 +- .../web/src/pages/_app/PageConfig/index.tsx | 8 +- .../src/pages/forgot-password/index.page.tsx | 22 +- .../pages/home/components/Filters/index.tsx | 11 +- template/apps/web/src/pages/home/constants.ts | 6 +- template/apps/web/src/pages/home/index.tsx | 16 +- .../profile/components/AvatarUpload/index.tsx | 19 +- .../apps/web/src/pages/profile/index.page.tsx | 33 +- .../src/pages/reset-password/index.page.tsx | 17 +- .../apps/web/src/pages/sign-in/index.page.tsx | 45 +- .../components/PasswordRules/index.tsx | 2 +- .../apps/web/src/pages/sign-up/index.page.tsx | 22 +- .../web/src/resources/account/account.api.ts | 70 -- .../apps/web/src/resources/account/index.ts | 3 - template/apps/web/src/resources/user/index.ts | 5 - .../apps/web/src/resources/user/user.api.ts | 25 - .../web/src/resources/user/user.handlers.ts | 21 - .../web/src/services/api-client.service.ts | 13 + template/apps/web/src/services/index.ts | 4 +- .../apps/web/src/services/socket-handlers.ts | 35 + template/apps/web/src/types.ts | 24 +- .../apps/web/src/utils/handle-error.util.ts | 5 +- template/packages/app-types/.gitignore | 33 - template/packages/app-types/.prettierignore | 35 - template/packages/app-types/.prettierrc.json | 1 - template/packages/app-types/eslint.config.js | 3 - template/packages/app-types/package.json | 39 - .../packages/app-types/src/account.types.ts | 9 - template/packages/app-types/src/index.ts | 5 - .../packages/app-types/src/token.types.ts | 7 - template/packages/app-types/src/user.types.ts | 17 - template/packages/enums/.gitignore | 33 - template/packages/enums/.prettierignore | 35 - template/packages/enums/.prettierrc.json | 1 - template/packages/enums/eslint.config.js | 3 - template/packages/enums/package.json | 33 - template/packages/enums/src/index.ts | 1 - template/packages/enums/src/token.enum.ts | 5 - template/packages/enums/tsconfig.json | 8 - template/packages/schemas/.gitignore | 33 - template/packages/schemas/.prettierignore | 35 - template/packages/schemas/.prettierrc.json | 1 - template/packages/schemas/eslint.config.js | 3 - template/packages/schemas/src/db.schema.ts | 9 - template/packages/schemas/src/index.ts | 4 - template/packages/schemas/src/token.schema.ts | 11 - template/packages/schemas/tsconfig.json | 8 - .../packages/shared/node_modules/.bin/eslint | 17 + .../packages/shared/node_modules/.bin/jiti | 17 + .../shared/node_modules/.bin/lint-staged | 17 + .../shared/node_modules/.bin/prettier | 17 + .../packages/shared/node_modules/.bin/tsc | 17 + .../shared/node_modules/.bin/tsserver | 17 + .../packages/shared/node_modules/.bin/tsx | 17 + .../packages/shared/node_modules/@types/node | 1 + template/packages/shared/node_modules/axios | 1 + template/packages/shared/node_modules/eslint | 1 + .../shared/node_modules/eslint-config | 1 + .../packages/shared/node_modules/lint-staged | 1 + .../packages/shared/node_modules/prettier | 1 + .../shared/node_modules/prettier-config | 1 + .../packages/shared/node_modules/tsconfig | 1 + template/packages/shared/node_modules/tsx | 1 + .../packages/shared/node_modules/typescript | 1 + template/packages/shared/node_modules/zod | 1 + .../packages/{schemas => shared}/package.json | 9 +- template/packages/shared/scripts/generate.ts | 717 ++++++++++++++++++ .../shared/src/client.ts} | 89 +-- template/packages/shared/src/constants.ts | 14 + .../packages/shared/src/generated/index.ts | 202 +++++ template/packages/shared/src/index.ts | 7 + .../src/schemas/account/account.schema.ts | 27 + .../shared/src/schemas/base.schema.ts | 52 ++ template/packages/shared/src/schemas/index.ts | 4 + .../shared/src/schemas/token/token.schema.ts | 16 + .../shared/src/schemas/users/user.schema.ts | 53 ++ .../common.types.ts => shared/src/types.ts} | 35 +- .../{app-types => shared}/tsconfig.json | 3 +- template/pnpm-lock.yaml | 128 +--- 148 files changed, 2748 insertions(+), 1678 deletions(-) delete mode 100644 template/apps/api/src/resources/account/account.routes.ts rename template/{packages/schemas/src => apps/api/src/resources/account}/account.schema.ts (84%) delete mode 100644 template/apps/api/src/resources/account/actions/forgot-password.ts delete mode 100644 template/apps/api/src/resources/account/actions/get.ts delete mode 100644 template/apps/api/src/resources/account/actions/google.ts delete mode 100644 template/apps/api/src/resources/account/actions/resend-email.ts delete mode 100644 template/apps/api/src/resources/account/actions/reset-password.ts delete mode 100644 template/apps/api/src/resources/account/actions/sign-in.ts delete mode 100644 template/apps/api/src/resources/account/actions/sign-out.ts delete mode 100644 template/apps/api/src/resources/account/actions/sign-up.ts delete mode 100644 template/apps/api/src/resources/account/actions/update.ts delete mode 100644 template/apps/api/src/resources/account/actions/verify-email.ts delete mode 100644 template/apps/api/src/resources/account/actions/verify-reset-token.ts create mode 100644 template/apps/api/src/resources/account/endpoints/forgot-password.ts create mode 100644 template/apps/api/src/resources/account/endpoints/get.ts create mode 100644 template/apps/api/src/resources/account/endpoints/google-callback.ts create mode 100644 template/apps/api/src/resources/account/endpoints/google.ts create mode 100644 template/apps/api/src/resources/account/endpoints/resend-email.ts create mode 100644 template/apps/api/src/resources/account/endpoints/reset-password.ts create mode 100644 template/apps/api/src/resources/account/endpoints/sign-in.ts create mode 100644 template/apps/api/src/resources/account/endpoints/sign-out.ts create mode 100644 template/apps/api/src/resources/account/endpoints/sign-up.ts create mode 100644 template/apps/api/src/resources/account/endpoints/update.ts create mode 100644 template/apps/api/src/resources/account/endpoints/verify-email.ts create mode 100644 template/apps/api/src/resources/account/endpoints/verify-reset-token.ts rename template/{packages/schemas/src/common.schema.ts => apps/api/src/resources/base.schema.ts} (54%) create mode 100644 template/apps/api/src/resources/token/token.schema.ts delete mode 100644 template/apps/api/src/resources/user/actions/list.ts delete mode 100644 template/apps/api/src/resources/user/actions/remove.ts delete mode 100644 template/apps/api/src/resources/user/actions/update.ts delete mode 100644 template/apps/api/src/resources/user/index.ts delete mode 100644 template/apps/api/src/resources/user/user.routes.ts create mode 100644 template/apps/api/src/resources/users/endpoints/list.ts create mode 100644 template/apps/api/src/resources/users/endpoints/remove.ts create mode 100644 template/apps/api/src/resources/users/endpoints/update.ts create mode 100644 template/apps/api/src/resources/users/index.ts rename template/apps/api/src/resources/{user => users}/user.handler.ts (100%) rename template/{packages/schemas/src => apps/api/src/resources/users}/user.schema.ts (69%) rename template/apps/api/src/resources/{user => users}/user.service.ts (93%) delete mode 100644 template/apps/api/src/routes/admin.routes.ts delete mode 100644 template/apps/api/src/routes/middlewares/admin-auth.middleware.ts create mode 100644 template/apps/api/src/routes/middlewares/index.ts create mode 100644 template/apps/api/src/routes/middlewares/is-admin.middleware.ts create mode 100644 template/apps/api/src/routes/middlewares/is-public.middleware.ts delete mode 100644 template/apps/api/src/routes/private.routes.ts delete mode 100644 template/apps/api/src/routes/public.routes.ts create mode 100644 template/apps/api/src/routes/routes.ts create mode 100644 template/apps/api/src/routes/types.ts create mode 100644 template/apps/api/src/utils/get-resource-endpoints.util.ts create mode 100644 template/apps/api/src/utils/get-resources.util.ts delete mode 100644 template/apps/api/src/utils/routes.util.ts create mode 100644 template/apps/web/src/hooks/index.ts create mode 100644 template/apps/web/src/hooks/use-api.hook.ts delete mode 100644 template/apps/web/src/resources/account/account.api.ts delete mode 100644 template/apps/web/src/resources/account/index.ts delete mode 100644 template/apps/web/src/resources/user/index.ts delete mode 100644 template/apps/web/src/resources/user/user.api.ts delete mode 100644 template/apps/web/src/resources/user/user.handlers.ts create mode 100644 template/apps/web/src/services/api-client.service.ts create mode 100644 template/apps/web/src/services/socket-handlers.ts delete mode 100644 template/packages/app-types/.gitignore delete mode 100644 template/packages/app-types/.prettierignore delete mode 100644 template/packages/app-types/.prettierrc.json delete mode 100644 template/packages/app-types/eslint.config.js delete mode 100644 template/packages/app-types/package.json delete mode 100644 template/packages/app-types/src/account.types.ts delete mode 100644 template/packages/app-types/src/index.ts delete mode 100644 template/packages/app-types/src/token.types.ts delete mode 100644 template/packages/app-types/src/user.types.ts delete mode 100644 template/packages/enums/.gitignore delete mode 100644 template/packages/enums/.prettierignore delete mode 100644 template/packages/enums/.prettierrc.json delete mode 100644 template/packages/enums/eslint.config.js delete mode 100644 template/packages/enums/package.json delete mode 100644 template/packages/enums/src/index.ts delete mode 100644 template/packages/enums/src/token.enum.ts delete mode 100644 template/packages/enums/tsconfig.json delete mode 100644 template/packages/schemas/.gitignore delete mode 100644 template/packages/schemas/.prettierignore delete mode 100644 template/packages/schemas/.prettierrc.json delete mode 100644 template/packages/schemas/eslint.config.js delete mode 100644 template/packages/schemas/src/db.schema.ts delete mode 100644 template/packages/schemas/src/index.ts delete mode 100644 template/packages/schemas/src/token.schema.ts delete mode 100644 template/packages/schemas/tsconfig.json create mode 100755 template/packages/shared/node_modules/.bin/eslint create mode 100755 template/packages/shared/node_modules/.bin/jiti create mode 100755 template/packages/shared/node_modules/.bin/lint-staged create mode 100755 template/packages/shared/node_modules/.bin/prettier create mode 100755 template/packages/shared/node_modules/.bin/tsc create mode 100755 template/packages/shared/node_modules/.bin/tsserver create mode 100755 template/packages/shared/node_modules/.bin/tsx create mode 120000 template/packages/shared/node_modules/@types/node create mode 120000 template/packages/shared/node_modules/axios create mode 120000 template/packages/shared/node_modules/eslint create mode 120000 template/packages/shared/node_modules/eslint-config create mode 120000 template/packages/shared/node_modules/lint-staged create mode 120000 template/packages/shared/node_modules/prettier create mode 120000 template/packages/shared/node_modules/prettier-config create mode 120000 template/packages/shared/node_modules/tsconfig create mode 120000 template/packages/shared/node_modules/tsx create mode 120000 template/packages/shared/node_modules/typescript create mode 120000 template/packages/shared/node_modules/zod rename template/packages/{schemas => shared}/package.json (83%) create mode 100644 template/packages/shared/scripts/generate.ts rename template/{apps/web/src/services/api.service.ts => packages/shared/src/client.ts} (50%) create mode 100644 template/packages/shared/src/constants.ts create mode 100644 template/packages/shared/src/generated/index.ts create mode 100644 template/packages/shared/src/index.ts create mode 100644 template/packages/shared/src/schemas/account/account.schema.ts create mode 100644 template/packages/shared/src/schemas/base.schema.ts create mode 100644 template/packages/shared/src/schemas/index.ts create mode 100644 template/packages/shared/src/schemas/token/token.schema.ts create mode 100644 template/packages/shared/src/schemas/users/user.schema.ts rename template/packages/{app-types/src/common.types.ts => shared/src/types.ts} (50%) rename template/packages/{app-types => shared}/tsconfig.json (55%) diff --git a/template/apps/api/package.json b/template/apps/api/package.json index 91333e582..9581fd8b5 100644 --- a/template/apps/api/package.json +++ b/template/apps/api/package.json @@ -31,7 +31,6 @@ "@socket.io/redis-adapter": "8.3.0", "@socket.io/redis-emitter": "5.1.0", "app-constants": "workspace:*", - "app-types": "workspace:*", "arctic": "3.7.0", "dayjs": "1.11.13", "ioredis": "5.6.1", @@ -48,7 +47,6 @@ "mixpanel": "0.18.1", "node-schedule": "2.1.1", "resend": "4.5.2", - "schemas": "workspace:*", "socket.io": "4.8.1", "tldts": "7.0.8", "winston": "3.17.0", diff --git a/template/apps/api/src/app.ts b/template/apps/api/src/app.ts index 7912965b7..a1d9af37a 100644 --- a/template/apps/api/src/app.ts +++ b/template/apps/api/src/app.ts @@ -6,7 +6,7 @@ import qs from 'koa-qs'; import http from 'node:http'; import { socketService } from 'services'; -import routes from 'routes'; +import defineRoutes from 'routes'; import config from 'config'; @@ -17,7 +17,7 @@ import logger from 'logger'; import { AppKoa } from 'types'; -const initKoa = () => { +const initKoa = async () => { const app = new AppKoa(); app.proxy = true; @@ -45,14 +45,13 @@ const initKoa = () => { }), ); - routes(app); + await defineRoutes(app); return app; }; -const app = initKoa(); - (async () => { + const app = await initKoa(); const server = http.createServer(app.callback()); if (config.REDIS_URI) { @@ -70,4 +69,4 @@ const app = initKoa(); }); })(); -export default app; +export default initKoa; diff --git a/template/apps/api/src/migrator/migrations/1.ts b/template/apps/api/src/migrator/migrations/1.ts index 98fb7eb0b..b8e56ff4f 100644 --- a/template/apps/api/src/migrator/migrations/1.ts +++ b/template/apps/api/src/migrator/migrations/1.ts @@ -1,4 +1,4 @@ -import { userService } from 'resources/user'; +import { userService } from 'resources/users'; import { promiseUtil } from 'utils'; diff --git a/template/apps/api/src/resources/account/account.routes.ts b/template/apps/api/src/resources/account/account.routes.ts deleted file mode 100644 index 14700b2fb..000000000 --- a/template/apps/api/src/resources/account/account.routes.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { routeUtil } from 'utils'; - -import forgotPassword from './actions/forgot-password'; -import get from './actions/get'; -import google from './actions/google'; -import resendEmail from './actions/resend-email'; -import resetPassword from './actions/reset-password'; -import signIn from './actions/sign-in'; -import signOut from './actions/sign-out'; -import signUp from './actions/sign-up'; -import update from './actions/update'; -import verifyEmail from './actions/verify-email'; -import verifyResetToken from './actions/verify-reset-token'; - -const publicRoutes = routeUtil.getRoutes([ - signUp, - signIn, - signOut, - verifyEmail, - forgotPassword, - resetPassword, - verifyResetToken, - resendEmail, - google, -]); - -const privateRoutes = routeUtil.getRoutes([get, update]); - -export default { - publicRoutes, - privateRoutes, -}; diff --git a/template/packages/schemas/src/account.schema.ts b/template/apps/api/src/resources/account/account.schema.ts similarity index 84% rename from template/packages/schemas/src/account.schema.ts rename to template/apps/api/src/resources/account/account.schema.ts index 2eecfc365..dd97523ab 100644 --- a/template/packages/schemas/src/account.schema.ts +++ b/template/apps/api/src/resources/account/account.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { emailSchema, passwordSchema } from './common.schema'; -import { userSchema } from './user.schema'; +import { emailSchema, passwordSchema } from '../base.schema'; +import { userSchema } from '../users/user.schema'; export const signInSchema = z.object({ email: emailSchema, diff --git a/template/apps/api/src/resources/account/actions/forgot-password.ts b/template/apps/api/src/resources/account/actions/forgot-password.ts deleted file mode 100644 index 0e129b480..000000000 --- a/template/apps/api/src/resources/account/actions/forgot-password.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { tokenService } from 'resources/token'; -import { userService } from 'resources/user'; - -import { rateLimitMiddleware, validateMiddleware } from 'middlewares'; -import { emailService } from 'services'; - -import config from 'config'; - -import { RESET_PASSWORD_TOKEN } from 'app-constants'; -import { forgotPasswordSchema } from 'schemas'; -import { AppKoaContext, AppRouter, ForgotPasswordParams, Next, Template, TokenType, User } from 'types'; - -interface ValidatedData extends ForgotPasswordParams { - user: User; -} - -async function validator(ctx: AppKoaContext, next: Next) { - const { email } = ctx.validatedData; - - const user = await userService.findOne({ email }); - - if (!user) { - ctx.status = 204; - return; - } - - ctx.validatedData.user = user; - await next(); -} - -async function handler(ctx: AppKoaContext) { - const { user } = ctx.validatedData; - - await Promise.all([ - tokenService.invalidateUserTokens(user._id, TokenType.ACCESS), - tokenService.invalidateUserTokens(user._id, TokenType.RESET_PASSWORD), - ]); - - const resetPasswordToken = await tokenService.createToken({ - userId: user._id, - type: TokenType.RESET_PASSWORD, - expiresIn: RESET_PASSWORD_TOKEN.EXPIRATION_SECONDS, - }); - - const resetPasswordUrl = new URL(`${config.API_URL}/account/verify-reset-token`); - - resetPasswordUrl.searchParams.set('token', resetPasswordToken); - - await emailService.sendTemplate({ - to: user.email, - subject: 'Password Reset Request for Ship', - template: Template.RESET_PASSWORD, - params: { - firstName: user.firstName, - href: resetPasswordUrl.toString(), - }, - }); - - ctx.status = 204; -} - -export default (router: AppRouter) => { - router.post( - '/forgot-password', - rateLimitMiddleware({ - limitDuration: 60 * 60, // 1 hour - requestsPerDuration: 10, - }), - validateMiddleware(forgotPasswordSchema), - validator, - handler, - ); -}; diff --git a/template/apps/api/src/resources/account/actions/get.ts b/template/apps/api/src/resources/account/actions/get.ts deleted file mode 100644 index b297fc5cc..000000000 --- a/template/apps/api/src/resources/account/actions/get.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { userService } from 'resources/user'; - -import { AppKoaContext, AppRouter } from 'types'; - -async function handler(ctx: AppKoaContext) { - const { user } = ctx.state; - - ctx.body = userService.getPublic(user); -} - -export default (router: AppRouter) => { - router.get('/', handler); -}; diff --git a/template/apps/api/src/resources/account/actions/google.ts b/template/apps/api/src/resources/account/actions/google.ts deleted file mode 100644 index 0bc56a92f..000000000 --- a/template/apps/api/src/resources/account/actions/google.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { authService, googleService } from 'services'; - -import config from 'config'; - -import { AppKoaContext, AppRouter } from 'types'; - -const handleGetOAuthUrl = async (ctx: AppKoaContext) => { - try { - const { state, codeVerifier, authorizationUrl } = googleService.createAuthUrl(); - - const cookieOptions = { - path: '/', - httpOnly: true, - secure: ctx.secure, - maxAge: 60 * 10 * 1000, // valid for 10 minutes - sameSite: 'lax' as const, - }; - - ctx.cookies.set('google-oauth-state', state, cookieOptions); - ctx.cookies.set('google-code-verifier', codeVerifier, cookieOptions); - - ctx.redirect(authorizationUrl); - } catch (error) { - ctx.throwGlobalErrorWithRedirect(error instanceof Error ? error.message : 'Failed to create Google OAuth URL'); - } -}; - -const handleOAuthCallback = async (ctx: AppKoaContext) => { - try { - const user = await googleService.validateCallback({ - code: ctx.request.query.code?.toString(), - state: ctx.request.query.state?.toString(), - storedState: ctx.cookies.get('google-oauth-state'), - codeVerifier: ctx.cookies.get('google-code-verifier'), - }); - - if (!user) { - throw new Error('Failed to authenticate with Google'); - } - - await authService.setAccessToken({ ctx, userId: user._id }); - - ctx.redirect(config.WEB_URL); - } catch (error) { - ctx.throwGlobalErrorWithRedirect(error instanceof Error ? error.message : 'Google authentication failed'); - } -}; - -export default (router: AppRouter) => { - router.get('/sign-in/google', handleGetOAuthUrl); - router.get('/sign-in/google/callback', handleOAuthCallback); -}; diff --git a/template/apps/api/src/resources/account/actions/resend-email.ts b/template/apps/api/src/resources/account/actions/resend-email.ts deleted file mode 100644 index f06516217..000000000 --- a/template/apps/api/src/resources/account/actions/resend-email.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { tokenService } from 'resources/token'; -import { userService } from 'resources/user'; - -import { rateLimitMiddleware, validateMiddleware } from 'middlewares'; -import { emailService } from 'services'; - -import config from 'config'; - -import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; -import { resendEmailSchema } from 'schemas'; -import { AppKoaContext, AppRouter, Next, ResendEmailParams, Template, TokenType, User } from 'types'; - -interface ValidatedData extends ResendEmailParams { - user: User; -} - -async function validator(ctx: AppKoaContext, next: Next) { - const { email } = ctx.validatedData; - - const user = await userService.findOne({ email }); - - if (!user) { - ctx.status = 204; - return; - } - - ctx.validatedData.user = user; - await next(); -} - -async function handler(ctx: AppKoaContext) { - const { user } = ctx.validatedData; - - await tokenService.invalidateUserTokens(user._id, TokenType.EMAIL_VERIFICATION); - - const emailVerificationToken = await tokenService.createToken({ - userId: user._id, - type: TokenType.EMAIL_VERIFICATION, - expiresIn: EMAIL_VERIFICATION_TOKEN.EXPIRATION_SECONDS, - }); - - const emailVerificationUrl = new URL(`${config.API_URL}/account/verify-email`); - - emailVerificationUrl.searchParams.set('token', emailVerificationToken); - - await emailService.sendTemplate({ - to: user.email, - subject: 'Please Confirm Your Email Address for Ship', - template: Template.VERIFY_EMAIL, - params: { - firstName: user.firstName, - href: emailVerificationUrl.toString(), - }, - }); - - ctx.status = 204; -} - -export default (router: AppRouter) => { - router.post('/resend-email', rateLimitMiddleware(), validateMiddleware(resendEmailSchema), validator, handler); -}; diff --git a/template/apps/api/src/resources/account/actions/reset-password.ts b/template/apps/api/src/resources/account/actions/reset-password.ts deleted file mode 100644 index ddd0a4cc6..000000000 --- a/template/apps/api/src/resources/account/actions/reset-password.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { tokenService } from 'resources/token'; -import { userService } from 'resources/user'; - -import { rateLimitMiddleware, validateMiddleware } from 'middlewares'; -import { securityUtil } from 'utils'; - -import { resetPasswordSchema } from 'schemas'; -import { AppKoaContext, AppRouter, Next, ResetPasswordParams, TokenType, User } from 'types'; - -interface ValidatedData extends ResetPasswordParams { - user: User; -} - -async function validator(ctx: AppKoaContext, next: Next) { - const { token } = ctx.validatedData; - - const resetPasswordToken = await tokenService.validateToken(token, TokenType.RESET_PASSWORD); - const user = await userService.findOne({ _id: resetPasswordToken?.userId }); - - if (!resetPasswordToken || !user) { - ctx.status = 204; - return; - } - - ctx.validatedData.user = user; - await next(); -} - -async function handler(ctx: AppKoaContext) { - const { user, password } = ctx.validatedData; - - const passwordHash = await securityUtil.hashPassword(password); - - await tokenService.invalidateUserTokens(user._id, TokenType.RESET_PASSWORD); - - await userService.updateOne({ _id: user._id }, () => ({ passwordHash })); - - ctx.status = 204; -} - -export default (router: AppRouter) => { - router.put('/reset-password', rateLimitMiddleware(), validateMiddleware(resetPasswordSchema), validator, handler); -}; diff --git a/template/apps/api/src/resources/account/actions/sign-in.ts b/template/apps/api/src/resources/account/actions/sign-in.ts deleted file mode 100644 index 8d69bcd93..000000000 --- a/template/apps/api/src/resources/account/actions/sign-in.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { tokenService } from 'resources/token'; -import { userService } from 'resources/user'; - -import { rateLimitMiddleware, validateMiddleware } from 'middlewares'; -import { authService } from 'services'; -import { securityUtil } from 'utils'; - -import { signInSchema } from 'schemas'; -import { AppKoaContext, AppRouter, Next, SignInParams, TokenType, User } from 'types'; - -interface ValidatedData extends SignInParams { - user: User; -} - -async function validator(ctx: AppKoaContext, next: Next) { - const { email, password } = ctx.validatedData; - - const user = await userService.findOne({ email }); - - ctx.assertClientError(user && user.passwordHash, { - credentials: 'The email or password you have entered is invalid', - }); - - const isPasswordMatch = await securityUtil.verifyPasswordHash(user.passwordHash, password); - - ctx.assertClientError(isPasswordMatch, { - credentials: 'The email or password you have entered is invalid', - }); - - if (!user.isEmailVerified) { - const existingEmailVerificationToken = await tokenService.getUserActiveToken( - user._id, - TokenType.EMAIL_VERIFICATION, - ); - - ctx.assertClientError(existingEmailVerificationToken, { - emailVerificationTokenExpired: true, - }); - } - - ctx.assertClientError(user.isEmailVerified, { - email: 'Please verify your email to sign in', - }); - - ctx.validatedData.user = user; - await next(); -} - -async function handler(ctx: AppKoaContext) { - const { user } = ctx.validatedData; - - await authService.setAccessToken({ ctx, userId: user._id }); - - ctx.body = userService.getPublic(user); -} - -export default (router: AppRouter) => { - router.post('/sign-in', rateLimitMiddleware(), validateMiddleware(signInSchema), validator, handler); -}; diff --git a/template/apps/api/src/resources/account/actions/sign-out.ts b/template/apps/api/src/resources/account/actions/sign-out.ts deleted file mode 100644 index ab58ce33f..000000000 --- a/template/apps/api/src/resources/account/actions/sign-out.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { authService } from 'services'; - -import { AppKoaContext, AppRouter } from 'types'; - -const handler = async (ctx: AppKoaContext) => { - await authService.unsetUserAccessToken({ ctx }); - - ctx.status = 204; -}; - -export default (router: AppRouter) => { - router.post('/sign-out', handler); -}; diff --git a/template/apps/api/src/resources/account/actions/sign-up.ts b/template/apps/api/src/resources/account/actions/sign-up.ts deleted file mode 100644 index c7b9f9d46..000000000 --- a/template/apps/api/src/resources/account/actions/sign-up.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { tokenService } from 'resources/token'; -import { userService } from 'resources/user'; - -import { rateLimitMiddleware, validateMiddleware } from 'middlewares'; -import { emailService } from 'services'; -import { securityUtil } from 'utils'; - -import config from 'config'; - -import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; -import { signUpSchema } from 'schemas'; -import { AppKoaContext, AppRouter, Next, SignUpParams, Template, TokenType } from 'types'; - -async function validator(ctx: AppKoaContext, next: Next) { - const { email } = ctx.validatedData; - - const isUserExists = await userService.exists({ email }); - - ctx.assertClientError(!isUserExists, { - email: 'User with this email is already registered', - }); - - await next(); -} - -async function handler(ctx: AppKoaContext) { - const { firstName, lastName, email, password } = ctx.validatedData; - - const user = await userService.insertOne({ - email, - firstName, - lastName, - passwordHash: await securityUtil.hashPassword(password), - isEmailVerified: false, - }); - - const emailVerificationToken = await tokenService.createToken({ - userId: user._id, - type: TokenType.EMAIL_VERIFICATION, - expiresIn: EMAIL_VERIFICATION_TOKEN.EXPIRATION_SECONDS, - }); - - await emailService.sendTemplate({ - to: user.email, - subject: 'Please Confirm Your Email Address for Ship', - template: Template.VERIFY_EMAIL, - params: { - firstName: user.firstName, - href: `${config.API_URL}/account/verify-email?token=${emailVerificationToken}`, - }, - }); - - if (config.IS_DEV) { - ctx.body = { emailVerificationToken }; - return; - } - - ctx.status = 204; -} - -export default (router: AppRouter) => { - router.post('/sign-up', rateLimitMiddleware(), validateMiddleware(signUpSchema), validator, handler); -}; diff --git a/template/apps/api/src/resources/account/actions/update.ts b/template/apps/api/src/resources/account/actions/update.ts deleted file mode 100644 index a993078f8..000000000 --- a/template/apps/api/src/resources/account/actions/update.ts +++ /dev/null @@ -1,57 +0,0 @@ -import _ from 'lodash'; - -import { accountUtils } from 'resources/account'; -import { userService } from 'resources/user'; - -import { validateMiddleware } from 'middlewares'; -import { securityUtil } from 'utils'; - -import { updateUserSchema } from 'schemas'; -import { AppKoaContext, AppRouter, Next, UpdateUserParamsBackend, User } from 'types'; - -interface ValidatedData extends UpdateUserParamsBackend { - passwordHash?: string; -} - -async function validator(ctx: AppKoaContext, next: Next) { - const { user } = ctx.state; - const { password } = ctx.validatedData; - - if (_.isEmpty(ctx.validatedData)) { - ctx.body = userService.getPublic(user); - - return; - } - - if (password) { - ctx.validatedData.passwordHash = await securityUtil.hashPassword(password); - - delete ctx.validatedData.password; - } - - await next(); -} - -async function handler(ctx: AppKoaContext) { - const { avatar } = ctx.validatedData; - const { user } = ctx.state; - - const nonEmptyValues = _.pickBy(ctx.validatedData, (value) => !_.isUndefined(value)); - const updateData: Partial = _.omit(nonEmptyValues, 'avatar'); - - if (avatar === '') { - await accountUtils.removeAvatar(user); - - updateData.avatarUrl = null; - } - - if (avatar) { - updateData.avatarUrl = await accountUtils.uploadAvatar(user, avatar); - } - - ctx.body = await userService.updateOne({ _id: user._id }, () => updateData).then(userService.getPublic); -} - -export default (router: AppRouter) => { - router.put('/', validateMiddleware(updateUserSchema), validator, handler); -}; diff --git a/template/apps/api/src/resources/account/actions/verify-email.ts b/template/apps/api/src/resources/account/actions/verify-email.ts deleted file mode 100644 index d1e26f758..000000000 --- a/template/apps/api/src/resources/account/actions/verify-email.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { z } from 'zod'; - -import { tokenService } from 'resources/token'; -import { userService } from 'resources/user'; - -import { rateLimitMiddleware, validateMiddleware } from 'middlewares'; -import { authService, emailService } from 'services'; - -import config from 'config'; - -import { AppKoaContext, AppRouter, Next, Template, TokenType, User } from 'types'; - -const schema = z.object({ - token: z.string().min(1, 'Token is required'), -}); - -interface ValidatedData extends z.infer { - user: User; -} - -async function validator(ctx: AppKoaContext, next: Next) { - const { token } = ctx.validatedData; - - const emailVerificationToken = await tokenService.validateToken(token, TokenType.EMAIL_VERIFICATION); - const user = await userService.findOne({ _id: emailVerificationToken?.userId }); - - if (!emailVerificationToken || !user) { - ctx.throwGlobalErrorWithRedirect('Token is invalid or expired.'); - return; - } - - ctx.validatedData.user = user; - await next(); -} - -async function handler(ctx: AppKoaContext) { - try { - const { user } = ctx.validatedData; - - await tokenService.invalidateUserTokens(user._id, TokenType.EMAIL_VERIFICATION); - - await userService.updateOne({ _id: user._id }, () => ({ isEmailVerified: true })); - - await authService.setAccessToken({ ctx, userId: user._id }); - - await emailService.sendTemplate({ - to: user.email, - subject: 'Welcome to Ship Community!', - template: Template.SIGN_UP_WELCOME, - params: { - firstName: user.firstName, - href: `${config.WEB_URL}/sign-in`, - }, - }); - - ctx.redirect(config.WEB_URL); - } catch (error) { - ctx.throwGlobalErrorWithRedirect('Failed to verify email. Please try again.'); - } -} - -export default (router: AppRouter) => { - router.get( - '/verify-email', - rateLimitMiddleware({ - limitDuration: 60 * 60, // 1 hour - requestsPerDuration: 10, - }), - validateMiddleware(schema), - validator, - handler, - ); -}; diff --git a/template/apps/api/src/resources/account/actions/verify-reset-token.ts b/template/apps/api/src/resources/account/actions/verify-reset-token.ts deleted file mode 100644 index 86e8db246..000000000 --- a/template/apps/api/src/resources/account/actions/verify-reset-token.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from 'zod'; - -import { tokenService } from 'resources/token'; -import { userService } from 'resources/user'; - -import { rateLimitMiddleware, validateMiddleware } from 'middlewares'; - -import config from 'config'; - -import { AppKoaContext, AppRouter, TokenType, User } from 'types'; - -const schema = z.object({ - token: z.string().min(1, 'Token is required'), -}); - -interface ValidatedData extends z.infer { - user: User; -} - -async function handler(ctx: AppKoaContext) { - try { - const { token } = ctx.validatedData; - - const resetPasswordToken = await tokenService.validateToken(token, TokenType.RESET_PASSWORD); - - const user = await userService.findOne({ _id: resetPasswordToken?.userId }); - - if (!resetPasswordToken || !user) { - ctx.throwGlobalErrorWithRedirect('Token is invalid or expired.'); - return; - } - - const redirectUrl = new URL(`${config.WEB_URL}/reset-password`); - - redirectUrl.searchParams.set('token', token); - - ctx.redirect(redirectUrl.toString()); - } catch (error) { - ctx.throwGlobalErrorWithRedirect('Failed to verify reset password token. Please try again.'); - } -} - -export default (router: AppRouter) => { - router.get('/verify-reset-token', rateLimitMiddleware(), validateMiddleware(schema), handler); -}; diff --git a/template/apps/api/src/resources/account/endpoints/forgot-password.ts b/template/apps/api/src/resources/account/endpoints/forgot-password.ts new file mode 100644 index 000000000..e68611beb --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/forgot-password.ts @@ -0,0 +1,78 @@ +import { tokenService } from 'resources/token'; +import { userService } from 'resources/users'; + +import { rateLimitMiddleware } from 'middlewares'; +import { emailService } from 'services'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +import config from 'config'; + +import { RESET_PASSWORD_TOKEN } from 'app-constants'; +import { forgotPasswordSchema } from '../account.schema'; +import { AppKoaContext, ForgotPasswordParams, Template, TokenType, User } from 'types'; + +export const schema = forgotPasswordSchema; + +interface ValidatedData extends ForgotPasswordParams { + user: User; +} + +const validator = createMiddleware(async (ctx, next) => { + const { email } = ctx.validatedData; + + const user = await userService.findOne({ email }); + + if (!user) { + ctx.status = 204; + return; + } + + ctx.validatedData.user = user; + await next(); +}); + +export default createEndpoint({ + method: 'post', + path: '/forgot-password', + schema, + middlewares: [ + isPublic, + rateLimitMiddleware({ + limitDuration: 60 * 60, // 1 hour + requestsPerDuration: 10, + }), + validator, + ], + + async handler(ctx) { + const { user } = (ctx as AppKoaContext).validatedData; + + await Promise.all([ + tokenService.invalidateUserTokens(user._id, TokenType.ACCESS), + tokenService.invalidateUserTokens(user._id, TokenType.RESET_PASSWORD), + ]); + + const resetPasswordToken = await tokenService.createToken({ + userId: user._id, + type: TokenType.RESET_PASSWORD, + expiresIn: RESET_PASSWORD_TOKEN.EXPIRATION_SECONDS, + }); + + const resetPasswordUrl = new URL(`${config.API_URL}/account/verify-reset-token`); + + resetPasswordUrl.searchParams.set('token', resetPasswordToken); + + await emailService.sendTemplate({ + to: user.email, + subject: 'Password Reset Request for Ship', + template: Template.RESET_PASSWORD, + params: { + firstName: user.firstName, + href: resetPasswordUrl.toString(), + }, + }); + + ctx.status = 204; + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/get.ts b/template/apps/api/src/resources/account/endpoints/get.ts new file mode 100644 index 000000000..c540340ef --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/get.ts @@ -0,0 +1,14 @@ +import { userService } from 'resources/users'; + +import { createEndpoint } from 'routes/types'; + +export default createEndpoint({ + method: 'get', + path: '/', + + async handler(ctx) { + const { user } = ctx.state; + + return userService.getPublic(user); + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/google-callback.ts b/template/apps/api/src/resources/account/endpoints/google-callback.ts new file mode 100644 index 000000000..3ff587956 --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/google-callback.ts @@ -0,0 +1,32 @@ +import { authService, googleService } from 'services'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint } from 'routes/types'; + +import config from 'config'; + +export default createEndpoint({ + method: 'get', + path: '/sign-in/google/callback', + middlewares: [isPublic], + + async handler(ctx) { + try { + const user = await googleService.validateCallback({ + code: ctx.request.query.code?.toString(), + state: ctx.request.query.state?.toString(), + storedState: ctx.cookies.get('google-oauth-state'), + codeVerifier: ctx.cookies.get('google-code-verifier'), + }); + + if (!user) { + throw new Error('Failed to authenticate with Google'); + } + + await authService.setAccessToken({ ctx, userId: user._id }); + + ctx.redirect(config.WEB_URL); + } catch (error) { + ctx.throwGlobalErrorWithRedirect(error instanceof Error ? error.message : 'Google authentication failed'); + } + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/google.ts b/template/apps/api/src/resources/account/endpoints/google.ts new file mode 100644 index 000000000..c89f7f5b8 --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/google.ts @@ -0,0 +1,30 @@ +import { googleService } from 'services'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint } from 'routes/types'; + +export default createEndpoint({ + method: 'get', + path: '/sign-in/google', + middlewares: [isPublic], + + async handler(ctx) { + try { + const { state, codeVerifier, authorizationUrl } = googleService.createAuthUrl(); + + const cookieOptions = { + path: '/', + httpOnly: true, + secure: ctx.secure, + maxAge: 60 * 10 * 1000, // valid for 10 minutes + sameSite: 'lax' as const, + }; + + ctx.cookies.set('google-oauth-state', state, cookieOptions); + ctx.cookies.set('google-code-verifier', codeVerifier, cookieOptions); + + ctx.redirect(authorizationUrl); + } catch (error) { + ctx.throwGlobalErrorWithRedirect(error instanceof Error ? error.message : 'Failed to create Google OAuth URL'); + } + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/resend-email.ts b/template/apps/api/src/resources/account/endpoints/resend-email.ts new file mode 100644 index 000000000..7d1bad8bb --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/resend-email.ts @@ -0,0 +1,68 @@ +import { tokenService } from 'resources/token'; +import { userService } from 'resources/users'; + +import { rateLimitMiddleware } from 'middlewares'; +import { emailService } from 'services'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +import config from 'config'; + +import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; +import { resendEmailSchema } from '../account.schema'; +import { AppKoaContext, ResendEmailParams, Template, TokenType, User } from 'types'; + +export const schema = resendEmailSchema; + +interface ValidatedData extends ResendEmailParams { + user: User; +} + +const validator = createMiddleware(async (ctx, next) => { + const { email } = ctx.validatedData; + + const user = await userService.findOne({ email }); + + if (!user) { + ctx.status = 204; + return; + } + + ctx.validatedData.user = user; + await next(); +}); + +export default createEndpoint({ + method: 'post', + path: '/resend-email', + schema, + middlewares: [isPublic, rateLimitMiddleware(), validator], + + async handler(ctx) { + const { user } = (ctx as AppKoaContext).validatedData; + + await tokenService.invalidateUserTokens(user._id, TokenType.EMAIL_VERIFICATION); + + const emailVerificationToken = await tokenService.createToken({ + userId: user._id, + type: TokenType.EMAIL_VERIFICATION, + expiresIn: EMAIL_VERIFICATION_TOKEN.EXPIRATION_SECONDS, + }); + + const emailVerificationUrl = new URL(`${config.API_URL}/account/verify-email`); + + emailVerificationUrl.searchParams.set('token', emailVerificationToken); + + await emailService.sendTemplate({ + to: user.email, + subject: 'Please Confirm Your Email Address for Ship', + template: Template.VERIFY_EMAIL, + params: { + firstName: user.firstName, + href: emailVerificationUrl.toString(), + }, + }); + + ctx.status = 204; + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/reset-password.ts b/template/apps/api/src/resources/account/endpoints/reset-password.ts new file mode 100644 index 000000000..179d85352 --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/reset-password.ts @@ -0,0 +1,50 @@ +import { tokenService } from 'resources/token'; +import { userService } from 'resources/users'; + +import { rateLimitMiddleware } from 'middlewares'; +import { securityUtil } from 'utils'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +import { resetPasswordSchema } from '../account.schema'; +import { AppKoaContext, ResetPasswordParams, TokenType, User } from 'types'; + +export const schema = resetPasswordSchema; + +interface ValidatedData extends ResetPasswordParams { + user: User; +} + +const validator = createMiddleware(async (ctx, next) => { + const { token } = ctx.validatedData; + + const resetPasswordToken = await tokenService.validateToken(token, TokenType.RESET_PASSWORD); + const user = await userService.findOne({ _id: resetPasswordToken?.userId }); + + if (!resetPasswordToken || !user) { + ctx.status = 204; + return; + } + + ctx.validatedData.user = user; + await next(); +}); + +export default createEndpoint({ + method: 'put', + path: '/reset-password', + schema, + middlewares: [isPublic, rateLimitMiddleware(), validator], + + async handler(ctx) { + const { user, password } = (ctx as AppKoaContext).validatedData; + + const passwordHash = await securityUtil.hashPassword(password); + + await tokenService.invalidateUserTokens(user._id, TokenType.RESET_PASSWORD); + + await userService.updateOne({ _id: user._id }, () => ({ passwordHash })); + + ctx.status = 204; + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/sign-in.ts b/template/apps/api/src/resources/account/endpoints/sign-in.ts new file mode 100644 index 000000000..e56e52e16 --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/sign-in.ts @@ -0,0 +1,66 @@ +import { tokenService } from 'resources/token'; +import { userService } from 'resources/users'; + +import { rateLimitMiddleware } from 'middlewares'; +import { authService } from 'services'; +import { securityUtil } from 'utils'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +import { signInSchema } from '../account.schema'; +import { AppKoaContext, SignInParams, TokenType, User } from 'types'; + +export const schema = signInSchema; + +interface ValidatedData extends SignInParams { + user: User; +} + +const validator = createMiddleware(async (ctx, next) => { + const { email, password } = ctx.validatedData; + + const user = await userService.findOne({ email }); + + ctx.assertClientError(user && user.passwordHash, { + credentials: 'The email or password you have entered is invalid', + }); + + const isPasswordMatch = await securityUtil.verifyPasswordHash(user!.passwordHash!, password); + + ctx.assertClientError(isPasswordMatch, { + credentials: 'The email or password you have entered is invalid', + }); + + if (!user!.isEmailVerified) { + const existingEmailVerificationToken = await tokenService.getUserActiveToken( + user!._id, + TokenType.EMAIL_VERIFICATION, + ); + + ctx.assertClientError(existingEmailVerificationToken, { + emailVerificationTokenExpired: true, + }); + } + + ctx.assertClientError(user!.isEmailVerified, { + email: 'Please verify your email to sign in', + }); + + ctx.validatedData.user = user!; + await next(); +}); + +export default createEndpoint({ + method: 'post', + path: '/sign-in', + schema, + middlewares: [isPublic, rateLimitMiddleware(), validator], + + async handler(ctx) { + const { user } = (ctx as AppKoaContext).validatedData; + + await authService.setAccessToken({ ctx, userId: user._id }); + + return userService.getPublic(user); + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/sign-out.ts b/template/apps/api/src/resources/account/endpoints/sign-out.ts new file mode 100644 index 000000000..d23242b40 --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/sign-out.ts @@ -0,0 +1,15 @@ +import { authService } from 'services'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint } from 'routes/types'; + +export default createEndpoint({ + method: 'post', + path: '/sign-out', + middlewares: [isPublic], + + async handler(ctx) { + await authService.unsetUserAccessToken({ ctx }); + + ctx.status = 204; + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/sign-up.ts b/template/apps/api/src/resources/account/endpoints/sign-up.ts new file mode 100644 index 000000000..5c07bc9ed --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/sign-up.ts @@ -0,0 +1,69 @@ +import { tokenService } from 'resources/token'; +import { userService } from 'resources/users'; + +import { rateLimitMiddleware } from 'middlewares'; +import { emailService } from 'services'; +import { securityUtil } from 'utils'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +import config from 'config'; + +import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; +import { signUpSchema } from '../account.schema'; +import { AppKoaContext, SignUpParams, Template, TokenType } from 'types'; + +export const schema = signUpSchema; + +const validator = createMiddleware(async (ctx, next) => { + const { email } = ctx.validatedData; + + const isUserExists = await userService.exists({ email }); + + ctx.assertClientError(!isUserExists, { + email: 'User with this email is already registered', + }); + + await next(); +}); + +export default createEndpoint({ + method: 'post', + path: '/sign-up', + schema, + middlewares: [isPublic, rateLimitMiddleware(), validator], + + async handler(ctx) { + const { firstName, lastName, email, password } = ctx.validatedData; + + const user = await userService.insertOne({ + email, + firstName, + lastName, + passwordHash: await securityUtil.hashPassword(password), + isEmailVerified: false, + }); + + const emailVerificationToken = await tokenService.createToken({ + userId: user._id, + type: TokenType.EMAIL_VERIFICATION, + expiresIn: EMAIL_VERIFICATION_TOKEN.EXPIRATION_SECONDS, + }); + + await emailService.sendTemplate({ + to: user.email, + subject: 'Please Confirm Your Email Address for Ship', + template: Template.VERIFY_EMAIL, + params: { + firstName: user.firstName, + href: `${config.API_URL}/account/verify-email?token=${emailVerificationToken}`, + }, + }); + + if (config.IS_DEV) { + return { emailVerificationToken }; + } + + ctx.status = 204; + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/update.ts b/template/apps/api/src/resources/account/endpoints/update.ts new file mode 100644 index 000000000..de1f7e19d --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/update.ts @@ -0,0 +1,62 @@ +import _ from 'lodash'; + +import { accountUtils } from 'resources/account'; +import { userService } from 'resources/users'; + +import { securityUtil } from 'utils'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +import { updateUserSchema } from '../../users/user.schema'; +import { AppKoaContext, UpdateUserParamsBackend, User } from 'types'; + +export const schema = updateUserSchema; + +interface ValidatedData extends UpdateUserParamsBackend { + passwordHash?: string; +} + +const validator = createMiddleware(async (ctx, next) => { + const { user } = ctx.state; + const { password } = ctx.validatedData; + + if (_.isEmpty(ctx.validatedData)) { + ctx.body = userService.getPublic(user); + return; + } + + if (password) { + ctx.validatedData.passwordHash = await securityUtil.hashPassword(password); + + delete ctx.validatedData.password; + } + + await next(); +}); + +export default createEndpoint({ + method: 'put', + path: '/', + schema, + middlewares: [validator], + + async handler(ctx) { + const typedCtx = ctx as AppKoaContext; + const { avatar } = typedCtx.validatedData; + const { user } = typedCtx.state; + + const nonEmptyValues = _.pickBy(typedCtx.validatedData, (value) => !_.isUndefined(value)); + const updateData: Partial = _.omit(nonEmptyValues, 'avatar'); + + if (avatar === '') { + await accountUtils.removeAvatar(user); + + updateData.avatarUrl = null; + } + + if (avatar) { + updateData.avatarUrl = await accountUtils.uploadAvatar(user, avatar); + } + + return userService.updateOne({ _id: user._id }, () => updateData).then(userService.getPublic); + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/verify-email.ts b/template/apps/api/src/resources/account/endpoints/verify-email.ts new file mode 100644 index 000000000..50a8e47ca --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/verify-email.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +import { tokenService } from 'resources/token'; +import { userService } from 'resources/users'; + +import { rateLimitMiddleware } from 'middlewares'; +import { authService, emailService } from 'services'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +import config from 'config'; + +import { AppKoaContext, Template, TokenType, User } from 'types'; + +export const schema = z.object({ + token: z.string().min(1, 'Token is required'), +}); + +interface ValidatedData extends z.infer { + user: User; +} + +const validator = createMiddleware(async (ctx, next) => { + const { token } = ctx.validatedData; + + const emailVerificationToken = await tokenService.validateToken(token, TokenType.EMAIL_VERIFICATION); + const user = await userService.findOne({ _id: emailVerificationToken?.userId }); + + if (!emailVerificationToken || !user) { + ctx.throwGlobalErrorWithRedirect('Token is invalid or expired.'); + return; + } + + ctx.validatedData.user = user; + await next(); +}); + +export default createEndpoint({ + method: 'get', + path: '/verify-email', + schema, + middlewares: [ + isPublic, + rateLimitMiddleware({ + limitDuration: 60 * 60, // 1 hour + requestsPerDuration: 10, + }), + validator, + ], + + async handler(ctx) { + try { + const { user } = (ctx as AppKoaContext).validatedData; + + await tokenService.invalidateUserTokens(user._id, TokenType.EMAIL_VERIFICATION); + + await userService.updateOne({ _id: user._id }, () => ({ isEmailVerified: true })); + + await authService.setAccessToken({ ctx, userId: user._id }); + + await emailService.sendTemplate({ + to: user.email, + subject: 'Welcome to Ship Community!', + template: Template.SIGN_UP_WELCOME, + params: { + firstName: user.firstName, + href: `${config.WEB_URL}/sign-in`, + }, + }); + + ctx.redirect(config.WEB_URL); + } catch (error) { + ctx.throwGlobalErrorWithRedirect('Failed to verify email. Please try again.'); + } + }, +}); diff --git a/template/apps/api/src/resources/account/endpoints/verify-reset-token.ts b/template/apps/api/src/resources/account/endpoints/verify-reset-token.ts new file mode 100644 index 000000000..f7ca39bc8 --- /dev/null +++ b/template/apps/api/src/resources/account/endpoints/verify-reset-token.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +import { tokenService } from 'resources/token'; +import { userService } from 'resources/users'; + +import { rateLimitMiddleware } from 'middlewares'; +import { isPublic } from 'routes/middlewares'; +import { createEndpoint } from 'routes/types'; + +import config from 'config'; + +import { TokenType } from 'types'; + +export const schema = z.object({ + token: z.string().min(1, 'Token is required'), +}); + +export default createEndpoint({ + method: 'get', + path: '/verify-reset-token', + schema, + middlewares: [isPublic, rateLimitMiddleware()], + + async handler(ctx) { + try { + const { token } = ctx.validatedData; + + const resetPasswordToken = await tokenService.validateToken(token, TokenType.RESET_PASSWORD); + + const user = await userService.findOne({ _id: resetPasswordToken?.userId }); + + if (!resetPasswordToken || !user) { + ctx.throwGlobalErrorWithRedirect('Token is invalid or expired.'); + return; + } + + const redirectUrl = new URL(`${config.WEB_URL}/reset-password`); + + redirectUrl.searchParams.set('token', token); + + ctx.redirect(redirectUrl.toString()); + } catch (error) { + ctx.throwGlobalErrorWithRedirect('Failed to verify reset password token. Please try again.'); + } + }, +}); diff --git a/template/apps/api/src/resources/account/index.ts b/template/apps/api/src/resources/account/index.ts index 054ff439a..7e98e42b1 100644 --- a/template/apps/api/src/resources/account/index.ts +++ b/template/apps/api/src/resources/account/index.ts @@ -1,4 +1,3 @@ -import accountRoutes from './account.routes'; import * as accountUtils from './account.utils'; -export { accountRoutes, accountUtils }; +export { accountUtils }; diff --git a/template/packages/schemas/src/common.schema.ts b/template/apps/api/src/resources/base.schema.ts similarity index 54% rename from template/packages/schemas/src/common.schema.ts rename to template/apps/api/src/resources/base.schema.ts index 57b7af3bc..4914e4798 100644 --- a/template/packages/schemas/src/common.schema.ts +++ b/template/apps/api/src/resources/base.schema.ts @@ -1,7 +1,12 @@ -import type { File as FormidableFile } from 'formidable'; import { z } from 'zod'; -import { ONE_MB_IN_BYTES, PASSWORD_RULES } from 'app-constants'; +export const dbSchema = z.object({ + _id: z.string(), + + createdOn: z.date().optional(), + updatedOn: z.date().optional(), + deletedOn: z.date().optional().nullable(), +}); export const paginationSchema = z.object({ page: z.coerce.number().default(1), @@ -16,6 +21,13 @@ export const paginationSchema = z.object({ .default({ createdOn: 'asc' }), }); +export const listResultSchema = (itemSchema: T) => + z.object({ + results: z.array(itemSchema), + pagesCount: z.number(), + count: z.number(), + }); + export const emailSchema = z .email() .min(1, 'Email is required') @@ -23,6 +35,12 @@ export const emailSchema = z .trim() .max(255, 'Email must be less than 255 characters.'); +const PASSWORD_RULES = { + MIN_LENGTH: 8, + MAX_LENGTH: 128, + REGEX: /^(?=.*[a-z])(?=.*\d).+$/i, +}; + export const passwordSchema = z .string() .min(1, 'Password is required') @@ -32,21 +50,3 @@ export const passwordSchema = z PASSWORD_RULES.REGEX, `The password must contain ${PASSWORD_RULES.MIN_LENGTH} or more characters with at least one letter (a-z) and one number (0-9).`, ); - -export const fileSchema = (fileSize: number, acceptedFileTypes: string[]) => - z - .union([z.custom(), z.custom()]) - .refine((file) => !!file, 'File is required.') - .refine((file) => file.size <= fileSize, `Max file size is ${Math.round(fileSize / ONE_MB_IN_BYTES)}MB.`) - .refine( - (file) => { - if (file) { - const mimetype = 'mimetype' in file ? file.mimetype : file?.type; - - return acceptedFileTypes.includes(mimetype as string); - } - - return false; - }, - `Only ${acceptedFileTypes.map((fileType) => `.${fileType.split('/')[1]}`).join(', ')} file formats are allowed.`, - ); diff --git a/template/apps/api/src/resources/token/token.schema.ts b/template/apps/api/src/resources/token/token.schema.ts new file mode 100644 index 000000000..2102526d9 --- /dev/null +++ b/template/apps/api/src/resources/token/token.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { dbSchema } from '../base.schema'; + +export enum TokenType { + ACCESS = 'access', + EMAIL_VERIFICATION = 'email-verification', + RESET_PASSWORD = 'reset-password', +} + +export const tokenSchema = dbSchema.extend({ + value: z.string(), + userId: z.string(), + type: z.enum([TokenType.ACCESS, TokenType.EMAIL_VERIFICATION, TokenType.RESET_PASSWORD]), + expiresOn: z.date(), +}); diff --git a/template/apps/api/src/resources/token/token.service.ts b/template/apps/api/src/resources/token/token.service.ts index ca6da2661..a8797850b 100644 --- a/template/apps/api/src/resources/token/token.service.ts +++ b/template/apps/api/src/resources/token/token.service.ts @@ -3,7 +3,7 @@ import { securityUtil } from 'utils'; import db from 'db'; import { DATABASE_DOCUMENTS } from 'app-constants'; -import { tokenSchema } from 'schemas'; +import { tokenSchema } from './token.schema'; import { Token, TokenType } from 'types'; const service = db.createService(DATABASE_DOCUMENTS.TOKENS, { diff --git a/template/apps/api/src/resources/user/actions/list.ts b/template/apps/api/src/resources/user/actions/list.ts deleted file mode 100644 index 15a90711f..000000000 --- a/template/apps/api/src/resources/user/actions/list.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { z } from 'zod'; - -import { userService } from 'resources/user'; - -import { validateMiddleware } from 'middlewares'; - -import { paginationSchema } from 'schemas'; -import { AppKoaContext, AppRouter, NestedKeys, User } from 'types'; - -const schema = paginationSchema.extend({ - filter: z - .object({ - createdOn: z - .object({ - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - }) - .optional(), - }) - .optional(), - sort: z - .object({ - firstName: z.enum(['asc', 'desc']).optional(), - lastName: z.enum(['asc', 'desc']).optional(), - createdOn: z.enum(['asc', 'desc']).default('asc'), - }) - .default({ createdOn: 'asc' }), -}); - -type ValidatedData = z.infer; - -async function handler(ctx: AppKoaContext) { - const { perPage, page, sort, searchValue, filter } = ctx.validatedData; - - const filterOptions = []; - - if (searchValue) { - const searchFields: NestedKeys[] = ['firstName', 'lastName', 'email']; - - filterOptions.push({ - $or: searchFields.map((field) => ({ [field]: { $regex: searchValue } })), - }); - } - - if (filter) { - const { createdOn, ...otherFilters } = filter; - - if (createdOn) { - const { startDate, endDate } = createdOn; - - filterOptions.push({ - createdOn: { - ...(startDate && { $gte: startDate }), - ...(endDate && { $lt: endDate }), - }, - }); - } - - Object.entries(otherFilters).forEach(([key, value]) => { - filterOptions.push({ [key]: value }); - }); - } - - const result = await userService.find( - { ...(filterOptions.length && { $and: filterOptions }) }, - { page, perPage }, - { sort }, - ); - - ctx.body = { ...result, results: result.results.map(userService.getPublic) }; -} - -export default (router: AppRouter) => { - router.get('/', validateMiddleware(schema), handler); -}; diff --git a/template/apps/api/src/resources/user/actions/remove.ts b/template/apps/api/src/resources/user/actions/remove.ts deleted file mode 100644 index 6d52511a3..000000000 --- a/template/apps/api/src/resources/user/actions/remove.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { userService } from 'resources/user'; - -import { AppKoaContext, AppRouter, Next } from 'types'; - -type ValidatedData = never; -interface Request { - params: { - id: string; - }; -} - -async function validator(ctx: AppKoaContext, next: Next) { - const isUserExists = await userService.exists({ _id: ctx.request.params.id }); - - ctx.assertError(isUserExists, 'User not found'); - - await next(); -} - -async function handler(ctx: AppKoaContext) { - await userService.deleteSoft({ _id: ctx.request.params.id }); - - ctx.status = 204; -} - -export default (router: AppRouter) => { - router.delete('/:id', validator, handler); -}; diff --git a/template/apps/api/src/resources/user/actions/update.ts b/template/apps/api/src/resources/user/actions/update.ts deleted file mode 100644 index 2ea6a0ba3..000000000 --- a/template/apps/api/src/resources/user/actions/update.ts +++ /dev/null @@ -1,44 +0,0 @@ -import _ from 'lodash'; -import { z } from 'zod'; - -import { userService } from 'resources/user'; - -import { validateMiddleware } from 'middlewares'; - -import { userSchema } from 'schemas'; -import { AppKoaContext, AppRouter, Next } from 'types'; - -const schema = userSchema.pick({ firstName: true, lastName: true, email: true }); - -type ValidatedData = z.infer; -interface Request { - params: { - id: string; - }; -} - -async function validator(ctx: AppKoaContext, next: Next) { - const { id } = ctx.request.params; - - ctx.assertError(id, 'User ID is required'); - - const isUserExists = await userService.exists({ _id: id }); - - ctx.assertError(isUserExists, 'User not found'); - - await next(); -} - -async function handler(ctx: AppKoaContext) { - const { id } = ctx.request.params; - - const nonEmptyValues = _.pickBy(ctx.validatedData, (value) => !_.isUndefined(value)); - - const updatedUser = await userService.updateOne({ _id: id }, () => nonEmptyValues); - - ctx.body = userService.getPublic(updatedUser); -} - -export default (router: AppRouter) => { - router.put('/:id', validateMiddleware(schema), validator, handler); -}; diff --git a/template/apps/api/src/resources/user/index.ts b/template/apps/api/src/resources/user/index.ts deleted file mode 100644 index 8b3654f96..000000000 --- a/template/apps/api/src/resources/user/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import './user.handler'; - -import userRoutes from './user.routes'; -import userService from './user.service'; - -export { userRoutes, userService }; diff --git a/template/apps/api/src/resources/user/user.routes.ts b/template/apps/api/src/resources/user/user.routes.ts deleted file mode 100644 index 36504e198..000000000 --- a/template/apps/api/src/resources/user/user.routes.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { routeUtil } from 'utils'; - -import list from './actions/list'; -import remove from './actions/remove'; -import update from './actions/update'; - -const publicRoutes = routeUtil.getRoutes([]); - -const privateRoutes = routeUtil.getRoutes([list]); - -const adminRoutes = routeUtil.getRoutes([list, update, remove]); - -export default { - publicRoutes, - privateRoutes, - adminRoutes, -}; diff --git a/template/apps/api/src/resources/users/endpoints/list.ts b/template/apps/api/src/resources/users/endpoints/list.ts new file mode 100644 index 000000000..bcc0c848f --- /dev/null +++ b/template/apps/api/src/resources/users/endpoints/list.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +import { userService } from 'resources/users'; + +import { createEndpoint } from 'routes/types'; + +import { paginationSchema } from '../../base.schema'; +import { userPublicSchema } from '../user.schema'; +import type { NestedKeys } from 'types'; + +export const schema = paginationSchema.extend({ + filter: z + .object({ + createdOn: z + .object({ + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + }) + .optional(), + }) + .optional(), + sort: z + .object({ + firstName: z.enum(['asc', 'desc']).optional(), + lastName: z.enum(['asc', 'desc']).optional(), + createdOn: z.enum(['asc', 'desc']).default('asc'), + }) + .default({ createdOn: 'asc' }), +}); + +export default createEndpoint({ + method: 'get', + path: '/', + schema, + + async handler(ctx) { + type User = z.infer; + const { perPage, page, sort, searchValue, filter } = ctx.validatedData; + + const filterOptions = []; + + if (searchValue) { + const searchFields: NestedKeys[] = ['firstName', 'lastName', 'email']; + + filterOptions.push({ + $or: searchFields.map((field) => ({ [field]: { $regex: searchValue } })), + }); + } + + if (filter) { + const { createdOn, ...otherFilters } = filter; + + if (createdOn) { + const { startDate, endDate } = createdOn; + + filterOptions.push({ + createdOn: { + ...(startDate && { $gte: startDate }), + ...(endDate && { $lt: endDate }), + }, + }); + } + + Object.entries(otherFilters).forEach(([key, value]) => { + filterOptions.push({ [key]: value }); + }); + } + + const result = await userService.find( + { ...(filterOptions.length && { $and: filterOptions }) }, + { page, perPage }, + { sort }, + ); + + return { ...result, results: result.results.map(userService.getPublic) }; + }, +}); diff --git a/template/apps/api/src/resources/users/endpoints/remove.ts b/template/apps/api/src/resources/users/endpoints/remove.ts new file mode 100644 index 000000000..c09f49588 --- /dev/null +++ b/template/apps/api/src/resources/users/endpoints/remove.ts @@ -0,0 +1,24 @@ +import { userService } from 'resources/users'; + +import { isAdmin } from 'routes/middlewares'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +const validator = createMiddleware(async (ctx, next) => { + const isUserExists = await userService.exists({ _id: ctx.params.id }); + + ctx.assertError(isUserExists, 'User not found'); + + await next(); +}); + +export default createEndpoint({ + method: 'delete', + path: '/:id', + middlewares: [isAdmin, validator], + + async handler(ctx) { + await userService.deleteSoft({ _id: ctx.request.params.id }); + + ctx.status = 204; + }, +}); diff --git a/template/apps/api/src/resources/users/endpoints/update.ts b/template/apps/api/src/resources/users/endpoints/update.ts new file mode 100644 index 000000000..41cb3c51b --- /dev/null +++ b/template/apps/api/src/resources/users/endpoints/update.ts @@ -0,0 +1,39 @@ +import _ from 'lodash'; + +import { userService } from 'resources/users'; + +import { isAdmin } from 'routes/middlewares'; +import { createEndpoint, createMiddleware } from 'routes/types'; + +import { userSchema } from '../user.schema'; + +export const schema = userSchema.pick({ firstName: true, lastName: true, email: true }); + +const validator = createMiddleware(async (ctx, next) => { + const { id } = ctx.params; + + ctx.assertError(id, 'User ID is required'); + + const isUserExists = await userService.exists({ _id: id }); + + ctx.assertError(isUserExists, 'User not found'); + + await next(); +}); + +export default createEndpoint({ + method: 'put', + path: '/:id', + schema, + middlewares: [isAdmin, validator], + + async handler(ctx) { + const { id } = ctx.request.params; + + const nonEmptyValues = _.pickBy(ctx.validatedData, (value) => !_.isUndefined(value)); + + const updatedUser = await userService.updateOne({ _id: id }, () => nonEmptyValues); + + return userService.getPublic(updatedUser); + }, +}); diff --git a/template/apps/api/src/resources/users/index.ts b/template/apps/api/src/resources/users/index.ts new file mode 100644 index 000000000..d7ea71a73 --- /dev/null +++ b/template/apps/api/src/resources/users/index.ts @@ -0,0 +1,5 @@ +import './user.handler'; + +import userService from './user.service'; + +export { userService }; diff --git a/template/apps/api/src/resources/user/user.handler.ts b/template/apps/api/src/resources/users/user.handler.ts similarity index 100% rename from template/apps/api/src/resources/user/user.handler.ts rename to template/apps/api/src/resources/users/user.handler.ts diff --git a/template/packages/schemas/src/user.schema.ts b/template/apps/api/src/resources/users/user.schema.ts similarity index 69% rename from template/packages/schemas/src/user.schema.ts rename to template/apps/api/src/resources/users/user.schema.ts index fa183ba0b..503f726c6 100644 --- a/template/packages/schemas/src/user.schema.ts +++ b/template/apps/api/src/resources/users/user.schema.ts @@ -1,9 +1,6 @@ import { z } from 'zod'; -import { USER_AVATAR } from 'app-constants'; - -import { emailSchema, fileSchema, passwordSchema } from './common.schema'; -import dbSchema from './db.schema'; +import { dbSchema, emailSchema, passwordSchema } from '../base.schema'; const oauthSchema = z.object({ google: z @@ -30,16 +27,20 @@ export const userSchema = dbSchema.extend({ lastRequest: z.date().optional(), }); +export const userPublicSchema = userSchema.omit({ + passwordHash: true, +}); + export const updateUserSchema = userSchema .pick({ firstName: true, lastName: true }) .extend({ password: z.union([ passwordSchema, - z.literal(''), // Allow empty string when password is unchanged on the front-end + z.literal(''), ]), avatar: z.union([ - fileSchema(USER_AVATAR.MAX_FILE_SIZE, USER_AVATAR.ACCEPTED_FILE_TYPES).nullable(), - z.literal(''), // Allow empty string to indicate removal - ]), + z.any(), // File validation handled at runtime (browser File or formidable File) + z.literal(''), + ]).nullable(), }) .partial(); diff --git a/template/apps/api/src/resources/user/user.service.ts b/template/apps/api/src/resources/users/user.service.ts similarity index 93% rename from template/apps/api/src/resources/user/user.service.ts rename to template/apps/api/src/resources/users/user.service.ts index f38346f71..ba7ced5f0 100644 --- a/template/apps/api/src/resources/user/user.service.ts +++ b/template/apps/api/src/resources/users/user.service.ts @@ -3,7 +3,7 @@ import _ from 'lodash'; import db from 'db'; import { DATABASE_DOCUMENTS } from 'app-constants'; -import { userSchema } from 'schemas'; +import { userSchema } from './user.schema'; import { User } from 'types'; const service = db.createService(DATABASE_DOCUMENTS.USERS, { diff --git a/template/apps/api/src/routes/admin.routes.ts b/template/apps/api/src/routes/admin.routes.ts deleted file mode 100644 index 0408ba141..000000000 --- a/template/apps/api/src/routes/admin.routes.ts +++ /dev/null @@ -1,12 +0,0 @@ -import compose from 'koa-compose'; -import mount from 'koa-mount'; - -import { userRoutes } from 'resources/user'; - -import { AppKoa } from 'types'; - -import adminAuth from './middlewares/admin-auth.middleware'; - -export default (app: AppKoa) => { - app.use(mount('/admin/users', compose([adminAuth, userRoutes.adminRoutes]))); -}; diff --git a/template/apps/api/src/routes/index.ts b/template/apps/api/src/routes/index.ts index 8f71b12df..91ffd40e4 100644 --- a/template/apps/api/src/routes/index.ts +++ b/template/apps/api/src/routes/index.ts @@ -1,24 +1,27 @@ -import { AppKoa } from 'types'; +import { AppKoa, AppRouter } from 'types'; import attachCustomErrors from './middlewares/attach-custom-errors.middleware'; import attachCustomProperties from './middlewares/attach-custom-properties.middleware'; import extractTokens from './middlewares/extract-tokens.middleware'; import routeErrorHandler from './middlewares/route-error-handler.middleware'; import tryToAttachUser from './middlewares/try-to-attach-user.middleware'; -import adminRoutes from './admin.routes'; -import privateRoutes from './private.routes'; -import publicRoutes from './public.routes'; +import { registerRoutes } from './routes'; -const defineRoutes = (app: AppKoa) => { +const healthCheckRouter = new AppRouter(); +healthCheckRouter.get('/health', (ctx) => { + ctx.status = 200; +}); + +const defineRoutes = async (app: AppKoa) => { app.use(attachCustomErrors); app.use(attachCustomProperties); app.use(routeErrorHandler); app.use(extractTokens); app.use(tryToAttachUser); - publicRoutes(app); - privateRoutes(app); - adminRoutes(app); + app.use(healthCheckRouter.routes()); + + await registerRoutes(app, AppRouter); }; export default defineRoutes; diff --git a/template/apps/api/src/routes/middlewares/admin-auth.middleware.ts b/template/apps/api/src/routes/middlewares/admin-auth.middleware.ts deleted file mode 100644 index f4f7b895e..000000000 --- a/template/apps/api/src/routes/middlewares/admin-auth.middleware.ts +++ /dev/null @@ -1,17 +0,0 @@ -import config from 'config'; - -import { AppKoaContext, Next } from 'types'; - -const adminAuth = (ctx: AppKoaContext, next: Next) => { - const adminKey = ctx.header['x-admin-key']; - - if (config.ADMIN_KEY && config.ADMIN_KEY === adminKey) { - return next(); - } - - ctx.status = 401; - - return null; -}; - -export default adminAuth; diff --git a/template/apps/api/src/routes/middlewares/index.ts b/template/apps/api/src/routes/middlewares/index.ts new file mode 100644 index 000000000..6f6098711 --- /dev/null +++ b/template/apps/api/src/routes/middlewares/index.ts @@ -0,0 +1,2 @@ +export { default as isAdmin } from './is-admin.middleware'; +export { default as isPublic } from './is-public.middleware'; diff --git a/template/apps/api/src/routes/middlewares/is-admin.middleware.ts b/template/apps/api/src/routes/middlewares/is-admin.middleware.ts new file mode 100644 index 000000000..83f4a7325 --- /dev/null +++ b/template/apps/api/src/routes/middlewares/is-admin.middleware.ts @@ -0,0 +1,22 @@ +import config from 'config'; + +import { createMiddleware } from 'routes/types'; + +import { AppKoaContextState } from 'types'; + +interface AdminState extends AppKoaContextState { + isAdmin: true; +} + +const isAdmin = createMiddleware(async (ctx, next) => { + const adminKey = ctx.header['x-admin-key']; + + if (config.ADMIN_KEY && config.ADMIN_KEY === adminKey) { + (ctx.state as AdminState).isAdmin = true; + return next(); + } + + ctx.status = 401; +}); + +export default isAdmin; diff --git a/template/apps/api/src/routes/middlewares/is-public.middleware.ts b/template/apps/api/src/routes/middlewares/is-public.middleware.ts new file mode 100644 index 000000000..2152768a2 --- /dev/null +++ b/template/apps/api/src/routes/middlewares/is-public.middleware.ts @@ -0,0 +1,5 @@ +import { createMiddleware } from 'routes/types'; + +export const isPublic = createMiddleware(async (_ctx, next) => next()); + +export default isPublic; diff --git a/template/apps/api/src/routes/middlewares/route-error-handler.middleware.ts b/template/apps/api/src/routes/middlewares/route-error-handler.middleware.ts index d49c42569..878828d9e 100644 --- a/template/apps/api/src/routes/middlewares/route-error-handler.middleware.ts +++ b/template/apps/api/src/routes/middlewares/route-error-handler.middleware.ts @@ -1,4 +1,4 @@ -import { userService } from 'resources/user'; +import { userService } from 'resources/users'; import config from 'config'; diff --git a/template/apps/api/src/routes/middlewares/try-to-attach-user.middleware.ts b/template/apps/api/src/routes/middlewares/try-to-attach-user.middleware.ts index 5f2d3ef50..92df5f13a 100644 --- a/template/apps/api/src/routes/middlewares/try-to-attach-user.middleware.ts +++ b/template/apps/api/src/routes/middlewares/try-to-attach-user.middleware.ts @@ -1,4 +1,4 @@ -import { userService } from 'resources/user'; +import { userService } from 'resources/users'; import { authService } from 'services'; diff --git a/template/apps/api/src/routes/private.routes.ts b/template/apps/api/src/routes/private.routes.ts deleted file mode 100644 index 39d09c5d6..000000000 --- a/template/apps/api/src/routes/private.routes.ts +++ /dev/null @@ -1,14 +0,0 @@ -import compose from 'koa-compose'; -import mount from 'koa-mount'; - -import { accountRoutes } from 'resources/account'; -import { userRoutes } from 'resources/user'; - -import { AppKoa } from 'types'; - -import auth from './middlewares/auth.middleware'; - -export default (app: AppKoa) => { - app.use(mount('/account', compose([auth, accountRoutes.privateRoutes]))); - app.use(mount('/users', compose([auth, userRoutes.privateRoutes]))); -}; diff --git a/template/apps/api/src/routes/public.routes.ts b/template/apps/api/src/routes/public.routes.ts deleted file mode 100644 index e8639188d..000000000 --- a/template/apps/api/src/routes/public.routes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import mount from 'koa-mount'; - -import { accountRoutes } from 'resources/account'; - -import { AppKoa, AppRouter } from 'types'; - -const healthCheckRouter = new AppRouter(); -healthCheckRouter.get('/health', (ctx) => { - ctx.status = 200; -}); - -export default (app: AppKoa) => { - app.use(healthCheckRouter.routes()); - app.use(mount('/account', accountRoutes.publicRoutes)); -}; diff --git a/template/apps/api/src/routes/routes.ts b/template/apps/api/src/routes/routes.ts new file mode 100644 index 000000000..0fd7158f6 --- /dev/null +++ b/template/apps/api/src/routes/routes.ts @@ -0,0 +1,63 @@ +import mount from 'koa-mount'; + +import { validateMiddleware } from 'middlewares'; +import { getResourceEndpoints } from 'utils/get-resource-endpoints.util'; +import { getResources } from 'utils/get-resources.util'; + +import logger from 'logger'; + +import type { AppKoa, AppRouter, AppRouterMiddleware } from 'types'; + +import auth from './middlewares/auth.middleware'; +import { isPublic } from 'routes/middlewares'; +import type { EndpointDefinition } from './types'; + +const registerEndpoint = (router: AppRouter, resourceName: string, endpoint: EndpointDefinition): void => { + const { method, path } = endpoint.endpoint; + const middlewares: AppRouterMiddleware[] = []; + + // Check if isPublic middleware is present (comparing function references) + const hasIsPublic = endpoint.middlewares?.some((m) => m === isPublic); + if (!hasIsPublic) { + middlewares.push(auth as AppRouterMiddleware); + } + + if (endpoint.middlewares?.length) { + middlewares.push(...(endpoint.middlewares as AppRouterMiddleware[])); + } + + if (endpoint.schema) { + middlewares.push(validateMiddleware(endpoint.schema) as AppRouterMiddleware); + } + + middlewares.push(endpoint.handler as AppRouterMiddleware); + + const fullPath = path.startsWith('/') ? path : `/${path}`; + router[method](fullPath, ...middlewares); + + const methodLabel = method.toUpperCase().padEnd(6); + const routePath = `/${resourceName}${fullPath === '/' ? '' : fullPath}`; + + logger.info(`[routes] ${methodLabel} ${routePath}`); +}; + +export const registerRoutes = async (app: AppKoa, AppRouterClass: typeof AppRouter): Promise => { + const resources = getResources(); + + for (const resourceName of resources) { + const endpoints = await getResourceEndpoints(resourceName); + + if (endpoints.length === 0) continue; + + const router = new AppRouterClass(); + + for (const endpoint of endpoints) { + registerEndpoint(router, resourceName, endpoint); + } + + app.use(mount(`/${resourceName}`, router.routes())); + app.use(mount(`/${resourceName}`, router.allowedMethods())); + } +}; + +export default registerRoutes; diff --git a/template/apps/api/src/routes/types.ts b/template/apps/api/src/routes/types.ts new file mode 100644 index 000000000..60e708801 --- /dev/null +++ b/template/apps/api/src/routes/types.ts @@ -0,0 +1,108 @@ +import type { Middleware } from 'koa'; +import type { z, ZodSchema, ZodType } from 'zod'; + +import type { AppKoaContext, Next } from 'types'; + +export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export interface EndpointConfig { + method: HttpMethod; + path: string; +} + +type RouteMiddleware = Middleware; + +export interface EndpointDefinition { + endpoint: EndpointConfig; + handler: RouteMiddleware; + schema?: ZodSchema; + middlewares?: RouteMiddleware[]; +} + +// Path parameter extraction +type ExtractPathParams = T extends `${infer _Start}:${infer Param}/${infer Rest}` + ? { [K in Param | keyof ExtractPathParams<`/${Rest}`>]: string } + : T extends `${infer _Start}:${infer Param}` + ? { [K in Param]: string } + : Record; + +// Request type with params +type RequestWithParams = ExtractPathParams extends Record + ? object + : { params: ExtractPathParams }; + +// Middleware that declares what it adds to state +export interface TypedMiddleware { + (ctx: AppKoaContext, next: Next): Promise; + _state?: TState; // phantom type, never used at runtime +} + +// Helper to define middleware with state type +// Accepts any context type for flexibility in validators +// eslint-disable-next-line ts/no-explicit-any +export function createMiddleware( + fn: (ctx: any, next: Next) => Promise, +): TypedMiddleware { + return fn as TypedMiddleware; +} + +// Extract state type from middleware +type ExtractState = T extends TypedMiddleware ? S : object; + +// Merge states from array of middlewares +type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never; + +type MergedState[]> = UnionToIntersection>; + +export interface EndpointOptions< + TPath extends string, + TSchema extends ZodType = ZodType, + TMiddlewares extends TypedMiddleware[] = [], + TResponse = void, +> { + method: HttpMethod; + path: TPath; + schema?: TSchema; + middlewares?: [...TMiddlewares]; + handler: ( + ctx: AppKoaContext, RequestWithParams> & { + state: MergedState; + }, + ) => Promise; +} + +// Result type that carries schema info for client-side inference +export interface EndpointResult< + TSchema extends ZodType = ZodType, +> { + endpoint: EndpointConfig; + handler: RouteMiddleware; + schema?: TSchema; + middlewares?: RouteMiddleware[]; +} + +export function createEndpoint< + TPath extends string, + TSchema extends ZodType = ZodType, + TMiddlewares extends TypedMiddleware[] = [], + TResponse = void, +>( + options: EndpointOptions, +): EndpointResult { + const wrappedHandler = async (ctx: AppKoaContext) => { + const result = await options.handler(ctx as never); + if (result !== undefined) { + ctx.body = result; + } + }; + + return { + endpoint: { + method: options.method, + path: options.path, + } as EndpointConfig, + handler: wrappedHandler as unknown as RouteMiddleware, + schema: options.schema, + middlewares: options.middlewares as unknown as RouteMiddleware[] | undefined, + }; +} diff --git a/template/apps/api/src/services/auth/auth.service.ts b/template/apps/api/src/services/auth/auth.service.ts index 4f8038670..4c8c0a1cf 100644 --- a/template/apps/api/src/services/auth/auth.service.ts +++ b/template/apps/api/src/services/auth/auth.service.ts @@ -1,5 +1,5 @@ import { tokenService } from 'resources/token'; -import { userService } from 'resources/user'; +import { userService } from 'resources/users'; import { cookieUtil } from 'utils'; diff --git a/template/apps/api/src/services/google/google.service.ts b/template/apps/api/src/services/google/google.service.ts index d8ec2391d..db2e97b10 100644 --- a/template/apps/api/src/services/google/google.service.ts +++ b/template/apps/api/src/services/google/google.service.ts @@ -9,7 +9,7 @@ import { } from 'arctic'; import { z } from 'zod'; -import { userService } from 'resources/user'; +import { userService } from 'resources/users'; import config from 'config'; diff --git a/template/apps/api/src/types.ts b/template/apps/api/src/types.ts index 98fd8c406..420f030e4 100644 --- a/template/apps/api/src/types.ts +++ b/template/apps/api/src/types.ts @@ -1,9 +1,69 @@ import Router from '@koa/router'; -import { User } from 'app-types'; import Koa, { Next, ParameterizedContext, Request } from 'koa'; import { Template } from 'mailer'; +import { z } from 'zod'; -export * from 'app-types'; +import { + forgotPasswordSchema, + resendEmailSchema, + resetPasswordSchema, + signInSchema, + signUpSchema, +} from 'resources/account/account.schema'; +import { tokenSchema, TokenType } from 'resources/token/token.schema'; +import { updateUserSchema, userSchema } from 'resources/users/user.schema'; + +// Entity types inferred from schemas +export type User = z.infer; +export type Token = z.infer; + +// Request param types inferred from schemas +export type SignInParams = z.infer; +export type SignUpParams = z.infer; +export type ResendEmailParams = z.infer; +export type ForgotPasswordParams = z.infer; +export type ResetPasswordParams = z.infer; +export type UpdateUserParams = z.infer; + +// File types +export interface BackendFile { + filepath: string; + mimetype?: string | null; + originalFilename?: string | null; + newFilename: string; + size: number; +} + +// User update variants +export interface UpdateUserParamsBackend extends Omit { + avatar?: BackendFile | ''; +} + +// Utility types +type Path = T extends object + ? { + [K in keyof T]: K extends string + ? T[K] extends (...args: never[]) => unknown + ? never + : `${K}` | (Path extends infer R ? (R extends never ? never : `${K}.${R & string}`) : never) + : never; + }[keyof T] + : never; + +export type NestedKeys = Path>; + +type CamelCase = S extends `${infer P1}_${infer P2}${infer P3}` + ? `${Lowercase}${Uppercase}${CamelCase}` + : S extends `${infer P1}${infer P2}` + ? `${Lowercase}${CamelCase}` + : Lowercase; + +export type ToCamelCase = { + [K in keyof T as CamelCase]: T[K] extends object ? ToCamelCase : T[K]; +}; + +// Re-export enums +export { TokenType }; export interface AppKoaContextState { user: User; diff --git a/template/apps/api/src/utils/get-resource-endpoints.util.ts b/template/apps/api/src/utils/get-resource-endpoints.util.ts new file mode 100644 index 000000000..86571f86d --- /dev/null +++ b/template/apps/api/src/utils/get-resource-endpoints.util.ts @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { EndpointDefinition } from 'routes/types'; + +import { getResourcePath } from './get-resources.util'; + +export const getResourceEndpoints = async (resourceName: string): Promise => { + const resourcePath = getResourcePath(resourceName); + const endpointsPath = path.join(resourcePath, 'endpoints'); + + if (!fs.existsSync(endpointsPath)) { + return []; + } + + const endpointFiles = fs.readdirSync(endpointsPath).filter((file) => file.endsWith('.ts') && !file.endsWith('.d.ts')); + + const endpoints: EndpointDefinition[] = []; + + for (const file of endpointFiles) { + const filePath = path.join(endpointsPath, file); + const module = await import(filePath); + const def = module.default; + + if (def?.endpoint && def?.handler) { + endpoints.push({ + endpoint: def.endpoint, + handler: def.handler, + schema: module.schema, + middlewares: def.middlewares || [], + }); + } + } + + return endpoints; +}; + +export default getResourceEndpoints; diff --git a/template/apps/api/src/utils/get-resources.util.ts b/template/apps/api/src/utils/get-resources.util.ts new file mode 100644 index 000000000..42563b4d6 --- /dev/null +++ b/template/apps/api/src/utils/get-resources.util.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const RESOURCES_PATH = path.join(__dirname, '../resources'); +const IGNORE_LIST = ['token']; + +export const getResources = (): string[] => { + const resourceDirs = fs + .readdirSync(RESOURCES_PATH, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .filter((dirent) => !IGNORE_LIST.includes(dirent.name)) + .map((dirent) => dirent.name); + + return resourceDirs; +}; + +export const getResourcePath = (resourceName: string): string => { + return path.join(RESOURCES_PATH, resourceName); +}; diff --git a/template/apps/api/src/utils/index.ts b/template/apps/api/src/utils/index.ts index e9f81a949..c398e324e 100644 --- a/template/apps/api/src/utils/index.ts +++ b/template/apps/api/src/utils/index.ts @@ -1,9 +1,10 @@ import * as caseUtil from './case.util'; import configUtil from './config.util'; import cookieUtil from './cookie.util'; +import { getResourceEndpoints } from './get-resource-endpoints.util'; +import { getResources } from './get-resources.util'; import objectUtil from './object.util'; import promiseUtil from './promise.util'; -import routeUtil from './routes.util'; import * as securityUtil from './security.util'; -export { caseUtil, configUtil, cookieUtil, objectUtil, promiseUtil, routeUtil, securityUtil }; +export { caseUtil, configUtil, cookieUtil, getResourceEndpoints, getResources, objectUtil, promiseUtil, securityUtil }; diff --git a/template/apps/api/src/utils/routes.util.ts b/template/apps/api/src/utils/routes.util.ts deleted file mode 100644 index edf7f292d..000000000 --- a/template/apps/api/src/utils/routes.util.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AppRouter, AppRouterMiddleware } from 'types'; - -export type RegisterRouteFunc = (router: AppRouter) => void; - -const getRoutes = (routeFunctions: RegisterRouteFunc[]): AppRouterMiddleware => { - const router = new AppRouter(); - - routeFunctions.forEach((func: RegisterRouteFunc) => { - func(router); - }); - - return router.routes(); -}; - -export default { - getRoutes, -}; diff --git a/template/apps/web/package.json b/template/apps/web/package.json index 7e23b8954..1323f6b19 100644 --- a/template/apps/web/package.json +++ b/template/apps/web/package.json @@ -26,8 +26,6 @@ "@tabler/icons-react": "3.10.0", "@tanstack/react-query": "5.74.4", "@tanstack/react-table": "8.19.2", - "app-constants": "workspace:*", - "app-types": "workspace:*", "axios": "1.12.2", "clsx": "2.1.1", "dayjs": "1.11.10", @@ -40,7 +38,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "7.57.0", - "schemas": "workspace:*", + "shared": "workspace:*", "socket.io-client": "4.7.5", "zod": "catalog:" }, diff --git a/template/apps/web/src/hooks/index.ts b/template/apps/web/src/hooks/index.ts new file mode 100644 index 000000000..8ef5ef4f0 --- /dev/null +++ b/template/apps/web/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-api.hook'; diff --git a/template/apps/web/src/hooks/use-api.hook.ts b/template/apps/web/src/hooks/use-api.hook.ts new file mode 100644 index 000000000..a7cfbabfe --- /dev/null +++ b/template/apps/web/src/hooks/use-api.hook.ts @@ -0,0 +1,89 @@ +import { useMutation, UseMutationOptions, useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm, UseFormProps, UseFormReturn } from 'react-hook-form'; +import { z, ZodType } from 'zod'; + +import { ApiError } from 'shared'; + +type InferParams = T extends { schema: ZodType } ? P : Record; +type InferPathParams = T extends { call: (params: infer _P, options: infer O) => unknown } + ? O extends { pathParams: infer PP } + ? PP + : undefined + : undefined; +type InferResponse = T extends { call: (...args: never[]) => Promise } + ? R + : unknown; + +type RequestOptions = TPathParams extends undefined + ? { pathParams?: never; headers?: Record } + : { pathParams: TPathParams; headers?: Record }; + +type UseApiQueryOptions = Omit, 'queryKey' | 'queryFn'>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ApiEndpoint = { + schema: ZodType | undefined; + path: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + call: (...args: any[]) => Promise; +}; + +export function useApiQuery( + endpoint: TEndpoint, + params?: InferParams, + options?: RequestOptions> & UseApiQueryOptions>, +): ReturnType>> { + const { pathParams, headers, ...queryOptions } = (options ?? {}) as { pathParams?: unknown; headers?: Record } & + UseApiQueryOptions>; + + const queryKey = [endpoint.path, params, pathParams].filter((v) => v !== undefined && v !== null); + + const callOptions = pathParams || headers ? { pathParams, headers } : undefined; + + return useQuery({ + queryKey, + queryFn: () => (callOptions ? endpoint.call(params ?? {}, callOptions) : endpoint.call(params ?? {})), + ...queryOptions, + }) as ReturnType>>; +} + +type UseApiMutationOptions = Omit, 'mutationFn'>; + +export function useApiMutation( + endpoint: TEndpoint, + options?: RequestOptions> & + UseApiMutationOptions, InferParams>, +): ReturnType, ApiError, InferParams>> { + const { pathParams, headers, ...mutationOptions } = (options ?? {}) as { pathParams?: unknown; headers?: Record } & + UseApiMutationOptions, InferParams>; + + const callOptions = pathParams || headers ? { pathParams, headers } : undefined; + + return useMutation({ + mutationFn: (params: InferParams) => + (callOptions ? endpoint.call(params, callOptions) : endpoint.call(params)) as Promise>, + ...mutationOptions, + }) as ReturnType, ApiError, InferParams>>; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type FormEndpoint = { + schema: ZodType>; + path: string; +}; + +type UseApiFormOptions> = Omit, 'resolver'>; + +export function useApiForm( + endpoint: TEndpoint, + options?: UseApiFormOptions>, +): UseFormReturn> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return useForm({ + resolver: zodResolver(endpoint.schema as any), + ...options, + }) as UseFormReturn>; +} + +export { useApiQuery as default }; diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx index af426fc4b..ab32a6b0e 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx @@ -1,12 +1,14 @@ import { FC } from 'react'; import { Avatar, UnstyledButton, UnstyledButtonProps, useMantineTheme } from '@mantine/core'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; + +import { useApiQuery } from 'hooks/use-api.hook'; const MenuToggle: FC = (props) => { const { primaryColor } = useMantineTheme(); - const { data: account } = accountApi.useGet(); + const { data: account } = useApiQuery(apiClient.account.get); if (!account) return null; diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx index 1c33e37bd..4c64260b9 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx @@ -3,14 +3,22 @@ import Link from 'next/link'; import { Menu } from '@mantine/core'; import { IconLogout, IconUserCircle } from '@tabler/icons-react'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; + +import { useApiMutation } from 'hooks/use-api.hook'; + +import queryClient from 'query-client'; import { RoutePath } from 'routes'; import MenuToggle from '../MenuToggle'; const UserMenu: FC = () => { - const { mutate: signOut } = accountApi.useSignOut(); + const { mutate: signOut } = useApiMutation(apiClient.account.signOut, { + onSuccess: () => { + queryClient.setQueryData([apiClient.account.get.path], null); + }, + }); return ( @@ -23,7 +31,7 @@ const UserMenu: FC = () => { Profile settings - signOut()} leftSection={}> + signOut({})} leftSection={}> Log out diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx index 40aaee415..5aa29f36f 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx @@ -2,7 +2,9 @@ import { FC, memo } from 'react'; import Link from 'next/link'; import { Anchor, AppShell, Group } from '@mantine/core'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; + +import { useApiQuery } from 'hooks/use-api.hook'; import { LogoImage } from 'public/images'; @@ -11,7 +13,7 @@ import { RoutePath } from 'routes'; import UserMenu from './components/UserMenu'; const Header: FC = () => { - const { data: account } = accountApi.useGet(); + const { data: account } = useApiQuery(apiClient.account.get); if (!account) return null; diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/index.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/index.tsx index 64410a466..c8e43e428 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/index.tsx @@ -1,7 +1,9 @@ import { FC, ReactElement } from 'react'; import { AppShell, Stack } from '@mantine/core'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; + +import { useApiQuery } from 'hooks/use-api.hook'; import Header from './Header'; @@ -10,7 +12,7 @@ interface MainLayoutProps { } const MainLayout: FC = ({ children }) => { - const { data: account } = accountApi.useGet(); + const { data: account } = useApiQuery(apiClient.account.get); if (!account) return null; diff --git a/template/apps/web/src/pages/_app/PageConfig/index.tsx b/template/apps/web/src/pages/_app/PageConfig/index.tsx index dd4f72188..96e240f7e 100644 --- a/template/apps/web/src/pages/_app/PageConfig/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/index.tsx @@ -1,9 +1,11 @@ -import 'resources/user/user.handlers'; +import 'services/socket-handlers'; import { FC, Fragment, ReactElement, useEffect } from 'react'; import { useRouter } from 'next/router'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; + +import { useApiQuery } from 'hooks/use-api.hook'; import { analyticsService } from 'services'; @@ -31,7 +33,7 @@ interface PageConfigProps { const PageConfig: FC = ({ children }) => { const { route, push } = useRouter(); - const { data: account, isLoading: isAccountLoading, dataUpdatedAt } = accountApi.useGet(); + const { data: account, isLoading: isAccountLoading, dataUpdatedAt } = useApiQuery(apiClient.account.get); useEffect(() => { if (!dataUpdatedAt || !config.MIXPANEL_API_KEY) return; diff --git a/template/apps/web/src/pages/forgot-password/index.page.tsx b/template/apps/web/src/pages/forgot-password/index.page.tsx index 2057b0a3c..5c0534207 100644 --- a/template/apps/web/src/pages/forgot-password/index.page.tsx +++ b/template/apps/web/src/pages/forgot-password/index.page.tsx @@ -3,18 +3,13 @@ import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { Anchor, Button, Group, Stack, Text, TextInput, Title } from '@mantine/core'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; - -import { accountApi } from 'resources/account'; +import { useApiForm, useApiMutation } from 'hooks'; +import { apiClient } from 'services/api-client.service'; import { handleApiError } from 'utils'; import { RoutePath } from 'routes'; -import { forgotPasswordSchema } from 'schemas'; -import { ForgotPasswordParams } from 'types'; - const ForgotPassword: NextPage = () => { const router = useRouter(); @@ -22,7 +17,7 @@ const ForgotPassword: NextPage = () => { mutate: forgotPassword, isPending: isForgotPasswordPending, isSuccess: isForgotPasswordSuccess, - } = accountApi.useForgotPassword(); + } = useApiMutation(apiClient.account.forgotPassword); const { register, @@ -30,16 +25,15 @@ const ForgotPassword: NextPage = () => { setError, watch, formState: { errors }, - } = useForm({ - resolver: zodResolver(forgotPasswordSchema), - }); + } = useApiForm(apiClient.account.forgotPassword); const email = watch('email'); - const onSubmit = (data: ForgotPasswordParams) => + const onSubmit = handleSubmit((data) => forgotPassword(data, { onError: (e) => handleApiError(e, setError), - }); + }), + ); if (isForgotPasswordSuccess && email) { return ( @@ -75,7 +69,7 @@ const ForgotPassword: NextPage = () => { Please enter your email and we'll send a link to reset your password. -
+ >[1]; + setParams: ReturnType>[1]; } const Filters: FC = ({ setParams }) => { @@ -32,7 +32,7 @@ const Filters: FC = ({ setParams }) => { const handleSort = (value: string | null) => { setSortBy(value); - setParams((old) => set(old, 'sort.createdOn', value === 'newest' ? 'desc' : 'asc')); + setParams((old: UsersListParams) => set(old, 'sort.createdOn', value === 'newest' ? 'desc' : 'asc')); }; const handleFilter = ([startDate, endDate]: DatesRangeValue) => { @@ -45,7 +45,10 @@ const Filters: FC = ({ setParams }) => { if (endDate) { setParams({ filter: { - createdOn: { startDate, endDate }, + createdOn: { + startDate: startDate instanceof Date ? startDate : undefined, + endDate: endDate instanceof Date ? endDate : undefined + }, }, }); } diff --git a/template/apps/web/src/pages/home/constants.ts b/template/apps/web/src/pages/home/constants.ts index 81f740fe6..366070e18 100644 --- a/template/apps/web/src/pages/home/constants.ts +++ b/template/apps/web/src/pages/home/constants.ts @@ -1,14 +1,16 @@ import { ColumnDef } from '@tanstack/react-table'; -import { UserListParams, UserListSortFields } from 'resources/user'; +import { UsersListParams } from 'shared'; import { User } from 'types'; export const DEFAULT_PAGE = 1; export const PER_PAGE = 10; + +type UserListSortFields = 'createdOn' | 'firstName' | 'lastName'; export const EXTERNAL_SORT_FIELDS: Array = ['createdOn']; -export const DEFAULT_PARAMS: UserListParams = { +export const DEFAULT_PARAMS: UsersListParams = { page: DEFAULT_PAGE, searchValue: '', perPage: PER_PAGE, diff --git a/template/apps/web/src/pages/home/index.tsx b/template/apps/web/src/pages/home/index.tsx index e27f86f5b..714d6cc7a 100644 --- a/template/apps/web/src/pages/home/index.tsx +++ b/template/apps/web/src/pages/home/index.tsx @@ -4,30 +4,32 @@ import { Stack, Title } from '@mantine/core'; import { useSetState } from '@mantine/hooks'; import { showNotification } from '@mantine/notifications'; import { SortDirection } from '@tanstack/react-table'; +import { useApiQuery } from 'hooks'; import { pick } from 'lodash'; - -import { userApi, UserListParams } from 'resources/user'; +import { UsersListParams, UsersListResponse } from 'shared'; import { Table } from 'components'; -import { User } from 'types'; +import { apiClient } from 'services'; import Filters from './components/Filters'; import { COLUMNS, DEFAULT_PAGE, DEFAULT_PARAMS, EXTERNAL_SORT_FIELDS, PER_PAGE } from './constants'; const Home: NextPage = () => { - const [params, setParams] = useSetState(DEFAULT_PARAMS); - - const { data: users, isLoading: isUserListLoading } = userApi.useList(params); + const [params, setParams] = useSetState(DEFAULT_PARAMS); + const { data: users, isLoading: isUserListLoading } = useApiQuery(apiClient.users.list, params); const onSortingChange = (sort: Record) => { - setParams((prev) => { + setParams((prev: UsersListParams) => { const combinedSort = { ...pick(prev.sort, EXTERNAL_SORT_FIELDS), ...sort }; return { sort: combinedSort }; }); }; + // Get user type from the response + type User = UsersListResponse['results'][number]; + const onRowClick = (user: User) => { showNotification({ title: 'Success', diff --git a/template/apps/web/src/pages/profile/components/AvatarUpload/index.tsx b/template/apps/web/src/pages/profile/components/AvatarUpload/index.tsx index 108c01f47..2b72ad9f2 100644 --- a/template/apps/web/src/pages/profile/components/AvatarUpload/index.tsx +++ b/template/apps/web/src/pages/profile/components/AvatarUpload/index.tsx @@ -3,26 +3,31 @@ import { Box, Button, Center, Group, Image, Stack, Text, Title } from '@mantine/ import { Dropzone } from '@mantine/dropzone'; import { IconPencil, IconPlus } from '@tabler/icons-react'; import cx from 'clsx'; +import { z } from 'zod'; import { Controller, useFormContext } from 'react-hook-form'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; + +import { useApiQuery } from 'hooks/use-api.hook'; import { handleDropzoneError } from 'utils'; -import { USER_AVATAR } from 'app-constants'; -import { UpdateUserParamsFrontend } from 'types'; +import { USER_AVATAR } from 'shared'; +import { updateUserSchema } from 'shared'; import classes from './index.module.css'; +type UpdateUserFormData = z.infer; + const AvatarUpload = () => { - const { data: account } = accountApi.useGet(); + const { data: account } = useApiQuery(apiClient.account.get); const { control, watch, setValue, formState: { errors }, - } = useFormContext(); + } = useFormContext(); const avatarValue = watch('avatar'); const avatarError = errors.avatar?.message; @@ -30,7 +35,7 @@ const AvatarUpload = () => { let imageSrc: string | null | undefined = account?.avatarUrl; if (typeof avatarValue === 'string') imageSrc = ''; - else if (avatarValue) imageSrc = URL.createObjectURL(avatarValue); + else if (avatarValue) imageSrc = URL.createObjectURL(avatarValue as Blob); return ( @@ -101,7 +106,7 @@ const AvatarUpload = () => { - {avatarError && {avatarError}} + {avatarError && {String(avatarError)}} ); }; diff --git a/template/apps/web/src/pages/profile/index.page.tsx b/template/apps/web/src/pages/profile/index.page.tsx index 9fa472e86..a9867bc5c 100644 --- a/template/apps/web/src/pages/profile/index.page.tsx +++ b/template/apps/web/src/pages/profile/index.page.tsx @@ -2,23 +2,21 @@ import { NextPage } from 'next'; import Head from 'next/head'; import { Button, PasswordInput, Stack, TextInput, Title } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; -import { zodResolver } from '@hookform/resolvers/zod'; +import { useApiForm, useApiMutation, useApiQuery } from 'hooks'; import { isUndefined, pickBy } from 'lodash'; import { serialize } from 'object-to-formdata'; -import { FormProvider, useForm } from 'react-hook-form'; - -import { accountApi } from 'resources/account'; +import { FormProvider } from 'react-hook-form'; +import { AccountGetResponse, updateUserSchema } from 'shared'; +import { z } from 'zod'; +import { apiClient } from 'services/api-client.service'; import { handleApiError } from 'utils'; import queryClient from 'query-client'; -import { updateUserSchema } from 'schemas'; -import { UpdateUserParams, User } from 'types'; - import AvatarUpload from './components/AvatarUpload'; -const getFormDefaultValues = (account?: User) => ({ +const getFormDefaultValues = (account?: AccountGetResponse) => ({ firstName: account?.firstName, lastName: account?.lastName, password: '', @@ -26,10 +24,9 @@ const getFormDefaultValues = (account?: User) => ({ }); const Profile: NextPage = () => { - const { data: account } = accountApi.useGet(); + const { data: account } = useApiQuery(apiClient.account.get); - const methods = useForm({ - resolver: zodResolver(updateUserSchema), + const methods = useApiForm(apiClient.account.update, { mode: 'onBlur', defaultValues: getFormDefaultValues(account), }); @@ -43,19 +40,19 @@ const Profile: NextPage = () => { formState: { errors, isDirty }, } = methods; - const { mutate: updateAccount, isPending: isUpdatePending } = accountApi.useUpdate(); + const { mutate: updateAccount, isPending: isUpdatePending } = useApiMutation(apiClient.account.update); - const onSubmit = (submitData: UpdateUserParams) => { + const onSubmit = handleSubmit((submitData) => { const updateData = pickBy(submitData, (value, key) => { - if (account && account[key as keyof User] === value) return false; + if (account && (account as Record)[key] === value) return false; if (key === 'password' && value === '') return false; return !isUndefined(value); }); - updateAccount(serialize(updateData), { + updateAccount(serialize(updateData) as unknown as z.infer, { onSuccess: (data) => { - queryClient.setQueryData(['account'], data); + queryClient.setQueryData([apiClient.account.get.path], data); showNotification({ title: 'Success', @@ -69,7 +66,7 @@ const Profile: NextPage = () => { }, onError: (e) => handleApiError(e, setError), }); - }; + }); return ( <> @@ -81,7 +78,7 @@ const Profile: NextPage = () => { Profile - + diff --git a/template/apps/web/src/pages/reset-password/index.page.tsx b/template/apps/web/src/pages/reset-password/index.page.tsx index 97bc631c3..43f5cacd1 100644 --- a/template/apps/web/src/pages/reset-password/index.page.tsx +++ b/template/apps/web/src/pages/reset-password/index.page.tsx @@ -6,17 +6,20 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; + +import { useApiMutation } from 'hooks'; import { handleApiError } from 'utils'; import { RoutePath } from 'routes'; -import { resetPasswordSchema } from 'schemas'; +import { resetPasswordSchema } from 'shared'; -const schema = resetPasswordSchema.omit({ token: true }); +// Form schema differs from API schema (token comes from URL, not form) +const formSchema = resetPasswordSchema.omit({ token: true }); -type ResetPasswordParams = z.infer; +type ResetPasswordFormData = z.infer; const ResetPassword: NextPage = () => { const router = useRouter(); @@ -27,15 +30,15 @@ const ResetPassword: NextPage = () => { register, handleSubmit, formState: { errors }, - } = useForm({ resolver: zodResolver(schema) }); + } = useForm({ resolver: zodResolver(formSchema) }); const { mutate: resetPassword, isPending: isResetPasswordPending, isSuccess: isResetPasswordSuccess, - } = accountApi.useResetPassword(); + } = useApiMutation(apiClient.account.resetPassword); - const onSubmit = (data: ResetPasswordParams) => { + const onSubmit = (data: ResetPasswordFormData) => { if (typeof token !== 'string') return; resetPassword( diff --git a/template/apps/web/src/pages/sign-in/index.page.tsx b/template/apps/web/src/pages/sign-in/index.page.tsx index 89936a466..675316010 100644 --- a/template/apps/web/src/pages/sign-in/index.page.tsx +++ b/template/apps/web/src/pages/sign-in/index.page.tsx @@ -3,27 +3,21 @@ import Head from 'next/head'; import Link from 'next/link'; import { Alert, Anchor, Button, Group, Loader, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; -import { zodResolver } from '@hookform/resolvers/zod'; import { IconAlertCircle } from '@tabler/icons-react'; -import { useForm } from 'react-hook-form'; +import { useApiForm, useApiMutation } from 'hooks'; +import { AccountGetResponse } from 'shared'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; import { GoogleIcon } from 'public/icons'; import { handleApiError } from 'utils'; +import queryClient from 'query-client'; + import { RoutePath } from 'routes'; import config from 'config'; -import { signInSchema } from 'schemas'; -import { SignInParams } from 'types'; - -interface SignInResponse { - emailVerificationTokenExpired?: boolean; - credentials?: string; -} - const SignIn: NextPage = () => { const { register, @@ -31,15 +25,26 @@ const SignIn: NextPage = () => { watch, formState: { errors }, setError, - } = useForm({ resolver: zodResolver(signInSchema) }); + } = useApiForm(apiClient.account.signIn); - const { mutate: signIn, isPending: isSignInPending } = accountApi.useSignIn(); - const { mutate: resendEmail, isPending: isResendEmailPending } = accountApi.useResendEmail(); + // API error fields set via setError + const apiErrors = errors as typeof errors & { + credentials?: { message?: string }; + emailVerificationTokenExpired?: { message?: string }; + }; + + const { mutate: signIn, isPending: isSignInPending } = useApiMutation(apiClient.account.signIn, { + onSuccess: (data: AccountGetResponse) => { + queryClient.setQueryData([apiClient.account.get.path], data); + }, + }); + const { mutate: resendEmail, isPending: isResendEmailPending } = useApiMutation(apiClient.account.resendEmail); - const onSubmit = (data: SignInParams) => + const onSubmit = handleSubmit((data) => signIn(data, { onError: (e) => handleApiError(e, setError), - }); + }), + ); const onResendEmail = () => { if (isResendEmailPending) return; @@ -69,7 +74,7 @@ const SignIn: NextPage = () => { Sign In - + { error={errors.password?.message} /> - {errors.credentials && ( + {apiErrors.credentials && ( } color="red"> - {errors.credentials.message} + {apiErrors.credentials.message} )} - {errors.emailVerificationTokenExpired && ( + {apiErrors.emailVerificationTokenExpired && ( } color="yellow"> Please verify your email to sign in. diff --git a/template/apps/web/src/pages/sign-up/components/PasswordRules/index.tsx b/template/apps/web/src/pages/sign-up/components/PasswordRules/index.tsx index 5244987a3..ae0d8f992 100644 --- a/template/apps/web/src/pages/sign-up/components/PasswordRules/index.tsx +++ b/template/apps/web/src/pages/sign-up/components/PasswordRules/index.tsx @@ -2,7 +2,7 @@ import { FC, ReactNode, useState } from 'react'; import { Checkbox, Stack, Title, Tooltip } from '@mantine/core'; import { useFormContext } from 'react-hook-form'; -import { PASSWORD_RULES } from 'app-constants'; +import { PASSWORD_RULES } from 'shared'; interface PasswordRulesRenderProps { onFocus: () => void; diff --git a/template/apps/web/src/pages/sign-up/index.page.tsx b/template/apps/web/src/pages/sign-up/index.page.tsx index 2becfe952..dd2e6cb16 100644 --- a/template/apps/web/src/pages/sign-up/index.page.tsx +++ b/template/apps/web/src/pages/sign-up/index.page.tsx @@ -2,10 +2,10 @@ import { NextPage } from 'next'; import Head from 'next/head'; import Link from 'next/link'; import { Anchor, Button, Group, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { FormProvider, useForm } from 'react-hook-form'; +import { FormProvider } from 'react-hook-form'; +import { useApiForm, useApiMutation } from 'hooks'; -import { accountApi } from 'resources/account'; +import { apiClient } from 'services/api-client.service'; import { GoogleIcon } from 'public/icons'; @@ -14,15 +14,10 @@ import { handleApiError } from 'utils'; import { RoutePath } from 'routes'; import config from 'config'; -import { signUpSchema } from 'schemas'; -import { SignUpParams } from 'types'; - import PasswordRules from './components/PasswordRules'; const SignUp: NextPage = () => { - const methods = useForm({ - resolver: zodResolver(signUpSchema), - }); + const methods = useApiForm(apiClient.account.signUp); const { register, handleSubmit, @@ -38,12 +33,13 @@ const SignUp: NextPage = () => { isPending: isSignUpPending, isSuccess: isSignUpSuccess, data: signUpData, - } = accountApi.useSignUp(); + } = useApiMutation(apiClient.account.signUp); - const onSubmit = (data: SignUpParams) => + const onSubmit = handleSubmit((data) => signUp(data, { onError: (e) => handleApiError(e, setError), - }); + }), + ); if (isSignUpSuccess) { return ( @@ -89,7 +85,7 @@ const SignUp: NextPage = () => { - + Sign Up diff --git a/template/apps/web/src/resources/account/account.api.ts b/template/apps/web/src/resources/account/account.api.ts deleted file mode 100644 index f455276bf..000000000 --- a/template/apps/web/src/resources/account/account.api.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useMutation, useQuery, UseQueryOptions } from '@tanstack/react-query'; - -import { apiService } from 'services'; - -import queryClient from 'query-client'; - -import { - ApiError, - ForgotPasswordParams, - ResendEmailParams, - ResetPasswordParams, - SignInParams, - SignUpParams, - UpdateUserParams, - User, -} from 'types'; - -export const useSignIn = () => - useMutation({ - mutationFn: (data: T) => apiService.post('/account/sign-in', data), - onSuccess: (data) => { - queryClient.setQueryData(['account'], data); - }, - }); - -export const useSignOut = () => - useMutation({ - mutationFn: () => apiService.post('/account/sign-out'), - onSuccess: () => { - queryClient.setQueryData(['account'], null); - }, - }); - -export const useSignUp = () => { - interface SignUpResponse { - emailVerificationToken: string; - } - - return useMutation({ - mutationFn: (data: T) => apiService.post('/account/sign-up', data), - }); -}; - -export const useForgotPassword = () => - useMutation({ - mutationFn: (data: T) => apiService.post('/account/forgot-password', data), - }); - -export const useResetPassword = () => - useMutation({ - mutationFn: (data: T) => apiService.put('/account/reset-password', data), - }); - -export const useResendEmail = () => - useMutation({ - mutationFn: (data: T) => apiService.post('/account/resend-email', data), - }); - -export const useGet = (options: Partial> = {}) => - useQuery({ - queryKey: ['account'], - queryFn: () => apiService.get('/account'), - staleTime: 60 * 1000, // 60 seconds - ...options, - }); - -export const useUpdate = () => - useMutation({ - mutationFn: (data: T) => apiService.put('/account', data), - }); diff --git a/template/apps/web/src/resources/account/index.ts b/template/apps/web/src/resources/account/index.ts deleted file mode 100644 index 87a52962c..000000000 --- a/template/apps/web/src/resources/account/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as accountApi from './account.api'; - -export { accountApi }; diff --git a/template/apps/web/src/resources/user/index.ts b/template/apps/web/src/resources/user/index.ts deleted file mode 100644 index 88e99badd..000000000 --- a/template/apps/web/src/resources/user/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as userApi from './user.api'; - -export type * from './user.api'; - -export { userApi }; diff --git a/template/apps/web/src/resources/user/user.api.ts b/template/apps/web/src/resources/user/user.api.ts deleted file mode 100644 index 713297d82..000000000 --- a/template/apps/web/src/resources/user/user.api.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DateValue } from '@mantine/dates'; -import { useQuery, UseQueryOptions } from '@tanstack/react-query'; - -import { apiService } from 'services'; - -import { ListParams, ListResult, User } from 'types'; - -export interface UserListFilterParams { - createdOn?: { - startDate: DateValue; - endDate: DateValue; - }; -} - -export type UserListSortFields = 'createdOn' | 'firstName' | 'lastName'; - -export type UserListParams = ListParams>; -export type UserListResponse = ListResult; - -export const useList = (params: T, options?: Partial>) => - useQuery({ - queryKey: ['users', params], - queryFn: () => apiService.get('/users', params), - ...options, - }); diff --git a/template/apps/web/src/resources/user/user.handlers.ts b/template/apps/web/src/resources/user/user.handlers.ts deleted file mode 100644 index dba7419d7..000000000 --- a/template/apps/web/src/resources/user/user.handlers.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { apiService, socketService } from 'services'; - -import queryClient from 'query-client'; - -import { User } from 'types'; - -apiService.on('error', (error) => { - if (error.status === 401) { - queryClient.setQueryData(['account'], null); - } -}); - -socketService.on('connect', () => { - const account = queryClient.getQueryData(['account']); - - if (account) socketService.emit('subscribe', `user-${account._id}`); -}); - -socketService.on('user:updated', (user) => { - queryClient.setQueryData(['account'], user); -}); diff --git a/template/apps/web/src/services/api-client.service.ts b/template/apps/web/src/services/api-client.service.ts new file mode 100644 index 000000000..812e51590 --- /dev/null +++ b/template/apps/web/src/services/api-client.service.ts @@ -0,0 +1,13 @@ +import { ApiClient, createApiEndpoints } from 'shared'; + +import config from 'config'; + +const client = new ApiClient({ + baseURL: config.API_URL, + withCredentials: true, +}); + +export const apiClient = createApiEndpoints(client); + +// Also export the raw client for event handling +export { client as apiClientRaw }; diff --git a/template/apps/web/src/services/index.ts b/template/apps/web/src/services/index.ts index 0d9cd3acc..9d8f31add 100644 --- a/template/apps/web/src/services/index.ts +++ b/template/apps/web/src/services/index.ts @@ -1,5 +1,5 @@ import * as analyticsService from './analytics.service'; -import apiService from './api.service'; +import { apiClient, apiClientRaw } from './api-client.service'; import * as socketService from './socket.service'; -export { analyticsService, apiService, socketService }; +export { analyticsService, apiClient, apiClientRaw, socketService }; diff --git a/template/apps/web/src/services/socket-handlers.ts b/template/apps/web/src/services/socket-handlers.ts new file mode 100644 index 000000000..36a6802d8 --- /dev/null +++ b/template/apps/web/src/services/socket-handlers.ts @@ -0,0 +1,35 @@ +/** + * Socket event handlers for real-time updates + * This file sets up listeners for socket events and API errors + */ + +import { AccountGetResponse } from 'shared'; + +import { apiClient, apiClientRaw } from 'services/api-client.service'; +import * as socketService from 'services/socket.service'; + +import queryClient from 'query-client'; + +// Query key for account data (matches the path used in useApiQuery) +const ACCOUNT_QUERY_KEY = [apiClient.account.get.path]; + +// Handle 401 errors by clearing the account data +apiClientRaw.on('error', (error) => { + if (error.status === 401) { + queryClient.setQueryData(ACCOUNT_QUERY_KEY, null); + } +}); + +// Subscribe to user-specific events when socket connects +socketService.on('connect', () => { + const account = queryClient.getQueryData(ACCOUNT_QUERY_KEY); + + if (account) { + socketService.emit('subscribe', `user-${account._id}`); + } +}); + +// Handle real-time user updates +socketService.on('user:updated', (user: AccountGetResponse) => { + queryClient.setQueryData(ACCOUNT_QUERY_KEY, user); +}); diff --git a/template/apps/web/src/types.ts b/template/apps/web/src/types.ts index 0b791d4bf..e3ea8262e 100644 --- a/template/apps/web/src/types.ts +++ b/template/apps/web/src/types.ts @@ -1,24 +1,4 @@ -export * from 'app-types'; -export type { ApiError } from 'services/api.service'; +export { type User, type UpdateUserParams, type UpdateUserParamsFrontend, type ListResult, type SortOrder, type SortParams, type ListParams } from 'shared'; +export type { ApiError } from 'shared'; export type QueryParam = string | string[] | undefined; - -export interface ListResult { - results: T[]; - pagesCount: number; - count: number; -} - -export type SortOrder = 'asc' | 'desc'; - -export type SortParams = { - [P in keyof F]?: SortOrder; -}; - -export interface ListParams { - page?: number; - perPage?: number; - searchValue?: string; - filter?: T; - sort?: SortParams; -} diff --git a/template/apps/web/src/utils/handle-error.util.ts b/template/apps/web/src/utils/handle-error.util.ts index a398c4e9c..56d652198 100644 --- a/template/apps/web/src/utils/handle-error.util.ts +++ b/template/apps/web/src/utils/handle-error.util.ts @@ -2,8 +2,9 @@ import { FileRejection } from '@mantine/dropzone'; import { showNotification } from '@mantine/notifications'; import { FieldValues, Path, UseFormSetError } from 'react-hook-form'; -import { ONE_MB_IN_BYTES } from 'app-constants'; -import { ApiError } from 'types'; +import { ApiError } from 'shared'; + +import { ONE_MB_IN_BYTES } from 'shared'; interface ValidationErrors { [name: string]: string[] | string; diff --git a/template/packages/app-types/.gitignore b/template/packages/app-types/.gitignore deleted file mode 100644 index 15989c8c9..000000000 --- a/template/packages/app-types/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo diff --git a/template/packages/app-types/.prettierignore b/template/packages/app-types/.prettierignore deleted file mode 100644 index bdb94e721..000000000 --- a/template/packages/app-types/.prettierignore +++ /dev/null @@ -1,35 +0,0 @@ -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo - -.prettierignore diff --git a/template/packages/app-types/.prettierrc.json b/template/packages/app-types/.prettierrc.json deleted file mode 100644 index 59a5c63a4..000000000 --- a/template/packages/app-types/.prettierrc.json +++ /dev/null @@ -1 +0,0 @@ -"prettier-config" diff --git a/template/packages/app-types/eslint.config.js b/template/packages/app-types/eslint.config.js deleted file mode 100644 index 4775b9538..000000000 --- a/template/packages/app-types/eslint.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import node from 'eslint-config/node'; - -export default node; diff --git a/template/packages/app-types/package.json b/template/packages/app-types/package.json deleted file mode 100644 index 53599526c..000000000 --- a/template/packages/app-types/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "app-types", - "type": "module", - "version": "0.0.0", - "main": "./src/index.ts", - "types": "./src/index.ts", - "scripts": { - "tsc": "tsc --noEmit --watch", - "prettier": "prettier . --write --config .prettierrc.json", - "eslint": "eslint . --fix", - "precommit": "lint-staged" - }, - "dependencies": { - "enums": "workspace:*", - "schemas": "workspace:*", - "zod": "catalog:" - }, - "devDependencies": { - "@types/formidable": "catalog:", - "@types/node": "catalog:", - "eslint": "catalog:", - "eslint-config": "workspace:*", - "lint-staged": "catalog:", - "prettier": "catalog:", - "prettier-config": "workspace:*", - "tsconfig": "workspace:*", - "typescript": "catalog:" - }, - "lint-staged": { - "*.ts": [ - "eslint . --fix", - "bash -c 'tsc --noEmit'", - "prettier . --write" - ], - "*.{json,md}": [ - "prettier . --write" - ] - } -} diff --git a/template/packages/app-types/src/account.types.ts b/template/packages/app-types/src/account.types.ts deleted file mode 100644 index 37774ce5c..000000000 --- a/template/packages/app-types/src/account.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -import { forgotPasswordSchema, resendEmailSchema, resetPasswordSchema, signInSchema, signUpSchema } from 'schemas'; - -export type SignInParams = z.infer; -export type SignUpParams = z.infer; -export type ResendEmailParams = z.infer; -export type ForgotPasswordParams = z.infer; -export type ResetPasswordParams = z.infer; diff --git a/template/packages/app-types/src/index.ts b/template/packages/app-types/src/index.ts deleted file mode 100644 index 0fc82e39d..000000000 --- a/template/packages/app-types/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './account.types'; -export * from './common.types'; -export * from './token.types'; -export * from './user.types'; -export * from 'enums'; diff --git a/template/packages/app-types/src/token.types.ts b/template/packages/app-types/src/token.types.ts deleted file mode 100644 index db0636193..000000000 --- a/template/packages/app-types/src/token.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -import { tokenSchema } from 'schemas'; - -export type Token = z.infer; - -export type TokenPayload = Pick; diff --git a/template/packages/app-types/src/user.types.ts b/template/packages/app-types/src/user.types.ts deleted file mode 100644 index 24f355995..000000000 --- a/template/packages/app-types/src/user.types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod'; - -import { updateUserSchema, userSchema } from 'schemas'; - -import { BackendFile, FrontendFile } from './common.types'; - -export type User = z.infer; -export type UpdateUserParams = z.infer; - -// Empty string indicates avatar removal -export interface UpdateUserParamsFrontend extends Omit { - avatar?: FrontendFile | ''; -} - -export interface UpdateUserParamsBackend extends Omit { - avatar?: BackendFile | ''; -} diff --git a/template/packages/enums/.gitignore b/template/packages/enums/.gitignore deleted file mode 100644 index 15989c8c9..000000000 --- a/template/packages/enums/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo diff --git a/template/packages/enums/.prettierignore b/template/packages/enums/.prettierignore deleted file mode 100644 index bdb94e721..000000000 --- a/template/packages/enums/.prettierignore +++ /dev/null @@ -1,35 +0,0 @@ -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo - -.prettierignore diff --git a/template/packages/enums/.prettierrc.json b/template/packages/enums/.prettierrc.json deleted file mode 100644 index 59a5c63a4..000000000 --- a/template/packages/enums/.prettierrc.json +++ /dev/null @@ -1 +0,0 @@ -"prettier-config" diff --git a/template/packages/enums/eslint.config.js b/template/packages/enums/eslint.config.js deleted file mode 100644 index 4775b9538..000000000 --- a/template/packages/enums/eslint.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import node from 'eslint-config/node'; - -export default node; diff --git a/template/packages/enums/package.json b/template/packages/enums/package.json deleted file mode 100644 index 791b2519b..000000000 --- a/template/packages/enums/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "enums", - "type": "module", - "version": "0.0.0", - "main": "./src/index.ts", - "types": "./src/index.ts", - "scripts": { - "tsc": "tsc --noEmit --watch", - "prettier": "prettier . --write --config .prettierrc.json", - "eslint": "eslint . --fix", - "precommit": "lint-staged" - }, - "devDependencies": { - "@types/node": "catalog:", - "eslint": "catalog:", - "eslint-config": "workspace:*", - "lint-staged": "catalog:", - "prettier": "catalog:", - "prettier-config": "workspace:*", - "tsconfig": "workspace:*", - "typescript": "catalog:" - }, - "lint-staged": { - "*.ts": [ - "eslint . --fix", - "bash -c 'tsc --noEmit'", - "prettier . --write" - ], - "*.{json,md}": [ - "prettier . --write" - ] - } -} diff --git a/template/packages/enums/src/index.ts b/template/packages/enums/src/index.ts deleted file mode 100644 index 3f5cca0de..000000000 --- a/template/packages/enums/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './token.enum'; diff --git a/template/packages/enums/src/token.enum.ts b/template/packages/enums/src/token.enum.ts deleted file mode 100644 index 165c38845..000000000 --- a/template/packages/enums/src/token.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum TokenType { - ACCESS = 'access', - EMAIL_VERIFICATION = 'email-verification', - RESET_PASSWORD = 'reset-password', -} diff --git a/template/packages/enums/tsconfig.json b/template/packages/enums/tsconfig.json deleted file mode 100644 index 5c7c801eb..000000000 --- a/template/packages/enums/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "tsconfig/nodejs.json", - "compilerOptions": { - "baseUrl": "src", - "rootDir": "." - }, - "include": ["**/*.ts", "**/*.json"] -} diff --git a/template/packages/schemas/.gitignore b/template/packages/schemas/.gitignore deleted file mode 100644 index 15989c8c9..000000000 --- a/template/packages/schemas/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo diff --git a/template/packages/schemas/.prettierignore b/template/packages/schemas/.prettierignore deleted file mode 100644 index bdb94e721..000000000 --- a/template/packages/schemas/.prettierignore +++ /dev/null @@ -1,35 +0,0 @@ -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo - -.prettierignore diff --git a/template/packages/schemas/.prettierrc.json b/template/packages/schemas/.prettierrc.json deleted file mode 100644 index 59a5c63a4..000000000 --- a/template/packages/schemas/.prettierrc.json +++ /dev/null @@ -1 +0,0 @@ -"prettier-config" diff --git a/template/packages/schemas/eslint.config.js b/template/packages/schemas/eslint.config.js deleted file mode 100644 index 4775b9538..000000000 --- a/template/packages/schemas/eslint.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import node from 'eslint-config/node'; - -export default node; diff --git a/template/packages/schemas/src/db.schema.ts b/template/packages/schemas/src/db.schema.ts deleted file mode 100644 index 217d1c166..000000000 --- a/template/packages/schemas/src/db.schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -export default z.object({ - _id: z.string(), - - createdOn: z.date().optional(), - updatedOn: z.date().optional(), - deletedOn: z.date().optional().nullable(), -}); diff --git a/template/packages/schemas/src/index.ts b/template/packages/schemas/src/index.ts deleted file mode 100644 index 9b2332ea7..000000000 --- a/template/packages/schemas/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './account.schema'; -export * from './common.schema'; -export * from './token.schema'; -export * from './user.schema'; diff --git a/template/packages/schemas/src/token.schema.ts b/template/packages/schemas/src/token.schema.ts deleted file mode 100644 index de5b753f7..000000000 --- a/template/packages/schemas/src/token.schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TokenType } from 'enums'; -import { z } from 'zod'; - -import dbSchema from './db.schema'; - -export const tokenSchema = dbSchema.extend({ - value: z.string(), - userId: z.string(), - type: z.enum(TokenType), - expiresOn: z.date(), -}); diff --git a/template/packages/schemas/tsconfig.json b/template/packages/schemas/tsconfig.json deleted file mode 100644 index 5c7c801eb..000000000 --- a/template/packages/schemas/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "tsconfig/nodejs.json", - "compilerOptions": { - "baseUrl": "src", - "rootDir": "." - }, - "include": ["**/*.ts", "**/*.json"] -} diff --git a/template/packages/shared/node_modules/.bin/eslint b/template/packages/shared/node_modules/.bin/eslint new file mode 100755 index 000000000..a40fa76fb --- /dev/null +++ b/template/packages/shared/node_modules/.bin/eslint @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../eslint/bin/eslint.js" "$@" +else + exec node "$basedir/../eslint/bin/eslint.js" "$@" +fi diff --git a/template/packages/shared/node_modules/.bin/jiti b/template/packages/shared/node_modules/.bin/jiti new file mode 100755 index 000000000..effd1d5d5 --- /dev/null +++ b/template/packages/shared/node_modules/.bin/jiti @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/lib/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/jiti@2.6.1/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/lib/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/jiti@2.6.1/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/lib/jiti-cli.mjs" "$@" +else + exec node "$basedir/../../../../node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/lib/jiti-cli.mjs" "$@" +fi diff --git a/template/packages/shared/node_modules/.bin/lint-staged b/template/packages/shared/node_modules/.bin/lint-staged new file mode 100755 index 000000000..7d584e8aa --- /dev/null +++ b/template/packages/shared/node_modules/.bin/lint-staged @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../lint-staged/bin/lint-staged.js" "$@" +else + exec node "$basedir/../lint-staged/bin/lint-staged.js" "$@" +fi diff --git a/template/packages/shared/node_modules/.bin/prettier b/template/packages/shared/node_modules/.bin/prettier new file mode 100755 index 000000000..bd686ec89 --- /dev/null +++ b/template/packages/shared/node_modules/.bin/prettier @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../prettier/bin/prettier.cjs" "$@" +else + exec node "$basedir/../prettier/bin/prettier.cjs" "$@" +fi diff --git a/template/packages/shared/node_modules/.bin/tsc b/template/packages/shared/node_modules/.bin/tsc new file mode 100755 index 000000000..884ecc461 --- /dev/null +++ b/template/packages/shared/node_modules/.bin/tsc @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@" +else + exec node "$basedir/../typescript/bin/tsc" "$@" +fi diff --git a/template/packages/shared/node_modules/.bin/tsserver b/template/packages/shared/node_modules/.bin/tsserver new file mode 100755 index 000000000..06a85be44 --- /dev/null +++ b/template/packages/shared/node_modules/.bin/tsserver @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@" +else + exec node "$basedir/../typescript/bin/tsserver" "$@" +fi diff --git a/template/packages/shared/node_modules/.bin/tsx b/template/packages/shared/node_modules/.bin/tsx new file mode 100755 index 000000000..227cfa622 --- /dev/null +++ b/template/packages/shared/node_modules/.bin/tsx @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/dist/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/dist/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../tsx/dist/cli.mjs" "$@" +else + exec node "$basedir/../tsx/dist/cli.mjs" "$@" +fi diff --git a/template/packages/shared/node_modules/@types/node b/template/packages/shared/node_modules/@types/node new file mode 120000 index 000000000..6284080bb --- /dev/null +++ b/template/packages/shared/node_modules/@types/node @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@types+node@22.10.10/node_modules/@types/node \ No newline at end of file diff --git a/template/packages/shared/node_modules/axios b/template/packages/shared/node_modules/axios new file mode 120000 index 000000000..d6bebedf9 --- /dev/null +++ b/template/packages/shared/node_modules/axios @@ -0,0 +1 @@ +../../../node_modules/.pnpm/axios@1.8.1/node_modules/axios \ No newline at end of file diff --git a/template/packages/shared/node_modules/eslint b/template/packages/shared/node_modules/eslint new file mode 120000 index 000000000..988a3e268 --- /dev/null +++ b/template/packages/shared/node_modules/eslint @@ -0,0 +1 @@ +../../../node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint \ No newline at end of file diff --git a/template/packages/shared/node_modules/eslint-config b/template/packages/shared/node_modules/eslint-config new file mode 120000 index 000000000..135c7ded6 --- /dev/null +++ b/template/packages/shared/node_modules/eslint-config @@ -0,0 +1 @@ +../../eslint-config \ No newline at end of file diff --git a/template/packages/shared/node_modules/lint-staged b/template/packages/shared/node_modules/lint-staged new file mode 120000 index 000000000..400a127ae --- /dev/null +++ b/template/packages/shared/node_modules/lint-staged @@ -0,0 +1 @@ +../../../node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged \ No newline at end of file diff --git a/template/packages/shared/node_modules/prettier b/template/packages/shared/node_modules/prettier new file mode 120000 index 000000000..8f17339fc --- /dev/null +++ b/template/packages/shared/node_modules/prettier @@ -0,0 +1 @@ +../../../node_modules/.pnpm/prettier@3.6.2/node_modules/prettier \ No newline at end of file diff --git a/template/packages/shared/node_modules/prettier-config b/template/packages/shared/node_modules/prettier-config new file mode 120000 index 000000000..8fab586dc --- /dev/null +++ b/template/packages/shared/node_modules/prettier-config @@ -0,0 +1 @@ +../../prettier-config \ No newline at end of file diff --git a/template/packages/shared/node_modules/tsconfig b/template/packages/shared/node_modules/tsconfig new file mode 120000 index 000000000..5bace2657 --- /dev/null +++ b/template/packages/shared/node_modules/tsconfig @@ -0,0 +1 @@ +../../tsconfig \ No newline at end of file diff --git a/template/packages/shared/node_modules/tsx b/template/packages/shared/node_modules/tsx new file mode 120000 index 000000000..ceb6053be --- /dev/null +++ b/template/packages/shared/node_modules/tsx @@ -0,0 +1 @@ +../../../node_modules/.pnpm/tsx@4.20.3/node_modules/tsx \ No newline at end of file diff --git a/template/packages/shared/node_modules/typescript b/template/packages/shared/node_modules/typescript new file mode 120000 index 000000000..5cbb7b2e0 --- /dev/null +++ b/template/packages/shared/node_modules/typescript @@ -0,0 +1 @@ +../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript \ No newline at end of file diff --git a/template/packages/shared/node_modules/zod b/template/packages/shared/node_modules/zod new file mode 120000 index 000000000..28169351a --- /dev/null +++ b/template/packages/shared/node_modules/zod @@ -0,0 +1 @@ +../../../node_modules/.pnpm/zod@4.0.5/node_modules/zod \ No newline at end of file diff --git a/template/packages/schemas/package.json b/template/packages/shared/package.json similarity index 83% rename from template/packages/schemas/package.json rename to template/packages/shared/package.json index 92066eaa6..cc874957a 100644 --- a/template/packages/schemas/package.json +++ b/template/packages/shared/package.json @@ -1,22 +1,22 @@ { - "name": "schemas", + "name": "shared", "type": "module", "version": "0.0.0", "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { + "generate": "tsx scripts/generate.ts", + "generate:watch": "tsx watch scripts/generate.ts", "tsc": "tsc --noEmit --watch", "prettier": "prettier . --write --config .prettierrc.json", "eslint": "eslint . --fix", "precommit": "lint-staged" }, "dependencies": { - "app-constants": "workspace:*", - "enums": "workspace:*", + "axios": "1.8.1", "zod": "catalog:" }, "devDependencies": { - "@types/formidable": "catalog:", "@types/node": "catalog:", "eslint": "catalog:", "eslint-config": "workspace:*", @@ -24,6 +24,7 @@ "prettier": "catalog:", "prettier-config": "workspace:*", "tsconfig": "workspace:*", + "tsx": "4.20.3", "typescript": "catalog:" }, "lint-staged": { diff --git a/template/packages/shared/scripts/generate.ts b/template/packages/shared/scripts/generate.ts new file mode 100644 index 000000000..59bf47a56 --- /dev/null +++ b/template/packages/shared/scripts/generate.ts @@ -0,0 +1,717 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const API_RESOURCES_PATH = path.resolve(__dirname, '../../../apps/api/src/resources'); +const SCHEMAS_OUTPUT_PATH = path.resolve(__dirname, '../src/schemas'); +const GENERATED_PATH = path.resolve(__dirname, '../src/generated'); +const IGNORE_RESOURCES = ['token']; + +// ─── Schema Sync ─────────────────────────────────────────────────────── + +function syncSchemas() { + console.log('📋 Syncing schemas from API resources...'); + + if (!fs.existsSync(SCHEMAS_OUTPUT_PATH)) { + fs.mkdirSync(SCHEMAS_OUTPUT_PATH, { recursive: true }); + } + + // Find all *.schema.ts files in resources (including base.schema.ts) + const schemaFiles: { src: string; relativePath: string }[] = []; + + // base.schema.ts in resources root + const baseSchemaPath = path.join(API_RESOURCES_PATH, 'base.schema.ts'); + if (fs.existsSync(baseSchemaPath)) { + schemaFiles.push({ src: baseSchemaPath, relativePath: 'base.schema.ts' }); + } + + // Resource-specific schemas + const resources = fs + .readdirSync(API_RESOURCES_PATH, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const resource of resources) { + const resourceDir = path.join(API_RESOURCES_PATH, resource); + const files = fs.readdirSync(resourceDir).filter((f) => f.endsWith('.schema.ts')); + + for (const file of files) { + schemaFiles.push({ + src: path.join(resourceDir, file), + relativePath: `${resource}/${file}`, + }); + } + } + + // Copy and transform each schema file + for (const { src, relativePath } of schemaFiles) { + let content = fs.readFileSync(src, 'utf-8'); + + // No import rewriting needed — the relative paths in source schema files + // (e.g. ../base.schema from account/account.schema.ts) already work correctly + // in the shared package since we preserve the same directory structure. + + const outputPath = path.join(SCHEMAS_OUTPUT_PATH, relativePath); + const outputDir = path.dirname(outputPath); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(outputPath, content); + console.log(` ✓ ${relativePath}`); + } + + // Generate schemas/index.ts barrel + const indexLines: string[] = ["export * from './base.schema';"]; + + for (const resource of resources) { + const resourceDir = path.join(API_RESOURCES_PATH, resource); + const files = fs.readdirSync(resourceDir).filter((f) => f.endsWith('.schema.ts')); + + for (const file of files) { + const moduleName = file.replace('.ts', ''); + indexLines.push(`export * from './${resource}/${moduleName}';`); + } + } + + fs.writeFileSync(path.join(SCHEMAS_OUTPUT_PATH, 'index.ts'), indexLines.join('\n') + '\n'); + console.log(` ✓ index.ts (barrel)`); +} + +// ─── API Client Generation ───────────────────────────────────────────── + +interface ParsedEndpoint { + name: string; + method: string; + path: string; + hasPathParams: boolean; + pathParams: string[]; + schemaImport: string | null; + schemaName: string | null; + fullSchemaCode: string | null; + isInlineSchema: boolean; + responseType: string | null; // inferred from handler return +} + +function toCamelCase(str: string): string { + return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +function toPascalCase(str: string): string { + const camel = toCamelCase(str); + return camel.charAt(0).toUpperCase() + camel.slice(1); +} + +function getResources(): string[] { + return fs + .readdirSync(API_RESOURCES_PATH, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .filter((dirent) => !IGNORE_RESOURCES.includes(dirent.name)) + .map((dirent) => dirent.name); +} + +function extractSchemaFromContent(content: string): { + schemaImport: string | null; + schemaName: string | null; + fullSchemaCode: string | null; + isInlineSchema: boolean; +} { + const simpleSchemaMatch = content.match(/export\s+const\s+schema\s*=\s*(\w+Schema)\s*;/); + if (simpleSchemaMatch) { + const afterMatch = content.slice(content.indexOf(simpleSchemaMatch[0]) + simpleSchemaMatch[0].length - 1); + if (!afterMatch.startsWith('Schema.')) { + return { + schemaImport: simpleSchemaMatch[1], + schemaName: simpleSchemaMatch[1], + fullSchemaCode: null, + isInlineSchema: false, + }; + } + } + + function extractBalancedExpression(text: string, startIdx: number): string { + let depth = 0; + let inString = false; + let stringChar = ''; + let result = ''; + + for (let i = startIdx; i < text.length; i++) { + const char = text[i]; + const prevChar = i > 0 ? text[i - 1] : ''; + + if (!inString && (char === '"' || char === "'" || char === '`')) { + inString = true; + stringChar = char; + } else if (inString && char === stringChar && prevChar !== '\\') { + inString = false; + } + + if (!inString) { + if (char === '(' || char === '{' || char === '[') { + depth++; + } else if (char === ')' || char === '}' || char === ']') { + depth--; + if (depth === 0) { + result += char; + break; + } + } + } + + result += char; + } + + return result; + } + + const exportedInlineStart = content.match(/export\s+const\s+schema\s*=\s*(paginationSchema|userSchema|z)\./); + if (exportedInlineStart) { + const startIdx = + content.indexOf(exportedInlineStart[0]) + exportedInlineStart[0].length - (exportedInlineStart[1].length + 1); + const schemaExpr = content.slice(startIdx); + + const match = schemaExpr.match(/^((?:paginationSchema|userSchema|z)[\s\S]*?)\);/); + if (match) { + const schemaCode = match[1] + ')'; + return { + schemaImport: null, + schemaName: 'schema', + fullSchemaCode: schemaCode, + isInlineSchema: true, + }; + } + } + + const inlineSchemaStart = content.match(/(? { + const imports = new Map(); + const importRegex = /import\s+(?:type\s+)?(?:{([^}]+)}|(\w+))\s+from\s+['"]([^'"]+)['"]/g; + + let match; + while ((match = importRegex.exec(content)) !== null) { + const namedImports = match[1]; + const defaultImport = match[2]; + const source = match[3]; + + if (namedImports) { + namedImports.split(',').forEach((imp) => { + const name = imp.trim().split(/\s+as\s+/)[0].trim(); + if (name) imports.set(name, source); + }); + } + if (defaultImport) { + imports.set(defaultImport, source); + } + } + + return imports; +} + +function extractResponseType(content: string): string | null { + // Extract the handler body from createEndpoint + const handlerMatch = content.match(/async\s+handler\s*\([^)]*\)\s*\{/); + if (!handlerMatch) return null; + + const handlerStart = content.indexOf(handlerMatch[0]) + handlerMatch[0].length; + + // Find the handler body by matching braces + let depth = 1; + let i = handlerStart; + while (i < content.length && depth > 0) { + if (content[i] === '{') depth++; + else if (content[i] === '}') depth--; + i++; + } + const handlerBody = content.slice(handlerStart, i - 1); + + // Find return statements (skip ones inside nested functions/callbacks) + const returnStatements: string[] = []; + const returnRegex = /\breturn\s+([^;]+);/g; + let match; + while ((match = returnRegex.exec(handlerBody)) !== null) { + // Check if this return is inside a nested function/arrow by looking + // for function/arrow signatures before unmatched opening braces + const before = handlerBody.slice(0, match.index); + let insideNestedFn = false; + let braceDepth = 0; + + for (let j = before.length - 1; j >= 0; j--) { + if (before[j] === '}') braceDepth++; + else if (before[j] === '{') { + if (braceDepth > 0) { + braceDepth--; + } else { + // Unmatched '{' — check what precedes it + const preceding = before.slice(0, j).trimEnd(); + // Arrow function: => { or function keyword + if (/=>\s*$/.test(preceding) || /\bfunction\s*\([^)]*\)\s*$/.test(preceding)) { + insideNestedFn = true; + break; + } + // Otherwise it's an if/else/for/while block — still handler level + } + } + } + + if (!insideNestedFn) { + returnStatements.push(match[1].trim()); + } + } + + if (returnStatements.length === 0) return null; + + // Analyze return expressions + for (const expr of returnStatements) { + // Pattern: userService.getPublic(...) or ...then(userService.getPublic) + if (/userService\.getPublic/.test(expr)) { + // Check if it's a list result pattern: { ...result, results: ...map(userService.getPublic) } + if (/results\s*:.*\.map\(userService\.getPublic\)/.test(expr)) { + return 'listResult(userPublicSchema)'; + } + return 'userPublicSchema'; + } + + // Pattern: inline object literal { key: value, ... } + if (expr.startsWith('{')) { + return inferObjectType(expr); + } + } + + return null; +} + +function inferObjectType(expr: string): string | null { + // Simple object literal like { emailVerificationToken } + const shorthand = expr.match(/^\{\s*([\w,\s]+)\s*\}$/); + if (shorthand) { + const keys = shorthand[1].split(',').map((k) => k.trim()).filter(Boolean); + const fields = keys.map((k) => `${k}: string`).join('; '); + return `{ ${fields} }`; + } + return null; +} + + +async function getEndpoints(resourceName: string): Promise { + const endpointsPath = path.join(API_RESOURCES_PATH, resourceName, 'endpoints'); + + if (!fs.existsSync(endpointsPath)) { + return []; + } + + const endpointFiles = fs + .readdirSync(endpointsPath) + .filter((file) => file.endsWith('.ts') && !file.endsWith('.d.ts')); + + const endpoints: ParsedEndpoint[] = []; + + for (const file of endpointFiles) { + const filePath = path.join(endpointsPath, file); + const content = fs.readFileSync(filePath, 'utf-8'); + + let method: string | null = null; + let endpointPath: string | null = null; + + const createEndpointStart = content.match(/createEndpoint(?:<[^>]+>)?\s*\(\s*\{/); + if (createEndpointStart) { + const afterCreateEndpoint = content.slice(content.indexOf(createEndpointStart[0])); + const methodMatch = afterCreateEndpoint.match(/method\s*:\s*['"](\w+)['"]/); + const pathMatch = afterCreateEndpoint.match(/path\s*:\s*['"]([^'"]+)['"]/); + + if (methodMatch && pathMatch) { + method = methodMatch[1]; + endpointPath = pathMatch[1]; + } + } + + if (!method || !endpointPath) { + const endpointMatch = content.match( + /export\s+const\s+endpoint\s*(?::\s*EndpointConfig)?\s*=\s*\{([^}]+)\}/s, + ); + + if (endpointMatch) { + const configBlock = endpointMatch[1]; + const methodMatch = configBlock.match(/method\s*:\s*['"](\w+)['"]/); + const pathMatch = configBlock.match(/path\s*:\s*['"]([^'"]+)['"]/); + + if (methodMatch && pathMatch) { + method = methodMatch[1]; + endpointPath = pathMatch[1]; + } + } + } + + if (!method || !endpointPath) continue; + + const pathParams = (endpointPath.match(/:(\w+)/g) || []).map((p) => p.slice(1)); + const hasPathParams = pathParams.length > 0; + + const schemaInfo = extractSchemaFromContent(content); + + const baseName = file.replace('.ts', ''); + const name = toCamelCase(baseName); + + endpoints.push({ + name, + method, + path: endpointPath, + hasPathParams, + pathParams, + schemaImport: schemaInfo.schemaImport, + schemaName: schemaInfo.schemaName, + fullSchemaCode: schemaInfo.fullSchemaCode, + isInlineSchema: schemaInfo.isInlineSchema, + responseType: extractResponseType(content), + }); + } + + return endpoints; +} + +function generateIndexFile( + resources: string[], + resourceEndpoints: Map, +): string { + const lines: string[] = [`import { z } from 'zod';`]; + + // Collect all schema imports needed from ../schemas + const allSchemaImports = new Set(); + + for (const [, endpoints] of resourceEndpoints) { + for (const endpoint of endpoints) { + if (endpoint.schemaImport) { + allSchemaImports.add(endpoint.schemaImport); + } + if (endpoint.fullSchemaCode) { + const schemaNames = endpoint.fullSchemaCode.match(/\b(\w+Schema)\b/g) || []; + schemaNames.forEach((name) => allSchemaImports.add(name)); + } + // Collect schema imports needed for response types + if (endpoint.responseType) { + const schemaNames = endpoint.responseType.match(/\b(\w+Schema)\b/g) || []; + schemaNames.forEach((name) => allSchemaImports.add(name)); + // If response uses listResult, import listResultSchema + if (endpoint.responseType.includes('listResult(')) { + allSchemaImports.add('listResultSchema'); + } + } + } + } + + if (allSchemaImports.size > 0) { + lines.push( + `import { ${Array.from(allSchemaImports).sort().join(', ')} } from '../schemas';`, + ); + } + lines.push(`import { ApiClient } from '../client';`); + lines.push(''); + + // schemas object + lines.push('export const schemas = {'); + + for (const [resourceName, endpoints] of resourceEndpoints) { + const schemaExports: string[] = []; + + for (const endpoint of endpoints) { + if (endpoint.isInlineSchema && endpoint.fullSchemaCode) { + schemaExports.push(` ${endpoint.name}: ${endpoint.fullSchemaCode},`); + } else if (endpoint.schemaImport) { + schemaExports.push(` ${endpoint.name}: ${endpoint.schemaImport},`); + } + } + + if (schemaExports.length > 0) { + lines.push(` ${resourceName}: {`); + lines.push(...schemaExports); + lines.push(` },`); + } else { + lines.push(` ${resourceName}: {},`); + } + } + + lines.push('} as const;'); + lines.push(''); + + // Path param types + for (const [resourceName, endpoints] of resourceEndpoints) { + for (const endpoint of endpoints) { + if (endpoint.hasPathParams) { + const typeName = `${toPascalCase(resourceName)}${toPascalCase(endpoint.name)}PathParams`; + const pathParamsType = `{ ${endpoint.pathParams.map((p) => `${p}: string`).join('; ')} }`; + lines.push(`export type ${typeName} = ${pathParamsType};`); + } + } + } + + lines.push(''); + + // Param types + for (const [resourceName, endpoints] of resourceEndpoints) { + for (const endpoint of endpoints) { + if (endpoint.schemaName) { + const paramsTypeName = `${toPascalCase(resourceName)}${toPascalCase(endpoint.name)}Params`; + lines.push( + `export type ${paramsTypeName} = z.infer;`, + ); + } + } + } + + lines.push(''); + + // Response types (inferred from handler return values) + for (const [resourceName, endpoints] of resourceEndpoints) { + for (const endpoint of endpoints) { + if (endpoint.responseType) { + const responseTypeName = `${toPascalCase(resourceName)}${toPascalCase(endpoint.name)}Response`; + + if (endpoint.responseType.startsWith('{')) { + // Inline object type + lines.push(`export type ${responseTypeName} = ${endpoint.responseType};`); + } else if (endpoint.responseType.includes('listResult(')) { + // listResult(schema) → z.infer> + const innerSchema = endpoint.responseType.match(/listResult\((\w+)\)/)?.[1]; + if (innerSchema) { + lines.push(`export type ${responseTypeName} = z.infer>>;`); + } + } else { + // Schema reference like userPublicSchema + lines.push(`export type ${responseTypeName} = z.infer;`); + } + } + } + } + + lines.push(''); + + // Endpoint creator functions + for (const [resourceName, endpoints] of resourceEndpoints) { + const pascalName = toPascalCase(resourceName); + + lines.push(`function create${pascalName}Endpoints(client: ApiClient) {`); + lines.push(' return {'); + + for (const endpoint of endpoints) { + const { name, method, path: endpointPath, hasPathParams, schemaName } = endpoint; + const fullPath = `/${resourceName}${endpointPath === '/' ? '' : endpointPath}`; + + const paramsTypeName = schemaName + ? `${toPascalCase(resourceName)}${toPascalCase(name)}Params` + : 'Record'; + + const pathParamsTypeName = hasPathParams + ? `${toPascalCase(resourceName)}${toPascalCase(name)}PathParams` + : 'never'; + + let pathExpr = `'${fullPath}'`; + if (hasPathParams) { + pathExpr = '`' + fullPath.replace(/:(\w+)/g, '${options.pathParams.$1}') + '`'; + } + + const needsData = ['post', 'put', 'patch'].includes(method); + + const responseTypeName = endpoint.responseType + ? `${toPascalCase(resourceName)}${toPascalCase(name)}Response` + : 'void'; + + lines.push(` ${name}: {`); + lines.push(` method: '${method}' as const,`); + lines.push(` path: '${fullPath}' as const,`); + lines.push( + ` schema: ${schemaName ? `schemas.${resourceName}.${name}` : 'undefined'},`, + ); + + if (hasPathParams) { + if (schemaName) { + lines.push( + ` call: (params: ${paramsTypeName}, options: { pathParams: ${pathParamsTypeName}; headers?: Record }) =>`, + ); + } else { + lines.push( + ` call: (params: Record | undefined, options: { pathParams: ${pathParamsTypeName}; headers?: Record }) =>`, + ); + } + lines.push( + ` client.${method}<${responseTypeName}>(${pathExpr}, params, options.headers ? { headers: options.headers } : undefined),`, + ); + } else { + if (schemaName) { + lines.push(` call: (params: ${paramsTypeName}) =>`); + } else { + lines.push(` call: (params?: Record) =>`); + } + lines.push(` client.${method}<${responseTypeName}>(${pathExpr}, params),`); + } + lines.push(` },`); + } + + lines.push(' };'); + lines.push('}'); + lines.push(''); + } + + lines.push('export function createApiEndpoints(client: ApiClient) {'); + lines.push(' return {'); + + for (const resource of resources) { + const camelName = toCamelCase(resource); + const pascalName = toPascalCase(resource); + lines.push(` ${camelName}: create${pascalName}Endpoints(client),`); + } + + lines.push(' };'); + lines.push('}'); + lines.push(''); + + lines.push('export type ApiEndpoints = ReturnType;'); + lines.push(''); + + lines.push( + `export interface ApiEndpoint {`, + ); + lines.push(` method: 'get' | 'post' | 'put' | 'patch' | 'delete';`); + lines.push(` path: string;`); + lines.push(` schema: z.ZodType | undefined;`); + lines.push(` call: TPathParams extends never`); + lines.push(` ? (params: TParams) => Promise`); + lines.push( + ` : (params: TParams, options: { pathParams: TPathParams; headers?: Record }) => Promise;`, + ); + lines.push(`}`); + lines.push(''); + + lines.push( + 'export type InferParams = T extends { schema: infer S } ? (S extends z.ZodType ? z.infer : Record) : Record;', + ); + lines.push(''); + lines.push( + 'export type InferPathParams = T extends { call: (params: unknown, options: { pathParams: infer PP }) => unknown } ? PP : never;', + ); + lines.push(''); + lines.push( + 'export type InferResponse = T extends { call: (...args: never[]) => Promise } ? R : unknown;', + ); + lines.push(''); + + return lines.join('\n'); +} + +// ─── Main ─────────────────────────────────────────────────────────────── + +async function generate() { + console.log('🔍 Scanning API resources...'); + + if (!fs.existsSync(GENERATED_PATH)) { + fs.mkdirSync(GENERATED_PATH, { recursive: true }); + } + + // Step 1: Sync schemas + syncSchemas(); + + // Step 2: Generate API client + const resources = getResources(); + console.log(`📦 Found resources: ${resources.join(', ')}`); + + const resourceEndpoints = new Map(); + const generatedResources: string[] = []; + + for (const resource of resources) { + const endpoints = await getEndpoints(resource); + + if (endpoints.length === 0) { + console.log(`⏭️ Skipping ${resource}: no endpoints found`); + continue; + } + + console.log(`✨ Processing ${resource} (${endpoints.length} endpoints)`); + + for (const endpoint of endpoints) { + const schemaInfo = endpoint.schemaName + ? endpoint.isInlineSchema + ? 'inline schema' + : `import: ${endpoint.schemaImport}` + : 'no schema'; + const responseInfo = endpoint.responseType + ? `response: ${endpoint.responseType}` + : 'void'; + console.log( + ` - ${endpoint.name}: ${endpoint.method.toUpperCase()} ${endpoint.path} (${schemaInfo}, ${responseInfo})`, + ); + } + + resourceEndpoints.set(resource, endpoints); + generatedResources.push(resource); + } + + const indexContent = generateIndexFile(generatedResources, resourceEndpoints); + fs.writeFileSync(path.join(GENERATED_PATH, 'index.ts'), indexContent); + + console.log('✅ Generation complete!'); +} + +// Watch mode support +const isWatch = process.argv.includes('--watch'); + +if (isWatch) { + console.log('👀 Starting watch mode...'); + await generate(); + + let debounceTimer: ReturnType | null = null; + + const debouncedGenerate = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + console.log('\n🔄 Changes detected, regenerating...'); + await generate(); + }, 300); + }; + + fs.watch(API_RESOURCES_PATH, { recursive: true }, (_, filename) => { + if (filename && (filename.endsWith('.schema.ts') || filename.includes('endpoints/'))) { + debouncedGenerate(); + } + }); + + console.log('👀 Watching for changes in API resources...'); +} else { + await generate(); +} diff --git a/template/apps/web/src/services/api.service.ts b/template/packages/shared/src/client.ts similarity index 50% rename from template/apps/web/src/services/api.service.ts rename to template/packages/shared/src/client.ts index 3a257a41d..a39963daa 100644 --- a/template/apps/web/src/services/api.service.ts +++ b/template/packages/shared/src/client.ts @@ -1,7 +1,5 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; -import config from 'config'; - type ApiErrorHandler = (error: ApiError) => void; type EventHandler = ApiErrorHandler; @@ -12,15 +10,11 @@ interface EventHandlers { export class ApiError extends Error { data: unknown; - status: number; constructor(data: unknown, status = 500, statusText = 'Internal Server Error') { super(`${status} ${statusText}`); - - this.constructor = ApiError; - - this.name = this.constructor.name; + this.name = 'ApiError'; this.data = data; this.status = status; @@ -34,25 +28,27 @@ export class ApiError extends Error { } } -interface ThrowApiErrorProps { - status: number; - statusText: string; - data: unknown; +export interface ApiClientConfig { + baseURL: string; + withCredentials?: boolean; } -class ApiClient { - _api: AxiosInstance; - +export class ApiClient { + private _api: AxiosInstance; private _handlers: Map>; - constructor(axiosConfig: AxiosRequestConfig) { + constructor(config: ApiClientConfig) { this._handlers = new Map>(); - this._api = axios.create(axiosConfig); + this._api = axios.create({ + baseURL: config.baseURL, + withCredentials: config.withCredentials ?? true, + responseType: 'json', + }); this._api.interceptors.response.use( (response: AxiosResponse) => response.data, (error) => { - const errorResponse: ThrowApiErrorProps = error.response || { + const errorResponse = error.response || { status: error.code ? Number.parseInt(error.code, 10) : 500, statusText: error.message || 'Network or timeout error', data: error.data, @@ -60,9 +56,8 @@ class ApiClient { const apiError = new ApiError(errorResponse.data, errorResponse.status, errorResponse.statusText); - const errorHandlers = this._handlers.get('error') as Set; - - errorHandlers.forEach((handler) => handler(apiError)); + const errorHandlers = this._handlers.get('error'); + errorHandlers?.forEach((handler) => handler(apiError)); throw apiError; }, @@ -71,55 +66,35 @@ class ApiClient { on(event: T, handler: EventHandlers[T]): void { let handlers = this._handlers.get(event); - if (!handlers) { handlers = new Set(); - this._handlers.set(event, handlers); } - handlers.add(handler as EventHandler); } - get(url: string, params: P | unknown = {}, requestConfig: AxiosRequestConfig = {}): Promise { - return this._api({ - method: 'get', - url, - params, - ...requestConfig, - }); + off(event: T, handler: EventHandlers[T]): void { + const handlers = this._handlers.get(event); + handlers?.delete(handler as EventHandler); } - post(url: string, data: D | unknown = {}, requestConfig: AxiosRequestConfig = {}): Promise { - return this._api({ - method: 'post', - url, - data, - ...requestConfig, - }); + get(url: string, params?: P, config?: AxiosRequestConfig): Promise { + return this._api({ method: 'get', url, params, ...config }); } - put(url: string, data: D | unknown = {}, requestConfig: AxiosRequestConfig = {}): Promise { - return this._api({ - method: 'put', - url, - data, - ...requestConfig, - }); + post(url: string, data?: D, config?: AxiosRequestConfig): Promise { + return this._api({ method: 'post', url, data, ...config }); } - delete(url: string, data: D | unknown = {}, requestConfig: AxiosRequestConfig = {}): Promise { - return this._api({ - method: 'delete', - url, - data, - ...requestConfig, - }); + put(url: string, data?: D, config?: AxiosRequestConfig): Promise { + return this._api({ method: 'put', url, data, ...config }); + } + + patch(url: string, data?: D, config?: AxiosRequestConfig): Promise { + return this._api({ method: 'patch', url, data, ...config }); } -} -export default new ApiClient({ - baseURL: config.API_URL, - withCredentials: true, - responseType: 'json', -}); + delete(url: string, data?: D, config?: AxiosRequestConfig): Promise { + return this._api({ method: 'delete', url, data, ...config }); + } +} diff --git a/template/packages/shared/src/constants.ts b/template/packages/shared/src/constants.ts new file mode 100644 index 000000000..2a1dee9f6 --- /dev/null +++ b/template/packages/shared/src/constants.ts @@ -0,0 +1,14 @@ +export const ONE_MB_IN_BYTES = 1_048_576; + +export const IMAGE_MIME_TYPE = ['image/jpg', 'image/jpeg', 'image/png']; + +export const USER_AVATAR = { + MAX_FILE_SIZE: 3 * ONE_MB_IN_BYTES, + ACCEPTED_FILE_TYPES: IMAGE_MIME_TYPE, +}; + +export const PASSWORD_RULES = { + MIN_LENGTH: 8, + MAX_LENGTH: 128, + REGEX: /^(?=.*[a-z])(?=.*\d).+$/i, +}; diff --git a/template/packages/shared/src/generated/index.ts b/template/packages/shared/src/generated/index.ts new file mode 100644 index 000000000..459285bd1 --- /dev/null +++ b/template/packages/shared/src/generated/index.ts @@ -0,0 +1,202 @@ +import { z } from 'zod'; +import { forgotPasswordSchema, listResultSchema, paginationSchema, resendEmailSchema, resetPasswordSchema, signInSchema, signUpSchema, updateUserSchema, userPublicSchema, userSchema } from '../schemas'; +import { ApiClient } from '../client'; + +export const schemas = { + account: { + forgotPassword: forgotPasswordSchema, + resendEmail: resendEmailSchema, + resetPassword: resetPasswordSchema, + signIn: signInSchema, + signUp: signUpSchema, + update: updateUserSchema, + verifyEmail: z.object({ + token: z.string().min(1, 'Token is required'), +}), + verifyResetToken: z.object({ + token: z.string().min(1, 'Token is required'), +}), + }, + users: { + list: paginationSchema.extend({ + filter: z + .object({ + createdOn: z + .object({ + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + }) + .optional(), + }) + .optional(), + sort: z + .object({ + firstName: z.enum(['asc', 'desc']).optional(), + lastName: z.enum(['asc', 'desc']).optional(), + createdOn: z.enum(['asc', 'desc']).default('asc'), + }) + .default({ createdOn: 'asc' }), +}), + update: userSchema.pick({ firstName: true, lastName: true, email: true }), + }, +} as const; + +export type UsersRemovePathParams = { id: string }; +export type UsersUpdatePathParams = { id: string }; + +export type AccountForgotPasswordParams = z.infer; +export type AccountResendEmailParams = z.infer; +export type AccountResetPasswordParams = z.infer; +export type AccountSignInParams = z.infer; +export type AccountSignUpParams = z.infer; +export type AccountUpdateParams = z.infer; +export type AccountVerifyEmailParams = z.infer; +export type AccountVerifyResetTokenParams = z.infer; +export type UsersListParams = z.infer; +export type UsersUpdateParams = z.infer; + +export type AccountGetResponse = z.infer; +export type AccountSignInResponse = z.infer; +export type AccountSignUpResponse = { emailVerificationToken: string }; +export type AccountUpdateResponse = z.infer; +export type UsersListResponse = z.infer>>; +export type UsersUpdateResponse = z.infer; + +function createAccountEndpoints(client: ApiClient) { + return { + forgotPassword: { + method: 'post' as const, + path: '/account/forgot-password' as const, + schema: schemas.account.forgotPassword, + call: (params: AccountForgotPasswordParams) => + client.post('/account/forgot-password', params), + }, + get: { + method: 'get' as const, + path: '/account' as const, + schema: undefined, + call: (params?: Record) => + client.get('/account', params), + }, + googleCallback: { + method: 'get' as const, + path: '/account/sign-in/google/callback' as const, + schema: undefined, + call: (params?: Record) => + client.get('/account/sign-in/google/callback', params), + }, + google: { + method: 'get' as const, + path: '/account/sign-in/google' as const, + schema: undefined, + call: (params?: Record) => + client.get('/account/sign-in/google', params), + }, + resendEmail: { + method: 'post' as const, + path: '/account/resend-email' as const, + schema: schemas.account.resendEmail, + call: (params: AccountResendEmailParams) => + client.post('/account/resend-email', params), + }, + resetPassword: { + method: 'put' as const, + path: '/account/reset-password' as const, + schema: schemas.account.resetPassword, + call: (params: AccountResetPasswordParams) => + client.put('/account/reset-password', params), + }, + signIn: { + method: 'post' as const, + path: '/account/sign-in' as const, + schema: schemas.account.signIn, + call: (params: AccountSignInParams) => + client.post('/account/sign-in', params), + }, + signOut: { + method: 'post' as const, + path: '/account/sign-out' as const, + schema: undefined, + call: (params?: Record) => + client.post('/account/sign-out', params), + }, + signUp: { + method: 'post' as const, + path: '/account/sign-up' as const, + schema: schemas.account.signUp, + call: (params: AccountSignUpParams) => + client.post('/account/sign-up', params), + }, + update: { + method: 'put' as const, + path: '/account' as const, + schema: schemas.account.update, + call: (params: AccountUpdateParams) => + client.put('/account', params), + }, + verifyEmail: { + method: 'get' as const, + path: '/account/verify-email' as const, + schema: schemas.account.verifyEmail, + call: (params: AccountVerifyEmailParams) => + client.get('/account/verify-email', params), + }, + verifyResetToken: { + method: 'get' as const, + path: '/account/verify-reset-token' as const, + schema: schemas.account.verifyResetToken, + call: (params: AccountVerifyResetTokenParams) => + client.get('/account/verify-reset-token', params), + }, + }; +} + +function createUsersEndpoints(client: ApiClient) { + return { + list: { + method: 'get' as const, + path: '/users' as const, + schema: schemas.users.list, + call: (params: UsersListParams) => + client.get('/users', params), + }, + remove: { + method: 'delete' as const, + path: '/users/:id' as const, + schema: undefined, + call: (params: Record | undefined, options: { pathParams: UsersRemovePathParams; headers?: Record }) => + client.delete(`/users/${options.pathParams.id}`, params, options.headers ? { headers: options.headers } : undefined), + }, + update: { + method: 'put' as const, + path: '/users/:id' as const, + schema: schemas.users.update, + call: (params: UsersUpdateParams, options: { pathParams: UsersUpdatePathParams; headers?: Record }) => + client.put(`/users/${options.pathParams.id}`, params, options.headers ? { headers: options.headers } : undefined), + }, + }; +} + +export function createApiEndpoints(client: ApiClient) { + return { + account: createAccountEndpoints(client), + users: createUsersEndpoints(client), + }; +} + +export type ApiEndpoints = ReturnType; + +export interface ApiEndpoint { + method: 'get' | 'post' | 'put' | 'patch' | 'delete'; + path: string; + schema: z.ZodType | undefined; + call: TPathParams extends never + ? (params: TParams) => Promise + : (params: TParams, options: { pathParams: TPathParams; headers?: Record }) => Promise; +} + +export type InferParams = T extends { schema: infer S } ? (S extends z.ZodType ? z.infer : Record) : Record; + +export type InferPathParams = T extends { call: (params: unknown, options: { pathParams: infer PP }) => unknown } ? PP : never; + +export type InferResponse = T extends { call: (...args: never[]) => Promise } ? R : unknown; diff --git a/template/packages/shared/src/index.ts b/template/packages/shared/src/index.ts new file mode 100644 index 000000000..de0cdc910 --- /dev/null +++ b/template/packages/shared/src/index.ts @@ -0,0 +1,7 @@ +export { ApiClient, ApiError } from './client'; +export type { ApiClientConfig } from './client'; + +export * from './types'; +export * from './constants'; +export * from './schemas'; +export * from './generated'; diff --git a/template/packages/shared/src/schemas/account/account.schema.ts b/template/packages/shared/src/schemas/account/account.schema.ts new file mode 100644 index 000000000..dd97523ab --- /dev/null +++ b/template/packages/shared/src/schemas/account/account.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { emailSchema, passwordSchema } from '../base.schema'; +import { userSchema } from '../users/user.schema'; + +export const signInSchema = z.object({ + email: emailSchema, + password: z.string().min(1, 'Password is required').max(128, 'Password must be less than 128 characters.'), +}); + +export const signUpSchema = userSchema.pick({ firstName: true, lastName: true }).extend({ + email: emailSchema, + password: passwordSchema, +}); + +export const resendEmailSchema = z.object({ + email: emailSchema, +}); + +export const forgotPasswordSchema = z.object({ + email: emailSchema, +}); + +export const resetPasswordSchema = z.object({ + token: z.string().min(1, 'Token is required'), + password: passwordSchema, +}); diff --git a/template/packages/shared/src/schemas/base.schema.ts b/template/packages/shared/src/schemas/base.schema.ts new file mode 100644 index 000000000..4914e4798 --- /dev/null +++ b/template/packages/shared/src/schemas/base.schema.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +export const dbSchema = z.object({ + _id: z.string(), + + createdOn: z.date().optional(), + updatedOn: z.date().optional(), + deletedOn: z.date().optional().nullable(), +}); + +export const paginationSchema = z.object({ + page: z.coerce.number().default(1), + perPage: z.coerce.number().default(10), + + searchValue: z.string().optional(), + + sort: z + .object({ + createdOn: z.enum(['asc', 'desc']).default('asc'), + }) + .default({ createdOn: 'asc' }), +}); + +export const listResultSchema = (itemSchema: T) => + z.object({ + results: z.array(itemSchema), + pagesCount: z.number(), + count: z.number(), + }); + +export const emailSchema = z + .email() + .min(1, 'Email is required') + .toLowerCase() + .trim() + .max(255, 'Email must be less than 255 characters.'); + +const PASSWORD_RULES = { + MIN_LENGTH: 8, + MAX_LENGTH: 128, + REGEX: /^(?=.*[a-z])(?=.*\d).+$/i, +}; + +export const passwordSchema = z + .string() + .min(1, 'Password is required') + .min(PASSWORD_RULES.MIN_LENGTH, `Password must be at least ${PASSWORD_RULES.MIN_LENGTH} characters.`) + .max(PASSWORD_RULES.MAX_LENGTH, `Password must be less than ${PASSWORD_RULES.MAX_LENGTH} characters.`) + .regex( + PASSWORD_RULES.REGEX, + `The password must contain ${PASSWORD_RULES.MIN_LENGTH} or more characters with at least one letter (a-z) and one number (0-9).`, + ); diff --git a/template/packages/shared/src/schemas/index.ts b/template/packages/shared/src/schemas/index.ts new file mode 100644 index 000000000..b7d7f7711 --- /dev/null +++ b/template/packages/shared/src/schemas/index.ts @@ -0,0 +1,4 @@ +export * from './base.schema'; +export * from './account/account.schema'; +export * from './token/token.schema'; +export * from './users/user.schema'; diff --git a/template/packages/shared/src/schemas/token/token.schema.ts b/template/packages/shared/src/schemas/token/token.schema.ts new file mode 100644 index 000000000..2102526d9 --- /dev/null +++ b/template/packages/shared/src/schemas/token/token.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { dbSchema } from '../base.schema'; + +export enum TokenType { + ACCESS = 'access', + EMAIL_VERIFICATION = 'email-verification', + RESET_PASSWORD = 'reset-password', +} + +export const tokenSchema = dbSchema.extend({ + value: z.string(), + userId: z.string(), + type: z.enum([TokenType.ACCESS, TokenType.EMAIL_VERIFICATION, TokenType.RESET_PASSWORD]), + expiresOn: z.date(), +}); diff --git a/template/packages/shared/src/schemas/users/user.schema.ts b/template/packages/shared/src/schemas/users/user.schema.ts new file mode 100644 index 000000000..d4fd026b3 --- /dev/null +++ b/template/packages/shared/src/schemas/users/user.schema.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +import { dbSchema, emailSchema, passwordSchema } from '../base.schema'; + +const ONE_MB_IN_BYTES = 1_048_576; +const IMAGE_MIME_TYPE = ['image/jpg', 'image/jpeg', 'image/png']; +const USER_AVATAR = { + MAX_FILE_SIZE: 3 * ONE_MB_IN_BYTES, + ACCEPTED_FILE_TYPES: IMAGE_MIME_TYPE, +}; + +const oauthSchema = z.object({ + google: z + .object({ + userId: z.string().min(1, 'Google user ID is required'), + connectedOn: z.date(), + }) + .optional(), +}); + +export const userSchema = dbSchema.extend({ + firstName: z.string().min(1, 'First name is required').max(128, 'First name must be less than 128 characters.'), + lastName: z.string().min(1, 'Last name is required').max(128, 'Last name must be less than 128 characters.'), + + email: emailSchema, + passwordHash: z.string().optional(), + + isEmailVerified: z.boolean().default(false), + + avatarUrl: z.string().nullable().optional(), + + oauth: oauthSchema.optional(), + + lastRequest: z.date().optional(), +}); + +export const userPublicSchema = userSchema.omit({ + passwordHash: true, +}); + +export const updateUserSchema = userSchema + .pick({ firstName: true, lastName: true }) + .extend({ + password: z.union([ + passwordSchema, + z.literal(''), + ]), + avatar: z.union([ + z.any(), // File validation handled at runtime (browser File or formidable File) + z.literal(''), + ]).nullable(), + }) + .partial(); diff --git a/template/packages/app-types/src/common.types.ts b/template/packages/shared/src/types.ts similarity index 50% rename from template/packages/app-types/src/common.types.ts rename to template/packages/shared/src/types.ts index 0e0862d98..388a50e80 100644 --- a/template/packages/app-types/src/common.types.ts +++ b/template/packages/shared/src/types.ts @@ -1,7 +1,16 @@ -import type { File as FormidableFile } from 'formidable'; +import type { z } from 'zod'; -export type BackendFile = FormidableFile; -export type FrontendFile = File; +import { userPublicSchema, updateUserSchema } from './schemas'; + +// Domain types +export type User = z.infer; +export type UpdateUserParams = z.infer; + +export interface UpdateUserParamsFrontend extends Omit { + avatar?: File | string; +} + +// Utility types type Path = T extends object ? { @@ -24,3 +33,23 @@ type CamelCase = S extends `${infer P1}_${infer P2}${infer P3} export type ToCamelCase = { [K in keyof T as CamelCase]: T[K] extends object ? ToCamelCase : T[K]; }; + +export interface ListResult { + results: T[]; + pagesCount: number; + count: number; +} + +export type SortOrder = 'asc' | 'desc'; + +export type SortParams = { + [P in keyof F]?: SortOrder; +}; + +export interface ListParams { + page?: number; + perPage?: number; + searchValue?: string; + filter?: T; + sort?: SortParams; +} diff --git a/template/packages/app-types/tsconfig.json b/template/packages/shared/tsconfig.json similarity index 55% rename from template/packages/app-types/tsconfig.json rename to template/packages/shared/tsconfig.json index 5c7c801eb..dc2cfe8b6 100644 --- a/template/packages/app-types/tsconfig.json +++ b/template/packages/shared/tsconfig.json @@ -4,5 +4,6 @@ "baseUrl": "src", "rootDir": "." }, - "include": ["**/*.ts", "**/*.json"] + "include": ["**/*.ts", "**/*.json"], + "exclude": ["node_modules", "dist", "scripts"] } diff --git a/template/pnpm-lock.yaml b/template/pnpm-lock.yaml index d2187867b..f5662e002 100644 --- a/template/pnpm-lock.yaml +++ b/template/pnpm-lock.yaml @@ -6,9 +6,6 @@ settings: catalogs: default: - '@types/formidable': - specifier: 2.0.5 - version: 2.0.5 '@types/node': specifier: 22.10.10 version: 22.10.10 @@ -105,9 +102,6 @@ importers: app-constants: specifier: workspace:* version: link:../../packages/app-constants - app-types: - specifier: workspace:* - version: link:../../packages/app-types arctic: specifier: 3.7.0 version: 3.7.0 @@ -156,9 +150,6 @@ importers: resend: specifier: 4.5.2 version: 4.5.2(react-dom@19.0.3(react@19.0.3))(react@19.0.3) - schemas: - specifier: workspace:* - version: link:../../packages/schemas socket.io: specifier: 4.8.1 version: 4.8.1 @@ -262,12 +253,6 @@ importers: '@tanstack/react-table': specifier: 8.19.2 version: 8.19.2(react-dom@19.0.3(react@19.0.3))(react@19.0.3) - app-constants: - specifier: workspace:* - version: link:../../packages/app-constants - app-types: - specifier: workspace:* - version: link:../../packages/app-types axios: specifier: 1.12.2 version: 1.12.2 @@ -304,9 +289,9 @@ importers: react-hook-form: specifier: 7.57.0 version: 7.57.0(react@19.0.3) - schemas: + shared: specifier: workspace:* - version: link:../../packages/schemas + version: link:../../packages/shared socket.io-client: specifier: 4.7.5 version: 4.7.5 @@ -396,73 +381,6 @@ importers: specifier: 'catalog:' version: 5.8.3 - packages/app-types: - dependencies: - enums: - specifier: workspace:* - version: link:../enums - schemas: - specifier: workspace:* - version: link:../schemas - zod: - specifier: 'catalog:' - version: 4.0.5 - devDependencies: - '@types/formidable': - specifier: 'catalog:' - version: 2.0.5 - '@types/node': - specifier: 'catalog:' - version: 22.10.10 - eslint: - specifier: 'catalog:' - version: 9.34.0(jiti@2.6.1) - eslint-config: - specifier: workspace:* - version: link:../eslint-config - lint-staged: - specifier: 'catalog:' - version: 16.1.2 - prettier: - specifier: 'catalog:' - version: 3.6.2 - prettier-config: - specifier: workspace:* - version: link:../prettier-config - tsconfig: - specifier: workspace:* - version: link:../tsconfig - typescript: - specifier: 'catalog:' - version: 5.8.3 - - packages/enums: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 22.10.10 - eslint: - specifier: 'catalog:' - version: 9.34.0(jiti@2.6.1) - eslint-config: - specifier: workspace:* - version: link:../eslint-config - lint-staged: - specifier: 'catalog:' - version: 16.1.2 - prettier: - specifier: 'catalog:' - version: 3.6.2 - prettier-config: - specifier: workspace:* - version: link:../prettier-config - tsconfig: - specifier: workspace:* - version: link:../tsconfig - typescript: - specifier: 'catalog:' - version: 5.8.3 - packages/eslint-config: devDependencies: '@antfu/eslint-config': @@ -547,21 +465,15 @@ importers: packages/prettier-config: {} - packages/schemas: + packages/shared: dependencies: - app-constants: - specifier: workspace:* - version: link:../app-constants - enums: - specifier: workspace:* - version: link:../enums + axios: + specifier: 1.8.1 + version: 1.8.1 zod: specifier: 'catalog:' version: 4.0.5 devDependencies: - '@types/formidable': - specifier: 'catalog:' - version: 2.0.5 '@types/node': specifier: 'catalog:' version: 22.10.10 @@ -583,6 +495,9 @@ importers: tsconfig: specifier: workspace:* version: link:../tsconfig + tsx: + specifier: 4.20.3 + version: 4.20.3 typescript: specifier: 'catalog:' version: 5.8.3 @@ -3237,6 +3152,9 @@ packages: axios@1.12.2: resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + axios@1.8.1: + resolution: {integrity: sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==} + babel-plugin-polyfill-corejs2@0.4.14: resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} peerDependencies: @@ -6162,6 +6080,11 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + tsx@4.20.3: + resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} + engines: {node: '>=18.0.0'} + hasBin: true + tsx@4.20.4: resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==} engines: {node: '>=18.0.0'} @@ -10070,6 +9993,14 @@ snapshots: transitivePeerDependencies: - debug + axios@1.8.1: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.0): dependencies: '@babel/compat-data': 7.28.0 @@ -10567,7 +10498,7 @@ snapshots: engine.io@6.6.4: dependencies: '@types/cors': 2.8.19 - '@types/node': 22.10.10 + '@types/node': 22.14.1 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -13510,6 +13441,13 @@ snapshots: tsscmp@1.0.6: {} + tsx@4.20.3: + dependencies: + esbuild: 0.25.10 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tsx@4.20.4: dependencies: esbuild: 0.25.10 From 0643097abcf9e3ff86aa68baf0cb70cd1e6cf021 Mon Sep 17 00:00:00 2001 From: Igor Krasnik Date: Wed, 11 Feb 2026 18:39:02 +0100 Subject: [PATCH 02/22] Get rid of validator method in endpoints --- .../account/endpoints/forgot-password.ts | 32 +++----- .../account/endpoints/resend-email.ts | 33 +++----- .../account/endpoints/reset-password.ts | 35 +++----- .../resources/account/endpoints/sign-in.ts | 81 +++++++++---------- .../resources/account/endpoints/sign-up.ts | 26 +++--- .../src/resources/account/endpoints/update.ts | 45 ++++------- .../account/endpoints/verify-email.ts | 34 +++----- .../src/resources/users/endpoints/remove.ts | 22 ++--- .../src/resources/users/endpoints/update.ts | 26 +++--- template/apps/api/src/routes/routes.ts | 7 +- template/apps/api/src/routes/types.ts | 18 +---- 11 files changed, 138 insertions(+), 221 deletions(-) diff --git a/template/apps/api/src/resources/account/endpoints/forgot-password.ts b/template/apps/api/src/resources/account/endpoints/forgot-password.ts index e68611beb..7babde169 100644 --- a/template/apps/api/src/resources/account/endpoints/forgot-password.ts +++ b/template/apps/api/src/resources/account/endpoints/forgot-password.ts @@ -4,34 +4,16 @@ import { userService } from 'resources/users'; import { rateLimitMiddleware } from 'middlewares'; import { emailService } from 'services'; import { isPublic } from 'routes/middlewares'; -import { createEndpoint, createMiddleware } from 'routes/types'; +import { createEndpoint } from 'routes/types'; import config from 'config'; import { RESET_PASSWORD_TOKEN } from 'app-constants'; import { forgotPasswordSchema } from '../account.schema'; -import { AppKoaContext, ForgotPasswordParams, Template, TokenType, User } from 'types'; +import { Template, TokenType } from 'types'; export const schema = forgotPasswordSchema; -interface ValidatedData extends ForgotPasswordParams { - user: User; -} - -const validator = createMiddleware(async (ctx, next) => { - const { email } = ctx.validatedData; - - const user = await userService.findOne({ email }); - - if (!user) { - ctx.status = 204; - return; - } - - ctx.validatedData.user = user; - await next(); -}); - export default createEndpoint({ method: 'post', path: '/forgot-password', @@ -42,11 +24,17 @@ export default createEndpoint({ limitDuration: 60 * 60, // 1 hour requestsPerDuration: 10, }), - validator, ], async handler(ctx) { - const { user } = (ctx as AppKoaContext).validatedData; + const { email } = ctx.validatedData; + + const user = await userService.findOne({ email }); + + if (!user) { + ctx.status = 204; + return; + } await Promise.all([ tokenService.invalidateUserTokens(user._id, TokenType.ACCESS), diff --git a/template/apps/api/src/resources/account/endpoints/resend-email.ts b/template/apps/api/src/resources/account/endpoints/resend-email.ts index 7d1bad8bb..758662a3a 100644 --- a/template/apps/api/src/resources/account/endpoints/resend-email.ts +++ b/template/apps/api/src/resources/account/endpoints/resend-email.ts @@ -4,42 +4,31 @@ import { userService } from 'resources/users'; import { rateLimitMiddleware } from 'middlewares'; import { emailService } from 'services'; import { isPublic } from 'routes/middlewares'; -import { createEndpoint, createMiddleware } from 'routes/types'; +import { createEndpoint } from 'routes/types'; import config from 'config'; import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; import { resendEmailSchema } from '../account.schema'; -import { AppKoaContext, ResendEmailParams, Template, TokenType, User } from 'types'; +import { Template, TokenType } from 'types'; export const schema = resendEmailSchema; -interface ValidatedData extends ResendEmailParams { - user: User; -} - -const validator = createMiddleware(async (ctx, next) => { - const { email } = ctx.validatedData; - - const user = await userService.findOne({ email }); - - if (!user) { - ctx.status = 204; - return; - } - - ctx.validatedData.user = user; - await next(); -}); - export default createEndpoint({ method: 'post', path: '/resend-email', schema, - middlewares: [isPublic, rateLimitMiddleware(), validator], + middlewares: [isPublic, rateLimitMiddleware()], async handler(ctx) { - const { user } = (ctx as AppKoaContext).validatedData; + const { email } = ctx.validatedData; + + const user = await userService.findOne({ email }); + + if (!user) { + ctx.status = 204; + return; + } await tokenService.invalidateUserTokens(user._id, TokenType.EMAIL_VERIFICATION); diff --git a/template/apps/api/src/resources/account/endpoints/reset-password.ts b/template/apps/api/src/resources/account/endpoints/reset-password.ts index 179d85352..ee1bf6b7f 100644 --- a/template/apps/api/src/resources/account/endpoints/reset-password.ts +++ b/template/apps/api/src/resources/account/endpoints/reset-password.ts @@ -4,40 +4,29 @@ import { userService } from 'resources/users'; import { rateLimitMiddleware } from 'middlewares'; import { securityUtil } from 'utils'; import { isPublic } from 'routes/middlewares'; -import { createEndpoint, createMiddleware } from 'routes/types'; +import { createEndpoint } from 'routes/types'; import { resetPasswordSchema } from '../account.schema'; -import { AppKoaContext, ResetPasswordParams, TokenType, User } from 'types'; +import { TokenType } from 'types'; export const schema = resetPasswordSchema; -interface ValidatedData extends ResetPasswordParams { - user: User; -} - -const validator = createMiddleware(async (ctx, next) => { - const { token } = ctx.validatedData; - - const resetPasswordToken = await tokenService.validateToken(token, TokenType.RESET_PASSWORD); - const user = await userService.findOne({ _id: resetPasswordToken?.userId }); - - if (!resetPasswordToken || !user) { - ctx.status = 204; - return; - } - - ctx.validatedData.user = user; - await next(); -}); - export default createEndpoint({ method: 'put', path: '/reset-password', schema, - middlewares: [isPublic, rateLimitMiddleware(), validator], + middlewares: [isPublic, rateLimitMiddleware()], async handler(ctx) { - const { user, password } = (ctx as AppKoaContext).validatedData; + const { token, password } = ctx.validatedData; + + const resetPasswordToken = await tokenService.validateToken(token, TokenType.RESET_PASSWORD); + const user = await userService.findOne({ _id: resetPasswordToken?.userId }); + + if (!resetPasswordToken || !user) { + ctx.status = 204; + return; + } const passwordHash = await securityUtil.hashPassword(password); diff --git a/template/apps/api/src/resources/account/endpoints/sign-in.ts b/template/apps/api/src/resources/account/endpoints/sign-in.ts index e56e52e16..e7f4c26ed 100644 --- a/template/apps/api/src/resources/account/endpoints/sign-in.ts +++ b/template/apps/api/src/resources/account/endpoints/sign-in.ts @@ -5,59 +5,56 @@ import { rateLimitMiddleware } from 'middlewares'; import { authService } from 'services'; import { securityUtil } from 'utils'; import { isPublic } from 'routes/middlewares'; -import { createEndpoint, createMiddleware } from 'routes/types'; +import { createEndpoint } from 'routes/types'; import { signInSchema } from '../account.schema'; -import { AppKoaContext, SignInParams, TokenType, User } from 'types'; +import { TokenType } from 'types'; export const schema = signInSchema; -interface ValidatedData extends SignInParams { - user: User; -} - -const validator = createMiddleware(async (ctx, next) => { - const { email, password } = ctx.validatedData; - - const user = await userService.findOne({ email }); - - ctx.assertClientError(user && user.passwordHash, { - credentials: 'The email or password you have entered is invalid', - }); - - const isPasswordMatch = await securityUtil.verifyPasswordHash(user!.passwordHash!, password); - - ctx.assertClientError(isPasswordMatch, { - credentials: 'The email or password you have entered is invalid', - }); - - if (!user!.isEmailVerified) { - const existingEmailVerificationToken = await tokenService.getUserActiveToken( - user!._id, - TokenType.EMAIL_VERIFICATION, - ); - - ctx.assertClientError(existingEmailVerificationToken, { - emailVerificationTokenExpired: true, - }); - } - - ctx.assertClientError(user!.isEmailVerified, { - email: 'Please verify your email to sign in', - }); - - ctx.validatedData.user = user!; - await next(); -}); - export default createEndpoint({ method: 'post', path: '/sign-in', schema, - middlewares: [isPublic, rateLimitMiddleware(), validator], + middlewares: [isPublic, rateLimitMiddleware()], async handler(ctx) { - const { user } = (ctx as AppKoaContext).validatedData; + const { email, password } = ctx.validatedData; + + const user = await userService.findOne({ email }); + + if (!user || !user.passwordHash) { + ctx.throwClientError({ + credentials: 'The email or password you have entered is invalid', + }); + } + + const isPasswordMatch = await securityUtil.verifyPasswordHash(user.passwordHash, password); + + if (!isPasswordMatch) { + ctx.throwClientError({ + credentials: 'The email or password you have entered is invalid', + }); + } + + if (!user.isEmailVerified) { + const existingEmailVerificationToken = await tokenService.getUserActiveToken( + user._id, + TokenType.EMAIL_VERIFICATION, + ); + + if (!existingEmailVerificationToken) { + ctx.throwClientError({ + emailVerificationTokenExpired: true, + }); + } + } + + if (!user.isEmailVerified) { + ctx.throwClientError({ + email: 'Please verify your email to sign in', + }); + } await authService.setAccessToken({ ctx, userId: user._id }); diff --git a/template/apps/api/src/resources/account/endpoints/sign-up.ts b/template/apps/api/src/resources/account/endpoints/sign-up.ts index 5c07bc9ed..36be183cc 100644 --- a/template/apps/api/src/resources/account/endpoints/sign-up.ts +++ b/template/apps/api/src/resources/account/endpoints/sign-up.ts @@ -5,37 +5,33 @@ import { rateLimitMiddleware } from 'middlewares'; import { emailService } from 'services'; import { securityUtil } from 'utils'; import { isPublic } from 'routes/middlewares'; -import { createEndpoint, createMiddleware } from 'routes/types'; +import { createEndpoint } from 'routes/types'; import config from 'config'; import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; import { signUpSchema } from '../account.schema'; -import { AppKoaContext, SignUpParams, Template, TokenType } from 'types'; +import { Template, TokenType } from 'types'; export const schema = signUpSchema; -const validator = createMiddleware(async (ctx, next) => { - const { email } = ctx.validatedData; - - const isUserExists = await userService.exists({ email }); - - ctx.assertClientError(!isUserExists, { - email: 'User with this email is already registered', - }); - - await next(); -}); - export default createEndpoint({ method: 'post', path: '/sign-up', schema, - middlewares: [isPublic, rateLimitMiddleware(), validator], + middlewares: [isPublic, rateLimitMiddleware()], async handler(ctx) { const { firstName, lastName, email, password } = ctx.validatedData; + const isUserExists = await userService.exists({ email }); + + if (isUserExists) { + ctx.throwClientError({ + email: 'User with this email is already registered', + }); + } + const user = await userService.insertOne({ email, firstName, diff --git a/template/apps/api/src/resources/account/endpoints/update.ts b/template/apps/api/src/resources/account/endpoints/update.ts index de1f7e19d..8e0bb4153 100644 --- a/template/apps/api/src/resources/account/endpoints/update.ts +++ b/template/apps/api/src/resources/account/endpoints/update.ts @@ -4,52 +4,35 @@ import { accountUtils } from 'resources/account'; import { userService } from 'resources/users'; import { securityUtil } from 'utils'; -import { createEndpoint, createMiddleware } from 'routes/types'; +import { createEndpoint } from 'routes/types'; import { updateUserSchema } from '../../users/user.schema'; -import { AppKoaContext, UpdateUserParamsBackend, User } from 'types'; +import type { User } from 'types'; export const schema = updateUserSchema; -interface ValidatedData extends UpdateUserParamsBackend { - passwordHash?: string; -} - -const validator = createMiddleware(async (ctx, next) => { - const { user } = ctx.state; - const { password } = ctx.validatedData; - - if (_.isEmpty(ctx.validatedData)) { - ctx.body = userService.getPublic(user); - return; - } - - if (password) { - ctx.validatedData.passwordHash = await securityUtil.hashPassword(password); - - delete ctx.validatedData.password; - } - - await next(); -}); - export default createEndpoint({ method: 'put', path: '/', schema, - middlewares: [validator], async handler(ctx) { - const typedCtx = ctx as AppKoaContext; - const { avatar } = typedCtx.validatedData; - const { user } = typedCtx.state; + const { user } = ctx.state; - const nonEmptyValues = _.pickBy(typedCtx.validatedData, (value) => !_.isUndefined(value)); - const updateData: Partial = _.omit(nonEmptyValues, 'avatar'); + if (_.isEmpty(ctx.validatedData)) { + return userService.getPublic(user); + } + + const { password, avatar, ...rest } = ctx.validatedData; + + const updateData: Partial = _.pickBy(rest, (value) => !_.isUndefined(value)); + + if (password) { + updateData.passwordHash = await securityUtil.hashPassword(password); + } if (avatar === '') { await accountUtils.removeAvatar(user); - updateData.avatarUrl = null; } diff --git a/template/apps/api/src/resources/account/endpoints/verify-email.ts b/template/apps/api/src/resources/account/endpoints/verify-email.ts index 50a8e47ca..7f0a85e8b 100644 --- a/template/apps/api/src/resources/account/endpoints/verify-email.ts +++ b/template/apps/api/src/resources/account/endpoints/verify-email.ts @@ -6,35 +6,16 @@ import { userService } from 'resources/users'; import { rateLimitMiddleware } from 'middlewares'; import { authService, emailService } from 'services'; import { isPublic } from 'routes/middlewares'; -import { createEndpoint, createMiddleware } from 'routes/types'; +import { createEndpoint } from 'routes/types'; import config from 'config'; -import { AppKoaContext, Template, TokenType, User } from 'types'; +import { Template, TokenType } from 'types'; export const schema = z.object({ token: z.string().min(1, 'Token is required'), }); -interface ValidatedData extends z.infer { - user: User; -} - -const validator = createMiddleware(async (ctx, next) => { - const { token } = ctx.validatedData; - - const emailVerificationToken = await tokenService.validateToken(token, TokenType.EMAIL_VERIFICATION); - const user = await userService.findOne({ _id: emailVerificationToken?.userId }); - - if (!emailVerificationToken || !user) { - ctx.throwGlobalErrorWithRedirect('Token is invalid or expired.'); - return; - } - - ctx.validatedData.user = user; - await next(); -}); - export default createEndpoint({ method: 'get', path: '/verify-email', @@ -45,12 +26,19 @@ export default createEndpoint({ limitDuration: 60 * 60, // 1 hour requestsPerDuration: 10, }), - validator, ], async handler(ctx) { try { - const { user } = (ctx as AppKoaContext).validatedData; + const { token } = ctx.validatedData; + + const emailVerificationToken = await tokenService.validateToken(token, TokenType.EMAIL_VERIFICATION); + const user = await userService.findOne({ _id: emailVerificationToken?.userId }); + + if (!emailVerificationToken || !user) { + ctx.throwGlobalErrorWithRedirect('Token is invalid or expired.'); + return; + } await tokenService.invalidateUserTokens(user._id, TokenType.EMAIL_VERIFICATION); diff --git a/template/apps/api/src/resources/users/endpoints/remove.ts b/template/apps/api/src/resources/users/endpoints/remove.ts index c09f49588..5973e4893 100644 --- a/template/apps/api/src/resources/users/endpoints/remove.ts +++ b/template/apps/api/src/resources/users/endpoints/remove.ts @@ -1,23 +1,23 @@ import { userService } from 'resources/users'; import { isAdmin } from 'routes/middlewares'; -import { createEndpoint, createMiddleware } from 'routes/types'; - -const validator = createMiddleware(async (ctx, next) => { - const isUserExists = await userService.exists({ _id: ctx.params.id }); - - ctx.assertError(isUserExists, 'User not found'); - - await next(); -}); +import { createEndpoint } from 'routes/types'; export default createEndpoint({ method: 'delete', path: '/:id', - middlewares: [isAdmin, validator], + middlewares: [isAdmin], async handler(ctx) { - await userService.deleteSoft({ _id: ctx.request.params.id }); + const { id } = ctx.request.params; + + const isUserExists = await userService.exists({ _id: id }); + + if (!isUserExists) { + ctx.throwError('User not found'); + } + + await userService.deleteSoft({ _id: id }); ctx.status = 204; }, diff --git a/template/apps/api/src/resources/users/endpoints/update.ts b/template/apps/api/src/resources/users/endpoints/update.ts index 41cb3c51b..fda4fcde4 100644 --- a/template/apps/api/src/resources/users/endpoints/update.ts +++ b/template/apps/api/src/resources/users/endpoints/update.ts @@ -3,33 +3,31 @@ import _ from 'lodash'; import { userService } from 'resources/users'; import { isAdmin } from 'routes/middlewares'; -import { createEndpoint, createMiddleware } from 'routes/types'; +import { createEndpoint } from 'routes/types'; import { userSchema } from '../user.schema'; export const schema = userSchema.pick({ firstName: true, lastName: true, email: true }); -const validator = createMiddleware(async (ctx, next) => { - const { id } = ctx.params; - - ctx.assertError(id, 'User ID is required'); - - const isUserExists = await userService.exists({ _id: id }); - - ctx.assertError(isUserExists, 'User not found'); - - await next(); -}); - export default createEndpoint({ method: 'put', path: '/:id', schema, - middlewares: [isAdmin, validator], + middlewares: [isAdmin], async handler(ctx) { const { id } = ctx.request.params; + if (!id) { + ctx.throwError('User ID is required'); + } + + const isUserExists = await userService.exists({ _id: id }); + + if (!isUserExists) { + ctx.throwError('User not found'); + } + const nonEmptyValues = _.pickBy(ctx.validatedData, (value) => !_.isUndefined(value)); const updatedUser = await userService.updateOne({ _id: id }, () => nonEmptyValues); diff --git a/template/apps/api/src/routes/routes.ts b/template/apps/api/src/routes/routes.ts index 0fd7158f6..d77563f50 100644 --- a/template/apps/api/src/routes/routes.ts +++ b/template/apps/api/src/routes/routes.ts @@ -22,14 +22,15 @@ const registerEndpoint = (router: AppRouter, resourceName: string, endpoint: End middlewares.push(auth as AppRouterMiddleware); } - if (endpoint.middlewares?.length) { - middlewares.push(...(endpoint.middlewares as AppRouterMiddleware[])); - } if (endpoint.schema) { middlewares.push(validateMiddleware(endpoint.schema) as AppRouterMiddleware); } + if (endpoint.middlewares?.length) { + middlewares.push(...(endpoint.middlewares as AppRouterMiddleware[])); + } + middlewares.push(endpoint.handler as AppRouterMiddleware); const fullPath = path.startsWith('/') ? path : `/${path}`; diff --git a/template/apps/api/src/routes/types.ts b/template/apps/api/src/routes/types.ts index 60e708801..2ca6b65de 100644 --- a/template/apps/api/src/routes/types.ts +++ b/template/apps/api/src/routes/types.ts @@ -46,28 +46,17 @@ export function createMiddleware( return fn as TypedMiddleware; } -// Extract state type from middleware -type ExtractState = T extends TypedMiddleware ? S : object; - -// Merge states from array of middlewares -type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never; - -type MergedState[]> = UnionToIntersection>; - export interface EndpointOptions< TPath extends string, TSchema extends ZodType = ZodType, - TMiddlewares extends TypedMiddleware[] = [], TResponse = void, > { method: HttpMethod; path: TPath; schema?: TSchema; - middlewares?: [...TMiddlewares]; + middlewares?: RouteMiddleware[]; handler: ( - ctx: AppKoaContext, RequestWithParams> & { - state: MergedState; - }, + ctx: AppKoaContext, RequestWithParams>, ) => Promise; } @@ -84,10 +73,9 @@ export interface EndpointResult< export function createEndpoint< TPath extends string, TSchema extends ZodType = ZodType, - TMiddlewares extends TypedMiddleware[] = [], TResponse = void, >( - options: EndpointOptions, + options: EndpointOptions, ): EndpointResult { const wrappedHandler = async (ctx: AppKoaContext) => { const result = await options.handler(ctx as never); From 7a88c536c78dceb06c9102cf3d9036b998637ba6 Mon Sep 17 00:00:00 2001 From: Igor Krasnik Date: Wed, 11 Feb 2026 19:14:21 +0100 Subject: [PATCH 03/22] Naming and exporting refactorings --- template/apps/api/src/middlewares/index.ts | 4 - .../isAdmin.ts} | 2 +- .../isPublic.ts} | 2 +- ...{rate-limit.middleware.ts => rateLimit.ts} | 0 .../{validate.middleware.ts => validate.ts} | 0 .../src/resources/account/account.schema.ts | 27 ------- .../src/resources/account/account.utils.ts | 3 +- .../{forgot-password.ts => forgotPassword.ts} | 17 ++-- .../src/resources/account/endpoints/get.ts | 2 +- .../src/resources/account/endpoints/google.ts | 4 +- .../{google-callback.ts => googleCallback.ts} | 4 +- .../{resend-email.ts => resendEmail.ts} | 17 ++-- .../{reset-password.ts => resetPassword.ts} | 17 ++-- .../endpoints/{sign-in.ts => signIn.ts} | 17 ++-- .../endpoints/{sign-out.ts => signOut.ts} | 4 +- .../endpoints/{sign-up.ts => signUp.ts} | 19 +++-- .../src/resources/account/endpoints/update.ts | 26 +++++-- .../{verify-email.ts => verifyEmail.ts} | 11 +-- ...ify-reset-token.ts => verifyResetToken.ts} | 10 +-- .../api/src/resources/token/token.schema.ts | 2 + .../api/src/resources/token/token.service.ts | 3 +- .../api/src/resources/users/endpoints/list.ts | 6 +- .../src/resources/users/endpoints/remove.ts | 4 +- .../src/resources/users/endpoints/update.ts | 6 +- .../api/src/resources/users/user.handler.ts | 2 +- .../api/src/resources/users/user.schema.ts | 35 +++------ .../api/src/resources/users/user.service.ts | 3 +- .../apps/api/src/routes/createEndpoint.ts | 67 ++++++++++++++++ .../apps/api/src/routes/createMiddleware.ts | 10 +++ template/apps/api/src/routes/index.ts | 10 +-- ...rs.middleware.ts => attachCustomErrors.ts} | 0 ...iddleware.ts => attachCustomProperties.ts} | 0 .../{auth.middleware.ts => auth.ts} | 0 ...-tokens.middleware.ts => extractTokens.ts} | 0 .../apps/api/src/routes/middlewares/index.ts | 2 - ...ler.middleware.ts => routeErrorHandler.ts} | 0 ...-user.middleware.ts => tryToAttachUser.ts} | 0 template/apps/api/src/routes/routes.ts | 6 +- template/apps/api/src/routes/types.ts | 78 +------------------ .../api/src/services/auth/auth.service.ts | 3 +- .../api/src/services/google/google.service.ts | 2 +- template/apps/api/src/types.ts | 33 +------- 42 files changed, 217 insertions(+), 241 deletions(-) delete mode 100644 template/apps/api/src/middlewares/index.ts rename template/apps/api/src/{routes/middlewares/is-admin.middleware.ts => middlewares/isAdmin.ts} (88%) rename template/apps/api/src/{routes/middlewares/is-public.middleware.ts => middlewares/isPublic.ts} (63%) rename template/apps/api/src/middlewares/{rate-limit.middleware.ts => rateLimit.ts} (100%) rename template/apps/api/src/middlewares/{validate.middleware.ts => validate.ts} (100%) delete mode 100644 template/apps/api/src/resources/account/account.schema.ts rename template/apps/api/src/resources/account/endpoints/{forgot-password.ts => forgotPassword.ts} (80%) rename template/apps/api/src/resources/account/endpoints/{google-callback.ts => googleCallback.ts} (89%) rename template/apps/api/src/resources/account/endpoints/{resend-email.ts => resendEmail.ts} (78%) rename template/apps/api/src/resources/account/endpoints/{reset-password.ts => resetPassword.ts} (69%) rename template/apps/api/src/resources/account/endpoints/{sign-in.ts => signIn.ts} (76%) rename template/apps/api/src/resources/account/endpoints/{sign-out.ts => signOut.ts} (71%) rename template/apps/api/src/resources/account/endpoints/{sign-up.ts => signUp.ts} (74%) rename template/apps/api/src/resources/account/endpoints/{verify-email.ts => verifyEmail.ts} (85%) rename template/apps/api/src/resources/account/endpoints/{verify-reset-token.ts => verifyResetToken.ts} (81%) create mode 100644 template/apps/api/src/routes/createEndpoint.ts create mode 100644 template/apps/api/src/routes/createMiddleware.ts rename template/apps/api/src/routes/middlewares/{attach-custom-errors.middleware.ts => attachCustomErrors.ts} (100%) rename template/apps/api/src/routes/middlewares/{attach-custom-properties.middleware.ts => attachCustomProperties.ts} (100%) rename template/apps/api/src/routes/middlewares/{auth.middleware.ts => auth.ts} (100%) rename template/apps/api/src/routes/middlewares/{extract-tokens.middleware.ts => extractTokens.ts} (100%) delete mode 100644 template/apps/api/src/routes/middlewares/index.ts rename template/apps/api/src/routes/middlewares/{route-error-handler.middleware.ts => routeErrorHandler.ts} (100%) rename template/apps/api/src/routes/middlewares/{try-to-attach-user.middleware.ts => tryToAttachUser.ts} (100%) diff --git a/template/apps/api/src/middlewares/index.ts b/template/apps/api/src/middlewares/index.ts deleted file mode 100644 index 6e6fe8221..000000000 --- a/template/apps/api/src/middlewares/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import rateLimitMiddleware from './rate-limit.middleware'; -import validateMiddleware from './validate.middleware'; - -export { rateLimitMiddleware, validateMiddleware }; diff --git a/template/apps/api/src/routes/middlewares/is-admin.middleware.ts b/template/apps/api/src/middlewares/isAdmin.ts similarity index 88% rename from template/apps/api/src/routes/middlewares/is-admin.middleware.ts rename to template/apps/api/src/middlewares/isAdmin.ts index 83f4a7325..0dc0659a9 100644 --- a/template/apps/api/src/routes/middlewares/is-admin.middleware.ts +++ b/template/apps/api/src/middlewares/isAdmin.ts @@ -1,6 +1,6 @@ import config from 'config'; -import { createMiddleware } from 'routes/types'; +import createMiddleware from 'routes/createMiddleware'; import { AppKoaContextState } from 'types'; diff --git a/template/apps/api/src/routes/middlewares/is-public.middleware.ts b/template/apps/api/src/middlewares/isPublic.ts similarity index 63% rename from template/apps/api/src/routes/middlewares/is-public.middleware.ts rename to template/apps/api/src/middlewares/isPublic.ts index 2152768a2..4f7e7f459 100644 --- a/template/apps/api/src/routes/middlewares/is-public.middleware.ts +++ b/template/apps/api/src/middlewares/isPublic.ts @@ -1,4 +1,4 @@ -import { createMiddleware } from 'routes/types'; +import createMiddleware from 'routes/createMiddleware'; export const isPublic = createMiddleware(async (_ctx, next) => next()); diff --git a/template/apps/api/src/middlewares/rate-limit.middleware.ts b/template/apps/api/src/middlewares/rateLimit.ts similarity index 100% rename from template/apps/api/src/middlewares/rate-limit.middleware.ts rename to template/apps/api/src/middlewares/rateLimit.ts diff --git a/template/apps/api/src/middlewares/validate.middleware.ts b/template/apps/api/src/middlewares/validate.ts similarity index 100% rename from template/apps/api/src/middlewares/validate.middleware.ts rename to template/apps/api/src/middlewares/validate.ts diff --git a/template/apps/api/src/resources/account/account.schema.ts b/template/apps/api/src/resources/account/account.schema.ts deleted file mode 100644 index dd97523ab..000000000 --- a/template/apps/api/src/resources/account/account.schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod'; - -import { emailSchema, passwordSchema } from '../base.schema'; -import { userSchema } from '../users/user.schema'; - -export const signInSchema = z.object({ - email: emailSchema, - password: z.string().min(1, 'Password is required').max(128, 'Password must be less than 128 characters.'), -}); - -export const signUpSchema = userSchema.pick({ firstName: true, lastName: true }).extend({ - email: emailSchema, - password: passwordSchema, -}); - -export const resendEmailSchema = z.object({ - email: emailSchema, -}); - -export const forgotPasswordSchema = z.object({ - email: emailSchema, -}); - -export const resetPasswordSchema = z.object({ - token: z.string().min(1, 'Token is required'), - password: passwordSchema, -}); diff --git a/template/apps/api/src/resources/account/account.utils.ts b/template/apps/api/src/resources/account/account.utils.ts index 83920503e..d7c9c8418 100644 --- a/template/apps/api/src/resources/account/account.utils.ts +++ b/template/apps/api/src/resources/account/account.utils.ts @@ -1,6 +1,7 @@ import { cloudStorageService } from 'services'; -import { BackendFile, User } from 'types'; +import type { User } from 'resources/users/user.schema'; +import { BackendFile } from 'types'; export const removeAvatar = async (user: User) => { if (user.avatarUrl) { diff --git a/template/apps/api/src/resources/account/endpoints/forgot-password.ts b/template/apps/api/src/resources/account/endpoints/forgotPassword.ts similarity index 80% rename from template/apps/api/src/resources/account/endpoints/forgot-password.ts rename to template/apps/api/src/resources/account/endpoints/forgotPassword.ts index 7babde169..8dcea8daa 100644 --- a/template/apps/api/src/resources/account/endpoints/forgot-password.ts +++ b/template/apps/api/src/resources/account/endpoints/forgotPassword.ts @@ -1,18 +1,23 @@ import { tokenService } from 'resources/token'; import { userService } from 'resources/users'; -import { rateLimitMiddleware } from 'middlewares'; +import { z } from 'zod'; + +import isPublic from 'middlewares/isPublic'; +import rateLimitMiddleware from 'middlewares/rateLimit'; import { emailService } from 'services'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import createEndpoint from 'routes/createEndpoint'; import config from 'config'; import { RESET_PASSWORD_TOKEN } from 'app-constants'; -import { forgotPasswordSchema } from '../account.schema'; -import { Template, TokenType } from 'types'; +import { emailSchema } from '../../base.schema'; +import { TokenType } from '../../token/token.schema'; +import { Template } from 'types'; -export const schema = forgotPasswordSchema; +const schema = z.object({ + email: emailSchema, +}); export default createEndpoint({ method: 'post', diff --git a/template/apps/api/src/resources/account/endpoints/get.ts b/template/apps/api/src/resources/account/endpoints/get.ts index c540340ef..a4cbe4274 100644 --- a/template/apps/api/src/resources/account/endpoints/get.ts +++ b/template/apps/api/src/resources/account/endpoints/get.ts @@ -1,6 +1,6 @@ import { userService } from 'resources/users'; -import { createEndpoint } from 'routes/types'; +import createEndpoint from 'routes/createEndpoint'; export default createEndpoint({ method: 'get', diff --git a/template/apps/api/src/resources/account/endpoints/google.ts b/template/apps/api/src/resources/account/endpoints/google.ts index c89f7f5b8..12c032e4c 100644 --- a/template/apps/api/src/resources/account/endpoints/google.ts +++ b/template/apps/api/src/resources/account/endpoints/google.ts @@ -1,6 +1,6 @@ import { googleService } from 'services'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import isPublic from 'middlewares/isPublic'; +import createEndpoint from 'routes/createEndpoint'; export default createEndpoint({ method: 'get', diff --git a/template/apps/api/src/resources/account/endpoints/google-callback.ts b/template/apps/api/src/resources/account/endpoints/googleCallback.ts similarity index 89% rename from template/apps/api/src/resources/account/endpoints/google-callback.ts rename to template/apps/api/src/resources/account/endpoints/googleCallback.ts index 3ff587956..5bff31055 100644 --- a/template/apps/api/src/resources/account/endpoints/google-callback.ts +++ b/template/apps/api/src/resources/account/endpoints/googleCallback.ts @@ -1,6 +1,6 @@ import { authService, googleService } from 'services'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import isPublic from 'middlewares/isPublic'; +import createEndpoint from 'routes/createEndpoint'; import config from 'config'; diff --git a/template/apps/api/src/resources/account/endpoints/resend-email.ts b/template/apps/api/src/resources/account/endpoints/resendEmail.ts similarity index 78% rename from template/apps/api/src/resources/account/endpoints/resend-email.ts rename to template/apps/api/src/resources/account/endpoints/resendEmail.ts index 758662a3a..9b185a841 100644 --- a/template/apps/api/src/resources/account/endpoints/resend-email.ts +++ b/template/apps/api/src/resources/account/endpoints/resendEmail.ts @@ -1,18 +1,23 @@ import { tokenService } from 'resources/token'; import { userService } from 'resources/users'; -import { rateLimitMiddleware } from 'middlewares'; +import { z } from 'zod'; + +import isPublic from 'middlewares/isPublic'; +import rateLimitMiddleware from 'middlewares/rateLimit'; import { emailService } from 'services'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import createEndpoint from 'routes/createEndpoint'; import config from 'config'; import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; -import { resendEmailSchema } from '../account.schema'; -import { Template, TokenType } from 'types'; +import { emailSchema } from '../../base.schema'; +import { TokenType } from '../../token/token.schema'; +import { Template } from 'types'; -export const schema = resendEmailSchema; +const schema = z.object({ + email: emailSchema, +}); export default createEndpoint({ method: 'post', diff --git a/template/apps/api/src/resources/account/endpoints/reset-password.ts b/template/apps/api/src/resources/account/endpoints/resetPassword.ts similarity index 69% rename from template/apps/api/src/resources/account/endpoints/reset-password.ts rename to template/apps/api/src/resources/account/endpoints/resetPassword.ts index ee1bf6b7f..69ee0f080 100644 --- a/template/apps/api/src/resources/account/endpoints/reset-password.ts +++ b/template/apps/api/src/resources/account/endpoints/resetPassword.ts @@ -1,15 +1,20 @@ import { tokenService } from 'resources/token'; import { userService } from 'resources/users'; -import { rateLimitMiddleware } from 'middlewares'; +import { z } from 'zod'; + +import isPublic from 'middlewares/isPublic'; +import rateLimitMiddleware from 'middlewares/rateLimit'; import { securityUtil } from 'utils'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import createEndpoint from 'routes/createEndpoint'; -import { resetPasswordSchema } from '../account.schema'; -import { TokenType } from 'types'; +import { passwordSchema } from '../../base.schema'; +import { TokenType } from '../../token/token.schema'; -export const schema = resetPasswordSchema; +const schema = z.object({ + token: z.string().min(1, 'Token is required'), + password: passwordSchema, +}); export default createEndpoint({ method: 'put', diff --git a/template/apps/api/src/resources/account/endpoints/sign-in.ts b/template/apps/api/src/resources/account/endpoints/signIn.ts similarity index 76% rename from template/apps/api/src/resources/account/endpoints/sign-in.ts rename to template/apps/api/src/resources/account/endpoints/signIn.ts index e7f4c26ed..8bd8080f3 100644 --- a/template/apps/api/src/resources/account/endpoints/sign-in.ts +++ b/template/apps/api/src/resources/account/endpoints/signIn.ts @@ -1,16 +1,21 @@ import { tokenService } from 'resources/token'; import { userService } from 'resources/users'; -import { rateLimitMiddleware } from 'middlewares'; +import { z } from 'zod'; + +import isPublic from 'middlewares/isPublic'; +import rateLimitMiddleware from 'middlewares/rateLimit'; import { authService } from 'services'; import { securityUtil } from 'utils'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import createEndpoint from 'routes/createEndpoint'; -import { signInSchema } from '../account.schema'; -import { TokenType } from 'types'; +import { emailSchema } from '../../base.schema'; +import { TokenType } from 'resources/token/token.schema'; -export const schema = signInSchema; +const schema = z.object({ + email: emailSchema, + password: z.string().min(1, 'Password is required').max(128, 'Password must be less than 128 characters.'), +}); export default createEndpoint({ method: 'post', diff --git a/template/apps/api/src/resources/account/endpoints/sign-out.ts b/template/apps/api/src/resources/account/endpoints/signOut.ts similarity index 71% rename from template/apps/api/src/resources/account/endpoints/sign-out.ts rename to template/apps/api/src/resources/account/endpoints/signOut.ts index d23242b40..8ed0bac05 100644 --- a/template/apps/api/src/resources/account/endpoints/sign-out.ts +++ b/template/apps/api/src/resources/account/endpoints/signOut.ts @@ -1,6 +1,6 @@ import { authService } from 'services'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import isPublic from 'middlewares/isPublic'; +import createEndpoint from 'routes/createEndpoint'; export default createEndpoint({ method: 'post', diff --git a/template/apps/api/src/resources/account/endpoints/sign-up.ts b/template/apps/api/src/resources/account/endpoints/signUp.ts similarity index 74% rename from template/apps/api/src/resources/account/endpoints/sign-up.ts rename to template/apps/api/src/resources/account/endpoints/signUp.ts index 36be183cc..82c8c4851 100644 --- a/template/apps/api/src/resources/account/endpoints/sign-up.ts +++ b/template/apps/api/src/resources/account/endpoints/signUp.ts @@ -1,19 +1,26 @@ import { tokenService } from 'resources/token'; import { userService } from 'resources/users'; -import { rateLimitMiddleware } from 'middlewares'; +import { z } from 'zod'; + +import isPublic from 'middlewares/isPublic'; +import rateLimitMiddleware from 'middlewares/rateLimit'; import { emailService } from 'services'; import { securityUtil } from 'utils'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import createEndpoint from 'routes/createEndpoint'; import config from 'config'; import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; -import { signUpSchema } from '../account.schema'; -import { Template, TokenType } from 'types'; +import { emailSchema, passwordSchema } from '../../base.schema'; +import { TokenType } from '../../token/token.schema'; +import { userSchema } from '../../users/user.schema'; +import { Template } from 'types'; -export const schema = signUpSchema; +const schema = userSchema.pick({ firstName: true, lastName: true }).extend({ + email: emailSchema, + password: passwordSchema, +}); export default createEndpoint({ method: 'post', diff --git a/template/apps/api/src/resources/account/endpoints/update.ts b/template/apps/api/src/resources/account/endpoints/update.ts index 8e0bb4153..c151777a3 100644 --- a/template/apps/api/src/resources/account/endpoints/update.ts +++ b/template/apps/api/src/resources/account/endpoints/update.ts @@ -1,15 +1,29 @@ import _ from 'lodash'; +import { z } from 'zod'; import { accountUtils } from 'resources/account'; import { userService } from 'resources/users'; import { securityUtil } from 'utils'; -import { createEndpoint } from 'routes/types'; - -import { updateUserSchema } from '../../users/user.schema'; -import type { User } from 'types'; - -export const schema = updateUserSchema; +import createEndpoint from 'routes/createEndpoint'; + +import { passwordSchema } from '../../base.schema'; +import { userSchema } from '../../users/user.schema'; +import type { User } from '../../users/user.schema'; + +const schema = userSchema + .pick({ firstName: true, lastName: true }) + .extend({ + password: z.union([ + passwordSchema, + z.literal(''), + ]), + avatar: z.union([ + z.any(), + z.literal(''), + ]).nullable(), + }) + .partial(); export default createEndpoint({ method: 'put', diff --git a/template/apps/api/src/resources/account/endpoints/verify-email.ts b/template/apps/api/src/resources/account/endpoints/verifyEmail.ts similarity index 85% rename from template/apps/api/src/resources/account/endpoints/verify-email.ts rename to template/apps/api/src/resources/account/endpoints/verifyEmail.ts index 7f0a85e8b..db9c82d09 100644 --- a/template/apps/api/src/resources/account/endpoints/verify-email.ts +++ b/template/apps/api/src/resources/account/endpoints/verifyEmail.ts @@ -3,16 +3,17 @@ import { z } from 'zod'; import { tokenService } from 'resources/token'; import { userService } from 'resources/users'; -import { rateLimitMiddleware } from 'middlewares'; +import isPublic from 'middlewares/isPublic'; +import rateLimitMiddleware from 'middlewares/rateLimit'; import { authService, emailService } from 'services'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import createEndpoint from 'routes/createEndpoint'; import config from 'config'; -import { Template, TokenType } from 'types'; +import { TokenType } from 'resources/token/token.schema'; +import { Template } from 'types'; -export const schema = z.object({ +const schema = z.object({ token: z.string().min(1, 'Token is required'), }); diff --git a/template/apps/api/src/resources/account/endpoints/verify-reset-token.ts b/template/apps/api/src/resources/account/endpoints/verifyResetToken.ts similarity index 81% rename from template/apps/api/src/resources/account/endpoints/verify-reset-token.ts rename to template/apps/api/src/resources/account/endpoints/verifyResetToken.ts index f7ca39bc8..748a9d90f 100644 --- a/template/apps/api/src/resources/account/endpoints/verify-reset-token.ts +++ b/template/apps/api/src/resources/account/endpoints/verifyResetToken.ts @@ -3,15 +3,15 @@ import { z } from 'zod'; import { tokenService } from 'resources/token'; import { userService } from 'resources/users'; -import { rateLimitMiddleware } from 'middlewares'; -import { isPublic } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import isPublic from 'middlewares/isPublic'; +import rateLimitMiddleware from 'middlewares/rateLimit'; +import createEndpoint from 'routes/createEndpoint'; import config from 'config'; -import { TokenType } from 'types'; +import { TokenType } from 'resources/token/token.schema'; -export const schema = z.object({ +const schema = z.object({ token: z.string().min(1, 'Token is required'), }); diff --git a/template/apps/api/src/resources/token/token.schema.ts b/template/apps/api/src/resources/token/token.schema.ts index 2102526d9..9dd4358fd 100644 --- a/template/apps/api/src/resources/token/token.schema.ts +++ b/template/apps/api/src/resources/token/token.schema.ts @@ -14,3 +14,5 @@ export const tokenSchema = dbSchema.extend({ type: z.enum([TokenType.ACCESS, TokenType.EMAIL_VERIFICATION, TokenType.RESET_PASSWORD]), expiresOn: z.date(), }); + +export type Token = z.infer; diff --git a/template/apps/api/src/resources/token/token.service.ts b/template/apps/api/src/resources/token/token.service.ts index a8797850b..a00b02767 100644 --- a/template/apps/api/src/resources/token/token.service.ts +++ b/template/apps/api/src/resources/token/token.service.ts @@ -3,8 +3,7 @@ import { securityUtil } from 'utils'; import db from 'db'; import { DATABASE_DOCUMENTS } from 'app-constants'; -import { tokenSchema } from './token.schema'; -import { Token, TokenType } from 'types'; +import { tokenSchema, type Token, TokenType } from './token.schema'; const service = db.createService(DATABASE_DOCUMENTS.TOKENS, { schemaValidator: (obj) => tokenSchema.parseAsync(obj), diff --git a/template/apps/api/src/resources/users/endpoints/list.ts b/template/apps/api/src/resources/users/endpoints/list.ts index bcc0c848f..f131d622a 100644 --- a/template/apps/api/src/resources/users/endpoints/list.ts +++ b/template/apps/api/src/resources/users/endpoints/list.ts @@ -2,13 +2,13 @@ import { z } from 'zod'; import { userService } from 'resources/users'; -import { createEndpoint } from 'routes/types'; +import createEndpoint from 'routes/createEndpoint'; import { paginationSchema } from '../../base.schema'; import { userPublicSchema } from '../user.schema'; import type { NestedKeys } from 'types'; -export const schema = paginationSchema.extend({ +const schema = paginationSchema.extend({ filter: z .object({ createdOn: z @@ -26,6 +26,8 @@ export const schema = paginationSchema.extend({ createdOn: z.enum(['asc', 'desc']).default('asc'), }) .default({ createdOn: 'asc' }), + + smthElse: z.string(), }); export default createEndpoint({ diff --git a/template/apps/api/src/resources/users/endpoints/remove.ts b/template/apps/api/src/resources/users/endpoints/remove.ts index 5973e4893..a8f9298ea 100644 --- a/template/apps/api/src/resources/users/endpoints/remove.ts +++ b/template/apps/api/src/resources/users/endpoints/remove.ts @@ -1,7 +1,7 @@ import { userService } from 'resources/users'; -import { isAdmin } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import isAdmin from 'middlewares/isAdmin'; +import createEndpoint from 'routes/createEndpoint'; export default createEndpoint({ method: 'delete', diff --git a/template/apps/api/src/resources/users/endpoints/update.ts b/template/apps/api/src/resources/users/endpoints/update.ts index fda4fcde4..2247dd2bf 100644 --- a/template/apps/api/src/resources/users/endpoints/update.ts +++ b/template/apps/api/src/resources/users/endpoints/update.ts @@ -2,12 +2,12 @@ import _ from 'lodash'; import { userService } from 'resources/users'; -import { isAdmin } from 'routes/middlewares'; -import { createEndpoint } from 'routes/types'; +import isAdmin from 'middlewares/isAdmin'; +import createEndpoint from 'routes/createEndpoint'; import { userSchema } from '../user.schema'; -export const schema = userSchema.pick({ firstName: true, lastName: true, email: true }); +const schema = userSchema.pick({ firstName: true, lastName: true, email: true }); export default createEndpoint({ method: 'put', diff --git a/template/apps/api/src/resources/users/user.handler.ts b/template/apps/api/src/resources/users/user.handler.ts index fae4eaeed..b678df339 100644 --- a/template/apps/api/src/resources/users/user.handler.ts +++ b/template/apps/api/src/resources/users/user.handler.ts @@ -7,7 +7,7 @@ import ioEmitter from 'io-emitter'; import logger from 'logger'; import { DATABASE_DOCUMENTS } from 'app-constants'; -import { User } from 'types'; +import type { User } from './user.schema'; import userService from './user.service'; diff --git a/template/apps/api/src/resources/users/user.schema.ts b/template/apps/api/src/resources/users/user.schema.ts index 503f726c6..808051c91 100644 --- a/template/apps/api/src/resources/users/user.schema.ts +++ b/template/apps/api/src/resources/users/user.schema.ts @@ -1,15 +1,6 @@ import { z } from 'zod'; -import { dbSchema, emailSchema, passwordSchema } from '../base.schema'; - -const oauthSchema = z.object({ - google: z - .object({ - userId: z.string().min(1, 'Google user ID is required'), - connectedOn: z.date(), - }) - .optional(), -}); +import { dbSchema, emailSchema } from '../base.schema'; export const userSchema = dbSchema.extend({ firstName: z.string().min(1, 'First name is required').max(128, 'First name must be less than 128 characters.'), @@ -22,25 +13,21 @@ export const userSchema = dbSchema.extend({ avatarUrl: z.string().nullable().optional(), - oauth: oauthSchema.optional(), + oauth: z.object({ + google: z + .object({ + userId: z.string().min(1, 'Google user ID is required'), + connectedOn: z.date(), + }) + .optional(), + }).optional(), lastRequest: z.date().optional(), }); +export type User = z.infer; + export const userPublicSchema = userSchema.omit({ passwordHash: true, }); -export const updateUserSchema = userSchema - .pick({ firstName: true, lastName: true }) - .extend({ - password: z.union([ - passwordSchema, - z.literal(''), - ]), - avatar: z.union([ - z.any(), // File validation handled at runtime (browser File or formidable File) - z.literal(''), - ]).nullable(), - }) - .partial(); diff --git a/template/apps/api/src/resources/users/user.service.ts b/template/apps/api/src/resources/users/user.service.ts index ba7ced5f0..da7d49492 100644 --- a/template/apps/api/src/resources/users/user.service.ts +++ b/template/apps/api/src/resources/users/user.service.ts @@ -3,8 +3,7 @@ import _ from 'lodash'; import db from 'db'; import { DATABASE_DOCUMENTS } from 'app-constants'; -import { userSchema } from './user.schema'; -import { User } from 'types'; +import { userSchema, type User } from './user.schema'; const service = db.createService(DATABASE_DOCUMENTS.USERS, { schemaValidator: (obj) => userSchema.parseAsync(obj), diff --git a/template/apps/api/src/routes/createEndpoint.ts b/template/apps/api/src/routes/createEndpoint.ts new file mode 100644 index 000000000..555ca30de --- /dev/null +++ b/template/apps/api/src/routes/createEndpoint.ts @@ -0,0 +1,67 @@ +import type { Middleware } from 'koa'; +import type { z, ZodType } from 'zod'; + +import type { AppKoaContext } from 'types'; + +import type { EndpointConfig, HttpMethod } from './types'; + +// Path parameter extraction +type ExtractPathParams = T extends `${infer _Start}:${infer Param}/${infer Rest}` + ? { [K in Param | keyof ExtractPathParams<`/${Rest}`>]: string } + : T extends `${infer _Start}:${infer Param}` + ? { [K in Param]: string } + : Record; + +// Request type with params +type RequestWithParams = ExtractPathParams extends Record + ? object + : { params: ExtractPathParams }; + +export interface EndpointOptions< + TPath extends string, + TSchema extends ZodType = ZodType, + TResponse = void, +> { + method: HttpMethod; + path: TPath; + schema?: TSchema; + middlewares?: Middleware[]; + handler: ( + ctx: AppKoaContext, RequestWithParams>, + ) => Promise; +} + +// Result type that carries schema info for client-side inference +export interface EndpointResult< + TSchema extends ZodType = ZodType, +> { + endpoint: EndpointConfig; + handler: Middleware; + schema?: TSchema; + middlewares?: Middleware[]; +} + +export default function createEndpoint< + TPath extends string, + TSchema extends ZodType = ZodType, + TResponse = void, +>( + options: EndpointOptions, +): EndpointResult { + const wrappedHandler = async (ctx: AppKoaContext) => { + const result = await options.handler(ctx as never); + if (result !== undefined) { + ctx.body = result; + } + }; + + return { + endpoint: { + method: options.method, + path: options.path, + } as EndpointConfig, + handler: wrappedHandler as unknown as Middleware, + schema: options.schema, + middlewares: options.middlewares as unknown as Middleware[] | undefined, + }; +} diff --git a/template/apps/api/src/routes/createMiddleware.ts b/template/apps/api/src/routes/createMiddleware.ts new file mode 100644 index 000000000..10724d6db --- /dev/null +++ b/template/apps/api/src/routes/createMiddleware.ts @@ -0,0 +1,10 @@ +import type { AppKoaContext, Next } from 'types'; + +import type { TypedMiddleware } from './types'; + +// eslint-disable-next-line ts/no-explicit-any +export default function createMiddleware( + fn: (ctx: any, next: Next) => Promise, +): TypedMiddleware { + return fn as TypedMiddleware; +} diff --git a/template/apps/api/src/routes/index.ts b/template/apps/api/src/routes/index.ts index 91ffd40e4..862360308 100644 --- a/template/apps/api/src/routes/index.ts +++ b/template/apps/api/src/routes/index.ts @@ -1,10 +1,10 @@ import { AppKoa, AppRouter } from 'types'; -import attachCustomErrors from './middlewares/attach-custom-errors.middleware'; -import attachCustomProperties from './middlewares/attach-custom-properties.middleware'; -import extractTokens from './middlewares/extract-tokens.middleware'; -import routeErrorHandler from './middlewares/route-error-handler.middleware'; -import tryToAttachUser from './middlewares/try-to-attach-user.middleware'; +import attachCustomErrors from './middlewares/attachCustomErrors'; +import attachCustomProperties from './middlewares/attachCustomProperties'; +import extractTokens from './middlewares/extractTokens'; +import routeErrorHandler from './middlewares/routeErrorHandler'; +import tryToAttachUser from './middlewares/tryToAttachUser'; import { registerRoutes } from './routes'; const healthCheckRouter = new AppRouter(); diff --git a/template/apps/api/src/routes/middlewares/attach-custom-errors.middleware.ts b/template/apps/api/src/routes/middlewares/attachCustomErrors.ts similarity index 100% rename from template/apps/api/src/routes/middlewares/attach-custom-errors.middleware.ts rename to template/apps/api/src/routes/middlewares/attachCustomErrors.ts diff --git a/template/apps/api/src/routes/middlewares/attach-custom-properties.middleware.ts b/template/apps/api/src/routes/middlewares/attachCustomProperties.ts similarity index 100% rename from template/apps/api/src/routes/middlewares/attach-custom-properties.middleware.ts rename to template/apps/api/src/routes/middlewares/attachCustomProperties.ts diff --git a/template/apps/api/src/routes/middlewares/auth.middleware.ts b/template/apps/api/src/routes/middlewares/auth.ts similarity index 100% rename from template/apps/api/src/routes/middlewares/auth.middleware.ts rename to template/apps/api/src/routes/middlewares/auth.ts diff --git a/template/apps/api/src/routes/middlewares/extract-tokens.middleware.ts b/template/apps/api/src/routes/middlewares/extractTokens.ts similarity index 100% rename from template/apps/api/src/routes/middlewares/extract-tokens.middleware.ts rename to template/apps/api/src/routes/middlewares/extractTokens.ts diff --git a/template/apps/api/src/routes/middlewares/index.ts b/template/apps/api/src/routes/middlewares/index.ts deleted file mode 100644 index 6f6098711..000000000 --- a/template/apps/api/src/routes/middlewares/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as isAdmin } from './is-admin.middleware'; -export { default as isPublic } from './is-public.middleware'; diff --git a/template/apps/api/src/routes/middlewares/route-error-handler.middleware.ts b/template/apps/api/src/routes/middlewares/routeErrorHandler.ts similarity index 100% rename from template/apps/api/src/routes/middlewares/route-error-handler.middleware.ts rename to template/apps/api/src/routes/middlewares/routeErrorHandler.ts diff --git a/template/apps/api/src/routes/middlewares/try-to-attach-user.middleware.ts b/template/apps/api/src/routes/middlewares/tryToAttachUser.ts similarity index 100% rename from template/apps/api/src/routes/middlewares/try-to-attach-user.middleware.ts rename to template/apps/api/src/routes/middlewares/tryToAttachUser.ts diff --git a/template/apps/api/src/routes/routes.ts b/template/apps/api/src/routes/routes.ts index d77563f50..3d8c4234a 100644 --- a/template/apps/api/src/routes/routes.ts +++ b/template/apps/api/src/routes/routes.ts @@ -1,6 +1,7 @@ import mount from 'koa-mount'; -import { validateMiddleware } from 'middlewares'; +import isPublic from 'middlewares/isPublic'; +import validateMiddleware from 'middlewares/validate'; import { getResourceEndpoints } from 'utils/get-resource-endpoints.util'; import { getResources } from 'utils/get-resources.util'; @@ -8,8 +9,7 @@ import logger from 'logger'; import type { AppKoa, AppRouter, AppRouterMiddleware } from 'types'; -import auth from './middlewares/auth.middleware'; -import { isPublic } from 'routes/middlewares'; +import auth from './middlewares/auth'; import type { EndpointDefinition } from './types'; const registerEndpoint = (router: AppRouter, resourceName: string, endpoint: EndpointDefinition): void => { diff --git a/template/apps/api/src/routes/types.ts b/template/apps/api/src/routes/types.ts index 2ca6b65de..65dbbbc06 100644 --- a/template/apps/api/src/routes/types.ts +++ b/template/apps/api/src/routes/types.ts @@ -1,5 +1,5 @@ import type { Middleware } from 'koa'; -import type { z, ZodSchema, ZodType } from 'zod'; +import type { ZodSchema } from 'zod'; import type { AppKoaContext, Next } from 'types'; @@ -10,87 +10,15 @@ export interface EndpointConfig { path: string; } -type RouteMiddleware = Middleware; - export interface EndpointDefinition { endpoint: EndpointConfig; - handler: RouteMiddleware; + handler: Middleware; schema?: ZodSchema; - middlewares?: RouteMiddleware[]; + middlewares?: Middleware[]; } -// Path parameter extraction -type ExtractPathParams = T extends `${infer _Start}:${infer Param}/${infer Rest}` - ? { [K in Param | keyof ExtractPathParams<`/${Rest}`>]: string } - : T extends `${infer _Start}:${infer Param}` - ? { [K in Param]: string } - : Record; - -// Request type with params -type RequestWithParams = ExtractPathParams extends Record - ? object - : { params: ExtractPathParams }; - // Middleware that declares what it adds to state export interface TypedMiddleware { (ctx: AppKoaContext, next: Next): Promise; _state?: TState; // phantom type, never used at runtime } - -// Helper to define middleware with state type -// Accepts any context type for flexibility in validators -// eslint-disable-next-line ts/no-explicit-any -export function createMiddleware( - fn: (ctx: any, next: Next) => Promise, -): TypedMiddleware { - return fn as TypedMiddleware; -} - -export interface EndpointOptions< - TPath extends string, - TSchema extends ZodType = ZodType, - TResponse = void, -> { - method: HttpMethod; - path: TPath; - schema?: TSchema; - middlewares?: RouteMiddleware[]; - handler: ( - ctx: AppKoaContext, RequestWithParams>, - ) => Promise; -} - -// Result type that carries schema info for client-side inference -export interface EndpointResult< - TSchema extends ZodType = ZodType, -> { - endpoint: EndpointConfig; - handler: RouteMiddleware; - schema?: TSchema; - middlewares?: RouteMiddleware[]; -} - -export function createEndpoint< - TPath extends string, - TSchema extends ZodType = ZodType, - TResponse = void, ->( - options: EndpointOptions, -): EndpointResult { - const wrappedHandler = async (ctx: AppKoaContext) => { - const result = await options.handler(ctx as never); - if (result !== undefined) { - ctx.body = result; - } - }; - - return { - endpoint: { - method: options.method, - path: options.path, - } as EndpointConfig, - handler: wrappedHandler as unknown as RouteMiddleware, - schema: options.schema, - middlewares: options.middlewares as unknown as RouteMiddleware[] | undefined, - }; -} diff --git a/template/apps/api/src/services/auth/auth.service.ts b/template/apps/api/src/services/auth/auth.service.ts index 4c8c0a1cf..5e2aa3afb 100644 --- a/template/apps/api/src/services/auth/auth.service.ts +++ b/template/apps/api/src/services/auth/auth.service.ts @@ -4,7 +4,8 @@ import { userService } from 'resources/users'; import { cookieUtil } from 'utils'; import { ACCESS_TOKEN } from 'app-constants'; -import { AppKoaContext, Token, TokenType } from 'types'; +import { type Token, TokenType } from 'resources/token/token.schema'; +import { AppKoaContext } from 'types'; interface SetAccessTokenOptions { ctx: AppKoaContext; diff --git a/template/apps/api/src/services/google/google.service.ts b/template/apps/api/src/services/google/google.service.ts index db2e97b10..6a09f6375 100644 --- a/template/apps/api/src/services/google/google.service.ts +++ b/template/apps/api/src/services/google/google.service.ts @@ -15,7 +15,7 @@ import config from 'config'; import logger from 'logger'; -import { User } from 'types'; +import type { User } from 'resources/users/user.schema'; const googleUserInfoSchema = z.object({ sub: z.string().describe('Unique Google user ID'), diff --git a/template/apps/api/src/types.ts b/template/apps/api/src/types.ts index 420f030e4..690d075c4 100644 --- a/template/apps/api/src/types.ts +++ b/template/apps/api/src/types.ts @@ -1,29 +1,8 @@ import Router from '@koa/router'; import Koa, { Next, ParameterizedContext, Request } from 'koa'; import { Template } from 'mailer'; -import { z } from 'zod'; - -import { - forgotPasswordSchema, - resendEmailSchema, - resetPasswordSchema, - signInSchema, - signUpSchema, -} from 'resources/account/account.schema'; -import { tokenSchema, TokenType } from 'resources/token/token.schema'; -import { updateUserSchema, userSchema } from 'resources/users/user.schema'; - -// Entity types inferred from schemas -export type User = z.infer; -export type Token = z.infer; - -// Request param types inferred from schemas -export type SignInParams = z.infer; -export type SignUpParams = z.infer; -export type ResendEmailParams = z.infer; -export type ForgotPasswordParams = z.infer; -export type ResetPasswordParams = z.infer; -export type UpdateUserParams = z.infer; + +import type { User } from 'resources/users/user.schema'; // File types export interface BackendFile { @@ -34,11 +13,6 @@ export interface BackendFile { size: number; } -// User update variants -export interface UpdateUserParamsBackend extends Omit { - avatar?: BackendFile | ''; -} - // Utility types type Path = T extends object ? { @@ -62,9 +36,6 @@ export type ToCamelCase = { [K in keyof T as CamelCase]: T[K] extends object ? ToCamelCase : T[K]; }; -// Re-export enums -export { TokenType }; - export interface AppKoaContextState { user: User; accessToken: string; From b55a65362d29756e757e0f071e88450fdce5bccf Mon Sep 17 00:00:00 2001 From: Alexey Kasperovich Date: Thu, 12 Feb 2026 13:23:41 +0300 Subject: [PATCH 04/22] fix: types, codegen, linter --- template/apps/api/src/middlewares/isAdmin.ts | 4 +- .../src/resources/account/account.utils.ts | 3 +- .../account/endpoints/forgotPassword.ts | 8 +- .../src/resources/account/endpoints/google.ts | 2 +- .../account/endpoints/googleCallback.ts | 2 +- .../account/endpoints/resendEmail.ts | 8 +- .../account/endpoints/resetPassword.ts | 9 +- .../src/resources/account/endpoints/signIn.ts | 17 +- .../resources/account/endpoints/signOut.ts | 2 +- .../src/resources/account/endpoints/signUp.ts | 8 +- .../src/resources/account/endpoints/update.ts | 17 +- .../account/endpoints/verifyEmail.ts | 2 +- .../account/endpoints/verifyResetToken.ts | 3 +- .../api/src/resources/token/token.service.ts | 4 +- .../api/src/resources/users/endpoints/list.ts | 7 +- .../api/src/resources/users/user.handler.ts | 2 +- .../api/src/resources/users/user.schema.ts | 19 +- .../api/src/resources/users/user.service.ts | 4 +- .../apps/api/src/routes/createEndpoint.ts | 31 +- .../apps/api/src/routes/createMiddleware.ts | 4 +- template/apps/api/src/routes/routes.ts | 1 - template/apps/api/src/routes/types.ts | 2 +- .../api/src/services/auth/auth.service.ts | 3 +- .../api/src/services/google/google.service.ts | 3 +- .../src/utils/get-resource-endpoints.util.ts | 2 +- template/apps/web/src/hooks/use-api.hook.ts | 35 +- .../profile/components/AvatarUpload/index.tsx | 12 +- .../apps/web/src/pages/profile/index.page.tsx | 4 +- .../src/pages/reset-password/index.page.tsx | 9 +- template/apps/web/src/types.ts | 2 +- template/packages/shared/eslint.config.js | 3 + .../packages/shared/node_modules/.bin/eslint | 4 +- .../shared/node_modules/.bin/lint-staged | 4 +- .../shared/node_modules/.bin/prettier | 4 +- .../packages/shared/node_modules/.bin/tsc | 4 +- .../shared/node_modules/.bin/tsserver | 4 +- .../packages/shared/node_modules/.bin/tsx | 4 +- template/packages/shared/scripts/generate.ts | 392 ++++++++++-------- .../packages/shared/src/generated/index.ts | 271 ++++++++---- template/packages/shared/src/schemas/index.ts | 7 +- .../shared/src/schemas/token/token.schema.ts | 18 +- .../shared/src/schemas/users/user.schema.ts | 57 +-- template/packages/shared/src/types.ts | 36 +- 43 files changed, 568 insertions(+), 469 deletions(-) create mode 100644 template/packages/shared/eslint.config.js diff --git a/template/apps/api/src/middlewares/isAdmin.ts b/template/apps/api/src/middlewares/isAdmin.ts index 0dc0659a9..0a612b3e2 100644 --- a/template/apps/api/src/middlewares/isAdmin.ts +++ b/template/apps/api/src/middlewares/isAdmin.ts @@ -1,7 +1,7 @@ -import config from 'config'; - import createMiddleware from 'routes/createMiddleware'; +import config from 'config'; + import { AppKoaContextState } from 'types'; interface AdminState extends AppKoaContextState { diff --git a/template/apps/api/src/resources/account/account.utils.ts b/template/apps/api/src/resources/account/account.utils.ts index d7c9c8418..3d170c2c0 100644 --- a/template/apps/api/src/resources/account/account.utils.ts +++ b/template/apps/api/src/resources/account/account.utils.ts @@ -1,6 +1,7 @@ +import type { User } from 'resources/users/user.schema'; + import { cloudStorageService } from 'services'; -import type { User } from 'resources/users/user.schema'; import { BackendFile } from 'types'; export const removeAvatar = async (user: User) => { diff --git a/template/apps/api/src/resources/account/endpoints/forgotPassword.ts b/template/apps/api/src/resources/account/endpoints/forgotPassword.ts index 8dcea8daa..b9e2d363c 100644 --- a/template/apps/api/src/resources/account/endpoints/forgotPassword.ts +++ b/template/apps/api/src/resources/account/endpoints/forgotPassword.ts @@ -1,8 +1,10 @@ +import { z } from 'zod'; + +import { emailSchema } from 'resources/base.schema'; import { tokenService } from 'resources/token'; +import { TokenType } from 'resources/token/token.schema'; import { userService } from 'resources/users'; -import { z } from 'zod'; - import isPublic from 'middlewares/isPublic'; import rateLimitMiddleware from 'middlewares/rateLimit'; import { emailService } from 'services'; @@ -11,8 +13,6 @@ import createEndpoint from 'routes/createEndpoint'; import config from 'config'; import { RESET_PASSWORD_TOKEN } from 'app-constants'; -import { emailSchema } from '../../base.schema'; -import { TokenType } from '../../token/token.schema'; import { Template } from 'types'; const schema = z.object({ diff --git a/template/apps/api/src/resources/account/endpoints/google.ts b/template/apps/api/src/resources/account/endpoints/google.ts index 12c032e4c..359853e54 100644 --- a/template/apps/api/src/resources/account/endpoints/google.ts +++ b/template/apps/api/src/resources/account/endpoints/google.ts @@ -1,5 +1,5 @@ -import { googleService } from 'services'; import isPublic from 'middlewares/isPublic'; +import { googleService } from 'services'; import createEndpoint from 'routes/createEndpoint'; export default createEndpoint({ diff --git a/template/apps/api/src/resources/account/endpoints/googleCallback.ts b/template/apps/api/src/resources/account/endpoints/googleCallback.ts index 5bff31055..ba3fc803f 100644 --- a/template/apps/api/src/resources/account/endpoints/googleCallback.ts +++ b/template/apps/api/src/resources/account/endpoints/googleCallback.ts @@ -1,5 +1,5 @@ -import { authService, googleService } from 'services'; import isPublic from 'middlewares/isPublic'; +import { authService, googleService } from 'services'; import createEndpoint from 'routes/createEndpoint'; import config from 'config'; diff --git a/template/apps/api/src/resources/account/endpoints/resendEmail.ts b/template/apps/api/src/resources/account/endpoints/resendEmail.ts index 9b185a841..a660fb87b 100644 --- a/template/apps/api/src/resources/account/endpoints/resendEmail.ts +++ b/template/apps/api/src/resources/account/endpoints/resendEmail.ts @@ -1,8 +1,10 @@ +import { z } from 'zod'; + +import { emailSchema } from 'resources/base.schema'; import { tokenService } from 'resources/token'; +import { TokenType } from 'resources/token/token.schema'; import { userService } from 'resources/users'; -import { z } from 'zod'; - import isPublic from 'middlewares/isPublic'; import rateLimitMiddleware from 'middlewares/rateLimit'; import { emailService } from 'services'; @@ -11,8 +13,6 @@ import createEndpoint from 'routes/createEndpoint'; import config from 'config'; import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; -import { emailSchema } from '../../base.schema'; -import { TokenType } from '../../token/token.schema'; import { Template } from 'types'; const schema = z.object({ diff --git a/template/apps/api/src/resources/account/endpoints/resetPassword.ts b/template/apps/api/src/resources/account/endpoints/resetPassword.ts index 69ee0f080..2decd838f 100644 --- a/template/apps/api/src/resources/account/endpoints/resetPassword.ts +++ b/template/apps/api/src/resources/account/endpoints/resetPassword.ts @@ -1,16 +1,15 @@ +import { z } from 'zod'; + +import { passwordSchema } from 'resources/base.schema'; import { tokenService } from 'resources/token'; +import { TokenType } from 'resources/token/token.schema'; import { userService } from 'resources/users'; -import { z } from 'zod'; - import isPublic from 'middlewares/isPublic'; import rateLimitMiddleware from 'middlewares/rateLimit'; import { securityUtil } from 'utils'; import createEndpoint from 'routes/createEndpoint'; -import { passwordSchema } from '../../base.schema'; -import { TokenType } from '../../token/token.schema'; - const schema = z.object({ token: z.string().min(1, 'Token is required'), password: passwordSchema, diff --git a/template/apps/api/src/resources/account/endpoints/signIn.ts b/template/apps/api/src/resources/account/endpoints/signIn.ts index 8bd8080f3..dd1ba8c90 100644 --- a/template/apps/api/src/resources/account/endpoints/signIn.ts +++ b/template/apps/api/src/resources/account/endpoints/signIn.ts @@ -1,17 +1,16 @@ +import { z } from 'zod'; + +import { emailSchema } from 'resources/base.schema'; import { tokenService } from 'resources/token'; +import { TokenType } from 'resources/token/token.schema'; import { userService } from 'resources/users'; -import { z } from 'zod'; - import isPublic from 'middlewares/isPublic'; import rateLimitMiddleware from 'middlewares/rateLimit'; import { authService } from 'services'; import { securityUtil } from 'utils'; import createEndpoint from 'routes/createEndpoint'; -import { emailSchema } from '../../base.schema'; -import { TokenType } from 'resources/token/token.schema'; - const schema = z.object({ email: emailSchema, password: z.string().min(1, 'Password is required').max(128, 'Password must be less than 128 characters.'), @@ -29,7 +28,7 @@ export default createEndpoint({ const user = await userService.findOne({ email }); if (!user || !user.passwordHash) { - ctx.throwClientError({ + return ctx.throwClientError({ credentials: 'The email or password you have entered is invalid', }); } @@ -37,7 +36,7 @@ export default createEndpoint({ const isPasswordMatch = await securityUtil.verifyPasswordHash(user.passwordHash, password); if (!isPasswordMatch) { - ctx.throwClientError({ + return ctx.throwClientError({ credentials: 'The email or password you have entered is invalid', }); } @@ -49,14 +48,14 @@ export default createEndpoint({ ); if (!existingEmailVerificationToken) { - ctx.throwClientError({ + return ctx.throwClientError({ emailVerificationTokenExpired: true, }); } } if (!user.isEmailVerified) { - ctx.throwClientError({ + return ctx.throwClientError({ email: 'Please verify your email to sign in', }); } diff --git a/template/apps/api/src/resources/account/endpoints/signOut.ts b/template/apps/api/src/resources/account/endpoints/signOut.ts index 8ed0bac05..b45f8b96b 100644 --- a/template/apps/api/src/resources/account/endpoints/signOut.ts +++ b/template/apps/api/src/resources/account/endpoints/signOut.ts @@ -1,5 +1,5 @@ -import { authService } from 'services'; import isPublic from 'middlewares/isPublic'; +import { authService } from 'services'; import createEndpoint from 'routes/createEndpoint'; export default createEndpoint({ diff --git a/template/apps/api/src/resources/account/endpoints/signUp.ts b/template/apps/api/src/resources/account/endpoints/signUp.ts index 82c8c4851..3e16dea8e 100644 --- a/template/apps/api/src/resources/account/endpoints/signUp.ts +++ b/template/apps/api/src/resources/account/endpoints/signUp.ts @@ -1,7 +1,8 @@ +import { emailSchema, passwordSchema } from 'resources/base.schema'; import { tokenService } from 'resources/token'; +import { TokenType } from 'resources/token/token.schema'; import { userService } from 'resources/users'; - -import { z } from 'zod'; +import { userSchema } from 'resources/users/user.schema'; import isPublic from 'middlewares/isPublic'; import rateLimitMiddleware from 'middlewares/rateLimit'; @@ -12,9 +13,6 @@ import createEndpoint from 'routes/createEndpoint'; import config from 'config'; import { EMAIL_VERIFICATION_TOKEN } from 'app-constants'; -import { emailSchema, passwordSchema } from '../../base.schema'; -import { TokenType } from '../../token/token.schema'; -import { userSchema } from '../../users/user.schema'; import { Template } from 'types'; const schema = userSchema.pick({ firstName: true, lastName: true }).extend({ diff --git a/template/apps/api/src/resources/account/endpoints/update.ts b/template/apps/api/src/resources/account/endpoints/update.ts index c151777a3..5ebd12e1c 100644 --- a/template/apps/api/src/resources/account/endpoints/update.ts +++ b/template/apps/api/src/resources/account/endpoints/update.ts @@ -2,26 +2,19 @@ import _ from 'lodash'; import { z } from 'zod'; import { accountUtils } from 'resources/account'; +import { passwordSchema } from 'resources/base.schema'; import { userService } from 'resources/users'; +import type { User } from 'resources/users/user.schema'; +import { userSchema } from 'resources/users/user.schema'; import { securityUtil } from 'utils'; import createEndpoint from 'routes/createEndpoint'; -import { passwordSchema } from '../../base.schema'; -import { userSchema } from '../../users/user.schema'; -import type { User } from '../../users/user.schema'; - const schema = userSchema .pick({ firstName: true, lastName: true }) .extend({ - password: z.union([ - passwordSchema, - z.literal(''), - ]), - avatar: z.union([ - z.any(), - z.literal(''), - ]).nullable(), + password: z.union([passwordSchema, z.literal('')]), + avatar: z.union([z.any(), z.literal('')]).nullable(), }) .partial(); diff --git a/template/apps/api/src/resources/account/endpoints/verifyEmail.ts b/template/apps/api/src/resources/account/endpoints/verifyEmail.ts index db9c82d09..696b2232e 100644 --- a/template/apps/api/src/resources/account/endpoints/verifyEmail.ts +++ b/template/apps/api/src/resources/account/endpoints/verifyEmail.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { tokenService } from 'resources/token'; +import { TokenType } from 'resources/token/token.schema'; import { userService } from 'resources/users'; import isPublic from 'middlewares/isPublic'; @@ -10,7 +11,6 @@ import createEndpoint from 'routes/createEndpoint'; import config from 'config'; -import { TokenType } from 'resources/token/token.schema'; import { Template } from 'types'; const schema = z.object({ diff --git a/template/apps/api/src/resources/account/endpoints/verifyResetToken.ts b/template/apps/api/src/resources/account/endpoints/verifyResetToken.ts index 748a9d90f..7817f9312 100644 --- a/template/apps/api/src/resources/account/endpoints/verifyResetToken.ts +++ b/template/apps/api/src/resources/account/endpoints/verifyResetToken.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { tokenService } from 'resources/token'; +import { TokenType } from 'resources/token/token.schema'; import { userService } from 'resources/users'; import isPublic from 'middlewares/isPublic'; @@ -9,8 +10,6 @@ import createEndpoint from 'routes/createEndpoint'; import config from 'config'; -import { TokenType } from 'resources/token/token.schema'; - const schema = z.object({ token: z.string().min(1, 'Token is required'), }); diff --git a/template/apps/api/src/resources/token/token.service.ts b/template/apps/api/src/resources/token/token.service.ts index a00b02767..a38195835 100644 --- a/template/apps/api/src/resources/token/token.service.ts +++ b/template/apps/api/src/resources/token/token.service.ts @@ -3,7 +3,9 @@ import { securityUtil } from 'utils'; import db from 'db'; import { DATABASE_DOCUMENTS } from 'app-constants'; -import { tokenSchema, type Token, TokenType } from './token.schema'; + +import type { Token } from './token.schema'; +import { tokenSchema, TokenType } from './token.schema'; const service = db.createService(DATABASE_DOCUMENTS.TOKENS, { schemaValidator: (obj) => tokenSchema.parseAsync(obj), diff --git a/template/apps/api/src/resources/users/endpoints/list.ts b/template/apps/api/src/resources/users/endpoints/list.ts index f131d622a..da348ce85 100644 --- a/template/apps/api/src/resources/users/endpoints/list.ts +++ b/template/apps/api/src/resources/users/endpoints/list.ts @@ -1,13 +1,14 @@ import { z } from 'zod'; +import { paginationSchema } from 'resources/base.schema'; import { userService } from 'resources/users'; import createEndpoint from 'routes/createEndpoint'; -import { paginationSchema } from '../../base.schema'; -import { userPublicSchema } from '../user.schema'; import type { NestedKeys } from 'types'; +import { userPublicSchema } from '../user.schema'; + const schema = paginationSchema.extend({ filter: z .object({ @@ -26,8 +27,6 @@ const schema = paginationSchema.extend({ createdOn: z.enum(['asc', 'desc']).default('asc'), }) .default({ createdOn: 'asc' }), - - smthElse: z.string(), }); export default createEndpoint({ diff --git a/template/apps/api/src/resources/users/user.handler.ts b/template/apps/api/src/resources/users/user.handler.ts index b678df339..31abb1d36 100644 --- a/template/apps/api/src/resources/users/user.handler.ts +++ b/template/apps/api/src/resources/users/user.handler.ts @@ -7,8 +7,8 @@ import ioEmitter from 'io-emitter'; import logger from 'logger'; import { DATABASE_DOCUMENTS } from 'app-constants'; -import type { User } from './user.schema'; +import type { User } from './user.schema'; import userService from './user.service'; const { USERS } = DATABASE_DOCUMENTS; diff --git a/template/apps/api/src/resources/users/user.schema.ts b/template/apps/api/src/resources/users/user.schema.ts index 808051c91..090ed93f9 100644 --- a/template/apps/api/src/resources/users/user.schema.ts +++ b/template/apps/api/src/resources/users/user.schema.ts @@ -13,14 +13,16 @@ export const userSchema = dbSchema.extend({ avatarUrl: z.string().nullable().optional(), - oauth: z.object({ - google: z - .object({ - userId: z.string().min(1, 'Google user ID is required'), - connectedOn: z.date(), - }) - .optional(), - }).optional(), + oauth: z + .object({ + google: z + .object({ + userId: z.string().min(1, 'Google user ID is required'), + connectedOn: z.date(), + }) + .optional(), + }) + .optional(), lastRequest: z.date().optional(), }); @@ -30,4 +32,3 @@ export type User = z.infer; export const userPublicSchema = userSchema.omit({ passwordHash: true, }); - diff --git a/template/apps/api/src/resources/users/user.service.ts b/template/apps/api/src/resources/users/user.service.ts index da7d49492..87a3064ab 100644 --- a/template/apps/api/src/resources/users/user.service.ts +++ b/template/apps/api/src/resources/users/user.service.ts @@ -3,7 +3,9 @@ import _ from 'lodash'; import db from 'db'; import { DATABASE_DOCUMENTS } from 'app-constants'; -import { userSchema, type User } from './user.schema'; + +import type { User } from './user.schema'; +import { userSchema } from './user.schema'; const service = db.createService(DATABASE_DOCUMENTS.USERS, { schemaValidator: (obj) => userSchema.parseAsync(obj), diff --git a/template/apps/api/src/routes/createEndpoint.ts b/template/apps/api/src/routes/createEndpoint.ts index 555ca30de..95113ec6c 100644 --- a/template/apps/api/src/routes/createEndpoint.ts +++ b/template/apps/api/src/routes/createEndpoint.ts @@ -3,7 +3,7 @@ import type { z, ZodType } from 'zod'; import type { AppKoaContext } from 'types'; -import type { EndpointConfig, HttpMethod } from './types'; +import type { EndpointConfig, HttpMethod, TypedMiddleware } from './types'; // Path parameter extraction type ExtractPathParams = T extends `${infer _Start}:${infer Param}/${infer Rest}` @@ -13,41 +13,32 @@ type ExtractPathParams = T extends `${infer _Start}:${infer Pa : Record; // Request type with params -type RequestWithParams = ExtractPathParams extends Record - ? object - : { params: ExtractPathParams }; +type RequestWithParams = + ExtractPathParams extends Record ? object : { params: ExtractPathParams }; -export interface EndpointOptions< - TPath extends string, - TSchema extends ZodType = ZodType, - TResponse = void, -> { +type AnyMiddleware = Middleware | TypedMiddleware; + +export interface EndpointOptions, TResponse = void> { method: HttpMethod; path: TPath; schema?: TSchema; - middlewares?: Middleware[]; - handler: ( - ctx: AppKoaContext, RequestWithParams>, - ) => Promise; + middlewares?: AnyMiddleware[]; + handler: (ctx: AppKoaContext, RequestWithParams>) => Promise; } // Result type that carries schema info for client-side inference -export interface EndpointResult< - TSchema extends ZodType = ZodType, -> { +export interface EndpointResult> { endpoint: EndpointConfig; handler: Middleware; schema?: TSchema; - middlewares?: Middleware[]; + middlewares?: AnyMiddleware[]; } export default function createEndpoint< TPath extends string, TSchema extends ZodType = ZodType, TResponse = void, ->( - options: EndpointOptions, -): EndpointResult { +>(options: EndpointOptions): EndpointResult { const wrappedHandler = async (ctx: AppKoaContext) => { const result = await options.handler(ctx as never); if (result !== undefined) { diff --git a/template/apps/api/src/routes/createMiddleware.ts b/template/apps/api/src/routes/createMiddleware.ts index 10724d6db..e2e0d129d 100644 --- a/template/apps/api/src/routes/createMiddleware.ts +++ b/template/apps/api/src/routes/createMiddleware.ts @@ -1,9 +1,9 @@ -import type { AppKoaContext, Next } from 'types'; +import type { Next } from 'types'; import type { TypedMiddleware } from './types'; -// eslint-disable-next-line ts/no-explicit-any export default function createMiddleware( + // eslint-disable-next-line ts/no-explicit-any fn: (ctx: any, next: Next) => Promise, ): TypedMiddleware { return fn as TypedMiddleware; diff --git a/template/apps/api/src/routes/routes.ts b/template/apps/api/src/routes/routes.ts index 3d8c4234a..635aad280 100644 --- a/template/apps/api/src/routes/routes.ts +++ b/template/apps/api/src/routes/routes.ts @@ -22,7 +22,6 @@ const registerEndpoint = (router: AppRouter, resourceName: string, endpoint: End middlewares.push(auth as AppRouterMiddleware); } - if (endpoint.schema) { middlewares.push(validateMiddleware(endpoint.schema) as AppRouterMiddleware); } diff --git a/template/apps/api/src/routes/types.ts b/template/apps/api/src/routes/types.ts index 65dbbbc06..695ab8039 100644 --- a/template/apps/api/src/routes/types.ts +++ b/template/apps/api/src/routes/types.ts @@ -14,7 +14,7 @@ export interface EndpointDefinition { endpoint: EndpointConfig; handler: Middleware; schema?: ZodSchema; - middlewares?: Middleware[]; + middlewares?: (Middleware | TypedMiddleware)[]; } // Middleware that declares what it adds to state diff --git a/template/apps/api/src/services/auth/auth.service.ts b/template/apps/api/src/services/auth/auth.service.ts index 5e2aa3afb..01f7b142e 100644 --- a/template/apps/api/src/services/auth/auth.service.ts +++ b/template/apps/api/src/services/auth/auth.service.ts @@ -1,10 +1,11 @@ import { tokenService } from 'resources/token'; +import type { Token } from 'resources/token/token.schema'; +import { TokenType } from 'resources/token/token.schema'; import { userService } from 'resources/users'; import { cookieUtil } from 'utils'; import { ACCESS_TOKEN } from 'app-constants'; -import { type Token, TokenType } from 'resources/token/token.schema'; import { AppKoaContext } from 'types'; interface SetAccessTokenOptions { diff --git a/template/apps/api/src/services/google/google.service.ts b/template/apps/api/src/services/google/google.service.ts index 6a09f6375..080a94fa0 100644 --- a/template/apps/api/src/services/google/google.service.ts +++ b/template/apps/api/src/services/google/google.service.ts @@ -10,13 +10,12 @@ import { import { z } from 'zod'; import { userService } from 'resources/users'; +import type { User } from 'resources/users/user.schema'; import config from 'config'; import logger from 'logger'; -import type { User } from 'resources/users/user.schema'; - const googleUserInfoSchema = z.object({ sub: z.string().describe('Unique Google user ID'), email: z.email().describe('User email'), diff --git a/template/apps/api/src/utils/get-resource-endpoints.util.ts b/template/apps/api/src/utils/get-resource-endpoints.util.ts index 86571f86d..f29849799 100644 --- a/template/apps/api/src/utils/get-resource-endpoints.util.ts +++ b/template/apps/api/src/utils/get-resource-endpoints.util.ts @@ -26,7 +26,7 @@ export const getResourceEndpoints = async (resourceName: string): Promise = T extends { schema: ZodType } ? P : Record; type InferPathParams = T extends { call: (params: infer _P, options: infer O) => unknown } @@ -11,9 +10,7 @@ type InferPathParams = T extends { call: (params: infer _P, options: infer O) ? PP : undefined : undefined; -type InferResponse = T extends { call: (...args: never[]) => Promise } - ? R - : unknown; +type InferResponse = T extends { call: (...args: never[]) => Promise } ? R : unknown; type RequestOptions = TPathParams extends undefined ? { pathParams?: never; headers?: Record } @@ -21,21 +18,22 @@ type RequestOptions = TPathParams extends undefined type UseApiQueryOptions = Omit, 'queryKey' | 'queryFn'>; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ApiEndpoint = { +interface ApiEndpoint { schema: ZodType | undefined; path: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line ts/no-explicit-any call: (...args: any[]) => Promise; -}; +} export function useApiQuery( endpoint: TEndpoint, params?: InferParams, options?: RequestOptions> & UseApiQueryOptions>, ): ReturnType>> { - const { pathParams, headers, ...queryOptions } = (options ?? {}) as { pathParams?: unknown; headers?: Record } & - UseApiQueryOptions>; + const { pathParams, headers, ...queryOptions } = (options ?? {}) as { + pathParams?: unknown; + headers?: Record; + } & UseApiQueryOptions>; const queryKey = [endpoint.path, params, pathParams].filter((v) => v !== undefined && v !== null); @@ -55,8 +53,10 @@ export function useApiMutation( options?: RequestOptions> & UseApiMutationOptions, InferParams>, ): ReturnType, ApiError, InferParams>> { - const { pathParams, headers, ...mutationOptions } = (options ?? {}) as { pathParams?: unknown; headers?: Record } & - UseApiMutationOptions, InferParams>; + const { pathParams, headers, ...mutationOptions } = (options ?? {}) as { + pathParams?: unknown; + headers?: Record; + } & UseApiMutationOptions, InferParams>; const callOptions = pathParams || headers ? { pathParams, headers } : undefined; @@ -67,11 +67,10 @@ export function useApiMutation( }) as ReturnType, ApiError, InferParams>>; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type FormEndpoint = { +interface FormEndpoint { schema: ZodType>; path: string; -}; +} type UseApiFormOptions> = Omit, 'resolver'>; @@ -79,8 +78,8 @@ export function useApiForm( endpoint: TEndpoint, options?: UseApiFormOptions>, ): UseFormReturn> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any return useForm({ + // eslint-disable-next-line ts/no-explicit-any resolver: zodResolver(endpoint.schema as any), ...options, }) as UseFormReturn>; diff --git a/template/apps/web/src/pages/profile/components/AvatarUpload/index.tsx b/template/apps/web/src/pages/profile/components/AvatarUpload/index.tsx index 2b72ad9f2..f07c3c571 100644 --- a/template/apps/web/src/pages/profile/components/AvatarUpload/index.tsx +++ b/template/apps/web/src/pages/profile/components/AvatarUpload/index.tsx @@ -3,21 +3,17 @@ import { Box, Button, Center, Group, Image, Stack, Text, Title } from '@mantine/ import { Dropzone } from '@mantine/dropzone'; import { IconPencil, IconPlus } from '@tabler/icons-react'; import cx from 'clsx'; -import { z } from 'zod'; +import { useApiQuery } from 'hooks/use-api.hook'; import { Controller, useFormContext } from 'react-hook-form'; +import { schemas, USER_AVATAR } from 'shared'; +import { z } from 'zod'; import { apiClient } from 'services/api-client.service'; - -import { useApiQuery } from 'hooks/use-api.hook'; - import { handleDropzoneError } from 'utils'; -import { USER_AVATAR } from 'shared'; -import { updateUserSchema } from 'shared'; - import classes from './index.module.css'; -type UpdateUserFormData = z.infer; +type UpdateUserFormData = z.infer; const AvatarUpload = () => { const { data: account } = useApiQuery(apiClient.account.get); diff --git a/template/apps/web/src/pages/profile/index.page.tsx b/template/apps/web/src/pages/profile/index.page.tsx index a9867bc5c..f3780f4df 100644 --- a/template/apps/web/src/pages/profile/index.page.tsx +++ b/template/apps/web/src/pages/profile/index.page.tsx @@ -6,7 +6,7 @@ import { useApiForm, useApiMutation, useApiQuery } from 'hooks'; import { isUndefined, pickBy } from 'lodash'; import { serialize } from 'object-to-formdata'; import { FormProvider } from 'react-hook-form'; -import { AccountGetResponse, updateUserSchema } from 'shared'; +import { AccountGetResponse, schemas } from 'shared'; import { z } from 'zod'; import { apiClient } from 'services/api-client.service'; @@ -50,7 +50,7 @@ const Profile: NextPage = () => { return !isUndefined(value); }); - updateAccount(serialize(updateData) as unknown as z.infer, { + updateAccount(serialize(updateData) as unknown as z.infer, { onSuccess: (data) => { queryClient.setQueryData([apiClient.account.get.path], data); diff --git a/template/apps/web/src/pages/reset-password/index.page.tsx b/template/apps/web/src/pages/reset-password/index.page.tsx index 43f5cacd1..d5c26cb44 100644 --- a/template/apps/web/src/pages/reset-password/index.page.tsx +++ b/template/apps/web/src/pages/reset-password/index.page.tsx @@ -3,21 +3,18 @@ import Head from 'next/head'; import { useRouter } from 'next/router'; import { Button, PasswordInput, Stack, Text, Title } from '@mantine/core'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useApiMutation } from 'hooks'; import { useForm } from 'react-hook-form'; +import { schemas } from 'shared'; import { z } from 'zod'; import { apiClient } from 'services/api-client.service'; - -import { useApiMutation } from 'hooks'; - import { handleApiError } from 'utils'; import { RoutePath } from 'routes'; -import { resetPasswordSchema } from 'shared'; - // Form schema differs from API schema (token comes from URL, not form) -const formSchema = resetPasswordSchema.omit({ token: true }); +const formSchema = schemas.account.resetPassword.omit({ token: true }); type ResetPasswordFormData = z.infer; diff --git a/template/apps/web/src/types.ts b/template/apps/web/src/types.ts index e3ea8262e..dc7056069 100644 --- a/template/apps/web/src/types.ts +++ b/template/apps/web/src/types.ts @@ -1,4 +1,4 @@ -export { type User, type UpdateUserParams, type UpdateUserParamsFrontend, type ListResult, type SortOrder, type SortParams, type ListParams } from 'shared'; +export { type ListParams, type ListResult, type SortOrder, type SortParams, type PublicUser as User } from 'shared'; export type { ApiError } from 'shared'; export type QueryParam = string | string[] | undefined; diff --git a/template/packages/shared/eslint.config.js b/template/packages/shared/eslint.config.js new file mode 100644 index 000000000..4775b9538 --- /dev/null +++ b/template/packages/shared/eslint.config.js @@ -0,0 +1,3 @@ +import node from 'eslint-config/node'; + +export default node; diff --git a/template/packages/shared/node_modules/.bin/eslint b/template/packages/shared/node_modules/.bin/eslint index a40fa76fb..e4a64287a 100755 --- a/template/packages/shared/node_modules/.bin/eslint +++ b/template/packages/shared/node_modules/.bin/eslint @@ -6,9 +6,9 @@ case `uname` in esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules/eslint/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/eslint@9.34.0_jiti@2.6.1/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules:$NODE_PATH" fi if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../eslint/bin/eslint.js" "$@" diff --git a/template/packages/shared/node_modules/.bin/lint-staged b/template/packages/shared/node_modules/.bin/lint-staged index 7d584e8aa..fe528b2e5 100755 --- a/template/packages/shared/node_modules/.bin/lint-staged +++ b/template/packages/shared/node_modules/.bin/lint-staged @@ -6,9 +6,9 @@ case `uname` in esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/lint-staged@16.1.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/lint-staged@16.1.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/lint-staged@16.1.2/node_modules/lint-staged/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/lint-staged@16.1.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules:$NODE_PATH" fi if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../lint-staged/bin/lint-staged.js" "$@" diff --git a/template/packages/shared/node_modules/.bin/prettier b/template/packages/shared/node_modules/.bin/prettier index bd686ec89..e1a10a638 100755 --- a/template/packages/shared/node_modules/.bin/prettier +++ b/template/packages/shared/node_modules/.bin/prettier @@ -6,9 +6,9 @@ case `uname` in esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/prettier@3.6.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/prettier@3.6.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/prettier@3.6.2/node_modules/prettier/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/prettier@3.6.2/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules:$NODE_PATH" fi if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../prettier/bin/prettier.cjs" "$@" diff --git a/template/packages/shared/node_modules/.bin/tsc b/template/packages/shared/node_modules/.bin/tsc index 884ecc461..b2a713585 100755 --- a/template/packages/shared/node_modules/.bin/tsc +++ b/template/packages/shared/node_modules/.bin/tsc @@ -6,9 +6,9 @@ case `uname` in esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules:$NODE_PATH" fi if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@" diff --git a/template/packages/shared/node_modules/.bin/tsserver b/template/packages/shared/node_modules/.bin/tsserver index 06a85be44..bacb0c9f0 100755 --- a/template/packages/shared/node_modules/.bin/tsserver +++ b/template/packages/shared/node_modules/.bin/tsserver @@ -6,9 +6,9 @@ case `uname` in esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules:$NODE_PATH" fi if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@" diff --git a/template/packages/shared/node_modules/.bin/tsx b/template/packages/shared/node_modules/.bin/tsx index 227cfa622..9649e9033 100755 --- a/template/packages/shared/node_modules/.bin/tsx +++ b/template/packages/shared/node_modules/.bin/tsx @@ -6,9 +6,9 @@ case `uname` in esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/dist/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/dist/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/tsx@4.20.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/dist/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/tsx@4.20.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship-donor/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/dist/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/tsx@4.20.3/node_modules/tsx/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/tsx@4.20.3/node_modules:/Users/a.kasperovich/Documents/ship-hive/ship/template/node_modules/.pnpm/node_modules:$NODE_PATH" fi if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../tsx/dist/cli.mjs" "$@" diff --git a/template/packages/shared/scripts/generate.ts b/template/packages/shared/scripts/generate.ts index 59bf47a56..ad6d03a72 100644 --- a/template/packages/shared/scripts/generate.ts +++ b/template/packages/shared/scripts/generate.ts @@ -1,19 +1,24 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +/* eslint-disable no-console */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const API_RESOURCES_PATH = path.resolve(__dirname, '../../../apps/api/src/resources'); -const SCHEMAS_OUTPUT_PATH = path.resolve(__dirname, '../src/schemas'); -const GENERATED_PATH = path.resolve(__dirname, '../src/generated'); -const IGNORE_RESOURCES = ['token']; +const API_RESOURCES_PATH = path.resolve( + __dirname, + "../../../apps/api/src/resources", +); +const SCHEMAS_OUTPUT_PATH = path.resolve(__dirname, "../src/schemas"); +const GENERATED_PATH = path.resolve(__dirname, "../src/generated"); +const IGNORE_RESOURCES = ["token"]; // ─── Schema Sync ─────────────────────────────────────────────────────── function syncSchemas() { - console.log('📋 Syncing schemas from API resources...'); + console.log("📋 Syncing schemas from API resources..."); if (!fs.existsSync(SCHEMAS_OUTPUT_PATH)) { fs.mkdirSync(SCHEMAS_OUTPUT_PATH, { recursive: true }); @@ -23,9 +28,9 @@ function syncSchemas() { const schemaFiles: { src: string; relativePath: string }[] = []; // base.schema.ts in resources root - const baseSchemaPath = path.join(API_RESOURCES_PATH, 'base.schema.ts'); + const baseSchemaPath = path.join(API_RESOURCES_PATH, "base.schema.ts"); if (fs.existsSync(baseSchemaPath)) { - schemaFiles.push({ src: baseSchemaPath, relativePath: 'base.schema.ts' }); + schemaFiles.push({ src: baseSchemaPath, relativePath: "base.schema.ts" }); } // Resource-specific schemas @@ -36,7 +41,9 @@ function syncSchemas() { for (const resource of resources) { const resourceDir = path.join(API_RESOURCES_PATH, resource); - const files = fs.readdirSync(resourceDir).filter((f) => f.endsWith('.schema.ts')); + const files = fs + .readdirSync(resourceDir) + .filter((f) => f.endsWith(".schema.ts")); for (const file of files) { schemaFiles.push({ @@ -48,7 +55,7 @@ function syncSchemas() { // Copy and transform each schema file for (const { src, relativePath } of schemaFiles) { - let content = fs.readFileSync(src, 'utf-8'); + const content = fs.readFileSync(src, "utf-8"); // No import rewriting needed — the relative paths in source schema files // (e.g. ../base.schema from account/account.schema.ts) already work correctly @@ -70,15 +77,20 @@ function syncSchemas() { for (const resource of resources) { const resourceDir = path.join(API_RESOURCES_PATH, resource); - const files = fs.readdirSync(resourceDir).filter((f) => f.endsWith('.schema.ts')); + const files = fs + .readdirSync(resourceDir) + .filter((f) => f.endsWith(".schema.ts")); for (const file of files) { - const moduleName = file.replace('.ts', ''); + const moduleName = file.replace(".ts", ""); indexLines.push(`export * from './${resource}/${moduleName}';`); } } - fs.writeFileSync(path.join(SCHEMAS_OUTPUT_PATH, 'index.ts'), indexLines.join('\n') + '\n'); + fs.writeFileSync( + path.join(SCHEMAS_OUTPUT_PATH, "index.ts"), + `${indexLines.join("\n")}\n`, + ); console.log(` ✓ index.ts (barrel)`); } @@ -114,16 +126,51 @@ function getResources(): string[] { .map((dirent) => dirent.name); } +function extractBalancedSchema(text: string): string | null { + // Extract a schema expression that ends with `;` at depth 0 + // e.g. `userSchema\n .pick({...})\n .extend({...})\n .partial();` + let depth = 0; + let inString = false; + let stringChar = ""; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const prevChar = i > 0 ? text[i - 1] : ""; + + if (!inString && (char === '"' || char === "'" || char === "`")) { + inString = true; + stringChar = char; + } else if (inString && char === stringChar && prevChar !== "\\") { + inString = false; + } + + if (!inString) { + if (char === "(" || char === "{" || char === "[") { + depth++; + } else if (char === ")" || char === "}" || char === "]") { + depth--; + } else if (char === ";" && depth === 0) { + return text.slice(0, i).trimEnd(); + } + } + } + return null; +} + function extractSchemaFromContent(content: string): { schemaImport: string | null; schemaName: string | null; fullSchemaCode: string | null; isInlineSchema: boolean; } { - const simpleSchemaMatch = content.match(/export\s+const\s+schema\s*=\s*(\w+Schema)\s*;/); + const simpleSchemaMatch = content.match( + /export\s+const\s+schema\s*=\s*(\w+Schema)\s*;/, + ); if (simpleSchemaMatch) { - const afterMatch = content.slice(content.indexOf(simpleSchemaMatch[0]) + simpleSchemaMatch[0].length - 1); - if (!afterMatch.startsWith('Schema.')) { + const afterMatch = content.slice( + content.indexOf(simpleSchemaMatch[0]) + simpleSchemaMatch[0].length - 1, + ); + if (!afterMatch.startsWith("Schema.")) { return { schemaImport: simpleSchemaMatch[1], schemaName: simpleSchemaMatch[1], @@ -133,82 +180,54 @@ function extractSchemaFromContent(content: string): { } } - function extractBalancedExpression(text: string, startIdx: number): string { - let depth = 0; - let inString = false; - let stringChar = ''; - let result = ''; - - for (let i = startIdx; i < text.length; i++) { - const char = text[i]; - const prevChar = i > 0 ? text[i - 1] : ''; - - if (!inString && (char === '"' || char === "'" || char === '`')) { - inString = true; - stringChar = char; - } else if (inString && char === stringChar && prevChar !== '\\') { - inString = false; - } - - if (!inString) { - if (char === '(' || char === '{' || char === '[') { - depth++; - } else if (char === ')' || char === '}' || char === ']') { - depth--; - if (depth === 0) { - result += char; - break; - } - } - } - - result += char; - } - - return result; - } - - const exportedInlineStart = content.match(/export\s+const\s+schema\s*=\s*(paginationSchema|userSchema|z)\./); + const exportedInlineStart = content.match( + /export\s+const\s+schema\s*=\s*(paginationSchema|userSchema|z)\s*\./, + ); if (exportedInlineStart) { + const matchIdx = content.indexOf(exportedInlineStart[0]); const startIdx = - content.indexOf(exportedInlineStart[0]) + exportedInlineStart[0].length - (exportedInlineStart[1].length + 1); + matchIdx + exportedInlineStart[0].indexOf(exportedInlineStart[1]); const schemaExpr = content.slice(startIdx); - const match = schemaExpr.match(/^((?:paginationSchema|userSchema|z)[\s\S]*?)\);/); - if (match) { - const schemaCode = match[1] + ')'; + const schemaCode = extractBalancedSchema(schemaExpr); + if (schemaCode) { return { schemaImport: null, - schemaName: 'schema', + schemaName: "schema", fullSchemaCode: schemaCode, isInlineSchema: true, }; } } - const inlineSchemaStart = content.match(/(? { - const imports = new Map(); - const importRegex = /import\s+(?:type\s+)?(?:{([^}]+)}|(\w+))\s+from\s+['"]([^'"]+)['"]/g; - - let match; - while ((match = importRegex.exec(content)) !== null) { - const namedImports = match[1]; - const defaultImport = match[2]; - const source = match[3]; - - if (namedImports) { - namedImports.split(',').forEach((imp) => { - const name = imp.trim().split(/\s+as\s+/)[0].trim(); - if (name) imports.set(name, source); - }); - } - if (defaultImport) { - imports.set(defaultImport, source); - } - } - - return imports; -} - function extractResponseType(content: string): string | null { // Extract the handler body from createEndpoint const handlerMatch = content.match(/async\s+handler\s*\([^)]*\)\s*\{/); if (!handlerMatch) return null; - const handlerStart = content.indexOf(handlerMatch[0]) + handlerMatch[0].length; + const handlerStart = + content.indexOf(handlerMatch[0]) + handlerMatch[0].length; // Find the handler body by matching braces let depth = 1; let i = handlerStart; while (i < content.length && depth > 0) { - if (content[i] === '{') depth++; - else if (content[i] === '}') depth--; + if (content[i] === "{") depth++; + else if (content[i] === "}") depth--; i++; } const handlerBody = content.slice(handlerStart, i - 1); // Find return statements (skip ones inside nested functions/callbacks) const returnStatements: string[] = []; - const returnRegex = /\breturn\s+([^;]+);/g; - let match; - while ((match = returnRegex.exec(handlerBody)) !== null) { + const returnRegex = /\breturn\s+([^\s;][^;]*);/g; + let match: RegExpExecArray | null = returnRegex.exec(handlerBody); + while (match !== null) { // Check if this return is inside a nested function/arrow by looking // for function/arrow signatures before unmatched opening braces const before = handlerBody.slice(0, match.index); @@ -275,15 +271,18 @@ function extractResponseType(content: string): string | null { let braceDepth = 0; for (let j = before.length - 1; j >= 0; j--) { - if (before[j] === '}') braceDepth++; - else if (before[j] === '{') { + if (before[j] === "}") braceDepth++; + else if (before[j] === "{") { if (braceDepth > 0) { braceDepth--; } else { // Unmatched '{' — check what precedes it const preceding = before.slice(0, j).trimEnd(); // Arrow function: => { or function keyword - if (/=>\s*$/.test(preceding) || /\bfunction\s*\([^)]*\)\s*$/.test(preceding)) { + if ( + /=>\s*$/.test(preceding) || + /\bfunction\s*\([^)]*\)\s*$/.test(preceding) + ) { insideNestedFn = true; break; } @@ -295,6 +294,8 @@ function extractResponseType(content: string): string | null { if (!insideNestedFn) { returnStatements.push(match[1].trim()); } + + match = returnRegex.exec(handlerBody); } if (returnStatements.length === 0) return null; @@ -305,13 +306,13 @@ function extractResponseType(content: string): string | null { if (/userService\.getPublic/.test(expr)) { // Check if it's a list result pattern: { ...result, results: ...map(userService.getPublic) } if (/results\s*:.*\.map\(userService\.getPublic\)/.test(expr)) { - return 'listResult(userPublicSchema)'; + return "listResult(userPublicSchema)"; } - return 'userPublicSchema'; + return "userPublicSchema"; } // Pattern: inline object literal { key: value, ... } - if (expr.startsWith('{')) { + if (expr.startsWith("{")) { return inferObjectType(expr); } } @@ -321,18 +322,24 @@ function extractResponseType(content: string): string | null { function inferObjectType(expr: string): string | null { // Simple object literal like { emailVerificationToken } - const shorthand = expr.match(/^\{\s*([\w,\s]+)\s*\}$/); + const shorthand = expr.match(/^\{\s*([\w,][\w\s,]*)\}$/); if (shorthand) { - const keys = shorthand[1].split(',').map((k) => k.trim()).filter(Boolean); - const fields = keys.map((k) => `${k}: string`).join('; '); + const keys = shorthand[1] + .split(",") + .map((k) => k.trim()) + .filter(Boolean); + const fields = keys.map((k) => `${k}: string`).join("; "); return `{ ${fields} }`; } return null; } - async function getEndpoints(resourceName: string): Promise { - const endpointsPath = path.join(API_RESOURCES_PATH, resourceName, 'endpoints'); + const endpointsPath = path.join( + API_RESOURCES_PATH, + resourceName, + "endpoints", + ); if (!fs.existsSync(endpointsPath)) { return []; @@ -340,22 +347,30 @@ async function getEndpoints(resourceName: string): Promise { const endpointFiles = fs .readdirSync(endpointsPath) - .filter((file) => file.endsWith('.ts') && !file.endsWith('.d.ts')); + .filter((file) => file.endsWith(".ts") && !file.endsWith(".d.ts")); const endpoints: ParsedEndpoint[] = []; for (const file of endpointFiles) { const filePath = path.join(endpointsPath, file); - const content = fs.readFileSync(filePath, 'utf-8'); + const content = fs.readFileSync(filePath, "utf-8"); let method: string | null = null; let endpointPath: string | null = null; - const createEndpointStart = content.match(/createEndpoint(?:<[^>]+>)?\s*\(\s*\{/); + const createEndpointStart = content.match( + /createEndpoint(?:<[^>]+>)?\s*\(\s*\{/, + ); if (createEndpointStart) { - const afterCreateEndpoint = content.slice(content.indexOf(createEndpointStart[0])); - const methodMatch = afterCreateEndpoint.match(/method\s*:\s*['"](\w+)['"]/); - const pathMatch = afterCreateEndpoint.match(/path\s*:\s*['"]([^'"]+)['"]/); + const afterCreateEndpoint = content.slice( + content.indexOf(createEndpointStart[0]), + ); + const methodMatch = afterCreateEndpoint.match( + /method\s*:\s*['"](\w+)['"]/, + ); + const pathMatch = afterCreateEndpoint.match( + /path\s*:\s*['"]([^'"]+)['"]/, + ); if (methodMatch && pathMatch) { method = methodMatch[1]; @@ -365,7 +380,7 @@ async function getEndpoints(resourceName: string): Promise { if (!method || !endpointPath) { const endpointMatch = content.match( - /export\s+const\s+endpoint\s*(?::\s*EndpointConfig)?\s*=\s*\{([^}]+)\}/s, + /export\s+const\s+endpoint\s*(?::\s*EndpointConfig\s*)?=\s*\{([^}]+)\}/, ); if (endpointMatch) { @@ -382,12 +397,14 @@ async function getEndpoints(resourceName: string): Promise { if (!method || !endpointPath) continue; - const pathParams = (endpointPath.match(/:(\w+)/g) || []).map((p) => p.slice(1)); + const pathParams = (endpointPath.match(/:(\w+)/g) || []).map((p) => + p.slice(1), + ); const hasPathParams = pathParams.length > 0; const schemaInfo = extractSchemaFromContent(content); - const baseName = file.replace('.ts', ''); + const baseName = file.replace(".ts", ""); const name = toCamelCase(baseName); endpoints.push({ @@ -422,16 +439,18 @@ function generateIndexFile( allSchemaImports.add(endpoint.schemaImport); } if (endpoint.fullSchemaCode) { - const schemaNames = endpoint.fullSchemaCode.match(/\b(\w+Schema)\b/g) || []; + const schemaNames = + endpoint.fullSchemaCode.match(/\b(\w+Schema)\b/g) || []; schemaNames.forEach((name) => allSchemaImports.add(name)); } // Collect schema imports needed for response types if (endpoint.responseType) { - const schemaNames = endpoint.responseType.match(/\b(\w+Schema)\b/g) || []; + const schemaNames = + endpoint.responseType.match(/\b(\w+Schema)\b/g) || []; schemaNames.forEach((name) => allSchemaImports.add(name)); // If response uses listResult, import listResultSchema - if (endpoint.responseType.includes('listResult(')) { - allSchemaImports.add('listResultSchema'); + if (endpoint.responseType.includes("listResult(")) { + allSchemaImports.add("listResultSchema"); } } } @@ -439,14 +458,14 @@ function generateIndexFile( if (allSchemaImports.size > 0) { lines.push( - `import { ${Array.from(allSchemaImports).sort().join(', ')} } from '../schemas';`, + `import { ${Array.from(allSchemaImports).sort().join(", ")} } from '../schemas';`, ); } lines.push(`import { ApiClient } from '../client';`); - lines.push(''); + lines.push(""); // schemas object - lines.push('export const schemas = {'); + lines.push("export const schemas = {"); for (const [resourceName, endpoints] of resourceEndpoints) { const schemaExports: string[] = []; @@ -468,21 +487,21 @@ function generateIndexFile( } } - lines.push('} as const;'); - lines.push(''); + lines.push("} as const;"); + lines.push(""); // Path param types for (const [resourceName, endpoints] of resourceEndpoints) { for (const endpoint of endpoints) { if (endpoint.hasPathParams) { const typeName = `${toPascalCase(resourceName)}${toPascalCase(endpoint.name)}PathParams`; - const pathParamsType = `{ ${endpoint.pathParams.map((p) => `${p}: string`).join('; ')} }`; + const pathParamsType = `{ ${endpoint.pathParams.map((p) => `${p}: string`).join("; ")} }`; lines.push(`export type ${typeName} = ${pathParamsType};`); } } } - lines.push(''); + lines.push(""); // Param types for (const [resourceName, endpoints] of resourceEndpoints) { @@ -496,7 +515,7 @@ function generateIndexFile( } } - lines.push(''); + lines.push(""); // Response types (inferred from handler return values) for (const [resourceName, endpoints] of resourceEndpoints) { @@ -504,60 +523,72 @@ function generateIndexFile( if (endpoint.responseType) { const responseTypeName = `${toPascalCase(resourceName)}${toPascalCase(endpoint.name)}Response`; - if (endpoint.responseType.startsWith('{')) { + if (endpoint.responseType.startsWith("{")) { // Inline object type - lines.push(`export type ${responseTypeName} = ${endpoint.responseType};`); - } else if (endpoint.responseType.includes('listResult(')) { + lines.push( + `export type ${responseTypeName} = ${endpoint.responseType};`, + ); + } else if (endpoint.responseType.includes("listResult(")) { // listResult(schema) → z.infer> - const innerSchema = endpoint.responseType.match(/listResult\((\w+)\)/)?.[1]; + const innerSchema = + endpoint.responseType.match(/listResult\((\w+)\)/)?.[1]; if (innerSchema) { - lines.push(`export type ${responseTypeName} = z.infer>>;`); + lines.push( + `export type ${responseTypeName} = z.infer>>;`, + ); } } else { // Schema reference like userPublicSchema - lines.push(`export type ${responseTypeName} = z.infer;`); + lines.push( + `export type ${responseTypeName} = z.infer;`, + ); } } } } - lines.push(''); + lines.push(""); // Endpoint creator functions for (const [resourceName, endpoints] of resourceEndpoints) { const pascalName = toPascalCase(resourceName); lines.push(`function create${pascalName}Endpoints(client: ApiClient) {`); - lines.push(' return {'); + lines.push(" return {"); for (const endpoint of endpoints) { - const { name, method, path: endpointPath, hasPathParams, schemaName } = endpoint; - const fullPath = `/${resourceName}${endpointPath === '/' ? '' : endpointPath}`; + const { + name, + method, + path: endpointPath, + hasPathParams, + schemaName, + } = endpoint; + const fullPath = `/${resourceName}${endpointPath === "/" ? "" : endpointPath}`; const paramsTypeName = schemaName ? `${toPascalCase(resourceName)}${toPascalCase(name)}Params` - : 'Record'; + : "Record"; const pathParamsTypeName = hasPathParams ? `${toPascalCase(resourceName)}${toPascalCase(name)}PathParams` - : 'never'; + : "never"; let pathExpr = `'${fullPath}'`; if (hasPathParams) { - pathExpr = '`' + fullPath.replace(/:(\w+)/g, '${options.pathParams.$1}') + '`'; + // eslint-disable-next-line no-template-curly-in-string + pathExpr = `\`${fullPath.replace(/:(\w+)/g, "${options.pathParams.$1}")}\``; } - const needsData = ['post', 'put', 'patch'].includes(method); - const responseTypeName = endpoint.responseType ? `${toPascalCase(resourceName)}${toPascalCase(name)}Response` - : 'void'; + : "void"; lines.push(` ${name}: {`); lines.push(` method: '${method}' as const,`); lines.push(` path: '${fullPath}' as const,`); lines.push( - ` schema: ${schemaName ? `schemas.${resourceName}.${name}` : 'undefined'},`, + ` schema: ${schemaName ? `schemas.${resourceName}.${name}` : "undefined"},`, ); if (hasPathParams) { @@ -579,18 +610,20 @@ function generateIndexFile( } else { lines.push(` call: (params?: Record) =>`); } - lines.push(` client.${method}<${responseTypeName}>(${pathExpr}, params),`); + lines.push( + ` client.${method}<${responseTypeName}>(${pathExpr}, params),`, + ); } lines.push(` },`); } - lines.push(' };'); - lines.push('}'); - lines.push(''); + lines.push(" };"); + lines.push("}"); + lines.push(""); } - lines.push('export function createApiEndpoints(client: ApiClient) {'); - lines.push(' return {'); + lines.push("export function createApiEndpoints(client: ApiClient) {"); + lines.push(" return {"); for (const resource of resources) { const camelName = toCamelCase(resource); @@ -598,12 +631,14 @@ function generateIndexFile( lines.push(` ${camelName}: create${pascalName}Endpoints(client),`); } - lines.push(' };'); - lines.push('}'); - lines.push(''); + lines.push(" };"); + lines.push("}"); + lines.push(""); - lines.push('export type ApiEndpoints = ReturnType;'); - lines.push(''); + lines.push( + "export type ApiEndpoints = ReturnType;", + ); + lines.push(""); lines.push( `export interface ApiEndpoint {`, @@ -617,28 +652,28 @@ function generateIndexFile( ` : (params: TParams, options: { pathParams: TPathParams; headers?: Record }) => Promise;`, ); lines.push(`}`); - lines.push(''); + lines.push(""); lines.push( - 'export type InferParams = T extends { schema: infer S } ? (S extends z.ZodType ? z.infer : Record) : Record;', + "export type InferParams = T extends { schema: infer S } ? (S extends z.ZodType ? z.infer : Record) : Record;", ); - lines.push(''); + lines.push(""); lines.push( - 'export type InferPathParams = T extends { call: (params: unknown, options: { pathParams: infer PP }) => unknown } ? PP : never;', + "export type InferPathParams = T extends { call: (params: unknown, options: { pathParams: infer PP }) => unknown } ? PP : never;", ); - lines.push(''); + lines.push(""); lines.push( - 'export type InferResponse = T extends { call: (...args: never[]) => Promise } ? R : unknown;', + "export type InferResponse = T extends { call: (...args: never[]) => Promise } ? R : unknown;", ); - lines.push(''); + lines.push(""); - return lines.join('\n'); + return lines.join("\n"); } // ─── Main ─────────────────────────────────────────────────────────────── async function generate() { - console.log('🔍 Scanning API resources...'); + console.log("🔍 Scanning API resources..."); if (!fs.existsSync(GENERATED_PATH)) { fs.mkdirSync(GENERATED_PATH, { recursive: true }); @@ -649,7 +684,7 @@ async function generate() { // Step 2: Generate API client const resources = getResources(); - console.log(`📦 Found resources: ${resources.join(', ')}`); + console.log(`📦 Found resources: ${resources.join(", ")}`); const resourceEndpoints = new Map(); const generatedResources: string[] = []; @@ -667,12 +702,12 @@ async function generate() { for (const endpoint of endpoints) { const schemaInfo = endpoint.schemaName ? endpoint.isInlineSchema - ? 'inline schema' + ? "inline schema" : `import: ${endpoint.schemaImport}` - : 'no schema'; + : "no schema"; const responseInfo = endpoint.responseType ? `response: ${endpoint.responseType}` - : 'void'; + : "void"; console.log( ` - ${endpoint.name}: ${endpoint.method.toUpperCase()} ${endpoint.path} (${schemaInfo}, ${responseInfo})`, ); @@ -683,16 +718,16 @@ async function generate() { } const indexContent = generateIndexFile(generatedResources, resourceEndpoints); - fs.writeFileSync(path.join(GENERATED_PATH, 'index.ts'), indexContent); + fs.writeFileSync(path.join(GENERATED_PATH, "index.ts"), indexContent); - console.log('✅ Generation complete!'); + console.log("✅ Generation complete!"); } // Watch mode support -const isWatch = process.argv.includes('--watch'); +const isWatch = process.argv.includes("--watch"); if (isWatch) { - console.log('👀 Starting watch mode...'); + console.log("👀 Starting watch mode..."); await generate(); let debounceTimer: ReturnType | null = null; @@ -700,18 +735,21 @@ if (isWatch) { const debouncedGenerate = () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { - console.log('\n🔄 Changes detected, regenerating...'); + console.log("\n🔄 Changes detected, regenerating..."); await generate(); }, 300); }; fs.watch(API_RESOURCES_PATH, { recursive: true }, (_, filename) => { - if (filename && (filename.endsWith('.schema.ts') || filename.includes('endpoints/'))) { + if ( + filename && + (filename.endsWith(".schema.ts") || filename.includes("endpoints/")) + ) { debouncedGenerate(); } }); - console.log('👀 Watching for changes in API resources...'); + console.log("👀 Watching for changes in API resources..."); } else { await generate(); } diff --git a/template/packages/shared/src/generated/index.ts b/template/packages/shared/src/generated/index.ts index 459285bd1..313369efa 100644 --- a/template/packages/shared/src/generated/index.ts +++ b/template/packages/shared/src/generated/index.ts @@ -1,152 +1,200 @@ -import { z } from 'zod'; -import { forgotPasswordSchema, listResultSchema, paginationSchema, resendEmailSchema, resetPasswordSchema, signInSchema, signUpSchema, updateUserSchema, userPublicSchema, userSchema } from '../schemas'; -import { ApiClient } from '../client'; +import { z } from "zod"; + +import { ApiClient } from "../client"; +import { + emailSchema, + listResultSchema, + paginationSchema, + passwordSchema, + userPublicSchema, + userSchema, +} from "../schemas"; export const schemas = { account: { - forgotPassword: forgotPasswordSchema, - resendEmail: resendEmailSchema, - resetPassword: resetPasswordSchema, - signIn: signInSchema, - signUp: signUpSchema, - update: updateUserSchema, + forgotPassword: z.object({ + email: emailSchema, + }), + resendEmail: z.object({ + email: emailSchema, + }), + resetPassword: z.object({ + token: z.string().min(1, "Token is required"), + password: passwordSchema, + }), + signIn: z.object({ + email: emailSchema, + password: z + .string() + .min(1, "Password is required") + .max(128, "Password must be less than 128 characters."), + }), + signUp: userSchema.pick({ firstName: true, lastName: true }).extend({ + email: emailSchema, + password: passwordSchema, + }), + update: userSchema + .pick({ firstName: true, lastName: true }) + .extend({ + password: z.union([passwordSchema, z.literal("")]), + avatar: z.union([z.any(), z.literal("")]).nullable(), + }) + .partial(), verifyEmail: z.object({ - token: z.string().min(1, 'Token is required'), -}), + token: z.string().min(1, "Token is required"), + }), verifyResetToken: z.object({ - token: z.string().min(1, 'Token is required'), -}), + token: z.string().min(1, "Token is required"), + }), }, users: { list: paginationSchema.extend({ - filter: z - .object({ - createdOn: z + filter: z .object({ - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), + createdOn: z + .object({ + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + }) + .optional(), }) .optional(), - }) - .optional(), - sort: z - .object({ - firstName: z.enum(['asc', 'desc']).optional(), - lastName: z.enum(['asc', 'desc']).optional(), - createdOn: z.enum(['asc', 'desc']).default('asc'), - }) - .default({ createdOn: 'asc' }), -}), + sort: z + .object({ + firstName: z.enum(["asc", "desc"]).optional(), + lastName: z.enum(["asc", "desc"]).optional(), + createdOn: z.enum(["asc", "desc"]).default("asc"), + }) + .default({ createdOn: "asc" }), + }), update: userSchema.pick({ firstName: true, lastName: true, email: true }), }, } as const; -export type UsersRemovePathParams = { id: string }; -export type UsersUpdatePathParams = { id: string }; +export interface UsersRemovePathParams { + id: string; +} +export interface UsersUpdatePathParams { + id: string; +} -export type AccountForgotPasswordParams = z.infer; -export type AccountResendEmailParams = z.infer; -export type AccountResetPasswordParams = z.infer; +export type AccountForgotPasswordParams = z.infer< + typeof schemas.account.forgotPassword +>; +export type AccountResendEmailParams = z.infer< + typeof schemas.account.resendEmail +>; +export type AccountResetPasswordParams = z.infer< + typeof schemas.account.resetPassword +>; export type AccountSignInParams = z.infer; export type AccountSignUpParams = z.infer; export type AccountUpdateParams = z.infer; -export type AccountVerifyEmailParams = z.infer; -export type AccountVerifyResetTokenParams = z.infer; +export type AccountVerifyEmailParams = z.infer< + typeof schemas.account.verifyEmail +>; +export type AccountVerifyResetTokenParams = z.infer< + typeof schemas.account.verifyResetToken +>; export type UsersListParams = z.infer; export type UsersUpdateParams = z.infer; export type AccountGetResponse = z.infer; export type AccountSignInResponse = z.infer; -export type AccountSignUpResponse = { emailVerificationToken: string }; +export interface AccountSignUpResponse { + emailVerificationToken: string; +} export type AccountUpdateResponse = z.infer; -export type UsersListResponse = z.infer>>; +export type UsersListResponse = z.infer< + ReturnType> +>; export type UsersUpdateResponse = z.infer; function createAccountEndpoints(client: ApiClient) { return { forgotPassword: { - method: 'post' as const, - path: '/account/forgot-password' as const, + method: "post" as const, + path: "/account/forgot-password" as const, schema: schemas.account.forgotPassword, call: (params: AccountForgotPasswordParams) => - client.post('/account/forgot-password', params), + client.post("/account/forgot-password", params), }, get: { - method: 'get' as const, - path: '/account' as const, + method: "get" as const, + path: "/account" as const, schema: undefined, call: (params?: Record) => - client.get('/account', params), + client.get("/account", params), }, - googleCallback: { - method: 'get' as const, - path: '/account/sign-in/google/callback' as const, + google: { + method: "get" as const, + path: "/account/sign-in/google" as const, schema: undefined, call: (params?: Record) => - client.get('/account/sign-in/google/callback', params), + client.get("/account/sign-in/google", params), }, - google: { - method: 'get' as const, - path: '/account/sign-in/google' as const, + googleCallback: { + method: "get" as const, + path: "/account/sign-in/google/callback" as const, schema: undefined, call: (params?: Record) => - client.get('/account/sign-in/google', params), + client.get("/account/sign-in/google/callback", params), }, resendEmail: { - method: 'post' as const, - path: '/account/resend-email' as const, + method: "post" as const, + path: "/account/resend-email" as const, schema: schemas.account.resendEmail, call: (params: AccountResendEmailParams) => - client.post('/account/resend-email', params), + client.post("/account/resend-email", params), }, resetPassword: { - method: 'put' as const, - path: '/account/reset-password' as const, + method: "put" as const, + path: "/account/reset-password" as const, schema: schemas.account.resetPassword, call: (params: AccountResetPasswordParams) => - client.put('/account/reset-password', params), + client.put("/account/reset-password", params), }, signIn: { - method: 'post' as const, - path: '/account/sign-in' as const, + method: "post" as const, + path: "/account/sign-in" as const, schema: schemas.account.signIn, call: (params: AccountSignInParams) => - client.post('/account/sign-in', params), + client.post("/account/sign-in", params), }, signOut: { - method: 'post' as const, - path: '/account/sign-out' as const, + method: "post" as const, + path: "/account/sign-out" as const, schema: undefined, call: (params?: Record) => - client.post('/account/sign-out', params), + client.post("/account/sign-out", params), }, signUp: { - method: 'post' as const, - path: '/account/sign-up' as const, + method: "post" as const, + path: "/account/sign-up" as const, schema: schemas.account.signUp, call: (params: AccountSignUpParams) => - client.post('/account/sign-up', params), + client.post("/account/sign-up", params), }, update: { - method: 'put' as const, - path: '/account' as const, + method: "put" as const, + path: "/account" as const, schema: schemas.account.update, call: (params: AccountUpdateParams) => - client.put('/account', params), + client.put("/account", params), }, verifyEmail: { - method: 'get' as const, - path: '/account/verify-email' as const, + method: "get" as const, + path: "/account/verify-email" as const, schema: schemas.account.verifyEmail, call: (params: AccountVerifyEmailParams) => - client.get('/account/verify-email', params), + client.get("/account/verify-email", params), }, verifyResetToken: { - method: 'get' as const, - path: '/account/verify-reset-token' as const, + method: "get" as const, + path: "/account/verify-reset-token" as const, schema: schemas.account.verifyResetToken, call: (params: AccountVerifyResetTokenParams) => - client.get('/account/verify-reset-token', params), + client.get("/account/verify-reset-token", params), }, }; } @@ -154,25 +202,45 @@ function createAccountEndpoints(client: ApiClient) { function createUsersEndpoints(client: ApiClient) { return { list: { - method: 'get' as const, - path: '/users' as const, + method: "get" as const, + path: "/users" as const, schema: schemas.users.list, call: (params: UsersListParams) => - client.get('/users', params), + client.get("/users", params), }, remove: { - method: 'delete' as const, - path: '/users/:id' as const, + method: "delete" as const, + path: "/users/:id" as const, schema: undefined, - call: (params: Record | undefined, options: { pathParams: UsersRemovePathParams; headers?: Record }) => - client.delete(`/users/${options.pathParams.id}`, params, options.headers ? { headers: options.headers } : undefined), + call: ( + params: Record | undefined, + options: { + pathParams: UsersRemovePathParams; + headers?: Record; + }, + ) => + client.delete( + `/users/${options.pathParams.id}`, + params, + options.headers ? { headers: options.headers } : undefined, + ), }, update: { - method: 'put' as const, - path: '/users/:id' as const, + method: "put" as const, + path: "/users/:id" as const, schema: schemas.users.update, - call: (params: UsersUpdateParams, options: { pathParams: UsersUpdatePathParams; headers?: Record }) => - client.put(`/users/${options.pathParams.id}`, params, options.headers ? { headers: options.headers } : undefined), + call: ( + params: UsersUpdateParams, + options: { + pathParams: UsersUpdatePathParams; + headers?: Record; + }, + ) => + client.put( + `/users/${options.pathParams.id}`, + params, + options.headers ? { headers: options.headers } : undefined, + ), }, }; } @@ -186,17 +254,36 @@ export function createApiEndpoints(client: ApiClient) { export type ApiEndpoints = ReturnType; -export interface ApiEndpoint { - method: 'get' | 'post' | 'put' | 'patch' | 'delete'; +export interface ApiEndpoint< + TParams = unknown, + TPathParams = never, + TResponse = unknown, +> { + method: "get" | "post" | "put" | "patch" | "delete"; path: string; schema: z.ZodType | undefined; call: TPathParams extends never ? (params: TParams) => Promise - : (params: TParams, options: { pathParams: TPathParams; headers?: Record }) => Promise; + : ( + params: TParams, + options: { pathParams: TPathParams; headers?: Record }, + ) => Promise; } -export type InferParams = T extends { schema: infer S } ? (S extends z.ZodType ? z.infer : Record) : Record; +export type InferParams = T extends { schema: infer S } + ? S extends z.ZodType + ? z.infer + : Record + : Record; -export type InferPathParams = T extends { call: (params: unknown, options: { pathParams: infer PP }) => unknown } ? PP : never; +export type InferPathParams = T extends { + call: (params: unknown, options: { pathParams: infer PP }) => unknown; +} + ? PP + : never; -export type InferResponse = T extends { call: (...args: never[]) => Promise } ? R : unknown; +export type InferResponse = T extends { + call: (...args: never[]) => Promise; +} + ? R + : unknown; diff --git a/template/packages/shared/src/schemas/index.ts b/template/packages/shared/src/schemas/index.ts index b7d7f7711..53bd12a09 100644 --- a/template/packages/shared/src/schemas/index.ts +++ b/template/packages/shared/src/schemas/index.ts @@ -1,4 +1,3 @@ -export * from './base.schema'; -export * from './account/account.schema'; -export * from './token/token.schema'; -export * from './users/user.schema'; +export * from "./base.schema"; +export * from "./token/token.schema"; +export * from "./users/user.schema"; diff --git a/template/packages/shared/src/schemas/token/token.schema.ts b/template/packages/shared/src/schemas/token/token.schema.ts index 2102526d9..a5345122e 100644 --- a/template/packages/shared/src/schemas/token/token.schema.ts +++ b/template/packages/shared/src/schemas/token/token.schema.ts @@ -1,16 +1,22 @@ -import { z } from 'zod'; +import { z } from "zod"; -import { dbSchema } from '../base.schema'; +import { dbSchema } from "../base.schema"; export enum TokenType { - ACCESS = 'access', - EMAIL_VERIFICATION = 'email-verification', - RESET_PASSWORD = 'reset-password', + ACCESS = "access", + EMAIL_VERIFICATION = "email-verification", + RESET_PASSWORD = "reset-password", } export const tokenSchema = dbSchema.extend({ value: z.string(), userId: z.string(), - type: z.enum([TokenType.ACCESS, TokenType.EMAIL_VERIFICATION, TokenType.RESET_PASSWORD]), + type: z.enum([ + TokenType.ACCESS, + TokenType.EMAIL_VERIFICATION, + TokenType.RESET_PASSWORD, + ]), expiresOn: z.date(), }); + +export type Token = z.infer; diff --git a/template/packages/shared/src/schemas/users/user.schema.ts b/template/packages/shared/src/schemas/users/user.schema.ts index d4fd026b3..7262a4744 100644 --- a/template/packages/shared/src/schemas/users/user.schema.ts +++ b/template/packages/shared/src/schemas/users/user.schema.ts @@ -1,26 +1,16 @@ -import { z } from 'zod'; +import { z } from "zod"; -import { dbSchema, emailSchema, passwordSchema } from '../base.schema'; - -const ONE_MB_IN_BYTES = 1_048_576; -const IMAGE_MIME_TYPE = ['image/jpg', 'image/jpeg', 'image/png']; -const USER_AVATAR = { - MAX_FILE_SIZE: 3 * ONE_MB_IN_BYTES, - ACCEPTED_FILE_TYPES: IMAGE_MIME_TYPE, -}; - -const oauthSchema = z.object({ - google: z - .object({ - userId: z.string().min(1, 'Google user ID is required'), - connectedOn: z.date(), - }) - .optional(), -}); +import { dbSchema, emailSchema } from "../base.schema"; export const userSchema = dbSchema.extend({ - firstName: z.string().min(1, 'First name is required').max(128, 'First name must be less than 128 characters.'), - lastName: z.string().min(1, 'Last name is required').max(128, 'Last name must be less than 128 characters.'), + firstName: z + .string() + .min(1, "First name is required") + .max(128, "First name must be less than 128 characters."), + lastName: z + .string() + .min(1, "Last name is required") + .max(128, "Last name must be less than 128 characters."), email: emailSchema, passwordHash: z.string().optional(), @@ -29,25 +19,22 @@ export const userSchema = dbSchema.extend({ avatarUrl: z.string().nullable().optional(), - oauth: oauthSchema.optional(), + oauth: z + .object({ + google: z + .object({ + userId: z.string().min(1, "Google user ID is required"), + connectedOn: z.date(), + }) + .optional(), + }) + .optional(), lastRequest: z.date().optional(), }); +export type User = z.infer; + export const userPublicSchema = userSchema.omit({ passwordHash: true, }); - -export const updateUserSchema = userSchema - .pick({ firstName: true, lastName: true }) - .extend({ - password: z.union([ - passwordSchema, - z.literal(''), - ]), - avatar: z.union([ - z.any(), // File validation handled at runtime (browser File or formidable File) - z.literal(''), - ]).nullable(), - }) - .partial(); diff --git a/template/packages/shared/src/types.ts b/template/packages/shared/src/types.ts index 388a50e80..e6bd1ea91 100644 --- a/template/packages/shared/src/types.ts +++ b/template/packages/shared/src/types.ts @@ -1,14 +1,9 @@ -import type { z } from 'zod'; +import type { z } from "zod"; -import { userPublicSchema, updateUserSchema } from './schemas'; +import { userPublicSchema } from "./schemas"; // Domain types -export type User = z.infer; -export type UpdateUserParams = z.infer; - -export interface UpdateUserParamsFrontend extends Omit { - avatar?: File | string; -} +export type PublicUser = z.infer; // Utility types @@ -17,21 +12,30 @@ type Path = T extends object [K in keyof T]: K extends string ? T[K] extends (...args: never[]) => unknown ? never - : `${K}` | (Path extends infer R ? (R extends never ? never : `${K}.${R & string}`) : never) + : + | `${K}` + | (Path extends infer R + ? R extends never + ? never + : `${K}.${R & string}` + : never) : never; }[keyof T] : never; export type NestedKeys = Path>; -type CamelCase = S extends `${infer P1}_${infer P2}${infer P3}` - ? `${Lowercase}${Uppercase}${CamelCase}` - : S extends `${infer P1}${infer P2}` - ? `${Lowercase}${CamelCase}` - : Lowercase; +type CamelCase = + S extends `${infer P1}_${infer P2}${infer P3}` + ? `${Lowercase}${Uppercase}${CamelCase}` + : S extends `${infer P1}${infer P2}` + ? `${Lowercase}${CamelCase}` + : Lowercase; export type ToCamelCase = { - [K in keyof T as CamelCase]: T[K] extends object ? ToCamelCase : T[K]; + [K in keyof T as CamelCase]: T[K] extends object + ? ToCamelCase + : T[K]; }; export interface ListResult { @@ -40,7 +44,7 @@ export interface ListResult { count: number; } -export type SortOrder = 'asc' | 'desc'; +export type SortOrder = "asc" | "desc"; export type SortParams = { [P in keyof F]?: SortOrder; From d3cbafb2f82b4192535810fbb123ac48ed006973 Mon Sep 17 00:00:00 2001 From: Alexey Kasperovich Date: Thu, 12 Feb 2026 13:24:22 +0300 Subject: [PATCH 05/22] fix: lint style --- .../Header/components/MenuToggle/index.tsx | 3 +- .../Header/components/UserMenu/index.tsx | 6 +- .../PageConfig/MainLayout/Header/index.tsx | 5 +- .../_app/PageConfig/MainLayout/index.tsx | 3 +- .../web/src/pages/_app/PageConfig/index.tsx | 4 +- .../pages/home/components/Filters/index.tsx | 7 +- template/apps/web/src/pages/home/constants.ts | 1 - .../apps/web/src/pages/sign-in/index.page.tsx | 6 +- .../components/PasswordRules/index.tsx | 1 - .../apps/web/src/pages/sign-up/index.page.tsx | 5 +- .../apps/web/src/utils/handle-error.util.ts | 5 +- template/packages/shared/eslint.config.js | 2 +- template/packages/shared/src/client.ts | 67 ++++++++++++++----- template/packages/shared/src/constants.ts | 2 +- template/packages/shared/src/index.ts | 12 ++-- .../src/schemas/account/account.schema.ts | 23 ++++--- .../shared/src/schemas/base.schema.ts | 22 +++--- 17 files changed, 100 insertions(+), 74 deletions(-) diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx index ab32a6b0e..24de991bb 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx @@ -1,10 +1,9 @@ import { FC } from 'react'; import { Avatar, UnstyledButton, UnstyledButtonProps, useMantineTheme } from '@mantine/core'; +import { useApiQuery } from 'hooks/use-api.hook'; import { apiClient } from 'services/api-client.service'; -import { useApiQuery } from 'hooks/use-api.hook'; - const MenuToggle: FC = (props) => { const { primaryColor } = useMantineTheme(); diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx index 4c64260b9..1b94568a5 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx @@ -2,14 +2,12 @@ import { FC } from 'react'; import Link from 'next/link'; import { Menu } from '@mantine/core'; import { IconLogout, IconUserCircle } from '@tabler/icons-react'; - -import { apiClient } from 'services/api-client.service'; - import { useApiMutation } from 'hooks/use-api.hook'; -import queryClient from 'query-client'; +import { apiClient } from 'services/api-client.service'; import { RoutePath } from 'routes'; +import queryClient from 'query-client'; import MenuToggle from '../MenuToggle'; diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx index 5aa29f36f..33e231c10 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx @@ -1,13 +1,12 @@ import { FC, memo } from 'react'; import Link from 'next/link'; import { Anchor, AppShell, Group } from '@mantine/core'; - -import { apiClient } from 'services/api-client.service'; - import { useApiQuery } from 'hooks/use-api.hook'; import { LogoImage } from 'public/images'; +import { apiClient } from 'services/api-client.service'; + import { RoutePath } from 'routes'; import UserMenu from './components/UserMenu'; diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/index.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/index.tsx index c8e43e428..902564ca9 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/index.tsx @@ -1,10 +1,9 @@ import { FC, ReactElement } from 'react'; import { AppShell, Stack } from '@mantine/core'; +import { useApiQuery } from 'hooks/use-api.hook'; import { apiClient } from 'services/api-client.service'; -import { useApiQuery } from 'hooks/use-api.hook'; - import Header from './Header'; interface MainLayoutProps { diff --git a/template/apps/web/src/pages/_app/PageConfig/index.tsx b/template/apps/web/src/pages/_app/PageConfig/index.tsx index 96e240f7e..8ca782f82 100644 --- a/template/apps/web/src/pages/_app/PageConfig/index.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/index.tsx @@ -2,12 +2,10 @@ import 'services/socket-handlers'; import { FC, Fragment, ReactElement, useEffect } from 'react'; import { useRouter } from 'next/router'; - -import { apiClient } from 'services/api-client.service'; - import { useApiQuery } from 'hooks/use-api.hook'; import { analyticsService } from 'services'; +import { apiClient } from 'services/api-client.service'; import { LayoutType, RoutePath, routesConfiguration, ScopeType } from 'routes'; import config from 'config'; diff --git a/template/apps/web/src/pages/home/components/Filters/index.tsx b/template/apps/web/src/pages/home/components/Filters/index.tsx index da5481853..da4ea9ea7 100644 --- a/template/apps/web/src/pages/home/components/Filters/index.tsx +++ b/template/apps/web/src/pages/home/components/Filters/index.tsx @@ -4,7 +4,6 @@ import { DatePickerInput, DatesRangeValue } from '@mantine/dates'; import { useDebouncedValue, useInputState, useSetState } from '@mantine/hooks'; import { IconSearch, IconSelector, IconX } from '@tabler/icons-react'; import { set } from 'lodash'; - import { UsersListParams } from 'shared'; const selectOptions: ComboboxItem[] = [ @@ -45,9 +44,9 @@ const Filters: FC = ({ setParams }) => { if (endDate) { setParams({ filter: { - createdOn: { - startDate: startDate instanceof Date ? startDate : undefined, - endDate: endDate instanceof Date ? endDate : undefined + createdOn: { + startDate: startDate instanceof Date ? startDate : undefined, + endDate: endDate instanceof Date ? endDate : undefined, }, }, }); diff --git a/template/apps/web/src/pages/home/constants.ts b/template/apps/web/src/pages/home/constants.ts index 366070e18..ba5e41a24 100644 --- a/template/apps/web/src/pages/home/constants.ts +++ b/template/apps/web/src/pages/home/constants.ts @@ -1,5 +1,4 @@ import { ColumnDef } from '@tanstack/react-table'; - import { UsersListParams } from 'shared'; import { User } from 'types'; diff --git a/template/apps/web/src/pages/sign-in/index.page.tsx b/template/apps/web/src/pages/sign-in/index.page.tsx index 675316010..05c098051 100644 --- a/template/apps/web/src/pages/sign-in/index.page.tsx +++ b/template/apps/web/src/pages/sign-in/index.page.tsx @@ -7,15 +7,13 @@ import { IconAlertCircle } from '@tabler/icons-react'; import { useApiForm, useApiMutation } from 'hooks'; import { AccountGetResponse } from 'shared'; -import { apiClient } from 'services/api-client.service'; - import { GoogleIcon } from 'public/icons'; +import { apiClient } from 'services/api-client.service'; import { handleApiError } from 'utils'; -import queryClient from 'query-client'; - import { RoutePath } from 'routes'; +import queryClient from 'query-client'; import config from 'config'; const SignIn: NextPage = () => { diff --git a/template/apps/web/src/pages/sign-up/components/PasswordRules/index.tsx b/template/apps/web/src/pages/sign-up/components/PasswordRules/index.tsx index ae0d8f992..3cd083272 100644 --- a/template/apps/web/src/pages/sign-up/components/PasswordRules/index.tsx +++ b/template/apps/web/src/pages/sign-up/components/PasswordRules/index.tsx @@ -1,7 +1,6 @@ import { FC, ReactNode, useState } from 'react'; import { Checkbox, Stack, Title, Tooltip } from '@mantine/core'; import { useFormContext } from 'react-hook-form'; - import { PASSWORD_RULES } from 'shared'; interface PasswordRulesRenderProps { diff --git a/template/apps/web/src/pages/sign-up/index.page.tsx b/template/apps/web/src/pages/sign-up/index.page.tsx index dd2e6cb16..59d8d6624 100644 --- a/template/apps/web/src/pages/sign-up/index.page.tsx +++ b/template/apps/web/src/pages/sign-up/index.page.tsx @@ -2,13 +2,12 @@ import { NextPage } from 'next'; import Head from 'next/head'; import Link from 'next/link'; import { Anchor, Button, Group, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core'; -import { FormProvider } from 'react-hook-form'; import { useApiForm, useApiMutation } from 'hooks'; - -import { apiClient } from 'services/api-client.service'; +import { FormProvider } from 'react-hook-form'; import { GoogleIcon } from 'public/icons'; +import { apiClient } from 'services/api-client.service'; import { handleApiError } from 'utils'; import { RoutePath } from 'routes'; diff --git a/template/apps/web/src/utils/handle-error.util.ts b/template/apps/web/src/utils/handle-error.util.ts index 56d652198..6a692f4cf 100644 --- a/template/apps/web/src/utils/handle-error.util.ts +++ b/template/apps/web/src/utils/handle-error.util.ts @@ -1,10 +1,7 @@ import { FileRejection } from '@mantine/dropzone'; import { showNotification } from '@mantine/notifications'; import { FieldValues, Path, UseFormSetError } from 'react-hook-form'; - -import { ApiError } from 'shared'; - -import { ONE_MB_IN_BYTES } from 'shared'; +import { ApiError, ONE_MB_IN_BYTES } from 'shared'; interface ValidationErrors { [name: string]: string[] | string; diff --git a/template/packages/shared/eslint.config.js b/template/packages/shared/eslint.config.js index 4775b9538..c38d47ed4 100644 --- a/template/packages/shared/eslint.config.js +++ b/template/packages/shared/eslint.config.js @@ -1,3 +1,3 @@ -import node from 'eslint-config/node'; +import node from "eslint-config/node"; export default node; diff --git a/template/packages/shared/src/client.ts b/template/packages/shared/src/client.ts index a39963daa..1c9f487f9 100644 --- a/template/packages/shared/src/client.ts +++ b/template/packages/shared/src/client.ts @@ -1,4 +1,4 @@ -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; type ApiErrorHandler = (error: ApiError) => void; @@ -12,9 +12,13 @@ export class ApiError extends Error { data: unknown; status: number; - constructor(data: unknown, status = 500, statusText = 'Internal Server Error') { + constructor( + data: unknown, + status = 500, + statusText = "Internal Server Error", + ) { super(`${status} ${statusText}`); - this.name = 'ApiError'; + this.name = "ApiError"; this.data = data; this.status = status; @@ -42,7 +46,7 @@ export class ApiClient { this._api = axios.create({ baseURL: config.baseURL, withCredentials: config.withCredentials ?? true, - responseType: 'json', + responseType: "json", }); this._api.interceptors.response.use( @@ -50,13 +54,17 @@ export class ApiClient { (error) => { const errorResponse = error.response || { status: error.code ? Number.parseInt(error.code, 10) : 500, - statusText: error.message || 'Network or timeout error', + statusText: error.message || "Network or timeout error", data: error.data, }; - const apiError = new ApiError(errorResponse.data, errorResponse.status, errorResponse.statusText); + const apiError = new ApiError( + errorResponse.data, + errorResponse.status, + errorResponse.statusText, + ); - const errorHandlers = this._handlers.get('error'); + const errorHandlers = this._handlers.get("error"); errorHandlers?.forEach((handler) => handler(apiError)); throw apiError; @@ -73,28 +81,51 @@ export class ApiClient { handlers.add(handler as EventHandler); } - off(event: T, handler: EventHandlers[T]): void { + off( + event: T, + handler: EventHandlers[T], + ): void { const handlers = this._handlers.get(event); handlers?.delete(handler as EventHandler); } - get(url: string, params?: P, config?: AxiosRequestConfig): Promise { - return this._api({ method: 'get', url, params, ...config }); + get( + url: string, + params?: P, + config?: AxiosRequestConfig, + ): Promise { + return this._api({ method: "get", url, params, ...config }); } - post(url: string, data?: D, config?: AxiosRequestConfig): Promise { - return this._api({ method: 'post', url, data, ...config }); + post( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise { + return this._api({ method: "post", url, data, ...config }); } - put(url: string, data?: D, config?: AxiosRequestConfig): Promise { - return this._api({ method: 'put', url, data, ...config }); + put( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise { + return this._api({ method: "put", url, data, ...config }); } - patch(url: string, data?: D, config?: AxiosRequestConfig): Promise { - return this._api({ method: 'patch', url, data, ...config }); + patch( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise { + return this._api({ method: "patch", url, data, ...config }); } - delete(url: string, data?: D, config?: AxiosRequestConfig): Promise { - return this._api({ method: 'delete', url, data, ...config }); + delete( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise { + return this._api({ method: "delete", url, data, ...config }); } } diff --git a/template/packages/shared/src/constants.ts b/template/packages/shared/src/constants.ts index 2a1dee9f6..323e3625d 100644 --- a/template/packages/shared/src/constants.ts +++ b/template/packages/shared/src/constants.ts @@ -1,6 +1,6 @@ export const ONE_MB_IN_BYTES = 1_048_576; -export const IMAGE_MIME_TYPE = ['image/jpg', 'image/jpeg', 'image/png']; +export const IMAGE_MIME_TYPE = ["image/jpg", "image/jpeg", "image/png"]; export const USER_AVATAR = { MAX_FILE_SIZE: 3 * ONE_MB_IN_BYTES, diff --git a/template/packages/shared/src/index.ts b/template/packages/shared/src/index.ts index de0cdc910..fc635cfcf 100644 --- a/template/packages/shared/src/index.ts +++ b/template/packages/shared/src/index.ts @@ -1,7 +1,7 @@ -export { ApiClient, ApiError } from './client'; -export type { ApiClientConfig } from './client'; +export { ApiClient, ApiError } from "./client"; +export type { ApiClientConfig } from "./client"; -export * from './types'; -export * from './constants'; -export * from './schemas'; -export * from './generated'; +export * from "./constants"; +export * from "./generated"; +export * from "./schemas"; +export * from "./types"; diff --git a/template/packages/shared/src/schemas/account/account.schema.ts b/template/packages/shared/src/schemas/account/account.schema.ts index dd97523ab..19c0b5a0e 100644 --- a/template/packages/shared/src/schemas/account/account.schema.ts +++ b/template/packages/shared/src/schemas/account/account.schema.ts @@ -1,17 +1,22 @@ -import { z } from 'zod'; +import { z } from "zod"; -import { emailSchema, passwordSchema } from '../base.schema'; -import { userSchema } from '../users/user.schema'; +import { emailSchema, passwordSchema } from "../base.schema"; +import { userSchema } from "../users/user.schema"; export const signInSchema = z.object({ email: emailSchema, - password: z.string().min(1, 'Password is required').max(128, 'Password must be less than 128 characters.'), + password: z + .string() + .min(1, "Password is required") + .max(128, "Password must be less than 128 characters."), }); -export const signUpSchema = userSchema.pick({ firstName: true, lastName: true }).extend({ - email: emailSchema, - password: passwordSchema, -}); +export const signUpSchema = userSchema + .pick({ firstName: true, lastName: true }) + .extend({ + email: emailSchema, + password: passwordSchema, + }); export const resendEmailSchema = z.object({ email: emailSchema, @@ -22,6 +27,6 @@ export const forgotPasswordSchema = z.object({ }); export const resetPasswordSchema = z.object({ - token: z.string().min(1, 'Token is required'), + token: z.string().min(1, "Token is required"), password: passwordSchema, }); diff --git a/template/packages/shared/src/schemas/base.schema.ts b/template/packages/shared/src/schemas/base.schema.ts index 4914e4798..27701b4c2 100644 --- a/template/packages/shared/src/schemas/base.schema.ts +++ b/template/packages/shared/src/schemas/base.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from "zod"; export const dbSchema = z.object({ _id: z.string(), @@ -16,9 +16,9 @@ export const paginationSchema = z.object({ sort: z .object({ - createdOn: z.enum(['asc', 'desc']).default('asc'), + createdOn: z.enum(["asc", "desc"]).default("asc"), }) - .default({ createdOn: 'asc' }), + .default({ createdOn: "asc" }), }); export const listResultSchema = (itemSchema: T) => @@ -30,10 +30,10 @@ export const listResultSchema = (itemSchema: T) => export const emailSchema = z .email() - .min(1, 'Email is required') + .min(1, "Email is required") .toLowerCase() .trim() - .max(255, 'Email must be less than 255 characters.'); + .max(255, "Email must be less than 255 characters."); const PASSWORD_RULES = { MIN_LENGTH: 8, @@ -43,9 +43,15 @@ const PASSWORD_RULES = { export const passwordSchema = z .string() - .min(1, 'Password is required') - .min(PASSWORD_RULES.MIN_LENGTH, `Password must be at least ${PASSWORD_RULES.MIN_LENGTH} characters.`) - .max(PASSWORD_RULES.MAX_LENGTH, `Password must be less than ${PASSWORD_RULES.MAX_LENGTH} characters.`) + .min(1, "Password is required") + .min( + PASSWORD_RULES.MIN_LENGTH, + `Password must be at least ${PASSWORD_RULES.MIN_LENGTH} characters.`, + ) + .max( + PASSWORD_RULES.MAX_LENGTH, + `Password must be less than ${PASSWORD_RULES.MAX_LENGTH} characters.`, + ) .regex( PASSWORD_RULES.REGEX, `The password must contain ${PASSWORD_RULES.MIN_LENGTH} or more characters with at least one letter (a-z) and one number (0-9).`, From e31c6cd3fd12af650a93ca9c6f1076734d3f2784 Mon Sep 17 00:00:00 2001 From: Variellka <50990037+Variellka@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:26:36 +0300 Subject: [PATCH 06/22] Add shadcn and tailwind (#405) * feat: init web-shadcn with tailwind + shadcn/ui * feat: add base components * feat: add base components * feat: add base components * feat: add base components * refactor * feat: add layout * add sign-up page * add sign-in and forgot-pass pages * add layout and rest pages * refactor * merge ak/boost * Add chat on main page (#406) * add front for chat and sidebar * add LLM chat with streaming * refactor * refactor * refactor * add useApiMutation and useApiQuery * change routes * add navbar to layout * refactor * add admin page * refactor * add useApiStreamMutation hook * ui refactor * add dark theme * fix mobile design * refactor --------- Co-authored-by: Xenia Levchenko --------- Co-authored-by: Xenia Levchenko --- template/apps/api/.env.example | 3 + template/apps/api/package.json | 2 + template/apps/api/src/config/index.ts | 1 + .../api/src/resources/chats/chat.schema.ts | 10 + .../api/src/resources/chats/chat.service.ts | 15 + .../src/resources/chats/endpoints/create.ts | 27 + .../src/resources/chats/endpoints/delete.ts | 31 + .../resources/chats/endpoints/get-messages.ts | 29 + .../api/src/resources/chats/endpoints/list.ts | 21 + .../resources/chats/endpoints/send-message.ts | 98 + .../apps/api/src/resources/chats/index.ts | 4 + .../api/src/resources/chats/message.schema.ts | 14 + .../src/resources/chats/message.service.ts | 15 + .../apps/api/src/services/ai/ai.service.ts | 29 + template/apps/api/src/services/index.ts | 3 +- template/apps/web/components.json | 20 + template/apps/web/eslint.config.js | 4 +- template/apps/web/package.json | 31 +- template/apps/web/postcss.config.json | 14 - template/apps/web/postcss.config.mjs | 5 + .../src/components/Table/EmptyState/index.tsx | 15 +- .../components/Table/LoadingState/index.tsx | 15 +- .../src/components/Table/Pagination/index.tsx | 78 +- .../components/Table/Tbody/index.module.css | 8 - .../web/src/components/Table/Tbody/index.tsx | 18 +- .../web/src/components/Table/Thead/index.tsx | 36 +- .../apps/web/src/components/Table/index.tsx | 37 +- .../web/src/components/theme-provider.tsx | 8 + .../web/src/components/ui/alert-dialog.tsx | 161 + template/apps/web/src/components/ui/alert.tsx | 49 + .../apps/web/src/components/ui/avatar.tsx | 89 + .../apps/web/src/components/ui/button.tsx | 62 + .../apps/web/src/components/ui/calendar.tsx | 159 + template/apps/web/src/components/ui/card.tsx | 56 + .../apps/web/src/components/ui/checkbox.tsx | 29 + .../web/src/components/ui/collapsible.tsx | 17 + .../apps/web/src/components/ui/dialog.tsx | 136 + .../web/src/components/ui/dropdown-menu.tsx | 217 + .../apps/web/src/components/ui/dropzone.tsx | 83 + template/apps/web/src/components/ui/form.tsx | 136 + template/apps/web/src/components/ui/input.tsx | 21 + template/apps/web/src/components/ui/label.tsx | 19 + .../apps/web/src/components/ui/pagination.tsx | 100 + .../web/src/components/ui/password-input.tsx | 46 + .../apps/web/src/components/ui/popover.tsx | 54 + .../web/src/components/ui/prompt-input.tsx | 198 + .../web/src/components/ui/scroll-area.tsx | 46 + .../apps/web/src/components/ui/select.tsx | 160 + .../apps/web/src/components/ui/separator.tsx | 28 + template/apps/web/src/components/ui/sheet.tsx | 105 + .../apps/web/src/components/ui/skeleton.tsx | 7 + .../apps/web/src/components/ui/sonner.tsx | 29 + template/apps/web/src/components/ui/table.tsx | 79 + .../apps/web/src/components/ui/textarea.tsx | 18 + .../apps/web/src/components/ui/tooltip.tsx | 44 + .../apps/web/src/components/ui/typography.tsx | 73 + template/apps/web/src/contexts/index.ts | 2 +- template/apps/web/src/globals.css | 187 + template/apps/web/src/hooks/use-api.hook.ts | 140 + template/apps/web/src/lib/utils.ts | 18 + .../apps/web/src/pages/404/index.page.tsx | 13 +- template/apps/web/src/pages/_app.page.tsx | 2 + .../pages/_app/GlobalErrorHandler/index.tsx | 8 +- .../Header/components/MenuToggle/index.tsx | 24 - .../Header/components/UserMenu/index.tsx | 40 - .../PageConfig/MainLayout/Header/index.tsx | 32 - .../_app/PageConfig/MainLayout/Navbar.tsx | 51 + .../MainLayout/components/Navigation.tsx | 157 + .../MainLayout/components/UserMenu.tsx | 81 + .../PageConfig/MainLayout/components/index.ts | 2 + .../_app/PageConfig/MainLayout/index.tsx | 65 +- .../PageConfig/UnauthorizedLayout/index.tsx | 14 +- template/apps/web/src/pages/_app/index.tsx | 35 +- .../apps/web/src/pages/_document/index.tsx | 8 +- .../pages/admin/components/Filters/index.tsx | 134 + .../src/pages/{home => admin}/constants.ts | 6 +- .../apps/web/src/pages/admin/index.page.tsx | 76 + .../apps/web/src/pages/chat/[chatId].page.tsx | 15 + .../web/src/pages/chat/components/ChatBox.tsx | 83 + .../src/pages/chat/components/ChatInput.tsx | 79 + .../src/pages/chat/components/ChatMessage.tsx | 43 + .../pages/chat/components/MessageSkeleton.tsx | 37 + .../web/src/pages/chat/components/index.ts | 5 + .../apps/web/src/pages/chat/hooks/index.ts | 1 + .../src/pages/chat/hooks/useChatManager.ts | 208 + .../apps/web/src/pages/chat/index.page.tsx | 48 + .../src/pages/forgot-password/index.page.tsx | 70 +- .../pages/home/components/Filters/index.tsx | 102 - template/apps/web/src/pages/home/index.tsx | 80 +- .../components/AvatarUpload/index.module.css | 60 - .../profile/components/AvatarUpload/index.tsx | 148 +- .../apps/web/src/pages/profile/index.page.tsx | 109 +- .../src/pages/reset-password/index.page.tsx | 60 +- .../apps/web/src/pages/sign-in/index.page.tsx | 131 +- .../components/PasswordRules/index.tsx | 36 +- .../apps/web/src/pages/sign-up/index.page.tsx | 158 +- template/apps/web/src/routes.ts | 15 + .../theme/components/Button/index.module.css | 12 - .../web/src/theme/components/Button/index.ts | 13 - .../theme/components/Image/index.module.css | 3 - .../web/src/theme/components/Image/index.ts | 9 - .../components/PasswordInput/index.module.css | 3 - .../theme/components/PasswordInput/index.ts | 15 - .../web/src/theme/components/Select/index.ts | 7 - .../components/TextInput/index.module.css | 3 - .../src/theme/components/TextInput/index.ts | 15 - .../apps/web/src/theme/components/index.ts | 5 - template/apps/web/src/theme/index.ts | 20 - .../apps/web/src/utils/handle-error.util.ts | 16 +- template/apps/web/tsconfig.json | 6 +- .../app-constants/src/api.constants.ts | 2 + template/packages/shared/scripts/generate.ts | 24 + .../packages/shared/src/generated/index.ts | 109 + .../shared/src/schemas/chats/chat.schema.ts | 10 + .../src/schemas/chats/message.schema.ts | 14 + template/packages/shared/src/schemas/index.ts | 2 + template/pnpm-lock.yaml | 3940 +++++++++++------ 117 files changed, 7347 insertions(+), 2276 deletions(-) create mode 100644 template/apps/api/src/resources/chats/chat.schema.ts create mode 100644 template/apps/api/src/resources/chats/chat.service.ts create mode 100644 template/apps/api/src/resources/chats/endpoints/create.ts create mode 100644 template/apps/api/src/resources/chats/endpoints/delete.ts create mode 100644 template/apps/api/src/resources/chats/endpoints/get-messages.ts create mode 100644 template/apps/api/src/resources/chats/endpoints/list.ts create mode 100644 template/apps/api/src/resources/chats/endpoints/send-message.ts create mode 100644 template/apps/api/src/resources/chats/index.ts create mode 100644 template/apps/api/src/resources/chats/message.schema.ts create mode 100644 template/apps/api/src/resources/chats/message.service.ts create mode 100644 template/apps/api/src/services/ai/ai.service.ts create mode 100644 template/apps/web/components.json delete mode 100644 template/apps/web/postcss.config.json create mode 100644 template/apps/web/postcss.config.mjs delete mode 100644 template/apps/web/src/components/Table/Tbody/index.module.css create mode 100644 template/apps/web/src/components/theme-provider.tsx create mode 100644 template/apps/web/src/components/ui/alert-dialog.tsx create mode 100644 template/apps/web/src/components/ui/alert.tsx create mode 100644 template/apps/web/src/components/ui/avatar.tsx create mode 100644 template/apps/web/src/components/ui/button.tsx create mode 100644 template/apps/web/src/components/ui/calendar.tsx create mode 100644 template/apps/web/src/components/ui/card.tsx create mode 100644 template/apps/web/src/components/ui/checkbox.tsx create mode 100644 template/apps/web/src/components/ui/collapsible.tsx create mode 100644 template/apps/web/src/components/ui/dialog.tsx create mode 100644 template/apps/web/src/components/ui/dropdown-menu.tsx create mode 100644 template/apps/web/src/components/ui/dropzone.tsx create mode 100644 template/apps/web/src/components/ui/form.tsx create mode 100644 template/apps/web/src/components/ui/input.tsx create mode 100644 template/apps/web/src/components/ui/label.tsx create mode 100644 template/apps/web/src/components/ui/pagination.tsx create mode 100644 template/apps/web/src/components/ui/password-input.tsx create mode 100644 template/apps/web/src/components/ui/popover.tsx create mode 100644 template/apps/web/src/components/ui/prompt-input.tsx create mode 100644 template/apps/web/src/components/ui/scroll-area.tsx create mode 100644 template/apps/web/src/components/ui/select.tsx create mode 100644 template/apps/web/src/components/ui/separator.tsx create mode 100644 template/apps/web/src/components/ui/sheet.tsx create mode 100644 template/apps/web/src/components/ui/skeleton.tsx create mode 100644 template/apps/web/src/components/ui/sonner.tsx create mode 100644 template/apps/web/src/components/ui/table.tsx create mode 100644 template/apps/web/src/components/ui/textarea.tsx create mode 100644 template/apps/web/src/components/ui/tooltip.tsx create mode 100644 template/apps/web/src/components/ui/typography.tsx create mode 100644 template/apps/web/src/globals.css create mode 100644 template/apps/web/src/lib/utils.ts delete mode 100644 template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/MenuToggle/index.tsx delete mode 100644 template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/components/UserMenu/index.tsx delete mode 100644 template/apps/web/src/pages/_app/PageConfig/MainLayout/Header/index.tsx create mode 100644 template/apps/web/src/pages/_app/PageConfig/MainLayout/Navbar.tsx create mode 100644 template/apps/web/src/pages/_app/PageConfig/MainLayout/components/Navigation.tsx create mode 100644 template/apps/web/src/pages/_app/PageConfig/MainLayout/components/UserMenu.tsx create mode 100644 template/apps/web/src/pages/_app/PageConfig/MainLayout/components/index.ts create mode 100644 template/apps/web/src/pages/admin/components/Filters/index.tsx rename template/apps/web/src/pages/{home => admin}/constants.ts (86%) create mode 100644 template/apps/web/src/pages/admin/index.page.tsx create mode 100644 template/apps/web/src/pages/chat/[chatId].page.tsx create mode 100644 template/apps/web/src/pages/chat/components/ChatBox.tsx create mode 100644 template/apps/web/src/pages/chat/components/ChatInput.tsx create mode 100644 template/apps/web/src/pages/chat/components/ChatMessage.tsx create mode 100644 template/apps/web/src/pages/chat/components/MessageSkeleton.tsx create mode 100644 template/apps/web/src/pages/chat/components/index.ts create mode 100644 template/apps/web/src/pages/chat/hooks/index.ts create mode 100644 template/apps/web/src/pages/chat/hooks/useChatManager.ts create mode 100644 template/apps/web/src/pages/chat/index.page.tsx delete mode 100644 template/apps/web/src/pages/home/components/Filters/index.tsx delete mode 100644 template/apps/web/src/pages/profile/components/AvatarUpload/index.module.css delete mode 100644 template/apps/web/src/theme/components/Button/index.module.css delete mode 100644 template/apps/web/src/theme/components/Button/index.ts delete mode 100644 template/apps/web/src/theme/components/Image/index.module.css delete mode 100644 template/apps/web/src/theme/components/Image/index.ts delete mode 100644 template/apps/web/src/theme/components/PasswordInput/index.module.css delete mode 100644 template/apps/web/src/theme/components/PasswordInput/index.ts delete mode 100644 template/apps/web/src/theme/components/Select/index.ts delete mode 100644 template/apps/web/src/theme/components/TextInput/index.module.css delete mode 100644 template/apps/web/src/theme/components/TextInput/index.ts delete mode 100644 template/apps/web/src/theme/components/index.ts delete mode 100644 template/apps/web/src/theme/index.ts create mode 100644 template/packages/shared/src/schemas/chats/chat.schema.ts create mode 100644 template/packages/shared/src/schemas/chats/message.schema.ts diff --git a/template/apps/api/.env.example b/template/apps/api/.env.example index f4dc4217e..56d0f267a 100644 --- a/template/apps/api/.env.example +++ b/template/apps/api/.env.example @@ -45,3 +45,6 @@ WEB_URL=http://localhost:3002 # CLOUD_STORAGE_ACCESS_KEY_ID=... # CLOUD_STORAGE_SECRET_ACCESS_KEY=.. # CLOUD_STORAGE_BUCKET=... + +# Google AI (Gemini) +# GOOGLE_GENERATIVE_AI_API_KEY=... diff --git a/template/apps/api/package.json b/template/apps/api/package.json index 9581fd8b5..4e5cbe6d4 100644 --- a/template/apps/api/package.json +++ b/template/apps/api/package.json @@ -19,6 +19,7 @@ "precommit": "lint-staged" }, "dependencies": { + "@ai-sdk/google": "3.0.29", "@aws-sdk/client-s3": "3.828.0", "@aws-sdk/lib-storage": "3.828.0", "@aws-sdk/s3-request-presigner": "3.828.0", @@ -30,6 +31,7 @@ "@paralect/node-mongo": "3.3.0", "@socket.io/redis-adapter": "8.3.0", "@socket.io/redis-emitter": "5.1.0", + "ai": "6.0.86", "app-constants": "workspace:*", "arctic": "3.7.0", "dayjs": "1.11.13", diff --git a/template/apps/api/src/config/index.ts b/template/apps/api/src/config/index.ts index 6ced793e8..35f2c0a28 100644 --- a/template/apps/api/src/config/index.ts +++ b/template/apps/api/src/config/index.ts @@ -26,6 +26,7 @@ const schema = z.object({ CLOUD_STORAGE_SECRET_ACCESS_KEY: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), + GOOGLE_GENERATIVE_AI_API_KEY: z.string().optional(), }); type Config = z.infer; diff --git a/template/apps/api/src/resources/chats/chat.schema.ts b/template/apps/api/src/resources/chats/chat.schema.ts new file mode 100644 index 000000000..18a5067ce --- /dev/null +++ b/template/apps/api/src/resources/chats/chat.schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +import { dbSchema } from '../base.schema'; + +export const chatSchema = dbSchema.extend({ + userId: z.string().min(1, 'User ID is required'), + title: z.string().min(1).max(255).default('New Chat'), +}); + +export type Chat = z.infer; diff --git a/template/apps/api/src/resources/chats/chat.service.ts b/template/apps/api/src/resources/chats/chat.service.ts new file mode 100644 index 000000000..72360dc4b --- /dev/null +++ b/template/apps/api/src/resources/chats/chat.service.ts @@ -0,0 +1,15 @@ +import db from 'db'; + +import { DATABASE_DOCUMENTS } from 'app-constants'; + +import type { Chat } from './chat.schema'; +import { chatSchema } from './chat.schema'; + +const service = db.createService(DATABASE_DOCUMENTS.CHATS, { + schemaValidator: (obj) => chatSchema.parseAsync(obj), +}); + +service.createIndex({ userId: 1 }); +service.createIndex({ userId: 1, updatedOn: -1 }); + +export default service; diff --git a/template/apps/api/src/resources/chats/endpoints/create.ts b/template/apps/api/src/resources/chats/endpoints/create.ts new file mode 100644 index 000000000..5e62047ae --- /dev/null +++ b/template/apps/api/src/resources/chats/endpoints/create.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { chatService } from 'resources/chats'; + +import createEndpoint from 'routes/createEndpoint'; + +const schema = z.object({ + title: z.string().min(1).max(255).optional(), +}); + +export default createEndpoint({ + method: 'post', + path: '/', + schema, + + async handler(ctx) { + const { title } = ctx.validatedData; + const userId = ctx.state.user._id; + + const chat = await chatService.insertOne({ + userId, + title: title || 'New Chat', + }); + + return chat; + }, +}); diff --git a/template/apps/api/src/resources/chats/endpoints/delete.ts b/template/apps/api/src/resources/chats/endpoints/delete.ts new file mode 100644 index 000000000..0d1cdff27 --- /dev/null +++ b/template/apps/api/src/resources/chats/endpoints/delete.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { chatService, messageService } from 'resources/chats'; + +import createEndpoint from 'routes/createEndpoint'; + +const schema = z.object({}); + +export default createEndpoint({ + method: 'delete', + path: '/:chatId', + schema, + + async handler(ctx) { + const { chatId } = ctx.params; + const userId = ctx.state.user._id; + + const chat = await chatService.findOne({ _id: chatId, userId }); + + if (!chat) { + ctx.throw(404, 'Chat not found'); + return; + } + + await messageService.deleteMany({ chatId }); + + await chatService.deleteOne({ _id: chatId }); + + return { success: true }; + }, +}); diff --git a/template/apps/api/src/resources/chats/endpoints/get-messages.ts b/template/apps/api/src/resources/chats/endpoints/get-messages.ts new file mode 100644 index 000000000..8127477af --- /dev/null +++ b/template/apps/api/src/resources/chats/endpoints/get-messages.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { chatService, messageService } from 'resources/chats'; + +import createEndpoint from 'routes/createEndpoint'; + +const schema = z.object({}); + +export default createEndpoint({ + method: 'get', + path: '/:chatId/messages', + schema, + + async handler(ctx) { + const { chatId } = ctx.params; + const userId = ctx.state.user._id; + + const chat = await chatService.findOne({ _id: chatId, userId }); + + if (!chat) { + ctx.throw(404, 'Chat not found'); + return; + } + + const { results: messages } = await messageService.find({ chatId }, {}, { sort: { createdOn: 1 } }); + + return messages; + }, +}); diff --git a/template/apps/api/src/resources/chats/endpoints/list.ts b/template/apps/api/src/resources/chats/endpoints/list.ts new file mode 100644 index 000000000..bf4a87e9a --- /dev/null +++ b/template/apps/api/src/resources/chats/endpoints/list.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { chatService } from 'resources/chats'; + +import createEndpoint from 'routes/createEndpoint'; + +const schema = z.object({}); + +export default createEndpoint({ + method: 'get', + path: '/', + schema, + + async handler(ctx) { + const userId = ctx.state.user._id; + + const { results: chats } = await chatService.find({ userId }, {}, { sort: { updatedOn: -1 } }); + + return chats; + }, +}); diff --git a/template/apps/api/src/resources/chats/endpoints/send-message.ts b/template/apps/api/src/resources/chats/endpoints/send-message.ts new file mode 100644 index 000000000..76cdb9d60 --- /dev/null +++ b/template/apps/api/src/resources/chats/endpoints/send-message.ts @@ -0,0 +1,98 @@ +import { z } from 'zod'; + +import { chatService, messageService } from 'resources/chats'; + +import { aiService } from 'services'; +import createEndpoint from 'routes/createEndpoint'; + +const schema = z.object({ + content: z.string().min(1), +}); + +export default createEndpoint({ + method: 'post', + path: '/:chatId/messages', + schema, + + async handler(ctx) { + const { chatId } = ctx.params; + const { content } = ctx.validatedData; + const userId = ctx.state.user._id; + + const chat = await chatService.findOne({ _id: chatId, userId }); + + if (!chat) { + ctx.throw(404, 'Chat not found'); + return; + } + + await messageService.insertOne({ + chatId, + role: 'user', + content, + }); + + const { results: allMessages } = await messageService.find({ chatId }, {}, { sort: { createdOn: 1 } }); + + const aiMessages = allMessages.map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })); + + const result = aiService.generateStreamingResponse(aiMessages); + + ctx.set('Content-Type', 'text/event-stream'); + ctx.set('Cache-Control', 'no-cache'); + ctx.set('Connection', 'keep-alive'); + + let fullResponse = ''; + + const stream = new ReadableStream({ + async start(controller) { + const textEncoder = new TextEncoder(); + + try { + for await (const chunk of result.textStream) { + fullResponse += chunk; + const data = JSON.stringify({ type: 'text', content: chunk }); + controller.enqueue(textEncoder.encode(`data: ${data}\n\n`)); + } + + if (!fullResponse.trim()) { + const errorData = JSON.stringify({ type: 'error', message: 'AI returned empty response' }); + controller.enqueue(textEncoder.encode(`data: ${errorData}\n\n`)); + controller.close(); + return; + } + + const assistantMessage = await messageService.insertOne({ + chatId, + role: 'assistant', + content: fullResponse, + }); + + const doneData = JSON.stringify({ + type: 'done', + messageId: assistantMessage._id, + }); + controller.enqueue(textEncoder.encode(`data: ${doneData}\n\n`)); + + if (!chat.title || chat.title === 'New Chat') { + const title = content.slice(0, 50) + (content.length > 50 ? '...' : ''); + await chatService.updateOne({ _id: chatId }, () => ({ title })); + } + + controller.close(); + } catch (error) { + console.error('AI streaming error:', error); + const errorMsg = error instanceof Error ? error.message : 'AI generation failed'; + const errorData = JSON.stringify({ type: 'error', message: errorMsg }); + controller.enqueue(textEncoder.encode(`data: ${errorData}\n\n`)); + controller.close(); + } + }, + }); + + ctx.body = stream; + }, +}); diff --git a/template/apps/api/src/resources/chats/index.ts b/template/apps/api/src/resources/chats/index.ts new file mode 100644 index 000000000..8e82f3b71 --- /dev/null +++ b/template/apps/api/src/resources/chats/index.ts @@ -0,0 +1,4 @@ +import chatService from './chat.service'; +import messageService from './message.service'; + +export { chatService, messageService }; diff --git a/template/apps/api/src/resources/chats/message.schema.ts b/template/apps/api/src/resources/chats/message.schema.ts new file mode 100644 index 000000000..749743048 --- /dev/null +++ b/template/apps/api/src/resources/chats/message.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { dbSchema } from '../base.schema'; + +export const messageRoleSchema = z.enum(['user', 'assistant']); + +export const messageSchema = dbSchema.extend({ + chatId: z.string().min(1, 'Chat ID is required'), + role: messageRoleSchema, + content: z.string().min(1, 'Content is required'), +}); + +export type Message = z.infer; +export type MessageRole = z.infer; diff --git a/template/apps/api/src/resources/chats/message.service.ts b/template/apps/api/src/resources/chats/message.service.ts new file mode 100644 index 000000000..a6197a90a --- /dev/null +++ b/template/apps/api/src/resources/chats/message.service.ts @@ -0,0 +1,15 @@ +import db from 'db'; + +import { DATABASE_DOCUMENTS } from 'app-constants'; + +import type { Message } from './message.schema'; +import { messageSchema } from './message.schema'; + +const service = db.createService(DATABASE_DOCUMENTS.MESSAGES, { + schemaValidator: (obj) => messageSchema.parseAsync(obj), +}); + +service.createIndex({ chatId: 1 }); +service.createIndex({ chatId: 1, createdOn: 1 }); + +export default service; diff --git a/template/apps/api/src/services/ai/ai.service.ts b/template/apps/api/src/services/ai/ai.service.ts new file mode 100644 index 000000000..82b17b2e2 --- /dev/null +++ b/template/apps/api/src/services/ai/ai.service.ts @@ -0,0 +1,29 @@ +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { streamText } from 'ai'; + +import config from 'config'; + +const MAX_CONTEXT_MESSAGES = 50; + +const google = createGoogleGenerativeAI({ + apiKey: config.GOOGLE_GENERATIVE_AI_API_KEY, +}); + +export interface AiMessage { + role: 'user' | 'assistant'; + content: string; +} + +const generateStreamingResponse = (messages: AiMessage[]) => { + const contextMessages = messages.slice(-MAX_CONTEXT_MESSAGES); + + return streamText({ + model: google('gemini-2.5-flash'), + messages: contextMessages, + }); +}; + +export default { + generateStreamingResponse, + MAX_CONTEXT_MESSAGES, +}; diff --git a/template/apps/api/src/services/index.ts b/template/apps/api/src/services/index.ts index bbe9d7d8a..7ffe0e22a 100644 --- a/template/apps/api/src/services/index.ts +++ b/template/apps/api/src/services/index.ts @@ -1,3 +1,4 @@ +import aiService from './ai/ai.service'; import analyticsService from './analytics/analytics.service'; import * as authService from './auth/auth.service'; import cloudStorageService from './cloud-storage/cloud-storage.service'; @@ -5,4 +6,4 @@ import emailService from './email/email.service'; import * as googleService from './google/google.service'; import socketService from './socket/socket.service'; -export { analyticsService, authService, cloudStorageService, emailService, googleService, socketService }; +export { aiService, analyticsService, authService, cloudStorageService, emailService, googleService, socketService }; diff --git a/template/apps/web/components.json b/template/apps/web/components.json new file mode 100644 index 000000000..353e3ee79 --- /dev/null +++ b/template/apps/web/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/template/apps/web/eslint.config.js b/template/apps/web/eslint.config.js index 539d359d1..c7a067f90 100644 --- a/template/apps/web/eslint.config.js +++ b/template/apps/web/eslint.config.js @@ -1,3 +1,5 @@ import next from 'eslint-config/next'; -export default next; +export default next.append({ + ignores: ['src/components/ui/**'], +}); diff --git a/template/apps/web/package.json b/template/apps/web/package.json index 1323f6b19..3e0ef1d8d 100644 --- a/template/apps/web/package.json +++ b/template/apps/web/package.json @@ -15,34 +15,46 @@ "precommit": "lint-staged" }, "dependencies": { + "@ai-sdk/react": "3.0.88", "@hookform/resolvers": "5.2.2", - "@mantine/core": "8.3.6", - "@mantine/dates": "8.3.6", - "@mantine/dropzone": "8.3.6", - "@mantine/hooks": "8.3.6", - "@mantine/modals": "8.3.6", - "@mantine/notifications": "8.3.6", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-slot": "^1.1.0", "@svgr/webpack": "8.1.0", "@tabler/icons-react": "3.10.0", "@tanstack/react-query": "5.74.4", "@tanstack/react-table": "8.19.2", "axios": "1.12.2", + "class-variance-authority": "^0.7.0", "clsx": "2.1.1", + "date-fns": "4.1.0", "dayjs": "1.11.10", "dompurify": "3.2.6", "dotenv-flow": "4.1.0", "lodash": "4.17.21", + "lucide-react": "^0.460.0", "mixpanel-browser": "2.53.0", "next": "15.5.9", + "next-themes": "0.4.6", "object-to-formdata": "4.5.1", + "radix-ui": "1.4.3", "react": "catalog:", + "react-day-picker": "9.13.2", "react-dom": "catalog:", + "react-dropzone": "15.0.0", "react-hook-form": "7.57.0", "shared": "workspace:*", "socket.io-client": "4.7.5", + "sonner": "^1.7.4", + "tailwind-merge": "^2.5.0", + "tailwindcss-animate": "^1.0.7", + "usehooks-ts": "^3.1.1", "zod": "catalog:" }, "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", "@tanstack/react-query-devtools": "5.50.1", "@types/lodash": "4.17.6", "@types/mixpanel-browser": "2.49.0", @@ -52,13 +64,9 @@ "eslint": "catalog:", "eslint-config": "workspace:*", "lint-staged": "15.5.2", - "postcss": "8.4.47", - "postcss-preset-mantine": "1.18.0", - "postcss-simple-vars": "7.0.1", "prettier": "catalog:", "prettier-config": "workspace:*", - "stylelint": "16.10.0", - "stylelint-config-standard-scss": "13.1.0", + "tailwindcss": "^4.0.0", "tsconfig": "workspace:*", "typescript": "catalog:" }, @@ -69,7 +77,6 @@ "prettier . --write" ], "*.css": [ - "stylelint . --fix", "prettier . --write" ], "*.{json,md}": [ diff --git a/template/apps/web/postcss.config.json b/template/apps/web/postcss.config.json deleted file mode 100644 index b415fcd6b..000000000 --- a/template/apps/web/postcss.config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "plugins": { - "postcss-preset-mantine": {}, - "postcss-simple-vars": { - "variables": { - "mantine-breakpoint-xs": "36em", - "mantine-breakpoint-sm": "48em", - "mantine-breakpoint-md": "62em", - "mantine-breakpoint-lg": "75em", - "mantine-breakpoint-xl": "88em" - } - } - } -} diff --git a/template/apps/web/postcss.config.mjs b/template/apps/web/postcss.config.mjs new file mode 100644 index 000000000..a34a3d560 --- /dev/null +++ b/template/apps/web/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/template/apps/web/src/components/Table/EmptyState/index.tsx b/template/apps/web/src/components/Table/EmptyState/index.tsx index 7883f2098..aadc33630 100644 --- a/template/apps/web/src/components/Table/EmptyState/index.tsx +++ b/template/apps/web/src/components/Table/EmptyState/index.tsx @@ -1,17 +1,14 @@ import { FC } from 'react'; -import { Center, CenterProps, Text, TextProps } from '@mantine/core'; -interface EmptyStateProps extends CenterProps { +interface EmptyStateProps { text?: string; - textProps?: TextProps; + className?: string; } -const EmptyState: FC = ({ text, textProps, ...rest }) => ( -
- - {text || 'No results found, try to adjust your search.'} - -
+const EmptyState: FC = ({ text, className }) => ( +
+

{text || 'No results found, try to adjust your search.'}

+
); export default EmptyState; diff --git a/template/apps/web/src/components/Table/LoadingState/index.tsx b/template/apps/web/src/components/Table/LoadingState/index.tsx index 16c1f03c7..6457e940c 100644 --- a/template/apps/web/src/components/Table/LoadingState/index.tsx +++ b/template/apps/web/src/components/Table/LoadingState/index.tsx @@ -1,17 +1,18 @@ import { FC } from 'react'; -import { Skeleton, SkeletonProps, Stack, StackProps } from '@mantine/core'; -interface LoadingStateProps extends StackProps { - skeletonProps?: SkeletonProps; +import { Skeleton } from '@/components/ui/skeleton'; + +interface LoadingStateProps { rowsCount?: number; + className?: string; } -const LoadingState: FC = ({ rowsCount = 3, skeletonProps, ...rest }) => ( - +const LoadingState: FC = ({ rowsCount = 3, className }) => ( +
{Array.from({ length: rowsCount }, (_, i) => ( - + ))} - +
); export default LoadingState; diff --git a/template/apps/web/src/components/Table/Pagination/index.tsx b/template/apps/web/src/components/Table/Pagination/index.tsx index 44bb20762..53bffae6e 100644 --- a/template/apps/web/src/components/Table/Pagination/index.tsx +++ b/template/apps/web/src/components/Table/Pagination/index.tsx @@ -1,13 +1,21 @@ import { FC } from 'react'; -import { Group, Pagination as MantinePagination, PaginationProps as MantinePaginationProps, Text } from '@mantine/core'; import { useTableContext } from 'contexts'; -interface PaginationProps extends Omit { +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination'; + +interface TablePaginationProps { totalCount?: number; } -const Pagination: FC = ({ totalCount, ...rest }) => { +const TablePagination: FC = ({ totalCount }) => { const table = useTableContext(); if (!table) return null; @@ -17,21 +25,63 @@ const Pagination: FC = ({ totalCount, ...rest }) => { if (pageCount === 1) return null; + const getVisiblePages = () => { + const pages: number[] = []; + const maxVisible = 5; + + let start = Math.max(0, currentPage - Math.floor(maxVisible / 2)); + const end = Math.min(pageCount, start + maxVisible); + + if (end - start < maxVisible) { + start = Math.max(0, end - maxVisible); + } + + for (let i = start; i < end; i++) { + pages.push(i); + } + + return pages; + }; + return ( - +
{totalCount && ( - +

Showing {table.getRowModel().rows.length} of {totalCount} results - +

)} - table.setPageIndex(v - 1)} - {...rest} - /> - + + + + table.previousPage()} + className={!table.getCanPreviousPage() ? 'pointer-events-none opacity-50' : 'cursor-pointer'} + /> + + + {getVisiblePages().map((page) => ( + + table.setPageIndex(page)} + isActive={page === currentPage} + className="cursor-pointer" + > + {page + 1} + + + ))} + + + table.nextPage()} + className={!table.getCanNextPage() ? 'pointer-events-none opacity-50' : 'cursor-pointer'} + /> + + + +
); }; -export default Pagination; + +export default TablePagination; diff --git a/template/apps/web/src/components/Table/Tbody/index.module.css b/template/apps/web/src/components/Table/Tbody/index.module.css deleted file mode 100644 index 73b3ddb6c..000000000 --- a/template/apps/web/src/components/Table/Tbody/index.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.tr { - cursor: pointer; - transition: background-color 0.2s ease-in-out; - - @mixin hover { - background-color: var(--mantine-color-gray-0); - } -} diff --git a/template/apps/web/src/components/Table/Tbody/index.tsx b/template/apps/web/src/components/Table/Tbody/index.tsx index da79e4d7a..0bf14f8b6 100644 --- a/template/apps/web/src/components/Table/Tbody/index.tsx +++ b/template/apps/web/src/components/Table/Tbody/index.tsx @@ -1,10 +1,8 @@ -import { Table } from '@mantine/core'; import { flexRender, RowData } from '@tanstack/react-table'; -import cx from 'clsx'; import { useTableContext } from 'contexts'; -import classes from './index.module.css'; +import { TableBody, TableCell, TableRow } from '@/components/ui/table'; interface TbodyProps { onRowClick?: (value: T) => void; @@ -18,21 +16,19 @@ const Tbody = ({ onRowClick }: TbodyProps) => { const { rows } = table.getRowModel(); return ( - + {rows.map((row) => ( - onRowClick && onRowClick(row.original)} - className={cx({ - [classes.tr]: !!onRowClick, - })} + className={onRowClick ? 'cursor-pointer' : ''} > {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} - + ))} - + ); }; diff --git a/template/apps/web/src/components/Table/Thead/index.tsx b/template/apps/web/src/components/Table/Thead/index.tsx index 26b0af9a4..c0d0b883a 100644 --- a/template/apps/web/src/components/Table/Thead/index.tsx +++ b/template/apps/web/src/components/Table/Thead/index.tsx @@ -1,10 +1,12 @@ import { FC } from 'react'; -import { Group, Table, UnstyledButton } from '@mantine/core'; -import { IconArrowsSort, IconSortAscending, IconSortDescending } from '@tabler/icons-react'; import { flexRender, SortDirection } from '@tanstack/react-table'; +import { ArrowUpDown, SortAsc, SortDesc } from 'lucide-react'; import { useTableContext } from 'contexts'; +import { Button } from '@/components/ui/button'; +import { TableHead, TableHeader, TableRow } from '@/components/ui/table'; + interface SortIconProps { state: false | SortDirection; } @@ -14,11 +16,11 @@ const SortIcon: FC = ({ state }) => { switch (state) { case 'asc': - return ; + return ; case 'desc': - return ; + return ; case false: - return ; + return ; default: return null; } @@ -32,36 +34,32 @@ const Thead = () => { const headerGroups = table.getHeaderGroups(); return ( - + {headerGroups.map((headerGroup) => ( - + {headerGroup.headers.map((header) => { const isSortable = header.column.getCanSort(); - const headerContent = flexRender(header.column.columnDef.header, header.getContext()); const columnSize = header.column.columnDef.size; const columnWidth = columnSize ? `${columnSize}%` : 'auto'; return ( - + {!header.isPlaceholder && (isSortable ? ( - - - {headerContent} - - - - + ) : ( headerContent ))} - + ); })} - + ))} - + ); }; diff --git a/template/apps/web/src/components/Table/index.tsx b/template/apps/web/src/components/Table/index.tsx index ff9dba4a8..941238cd4 100644 --- a/template/apps/web/src/components/Table/index.tsx +++ b/template/apps/web/src/components/Table/index.tsx @@ -1,6 +1,4 @@ -import { ComponentType, useEffect, useMemo, useState } from 'react'; -import { Paper, Stack, Table as TableContainer, TableProps as TableContainerProps } from '@mantine/core'; -import { useSetState } from '@mantine/hooks'; +import { ComponentType, useCallback, useEffect, useMemo, useState } from 'react'; import { ColumnDef, getCoreRowModel, @@ -17,10 +15,13 @@ import { TableContext } from 'contexts'; import TableEmptyState from './EmptyState'; import TableLoadingState from './LoadingState'; -import Pagination from './Pagination'; +import TablePagination from './Pagination'; import Tbody from './Tbody'; import Thead from './Thead'; +import { Card } from '@/components/ui/card'; +import { Table as TableContainer } from '@/components/ui/table'; + type SortingFieldsState = Record; interface TableProps { @@ -34,7 +35,6 @@ interface TableProps { onSortingChange?: (sort: SortingFieldsState) => void; onPageChange?: (page: number) => void; onRowClick?: (value: T) => void; - tableContainerProps?: TableContainerProps; EmptyState?: ComponentType; LoadingState?: ComponentType; } @@ -50,19 +50,26 @@ const Table = ({ onSortingChange, onPageChange, onRowClick, - tableContainerProps, EmptyState = TableEmptyState, LoadingState = TableLoadingState, }: TableProps) => { - const [pagination, setPagination] = useSetState({ + const [pagination, setPaginationState] = useState({ pageIndex: (page && page - 1) || 0, pageSize: perPage, }); + const setPagination = useCallback( + (value: Partial | ((prev: PaginationState) => Partial)) => { + setPaginationState((prev) => { + const newValue = typeof value === 'function' ? value(prev) : value; + return { ...prev, ...newValue }; + }); + }, + [], + ); const [sorting, setSorting] = useState([]); const table = useReactTable({ data: data || [], - // disable column sorting and reset size by default. columns: columns.map((c) => ({ ...c, enableSorting: c.enableSorting || false, size: 0 })), state: { pagination, @@ -82,7 +89,6 @@ const Table = ({ onSortingChange( sorting.reduce((acc, value) => { acc[value.id] = value.desc ? 'desc' : 'asc'; - return acc; }, {}), ); @@ -99,17 +105,16 @@ const Table = ({ {!isLoading && (totalCount > 0 ? ( - - - +
+ + - onRowClick={onRowClick} /> - + - - + +
) : ( ))} diff --git a/template/apps/web/src/components/theme-provider.tsx b/template/apps/web/src/components/theme-provider.tsx new file mode 100644 index 000000000..6d9d03b3a --- /dev/null +++ b/template/apps/web/src/components/theme-provider.tsx @@ -0,0 +1,8 @@ +'use client'; + +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; + +export const ThemeProvider = ({ children, ...props }: React.ComponentProps) => { + return {children}; +}; diff --git a/template/apps/web/src/components/ui/alert-dialog.tsx b/template/apps/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..84238f357 --- /dev/null +++ b/template/apps/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,161 @@ +import * as React from 'react'; +import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = 'default', + ...props +}: React.ComponentProps & { + size?: 'default' | 'sm'; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogMedia({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDialogAction({ + className, + variant = 'default', + size = 'default', + ...props +}: React.ComponentProps & + Pick, 'variant' | 'size'>) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = 'outline', + size = 'default', + ...props +}: React.ComponentProps & + Pick, 'variant' | 'size'>) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/template/apps/web/src/components/ui/alert.tsx b/template/apps/web/src/components/ui/alert.tsx new file mode 100644 index 000000000..22b751554 --- /dev/null +++ b/template/apps/web/src/components/ui/alert.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps) { + return
; +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/template/apps/web/src/components/ui/avatar.tsx b/template/apps/web/src/components/ui/avatar.tsx new file mode 100644 index 000000000..289c79d17 --- /dev/null +++ b/template/apps/web/src/components/ui/avatar.tsx @@ -0,0 +1,89 @@ +'use client'; + +import * as React from 'react'; +import { Avatar as AvatarPrimitive } from 'radix-ui'; + +import { cn } from '@/lib/utils'; + +function Avatar({ + className, + size = 'default', + ...props +}: React.ComponentProps & { + size?: 'default' | 'sm' | 'lg'; +}) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { + return ( + svg]:hidden', + 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', + className, + )} + {...props} + /> + ); +} + +export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount }; diff --git a/template/apps/web/src/components/ui/button.tsx b/template/apps/web/src/components/ui/button.tsx new file mode 100644 index 000000000..7928f17bb --- /dev/null +++ b/template/apps/web/src/components/ui/button.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Slot } from 'radix-ui'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Button({ + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot.Root : 'button'; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/template/apps/web/src/components/ui/calendar.tsx b/template/apps/web/src/components/ui/calendar.tsx new file mode 100644 index 000000000..155b8c584 --- /dev/null +++ b/template/apps/web/src/components/ui/calendar.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { DayPicker, getDefaultClassNames, type DayButton } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; +import { Button, buttonVariants } from '@/components/ui/button'; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = 'label', + buttonVariant = 'ghost', + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps['variant']; +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className, + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }), + ...formatters, + }} + classNames={{ + root: cn('w-fit', defaultClassNames.root), + months: cn('flex gap-4 flex-col md:flex-row relative', defaultClassNames.months), + month: cn('flex flex-col w-full gap-4', defaultClassNames.month), + nav: cn('flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between', defaultClassNames.nav), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', + defaultClassNames.button_next, + ), + month_caption: cn( + 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)', + defaultClassNames.month_caption, + ), + dropdowns: cn( + 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5', + defaultClassNames.dropdowns, + ), + dropdown_root: cn( + 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md', + defaultClassNames.dropdown_root, + ), + dropdown: cn('absolute bg-popover inset-0 opacity-0', defaultClassNames.dropdown), + caption_label: cn( + 'select-none font-medium', + captionLayout === 'label' + ? 'text-sm' + : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5', + defaultClassNames.caption_label, + ), + table: 'w-full border-collapse', + weekdays: cn('flex', defaultClassNames.weekdays), + weekday: cn( + 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none', + defaultClassNames.weekday, + ), + week: cn('flex w-full mt-2', defaultClassNames.week), + week_number_header: cn('select-none w-(--cell-size)', defaultClassNames.week_number_header), + week_number: cn('text-[0.8rem] select-none text-muted-foreground', defaultClassNames.week_number), + day: cn( + 'relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none', + props.showWeekNumber + ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md' + : '[&:first-child[data-selected=true]_button]:rounded-l-md', + defaultClassNames.day, + ), + range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start), + range_middle: cn('rounded-none', defaultClassNames.range_middle), + range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end), + today: cn( + 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', + defaultClassNames.today, + ), + outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside), + disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled), + hidden: cn('invisible', defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return
; + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === 'left') { + return ; + } + + if (orientation === 'right') { + return ; + } + + return ; + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
{children}
+ + ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( + + + )} +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/template/apps/web/src/components/ui/dropdown-menu.tsx b/template/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..bb1600007 --- /dev/null +++ b/template/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,217 @@ +import * as React from 'react'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; +import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'; + +import { cn } from '@/lib/utils'; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/template/apps/web/src/components/ui/dropzone.tsx b/template/apps/web/src/components/ui/dropzone.tsx new file mode 100644 index 000000000..3fb56dee3 --- /dev/null +++ b/template/apps/web/src/components/ui/dropzone.tsx @@ -0,0 +1,83 @@ +'use client'; + +import * as React from 'react'; +import { UploadCloud, X } from 'lucide-react'; +import { useDropzone, type DropzoneOptions, type FileRejection } from 'react-dropzone'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +export interface DropzoneProps extends Omit { + className?: string; + value?: File[]; + onChange?: (files: File[]) => void; + onReject?: (rejections: FileRejection[]) => void; +} + +const Dropzone = ({ className, value = [], onChange, onReject, ...props }: DropzoneProps) => { + const onDrop = React.useCallback( + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + if (rejectedFiles.length > 0 && onReject) { + onReject(rejectedFiles); + } + if (acceptedFiles.length > 0) { + onChange?.([...value, ...acceptedFiles]); + } + }, + [onChange, onReject, value], + ); + + const removeFile = (index: number) => { + const newFiles = [...value]; + newFiles.splice(index, 1); + onChange?.(newFiles); + }; + + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ + onDrop, + ...props, + }); + + return ( +
+
+ + + {isDragActive ? ( +

{isDragReject ? 'File type not accepted' : 'Drop files here'}

+ ) : ( + <> +

Drag & drop files here

+

or click to browse

+ + )} +
+ + {value.length > 0 && ( +
    + {value.map((file, index) => ( +
  • + {file.name} + +
  • + ))} +
+ )} +
+ ); +}; + +export { Dropzone }; diff --git a/template/apps/web/src/components/ui/form.tsx b/template/apps/web/src/components/ui/form.tsx new file mode 100644 index 000000000..10ba9eee1 --- /dev/null +++ b/template/apps/web/src/components/ui/form.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import type { Label as LabelPrimitive } from 'radix-ui'; +import { Slot } from 'radix-ui'; +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from 'react-hook-form'; + +import { cn } from '@/lib/utils'; +import { Label } from '@/components/ui/label'; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within '); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext({} as FormItemContextValue); + +function FormItem({ className, ...props }: React.ComponentProps<'div'>) { + const id = React.useId(); + + return ( + +
+ + ); +} + +function FormLabel({ className, ...props }: React.ComponentProps) { + const { error, formItemId } = useFormField(); + + return ( +