17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
--------------------------------------------------------------------------------
/apps/server/src/email/email.service.ts:
--------------------------------------------------------------------------------
1 | import { MailerService } from '@nestjs-modules/mailer';
2 | import { Injectable } from '@nestjs/common';
3 | import { User } from '@prisma/client';
4 | import { ConfigService } from '@server/config/config.service';
5 |
6 | @Injectable()
7 | export class EmailService {
8 | constructor(
9 | private configService: ConfigService,
10 | private mailerService: MailerService,
11 | ) {}
12 |
13 | async sendUserWelcome(user: User, token: string) {
14 | const confirmationUrl = `${this.configService.get('WEB_HOST')}/verify-email?token=${token}`;
15 | await this.mailerService.sendMail({
16 | to: user.email,
17 | subject: 'Welcome to Hanmi News! Confirm your Email',
18 | template: './welcome',
19 | context: {
20 | firstName: user.firstName,
21 | confirmationUrl,
22 | },
23 | });
24 | }
25 |
26 | async sendResetPassword(user: User, password: string) {
27 | const confirmationUrl = `${this.configService.get('WEB_HOST')}/login`;
28 | await this.mailerService.sendMail({
29 | to: user.email,
30 | subject: 'Password has been reset',
31 | template: './reset-password',
32 | context: {
33 | firstName: user.firstName,
34 | confirmationUrl,
35 | password,
36 | },
37 | });
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/apps/web/components/home/Header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import NavMenu from "./menus/NavMenu";
3 |
4 | const Header = () => {
5 | return (
6 |
7 |
8 |
9 | Ult
10 |
11 |
12 |
13 |
14 |
18 |
19 | The Ultimate Stack for Modern Frameworks
20 |
21 |
22 |
23 |
34 |
35 | );
36 | };
37 |
38 | export default Header;
39 |
--------------------------------------------------------------------------------
/apps/web/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/.pnpm-debug.log:
--------------------------------------------------------------------------------
1 | {
2 | "0 debug pnpm:scope": {
3 | "selected": 1,
4 | "workspacePrefix": "/Users/jae/Sites/ult"
5 | },
6 | "1 error pnpm": {
7 | "code": "ERR_PNPM_ADDING_TO_ROOT",
8 | "err": {
9 | "name": "Error",
10 | "message": "Running this command will add the dependency to the workspace root, which might not be what you want - if you really meant it, make it explicit by running this command again with the -w flag (or --workspace-root). If you don't want to see this warning anymore, you may set the ignore-workspace-root-check setting to true.",
11 | "code": "ERR_PNPM_ADDING_TO_ROOT",
12 | "stack": "Error: Running this command will add the dependency to the workspace root, which might not be what you want - if you really meant it, make it explicit by running this command again with the -w flag (or --workspace-root). If you don't want to see this warning anymore, you may set the ignore-workspace-root-check setting to true.\n at Object.handler [as add] (/Users/jae/.node/corepack/pnpm/6.11.0/dist/pnpm.cjs:116366:15)\n at /Users/jae/.node/corepack/pnpm/6.11.0/dist/pnpm.cjs:125612:82\n at async run (/Users/jae/.node/corepack/pnpm/6.11.0/dist/pnpm.cjs:125589:34)\n at async runPnpm (/Users/jae/.node/corepack/pnpm/6.11.0/dist/pnpm.cjs:125799:5)\n at async /Users/jae/.node/corepack/pnpm/6.11.0/dist/pnpm.cjs:125791:7"
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/apps/web/components/common/auth/WithAuth.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useUserContext } from "@web/app/user/UserContext";
4 | import { useRouter } from "next/navigation";
5 | import React, { ComponentType, useEffect } from "react";
6 |
7 | function WithAuth(
8 | Component: ComponentType
,
9 | allowedRoles: string[],
10 | failedRedirectPath = "/login"
11 | ): React.FC
{
12 | const WithAuthComponent: React.FC
= (props) => {
13 | const { currentUser, isAuthenticating } = useUserContext();
14 | const router = useRouter();
15 |
16 | useEffect(() => {
17 | if (!isAuthenticating) {
18 | // If there's no user or the user's roles don't include the required roles, redirect them.
19 | const isAuthorized = currentUser?.roles?.some((role) =>
20 | allowedRoles.includes(role.name)
21 | );
22 | if (!currentUser || !isAuthorized) {
23 | router.push(failedRedirectPath);
24 | return;
25 | }
26 | }
27 | }, [currentUser, isAuthenticating, router]);
28 |
29 | // Render a loading state or null until authentication is determined.
30 | if (isAuthenticating || !currentUser) {
31 | return <>>;
32 | }
33 |
34 | // Spread the props to the component when rendering.
35 | return ;
36 | };
37 |
38 | return WithAuthComponent;
39 | }
40 |
41 | export default WithAuth;
42 |
--------------------------------------------------------------------------------
/apps/web/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@web/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | success:
19 | "border-transparent bg-green-100 text-green-800 dark:bg-green-500/10 dark:text-green-500",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | },
25 | }
26 | )
27 |
28 | export interface BadgeProps
29 | extends React.HTMLAttributes,
30 | VariantProps {}
31 |
32 | function Badge({ className, variant, ...props }: BadgeProps) {
33 | return (
34 |
35 | )
36 | }
37 |
38 | export { Badge, badgeVariants }
--------------------------------------------------------------------------------
/apps/server/src/email/email.module.ts:
--------------------------------------------------------------------------------
1 | // email.module.ts
2 |
3 | import { MailerModule } from '@nestjs-modules/mailer';
4 | import { EjsAdapter } from '@nestjs-modules/mailer/dist/adapters/ejs.adapter';
5 | import { Global, Module } from '@nestjs/common';
6 | import { ConfigService } from '@server/config/config.service';
7 | import { join } from 'path';
8 | import { EmailService } from './email.service';
9 |
10 | @Global()
11 | @Module({
12 | imports: [
13 | MailerModule.forRootAsync({
14 | useFactory: async (configService: ConfigService) => ({
15 | transport: {
16 | host: configService.get('MAIL_SMTP_HOST'),
17 | port: configService.get('MAIL_SMTP_PORT'),
18 | secure: configService.get('MAIL_SMTP_SECURE'),
19 | auth: {
20 | user: configService.get('MAIL_SMTP_USERNAME'),
21 | pass: configService.get('MAIL_SMTP_PASSWORD'),
22 | },
23 | },
24 | defaults: {
25 | from: `${configService.get('MAIL_DEFAULT_FROM')}`,
26 | },
27 | template: {
28 | dir: join(__dirname, 'templates'),
29 | adapter: new EjsAdapter(),
30 | options: {
31 | strict: false,
32 | },
33 | },
34 | }),
35 | inject: [ConfigService],
36 | }),
37 | ],
38 | providers: [EmailService, ConfigService],
39 | exports: [EmailService, ConfigService],
40 | })
41 | export class EmailModule {}
42 |
--------------------------------------------------------------------------------
/scripts/configure-s3-cors.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const { S3Client, PutBucketCorsCommand } = require('@aws-sdk/client-s3');
4 | require('dotenv').config({ path: './apps/server/.env.development' });
5 |
6 | const s3Client = new S3Client({
7 | region: process.env.AWS_REGION || 'us-west-2',
8 | credentials: {
9 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
10 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
11 | },
12 | endpoint: process.env.AWS_ENDPOINT, // For local testing with LocalStack
13 | });
14 |
15 | const corsConfiguration = {
16 | CORSRules: [
17 | {
18 | AllowedHeaders: ['*'],
19 | AllowedMethods: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'],
20 | AllowedOrigins: [
21 | 'http://localhost:3000',
22 | 'http://localhost:3001',
23 | 'http://localhost:3002',
24 | 'https://your-production-domain.com', // Add your production domain
25 | ],
26 | ExposeHeaders: ['ETag'],
27 | MaxAgeSeconds: 3600,
28 | },
29 | ],
30 | };
31 |
32 | async function configureCors() {
33 | try {
34 | const command = new PutBucketCorsCommand({
35 | Bucket: process.env.AWS_S3_BUCKET || 'ult-s3',
36 | CORSConfiguration: corsConfiguration,
37 | });
38 |
39 | await s3Client.send(command);
40 | console.log('✅ CORS configuration applied successfully to S3 bucket:', process.env.AWS_S3_BUCKET);
41 | } catch (error) {
42 | console.error('❌ Error configuring CORS:', error);
43 | }
44 | }
45 |
46 | configureCors();
--------------------------------------------------------------------------------
/apps/shared/transformer.ts:
--------------------------------------------------------------------------------
1 | export const transformer = {
2 | serialize: (value: any) => {
3 | // Recursively search for Date objects to convert to strings
4 | const serializeDate = (obj: any): any => {
5 | if (obj instanceof Date) {
6 | return obj.toISOString();
7 | } else if (Array.isArray(obj)) {
8 | return obj.map(serializeDate);
9 | } else if (typeof obj === "object" && obj !== null) {
10 | const serializedObj: Record = {};
11 | for (const key in obj) {
12 | serializedObj[key] = serializeDate(obj[key]);
13 | }
14 | return serializedObj;
15 | }
16 | return obj;
17 | };
18 |
19 | return serializeDate(value);
20 | },
21 | deserialize: (value: any) => {
22 | // Recursively search for stringified dates to convert back to Date objects
23 | const deserializeDate = (obj: any): any => {
24 | if (typeof obj === "string") {
25 | const date = Date.parse(obj);
26 | if (!isNaN(date)) {
27 | return new Date(date);
28 | }
29 | } else if (Array.isArray(obj)) {
30 | return obj.map(deserializeDate);
31 | } else if (typeof obj === "object" && obj !== null) {
32 | const deserializedObj: Record = {};
33 | for (const key in obj) {
34 | deserializedObj[key] = deserializeDate(obj[key]);
35 | }
36 | return deserializedObj;
37 | }
38 | return obj;
39 | };
40 |
41 | return deserializeDate(value);
42 | },
43 | };
44 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/trpc.router.ts:
--------------------------------------------------------------------------------
1 | import { INestApplication, Injectable } from '@nestjs/common';
2 | import { CategoryRouter } from '@server/category/category.router';
3 | import { PostRouter } from '@server/post/post.router';
4 | import { RoleRouter } from '@server/role/role.router';
5 | import { TrpcService, createContext } from '@server/trpc/trpc.service';
6 | import { UploadRouter } from '@server/upload/upload.router';
7 | import { UserRouter } from '@server/user/user.router';
8 | import * as trpcExpress from '@trpc/server/adapters/express';
9 |
10 | @Injectable()
11 | export class TrpcRouter {
12 | public readonly appRouter;
13 |
14 | constructor(
15 | private readonly trpcService: TrpcService,
16 | private readonly userRouter: UserRouter,
17 | private readonly postRouter: PostRouter,
18 | private readonly roleRouter: RoleRouter,
19 | private readonly categoryRouter: CategoryRouter,
20 | private readonly uploadRouter: UploadRouter,
21 | ) {
22 | this.appRouter = this.trpcService.trpc.router({
23 | ...this.userRouter.apply(),
24 | ...this.postRouter.apply(),
25 | ...this.roleRouter.apply(),
26 | ...this.categoryRouter.apply(),
27 | ...this.uploadRouter.apply(),
28 | });
29 | }
30 |
31 | async applyMiddleware(app: INestApplication) {
32 | app.use(
33 | `/trpc`,
34 | trpcExpress.createExpressMiddleware({
35 | router: this.appRouter,
36 | createContext,
37 | }),
38 | );
39 | }
40 | }
41 |
42 | export type AppRouter = TrpcRouter['appRouter'];
43 |
--------------------------------------------------------------------------------
/apps/server/src/auth/google.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { PassportStrategy } from '@nestjs/passport';
3 | import { Strategy, VerifyCallback } from 'passport-google-oauth20';
4 | import { AuthService } from './auth.service';
5 | import { ConfigService } from '@nestjs/config';
6 |
7 | @Injectable()
8 | export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
9 | constructor(
10 | private readonly authService: AuthService,
11 | private readonly configService: ConfigService,
12 | ) {
13 | super({
14 | clientID: configService.get('GOOGLE_CLIENT_ID') || '',
15 | clientSecret: configService.get('GOOGLE_CLIENT_SECRET') || '',
16 | callbackURL: configService.get('GOOGLE_CALLBACK_URL') || '',
17 | scope: ['email', 'profile'],
18 | });
19 | }
20 |
21 | // async validate(
22 | // accessToken: string,
23 | // refreshToken: string,
24 | // profile: any,
25 | // done: VerifyCallback,
26 | // ): Promise {
27 | // const user = await this.authService.validateUser(profile);
28 | // done(null, user);
29 | // }
30 |
31 | async validate(
32 | accessToken: string,
33 | refreshToken: string,
34 | profile: any,
35 | ): Promise {
36 | const { name, emails, photos } = profile;
37 | const user = {
38 | email: emails[0].value,
39 | firstName: name.givenName,
40 | lastName: name.familyName,
41 | picture: photos[0].value,
42 | accessToken,
43 | };
44 | return user;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@web/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
--------------------------------------------------------------------------------
/apps/server/src/scripts/configure-cors.ts:
--------------------------------------------------------------------------------
1 | import { S3Client, PutBucketCorsCommand } from '@aws-sdk/client-s3';
2 | import * as dotenv from 'dotenv';
3 | import { resolve } from 'path';
4 |
5 | // Load environment variables
6 | dotenv.config({ path: resolve(__dirname, '../../.env.development') });
7 |
8 | const s3Client = new S3Client({
9 | region: process.env.AWS_REGION || 'us-west-2',
10 | credentials: process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY ? {
11 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
12 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
13 | } : undefined,
14 | endpoint: process.env.AWS_ENDPOINT, // For LocalStack
15 | });
16 |
17 | const corsConfiguration = {
18 | CORSRules: [
19 | {
20 | AllowedHeaders: ['*'],
21 | AllowedMethods: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'],
22 | AllowedOrigins: [
23 | 'http://localhost:3000',
24 | 'http://localhost:3001',
25 | 'http://localhost:3002',
26 | 'https://*', // Allow all HTTPS origins - adjust for production
27 | ],
28 | ExposeHeaders: ['ETag', 'x-amz-request-id'],
29 | MaxAgeSeconds: 3600,
30 | },
31 | ],
32 | };
33 |
34 | async function configureCors() {
35 | try {
36 | const command = new PutBucketCorsCommand({
37 | Bucket: process.env.AWS_S3_BUCKET || 'ult-s3',
38 | CORSConfiguration: corsConfiguration,
39 | });
40 |
41 | await s3Client.send(command);
42 | console.log('✅ CORS configuration applied successfully to S3 bucket:', process.env.AWS_S3_BUCKET);
43 | } catch (error) {
44 | console.error('❌ Error configuring CORS:', error);
45 | console.log('Note: You may need to configure CORS manually in your S3 bucket settings.');
46 | }
47 | }
48 |
49 | configureCors();
--------------------------------------------------------------------------------
/apps/web/app/dashboard/components/recent-sales.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Avatar, AvatarFallback, AvatarImage } from "@web/components/ui/avatar";
4 |
5 | interface User {
6 | id: string;
7 | firstName?: string | null;
8 | lastName?: string | null;
9 | email?: string | null;
10 | profilePicUrl?: string | null;
11 | }
12 |
13 | interface RecentSalesProps {
14 | users: User[];
15 | }
16 |
17 | export function RecentSales({ users }: RecentSalesProps) {
18 | // Generate mock revenue for each user
19 | const usersWithRevenue = users.map((user) => ({
20 | ...user,
21 | revenue: Math.floor(Math.random() * 5000) + 100,
22 | }));
23 |
24 | if (users.length === 0) {
25 | return (
26 |
27 |
No recent users
28 |
29 | );
30 | }
31 |
32 | return (
33 |
34 | {usersWithRevenue.map((user) => (
35 |
36 |
37 |
38 |
39 | {user.firstName?.[0]?.toUpperCase()}
40 | {user.lastName?.[0]?.toUpperCase()}
41 |
42 |
43 |
44 |
45 | {user.firstName} {user.lastName}
46 |
47 |
{user.email}
48 |
49 |
+${user.revenue.toLocaleString()}
50 |
51 | ))}
52 |
53 | );
54 | }
--------------------------------------------------------------------------------
/apps/web/components/common/preloaders/CircularProgress.tsx:
--------------------------------------------------------------------------------
1 | export const CircularProgress = () => {
2 | return (
3 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/apps/web/app/auth/google/callback/AuthGoogle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import React, { useEffect } from "react";
5 | import { StringParam, useQueryParam, withDefault } from "use-query-params";
6 | import { useUserContext } from "../../../user/UserContext";
7 |
8 | export default function AuthGoogle() {
9 | const router = useRouter();
10 | const { setAccessToken, currentUser } = useUserContext();
11 | const [code] = useQueryParam("code", withDefault(StringParam, ""));
12 |
13 | useEffect(() => {
14 | const verifyGoogleCode = async () => {
15 | const response = await fetch(
16 | `${process.env.NEXT_PUBLIC_API_URL}/auth/google/callback?code=${code}`
17 | );
18 | const data = await response.json();
19 | if (data) {
20 | setAccessToken(data.jwt.accessToken, data.jwt.expiresIn);
21 | window.location.href = "/";
22 | }
23 | };
24 | if (code) {
25 | verifyGoogleCode();
26 | }
27 | }, [code, router, setAccessToken]);
28 |
29 | return (
30 |
31 |
32 |
39 |
40 |
41 | Please wait while we verify your account
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/apps/server/src/user/user.service.test.ts:
--------------------------------------------------------------------------------
1 | import { UserService } from '@server/user/user.service';
2 | import { createMock } from 'ts-auto-mock';
3 | import { PrismaService } from '@server/prisma/prisma.service';
4 | import { AuthService } from '@server/auth/auth.service';
5 | import { EmailService } from '@server/email/email.service';
6 | import { mock, mockDeep } from 'jest-mock-extended';
7 | import { User } from '@prisma/client';
8 |
9 | describe('UserService', () => {
10 | const prismaService = mockDeep();
11 | const authService = mock();
12 | const emailService = createMock();
13 |
14 | const service = new UserService(prismaService, authService, emailService);
15 |
16 | describe('login', () => {
17 | it('should throw NotFoundException if user does not exist', async () => {
18 | prismaService.user.findFirst.mockResolvedValueOnce(null);
19 | await expect(
20 | service.login({ email: 'test', password: 'test' }),
21 | ).rejects.toThrowError('Invalid login');
22 | });
23 |
24 | it('should throw UnauthorizedException if password is invalid', async () => {
25 | prismaService.user.findFirst.mockResolvedValueOnce(createMock());
26 | authService.verifyPassword.mockResolvedValueOnce(false);
27 | await expect(
28 | service.login({ email: 'test', password: 'test' }),
29 | ).rejects.toThrowError('Invalid login');
30 | });
31 |
32 | it('should return user if login is successful', async () => {
33 | const user = createMock();
34 | prismaService.user.findFirst.mockResolvedValueOnce(user);
35 | authService.verifyPassword.mockResolvedValueOnce(true);
36 |
37 | const res = await service.login({ email: 'test', password: 'test' });
38 |
39 | expect(res).toEqual(expect.objectContaining({ user }));
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/apps/web/app/verify-email/VerifyEmail.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTrpc } from "@web/contexts/TrpcContext";
4 | import { useRouter } from "next/navigation";
5 | import { useEffect } from "react";
6 | import { StringParam, useQueryParam, withDefault } from "use-query-params";
7 | import { useUserContext } from "../user/UserContext";
8 |
9 | export default function VerifyEmail() {
10 | const { trpc } = useTrpc();
11 | const router = useRouter();
12 | const { setAccessToken, currentUser } = useUserContext();
13 | const [token] = useQueryParam("token", withDefault(StringParam, ""));
14 |
15 | const userJwt = trpc.userRouter.verifyAccessToken.useQuery({
16 | accessToken: token,
17 | });
18 |
19 | useEffect(() => {
20 | if (userJwt.data) {
21 | setAccessToken(token, userJwt.data.jwt.expiresIn);
22 | }
23 | }, [userJwt, setAccessToken, token]);
24 |
25 | useEffect(() => {
26 | if (currentUser) {
27 | router.push("/");
28 | }
29 | }, [currentUser, router]);
30 |
31 | return (
32 |
33 |
34 |
41 |
42 |
43 | {token
44 | ? "Verifying..."
45 | : "Please check your email to verify your account"}
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/apps/web/components/home/menus/NavMenu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Menu, X } from "lucide-react";
5 | import Link from "next/link";
6 | import { useUserContext } from "@web/app/user/UserContext";
7 |
8 | export default function NavMenu() {
9 | const [isMenuOpen, setIsMenuOpen] = useState(false);
10 | const { currentUser } = useUserContext();
11 |
12 | const toggleMenu = () => {
13 | setIsMenuOpen(!isMenuOpen);
14 | };
15 |
16 | return (
17 |
45 | );
46 | }
47 |
48 | function MobileNavLink({
49 | href,
50 | children,
51 | }: {
52 | href: string;
53 | children: React.ReactNode;
54 | }) {
55 | return (
56 |
60 | {children}
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@nestjs/mapped-types": "*",
4 | "@radix-ui/react-avatar": "^1.1.10",
5 | "@radix-ui/react-checkbox": "^1.3.3",
6 | "@radix-ui/react-dialog": "^1.1.15",
7 | "@radix-ui/react-dropdown-menu": "^2.1.16",
8 | "@radix-ui/react-icons": "^1.3.2",
9 | "@tanstack/react-table": "^8.21.3"
10 | },
11 | "name": "ult",
12 | "version": "0.3.0",
13 | "description": "",
14 | "main": "index.js",
15 | "scripts": {
16 | "test": "echo \"Error: no test specified\" && exit 1",
17 | "build": "pnpm run --parallel build",
18 | "dev": "dotenv -e ./apps/server/.env.development -- pnpm db:prisma:generate && pnpm run --parallel dev",
19 | "db:prisma:generate": "dotenv -e ./apps/server/.env.development -- pnpm run --filter=server prisma:generate",
20 | "db:migrate": "dotenv -e ./apps/server/.env.development -- pnpm db:prisma:generate && pnpm run --filter=server db:migrate",
21 | "db:migrate:new": "dotenv -e ./apps/server/.env.development -- pnpm db:prisma:generate && pnpm run --filter=server db:migrate:new",
22 | "db:seed": "dotenv -e ./apps/server/.env.development -- pnpm db:prisma:generate && pnpm run --filter=server db:seed",
23 | "prod:server": "dotenv -e ./apps/server/.env.production -- pnpm db:prisma:generate && pnpm run --parallel --filter=server prod",
24 | "prod:web": "dotenv -e ./apps/server/.env.production -- pnpm db:prisma:generate && pnpm run --parallel --filter=web prod",
25 | "prod:db:migrate": "dotenv -e ./apps/server/.env.production -- pnpm run --filter=server db:migrate",
26 | "prod:db:seed": "dotenv -e ./apps/server/.env.production -- pnpm run --filter=server db:seed"
27 | },
28 | "author": "",
29 | "license": "ISC",
30 | "devDependencies": {
31 | "@swc/cli": "^0.3.14",
32 | "@swc/core": "^1.7.23",
33 | "dotenv-cli": "^7.2.1",
34 | "jest-mock-extended": "^3.0.5",
35 | "jest-ts-auto-mock": "^2.1.0",
36 | "ts-auto-mock": "^3.7.2",
37 | "ts-patch": "^3.1.2"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/apps/server/src/upload/upload.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
3 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
4 | import { ConfigService } from '@nestjs/config';
5 |
6 | @Injectable()
7 | export class UploadService {
8 | private s3Client: S3Client;
9 | private bucketName: string;
10 |
11 | constructor(private configService: ConfigService) {
12 | const region = this.configService.get('AWS_REGION', 'us-east-1');
13 | const accessKeyId = this.configService.get('AWS_ACCESS_KEY_ID');
14 | const secretAccessKey = this.configService.get('AWS_SECRET_ACCESS_KEY');
15 |
16 | this.bucketName = this.configService.get('AWS_S3_BUCKET', 'ult-uploads');
17 |
18 | if (accessKeyId && secretAccessKey) {
19 | this.s3Client = new S3Client({
20 | region,
21 | credentials: {
22 | accessKeyId,
23 | secretAccessKey,
24 | },
25 | });
26 | } else {
27 | // Use default AWS credentials (from IAM role, etc.)
28 | this.s3Client = new S3Client({ region });
29 | }
30 | }
31 |
32 | async getPresignedUploadUrl(
33 | fileName: string,
34 | fileType: string,
35 | expiresIn = 3600,
36 | ): Promise<{ uploadUrl: string; fileUrl: string }> {
37 | const key = `uploads/${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
38 |
39 | const command = new PutObjectCommand({
40 | Bucket: this.bucketName,
41 | Key: key,
42 | ContentType: fileType,
43 | });
44 |
45 | const uploadUrl = await getSignedUrl(this.s3Client, command, { expiresIn });
46 |
47 | // Use the correct S3 URL format based on region
48 | const region = this.configService.get('AWS_REGION', 'us-east-1');
49 | const fileUrl = `https://${this.bucketName}.s3.${region}.amazonaws.com/${key}`;
50 |
51 | return {
52 | uploadUrl,
53 | fileUrl,
54 | };
55 | }
56 | }
--------------------------------------------------------------------------------
/apps/web/components/dashboard/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 |
3 | type DialogProps = {
4 | children: React.ReactNode;
5 | onClose: () => void;
6 | };
7 |
8 | export const Dialog: React.FC = ({ children, onClose }) => {
9 | const modalRef = useRef(null); // Reference to the modal content
10 |
11 | const handleOutsideClick = (event: MouseEvent) => {
12 | if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
13 | onClose();
14 | }
15 | };
16 |
17 | const handleEscapeKeyPress = (event: KeyboardEvent) => {
18 | if (event.key === "Escape") {
19 | onClose();
20 | }
21 | };
22 |
23 | useEffect(() => {
24 | // Disable body scroll
25 | const originalStyle = window.getComputedStyle(document.body).overflow;
26 | document.body.style.overflow = 'hidden';
27 |
28 | document.addEventListener("mousedown", handleOutsideClick);
29 | document.addEventListener("keydown", handleEscapeKeyPress);
30 |
31 | return () => {
32 | // Re-enable body scroll
33 | document.body.style.overflow = originalStyle;
34 |
35 | document.removeEventListener("mousedown", handleOutsideClick);
36 | document.removeEventListener("keydown", handleEscapeKeyPress);
37 | };
38 | }, []);
39 |
40 | return (
41 |
42 |
43 |
44 |
51 | {children}
52 |
53 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/apps/server/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { PrismaClient } from '@prisma/client';
3 | import { AppModule } from '@server/app.module';
4 | import { CategoryService } from '@server/category/category.service';
5 | import { UserService } from '@server/user/user.service';
6 | import { Roles } from '@shared/interfaces';
7 |
8 | const prisma = new PrismaClient();
9 |
10 | async function main() {
11 | const app = await NestFactory.createApplicationContext(AppModule);
12 | const userService = app.get(UserService);
13 | const categoryService = app.get(CategoryService);
14 |
15 | // setup roles
16 | const adminRole = await prisma.role.create({
17 | data: {
18 | name: 'Admin',
19 | },
20 | });
21 | console.log(`Created new role: ${adminRole.name} (ID: ${adminRole.id})`);
22 | const userRole = await prisma.role.create({
23 | data: {
24 | name: 'User',
25 | },
26 | });
27 | console.log(`Created new role: ${userRole.name} (ID: ${userRole.id})`);
28 |
29 | // setup users
30 | const admin = await userService.create({
31 | firstName: 'Admin',
32 | lastName: 'User',
33 | email: 'admin@example.com',
34 | password: 'password',
35 | roles: [Roles.Admin],
36 | });
37 | console.log(`Created new user: ${admin.user.email} (ID: ${admin.user.id})`);
38 | const normalUser = await userService.create({
39 | firstName: 'Normal',
40 | lastName: 'User',
41 | email: 'user@example.com',
42 | password: 'password',
43 | roles: [Roles.User],
44 | });
45 | console.log(
46 | `Created new user: ${normalUser.user.email} (ID: ${normalUser.user.id})`,
47 | );
48 |
49 | // setup categories
50 | const categories = ['News', 'Events', 'Message Board'];
51 | for (const name of categories) {
52 | await categoryService.create({
53 | name,
54 | });
55 | }
56 |
57 | // Add more seeding logic as needed
58 | }
59 |
60 | main()
61 | .catch((e) => {
62 | console.error(e);
63 | process.exit(1);
64 | })
65 | .finally(async () => {
66 | await prisma.$disconnect();
67 | });
68 |
--------------------------------------------------------------------------------
/apps/web/app/dashboard/components/overview.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Legend } from "recharts";
4 |
5 | const data = [
6 | {
7 | name: "Jan",
8 | total: Math.floor(Math.random() * 5000) + 1000,
9 | },
10 | {
11 | name: "Feb",
12 | total: Math.floor(Math.random() * 5000) + 1000,
13 | },
14 | {
15 | name: "Mar",
16 | total: Math.floor(Math.random() * 5000) + 1000,
17 | },
18 | {
19 | name: "Apr",
20 | total: Math.floor(Math.random() * 5000) + 1000,
21 | },
22 | {
23 | name: "May",
24 | total: Math.floor(Math.random() * 5000) + 1000,
25 | },
26 | {
27 | name: "Jun",
28 | total: Math.floor(Math.random() * 5000) + 1000,
29 | },
30 | {
31 | name: "Jul",
32 | total: Math.floor(Math.random() * 5000) + 1000,
33 | },
34 | {
35 | name: "Aug",
36 | total: Math.floor(Math.random() * 5000) + 1000,
37 | },
38 | {
39 | name: "Sep",
40 | total: Math.floor(Math.random() * 5000) + 1000,
41 | },
42 | {
43 | name: "Oct",
44 | total: Math.floor(Math.random() * 5000) + 1000,
45 | },
46 | {
47 | name: "Nov",
48 | total: Math.floor(Math.random() * 5000) + 1000,
49 | },
50 | {
51 | name: "Dec",
52 | total: Math.floor(Math.random() * 5000) + 1000,
53 | },
54 | ];
55 |
56 | export function Overview() {
57 | return (
58 |
59 |
60 |
67 | `$${value}`}
73 | />
74 |
75 |
81 |
82 |
83 | );
84 | }
--------------------------------------------------------------------------------
/apps/web/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@web/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
--------------------------------------------------------------------------------
/apps/server/src/post/post.dto.ts:
--------------------------------------------------------------------------------
1 | import { PostReactionType, User } from '@prisma/client';
2 | import { z } from 'zod';
3 |
4 | export const PostCreateDto = z.object({
5 | title: z.string(),
6 | categoryId: z.number(),
7 | description: z.string().optional(),
8 | imageUrl: z.string().optional(),
9 | });
10 | export type PostCreateDtoType = z.infer;
11 |
12 | export const PostUpdateDto = z.object({
13 | id: z.number(),
14 | title: z.string().optional(),
15 | categoryId: z.number().optional(),
16 | teaser: z.string().optional(),
17 | description: z.string().optional(),
18 | imageUrl: z.string().optional(),
19 | });
20 | export type PostUpdateDtoType = z.infer;
21 |
22 | export const PostRemoveDto = z.object({
23 | id: z.array(z.number()),
24 | });
25 | export type PostRemoveDtoType = z.infer;
26 |
27 | export const PostFindAllDto = z.object({
28 | page: z.number().default(1),
29 | perPage: z.number().default(10),
30 | categoryId: z.number().optional(),
31 | search: z.string().optional(),
32 | });
33 | export type PostFindAllDtoType = z.infer;
34 |
35 | export const PostFindByIdDto = z.object({
36 | id: z.number(),
37 | });
38 | export type PostFindByIdDtoType = z.infer;
39 |
40 | export const PostReactionCreateDto = z.object({
41 | postId: z.number(),
42 | type: z.nativeEnum(PostReactionType),
43 | });
44 | export type PostReactionCreateDtoType = z.infer;
45 |
46 | export const PostCommentCreateDto = z.object({
47 | postId: z.number(),
48 | message: z.string().min(1, 'Message can not be empty'),
49 | });
50 | export type PostCommentCreateDtoType = z.infer;
51 |
52 | export const PostCommentRemoveDto = z.object({
53 | id: z.number(),
54 | });
55 | export type PostCommentRemoveDtoType = z.infer;
56 |
57 | export const getUsername = (user?: User) => {
58 | if (!user) return;
59 | if (user?.username) {
60 | return user?.username;
61 | }
62 | const username = `${user?.firstName} ${user?.lastName}`;
63 | return username;
64 | };
65 |
--------------------------------------------------------------------------------
/apps/web/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@web/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
--------------------------------------------------------------------------------
/apps/web/contexts/TrpcContext.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AppRouter } from "@server/trpc/trpc.router"; // Adjust the import path as necessary
4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5 | import {
6 | CreateTRPCClientOptions,
7 | createTRPCProxyClient,
8 | createTRPCReact,
9 | httpBatchLink,
10 | } from "@trpc/react-query";
11 | import Cookies from "js-cookie";
12 | import React, { ReactNode, createContext, useContext, useState } from "react";
13 | import { transformer } from "@shared/transformer";
14 |
15 | const trpc = createTRPCReact();
16 | const trpcUrl =
17 | process.env.NEXT_PUBLIC_TRPC_URL || "http://localhost:3001/trpc"; // Default URL as fallback
18 | const jwtAccessToken = Cookies.get("jwtAccessToken");
19 | const httpBatchLinkOptions = {
20 | url: trpcUrl,
21 | transformer,
22 | headers: jwtAccessToken
23 | ? {
24 | Authorization: `Bearer ${jwtAccessToken}`,
25 | }
26 | : undefined,
27 | };
28 | const trpcClientOpts: CreateTRPCClientOptions = {
29 | links: [httpBatchLink(httpBatchLinkOptions)],
30 | transformer,
31 | };
32 |
33 | const trpcReactClient = trpc.createClient(trpcClientOpts);
34 | const trpcAsync = createTRPCProxyClient(trpcClientOpts);
35 |
36 | interface TrpcContextState {
37 | trpc: typeof trpc;
38 | trpcAsync: typeof trpcAsync;
39 | }
40 |
41 | // Context will now directly store the trpc client
42 | const TrpcContext = createContext({
43 | trpc,
44 | trpcAsync,
45 | });
46 | export const TrpcProvider: React.FC<{ children: ReactNode }> = ({
47 | children,
48 | }) => {
49 | const [queryClient] = useState(() => new QueryClient());
50 | const [trpcClient] = useState(() => trpcReactClient);
51 | return (
52 |
58 |
59 |
60 | {children}
61 |
62 |
63 |
64 | );
65 | };
66 | export const useTrpc = () => {
67 | const context = useContext(TrpcContext);
68 | if (context === undefined) {
69 | throw new Error("useTrpc must be used within a TrpcProvider");
70 | }
71 | return context;
72 | };
73 |
--------------------------------------------------------------------------------
/apps/web/app/test/Test.tsx:
--------------------------------------------------------------------------------
1 | // "use client";
2 |
3 | // import { useTrpcQuery } from "@web/hooks/useTrpcQuery";
4 | // import { useCallback, useState } from "react";
5 |
6 | // import {
7 | // UserCreateDtoType,
8 | // UserFindAllDtoType,
9 | // UserRemoveDtoType,
10 | // } from "@server/user/dto/user.dto";
11 | // import { trpc } from "@web/utils/trpc/trpc";
12 | // import { useTrpcMutate } from "@web/hooks/useTrpcMutate";
13 |
14 | // export default function Test() {
15 | // const [email, setEmail] = useState("");
16 | // const {
17 | // query: getUsers,
18 | // data: users,
19 | // isLoading: gettingUsers,
20 | // error: gettingUsersError,
21 | // } = useTrpcQuery(
22 | // useCallback(
23 | // (opts: UserFindAllDtoType) => trpc.userRouter.findAll.query(opts),
24 | // []
25 | // ),
26 | // false
27 | // );
28 |
29 | // const {
30 | // // mutate: createUser,
31 | // mutateAsync: createUser,
32 | // data: createdUser,
33 | // isLoading: creatingUser,
34 | // error: creatingUserError,
35 | // } = useTrpcMutate(async (userData: UserCreateDtoType) =>
36 | // trpc.userRouter.create.mutate(userData)
37 | // );
38 |
39 | // const {
40 | // mutate: removeUser,
41 | // data: removedUser,
42 | // isLoading: removingUser,
43 | // error: removingUserError,
44 | // } = useTrpcMutate(async (userData: UserRemoveDtoType) =>
45 | // trpc.userRouter.remove.mutate(userData)
46 | // );
47 |
48 | // return (
49 | // <>
50 | // {JSON.stringify(users)}
51 | // by email:
52 | // {
56 | // setEmail(e.target.value);
57 | // getUsers({
58 | // email,
59 | // });
60 | // }}
61 | // />
62 | // {createdUser && {JSON.stringify(createdUser)}
}
63 | // {
65 | // const createdUser = await createUser({
66 | // email: email || "jn@ni.com",
67 | // password: "password",
68 | // firstName: "john",
69 | // lastName: "cena",
70 | // });
71 | // await removeUser({ id: createdUser.id || 0 });
72 | // }}
73 | // >
74 | // create
75 | //
76 | // >
77 | // );
78 | // }
79 |
--------------------------------------------------------------------------------
/apps/server/src/role/role.router.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, UseFilters } from '@nestjs/common';
2 | import { RoleService } from '@server/role/role.service';
3 | import { TrpcExceptionFilter } from '@server/trpc/trpc.exception-handler';
4 | import { TrpcService } from '@server/trpc/trpc.service';
5 | import { Roles } from '@shared/interfaces';
6 | import {
7 | RoleCreateDto,
8 | RoleFindAllDto,
9 | RoleFindByIdDto,
10 | RoleRemoveDto,
11 | RoleUpdateDto,
12 | } from './role.dto';
13 |
14 | @Injectable()
15 | @UseFilters(new TrpcExceptionFilter())
16 | export class RoleRouter {
17 | constructor(
18 | private readonly trpcService: TrpcService,
19 | private readonly roleService: RoleService,
20 | ) {}
21 | apply() {
22 | const roleRouter = this.trpcService.trpc.router({
23 | // creates a role from dashboard
24 | create: this.trpcService
25 | .protectedProcedure([Roles.Admin])
26 | .input(RoleCreateDto)
27 | .mutation(async ({ input, ctx }) => {
28 | if (ctx.user) {
29 | return this.roleService.create(input);
30 | }
31 | }),
32 |
33 | // update role
34 | update: this.trpcService
35 | .protectedProcedure([Roles.Admin])
36 | .input(RoleUpdateDto)
37 | .mutation(async ({ input, ctx }) => {
38 | if (ctx.user) {
39 | return this.roleService.update(input);
40 | }
41 | }),
42 |
43 | // remove role
44 | remove: this.trpcService
45 | .protectedProcedure([Roles.Admin])
46 | .input(RoleRemoveDto)
47 | .mutation(async ({ input, ctx }) => {
48 | if (ctx.user) {
49 | return this.roleService.remove(input.id);
50 | }
51 | }),
52 |
53 | // get role by id
54 | findById: this.trpcService
55 | .publicProcedure()
56 | .input(RoleFindByIdDto)
57 | .query(async ({ input }) => {
58 | return this.roleService.findById(input.id);
59 | }),
60 |
61 | // get all roles
62 | findAll: this.trpcService
63 | .publicProcedure()
64 | .input(RoleFindAllDto)
65 | .query(async ({ input }) => {
66 | return this.roleService.findAll(input);
67 | }),
68 | });
69 |
70 | return {
71 | roleRouter,
72 | } as const;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/apps/server/src/role/role.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { InternalServerErrorException } from '@nestjs/common/exceptions';
3 | import { PrismaService } from '@server/prisma/prisma.service';
4 | import {
5 | RoleCreateDtoType,
6 | RoleFindAllDtoType,
7 | RoleUpdateDtoType,
8 | } from './role.dto';
9 |
10 | @Injectable()
11 | export class RoleService {
12 | constructor(private readonly prismaService: PrismaService) {}
13 |
14 | async create(roleCreateDto: RoleCreateDtoType) {
15 | // create
16 | try {
17 | const role = await this.prismaService.role.create({
18 | data: {
19 | ...roleCreateDto,
20 | },
21 | });
22 | return role;
23 | } catch (error: any) {
24 | throw new InternalServerErrorException(error.message);
25 | }
26 | }
27 |
28 | async update(roleUpdateDto: RoleUpdateDtoType) {
29 | try {
30 | const updatedRole = await this.prismaService.role.update({
31 | where: {
32 | id: roleUpdateDto.id,
33 | },
34 | data: { name: roleUpdateDto.name },
35 | });
36 | return updatedRole;
37 | } catch (error: any) {
38 | throw new InternalServerErrorException(error.message);
39 | }
40 | }
41 |
42 | async findAll(opts: RoleFindAllDtoType) {
43 | const records = await this.prismaService.role.findMany({
44 | skip: (opts.page - 1) * opts.perPage,
45 | take: opts.perPage,
46 | orderBy: {
47 | name: 'asc',
48 | },
49 | });
50 | const total = await this.prismaService.role.count();
51 | const lastPage = Math.ceil(total / opts.perPage);
52 | return {
53 | records,
54 | total,
55 | currentPage: opts.page,
56 | lastPage,
57 | perPage: opts.perPage,
58 | };
59 | }
60 |
61 | async findById(id: number) {
62 | return this.prismaService.role.findFirstOrThrow({
63 | where: { id },
64 | });
65 | }
66 |
67 | async remove(id: number | number[]) {
68 | let ids: number[] = [];
69 | if (id instanceof Array) {
70 | ids = id;
71 | } else if (typeof id === 'number') {
72 | ids = [id];
73 | }
74 | const output = [];
75 | for (const id of ids) {
76 | output.push(
77 | await this.prismaService.role.delete({
78 | where: {
79 | id,
80 | },
81 | }),
82 | );
83 | }
84 | return output;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/apps/server/src/category/category.router.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, UseFilters } from '@nestjs/common';
2 | import { CategoryService } from '@server/category/category.service';
3 | import { TrpcExceptionFilter } from '@server/trpc/trpc.exception-handler';
4 | import { TrpcService } from '@server/trpc/trpc.service';
5 | import { Roles } from '@shared/interfaces';
6 | import {
7 | CategoryCreateDto,
8 | CategoryFindAllDto,
9 | CategoryFindByIdDto,
10 | CategoryRemoveDto,
11 | CategoryUpdateDto,
12 | } from './category.dto';
13 |
14 | @Injectable()
15 | @UseFilters(new TrpcExceptionFilter())
16 | export class CategoryRouter {
17 | constructor(
18 | private readonly trpcService: TrpcService,
19 | private readonly categoryService: CategoryService,
20 | ) {}
21 | apply() {
22 | const categoryRouter = this.trpcService.trpc.router({
23 | // creates a category from dashboard
24 | create: this.trpcService
25 | .protectedProcedure()
26 | .input(CategoryCreateDto)
27 | .mutation(async ({ input, ctx }) => {
28 | if (ctx.user) {
29 | return this.categoryService.create(input);
30 | }
31 | }),
32 |
33 | // update category
34 | update: this.trpcService
35 | .protectedProcedure()
36 | .input(CategoryUpdateDto)
37 | .mutation(async ({ input, ctx }) => {
38 | if (ctx.user) {
39 | return this.categoryService.update(input);
40 | }
41 | }),
42 |
43 | // remove category
44 | remove: this.trpcService
45 | .protectedProcedure([Roles.Admin])
46 | .input(CategoryRemoveDto)
47 | .mutation(async ({ input, ctx }) => {
48 | if (ctx.user) {
49 | return this.categoryService.remove(input.id);
50 | }
51 | }),
52 |
53 | // get category by id
54 | findById: this.trpcService
55 | .publicProcedure()
56 | .input(CategoryFindByIdDto)
57 | .query(async ({ input }) => {
58 | return this.categoryService.findById(input.id);
59 | }),
60 |
61 | // get all categorys
62 | findAll: this.trpcService
63 | .publicProcedure()
64 | .input(CategoryFindAllDto)
65 | .query(async ({ input }) => {
66 | return this.categoryService.findAll(input);
67 | }),
68 | });
69 |
70 | return {
71 | categoryRouter,
72 | } as const;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/trpc.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { Role } from '@prisma/client';
3 | import { UserService } from '@server/user/user.service';
4 | import { UserById } from '@shared/interfaces';
5 | import { transformer } from '@shared/transformer';
6 | import { TRPCError, initTRPC } from '@trpc/server';
7 | import * as trpcExpress from '@trpc/server/adapters/express';
8 | import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
9 |
10 | export interface TrpcContext extends CreateExpressContextOptions {
11 | user?: UserById;
12 | }
13 |
14 | export const createContext = async (
15 | opts: trpcExpress.CreateExpressContextOptions,
16 | ) => {
17 | return {
18 | req: opts.req,
19 | res: opts.res,
20 | };
21 | };
22 |
23 | @Injectable()
24 | export class TrpcService {
25 | trpc;
26 | constructor(private readonly userService: UserService) {
27 | this.trpc = initTRPC.context().create({
28 | transformer,
29 | });
30 | }
31 |
32 | // these routes are publicly accessible to everyone
33 | publicProcedure() {
34 | return this.trpc.procedure;
35 | }
36 |
37 | // these routes requires authentication:
38 | // if allowedRoles is empty, it requires an authenticated user (access token in the header)
39 | // if allowedRoles is not empty, it requires an authenticated user with one of the allowed roles
40 | protectedProcedure(allowedRoles?: string[]) {
41 | const procedure = this.trpc.procedure.use(async (opts) => {
42 | // get bearer from headers
43 | const userJwt = await this.getJwtUserFromHeader(opts.ctx);
44 | // throw error if user is unauthorized
45 | if (
46 | !userJwt ||
47 | (allowedRoles?.length &&
48 | !userJwt.user.roles?.some((role) => allowedRoles.includes(role.name)))
49 | ) {
50 | throw new TRPCError({ code: 'UNAUTHORIZED' });
51 | }
52 | // user is authorized
53 | return opts.next({
54 | ctx: {
55 | ...opts.ctx,
56 | user: userJwt.user,
57 | },
58 | });
59 | });
60 | return procedure;
61 | }
62 |
63 | async getJwtUserFromHeader(ctx: TrpcContext) {
64 | // get bearer from headers
65 | const accessToken =
66 | ctx.req.headers.authorization?.replace('Bearer ', '') || '';
67 | if (!accessToken) {
68 | throw new TRPCError({ code: 'UNAUTHORIZED' });
69 | }
70 |
71 | // check if user has role privilege
72 | return this.userService.verifyAccessToken(accessToken);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.2.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "prod": "next build && pnpm start",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@headlessui/react": "^1.7.18",
14 | "@heroicons/react": "^2.1.1",
15 | "@hookform/resolvers": "^3.3.4",
16 | "@material-tailwind/react": "^2.1.9",
17 | "@next/third-parties": "^15.5.2",
18 | "@radix-ui/react-alert-dialog": "^1.1.15",
19 | "@radix-ui/react-dropdown-menu": "^2.1.16",
20 | "@radix-ui/react-label": "^2.1.7",
21 | "@radix-ui/react-navigation-menu": "^1.2.14",
22 | "@radix-ui/react-select": "^2.2.6",
23 | "@radix-ui/react-separator": "^1.1.7",
24 | "@radix-ui/react-slot": "^1.2.3",
25 | "@radix-ui/react-tooltip": "^1.2.8",
26 | "@react-pdf/renderer": "^3.4.2",
27 | "@tailwindcss/forms": "^0.5.7",
28 | "@tanstack/react-query": "^4.35.3",
29 | "@trpc/client": "^10.45.2",
30 | "@trpc/next": "^10.45.1",
31 | "@trpc/react-query": "^10.45.1",
32 | "@trpc/server": "^10.45.2",
33 | "axios": "^1.8.2",
34 | "class-variance-authority": "^0.7.1",
35 | "clsx": "^2.1.1",
36 | "date-fns": "^3.3.1",
37 | "date-fns-tz": "^2.0.0",
38 | "embla-carousel-autoplay": "^8.2.1",
39 | "embla-carousel-react": "^8.2.1",
40 | "js-cookie": "^3.0.5",
41 | "lodash": "^4.17.21",
42 | "lucide-react": "^0.542.0",
43 | "next": "^15.5.2",
44 | "next-query-params": "^5.0.0",
45 | "pdfjs-dist": "^4.8.69",
46 | "preline": "^2.0.3",
47 | "quill": "^2.0.3",
48 | "react": "^19.1.1",
49 | "react-aria": "^3.32.1",
50 | "react-dom": "^19.1.1",
51 | "react-hook-form": "^7.50.1",
52 | "react-phone-input-2": "^2.15.1",
53 | "react-phone-number-input": "^3.4.8",
54 | "react-query": "3",
55 | "react-quill": "^2.0.0",
56 | "react-stately": "^3.30.1",
57 | "react-toastify": "^10.0.4",
58 | "recharts": "^3.1.2",
59 | "tailwind-merge": "^3.3.1",
60 | "tailwindcss-animate": "^1.0.7",
61 | "use-query-params": "^2.2.1",
62 | "zod": "^3.22.4"
63 | },
64 | "devDependencies": {
65 | "@types/js-cookie": "^3.0.6",
66 | "@types/lodash": "^4.14.202",
67 | "@types/node": "^20",
68 | "@types/quill": "^2.0.14",
69 | "@types/react": "^19",
70 | "@types/react-dom": "^19",
71 | "autoprefixer": "^10.0.1",
72 | "eslint": "^8",
73 | "eslint-config-next": "^15.5.2",
74 | "postcss": "^8",
75 | "tailwindcss": "^3.3.0",
76 | "typescript": "^5"
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/apps/web/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const withMT = require("@material-tailwind/react/utils/withMT");
4 |
5 | const config: Config = {
6 | darkMode: ["class"],
7 | content: [
8 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
10 | "./node_modules/preline/dist/*.js",
11 | ],
12 | theme: {
13 | extend: {
14 | colors: {
15 | beige: {
16 | '200': '#f5f5dc',
17 | '300': '#ede9e0',
18 | '500': '#d2b48c'
19 | },
20 | background: 'hsl(var(--background))',
21 | foreground: 'hsl(var(--foreground))',
22 | card: {
23 | DEFAULT: 'hsl(var(--card))',
24 | foreground: 'hsl(var(--card-foreground))'
25 | },
26 | popover: {
27 | DEFAULT: 'hsl(var(--popover))',
28 | foreground: 'hsl(var(--popover-foreground))'
29 | },
30 | primary: {
31 | DEFAULT: 'hsl(var(--primary))',
32 | foreground: 'hsl(var(--primary-foreground))'
33 | },
34 | secondary: {
35 | DEFAULT: 'hsl(var(--secondary))',
36 | foreground: 'hsl(var(--secondary-foreground))'
37 | },
38 | muted: {
39 | DEFAULT: 'hsl(var(--muted))',
40 | foreground: 'hsl(var(--muted-foreground))'
41 | },
42 | accent: {
43 | DEFAULT: 'hsl(var(--accent))',
44 | foreground: 'hsl(var(--accent-foreground))'
45 | },
46 | destructive: {
47 | DEFAULT: 'hsl(var(--destructive))',
48 | foreground: 'hsl(var(--destructive-foreground))'
49 | },
50 | border: 'hsl(var(--border))',
51 | input: 'hsl(var(--input))',
52 | ring: 'hsl(var(--ring))',
53 | chart: {
54 | '1': 'hsl(var(--chart-1))',
55 | '2': 'hsl(var(--chart-2))',
56 | '3': 'hsl(var(--chart-3))',
57 | '4': 'hsl(var(--chart-4))',
58 | '5': 'hsl(var(--chart-5))'
59 | },
60 | sidebar: {
61 | DEFAULT: 'hsl(var(--sidebar-background))',
62 | foreground: 'hsl(var(--sidebar-foreground))',
63 | primary: 'hsl(var(--sidebar-primary))',
64 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
65 | accent: 'hsl(var(--sidebar-accent))',
66 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
67 | border: 'hsl(var(--sidebar-border))',
68 | ring: 'hsl(var(--sidebar-ring))'
69 | }
70 | },
71 | borderRadius: {
72 | lg: 'var(--radius)',
73 | md: 'calc(var(--radius) - 2px)',
74 | sm: 'calc(var(--radius) - 4px)'
75 | }
76 | }
77 | },
78 | plugins: [require("@tailwindcss/forms"), require("preline/plugin"), require("tailwindcss-animate")],
79 | };
80 | export default withMT(config);
81 |
--------------------------------------------------------------------------------
/apps/server/src/user/user.dto.ts:
--------------------------------------------------------------------------------
1 | import { Roles } from '@shared/interfaces';
2 | import { z } from 'zod';
3 |
4 | export const UserLoginDto = z.object({
5 | email: z.string().email(),
6 | password: z.string().min(8, 'Password must be at least 8 characters'),
7 | });
8 | export type UserLoginDtoType = z.infer;
9 |
10 | export const UserSignupDto = z.object({
11 | email: z.string().email(),
12 | password: z.string().min(8, 'Password must be at least 8 characters'),
13 | firstName: z.string(),
14 | lastName: z.string(),
15 | phone: z.string().optional(),
16 | });
17 | export type UserSignupDtoType = z.infer;
18 |
19 | export const UserCreateDto = z.object({
20 | email: z.string().email(),
21 | password: z.string().min(8, 'Password must be at least 8 characters'),
22 | firstName: z.string(),
23 | lastName: z.string(),
24 | roles: z.array(z.nativeEnum(Roles)).optional(),
25 | phone: z.string().optional(),
26 | gender: z.string().optional(),
27 | bio: z.string().optional(),
28 | profilePicUrl: z.string().optional(),
29 | });
30 | export type UserCreateDtoType = z.infer;
31 |
32 | export const UserUpdateDto = z.object({
33 | id: z.number(),
34 | firstName: z.string().min(1, 'First name is required'),
35 | lastName: z.string().min(1, 'Last name is required'),
36 | username: z.string(),
37 | roles: z.array(z.nativeEnum(Roles)).optional(),
38 | email: z.string().email(),
39 | password: z
40 | .string()
41 | // .min(8, 'Password must be at least 8 characters')
42 | .optional(),
43 | phone: z.string().optional(),
44 | gender: z.string().optional(),
45 | bio: z.string().optional(),
46 | profilePicUrl: z.string().optional(),
47 | });
48 | export type UserUpdateDtoType = z.infer;
49 |
50 | export const UserRemoveDto = z.object({
51 | id: z.array(z.number()),
52 | });
53 | export type UserRemoveDtoType = z.infer;
54 |
55 | export const UserFindAllDto = z.object({
56 | page: z.number().default(1),
57 | perPage: z.number().default(10),
58 | search: z.string().optional(),
59 | });
60 | export type UserFindAllDtoType = z.infer;
61 |
62 | export const UserFindByIdDto = z.object({
63 | id: z.number(),
64 | });
65 | export type UserFindByIdDtoType = z.infer;
66 |
67 | export const UserVerifyAccessTokenDto = z.object({
68 | accessToken: z.string(),
69 | });
70 | export type UserVerifyAccessTokenType = z.infer<
71 | typeof UserVerifyAccessTokenDto
72 | >;
73 |
74 | export const UserResetPasswordDto = z.object({
75 | email: z.string().email(),
76 | });
77 | export type UserResetPasswordType = z.infer;
78 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-with-ssh.yml:
--------------------------------------------------------------------------------
1 | name: deploy-with-ssh
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build_and_deploy:
10 | runs-on: ubuntu-latest
11 |
12 | permissions:
13 | contents: read
14 | packages: write
15 |
16 | env:
17 | SSH_HOST: ${{ secrets.SSH_SERVER }}
18 | WORKING_DIR: ${{ secrets.SSH_WORKING_DIR }}
19 | DOCKER_COMPOSE_FILE: docker-compose.prod.yml
20 |
21 | steps:
22 | - name: Checkout code
23 | uses: actions/checkout@v2
24 |
25 | - name: Login to GitHub Container Registry
26 | uses: docker/login-action@v1
27 | with:
28 | registry: ghcr.io
29 | username: ${{ github.actor }}
30 | password: ${{ secrets.GITHUB_TOKEN }}
31 |
32 | - name: Set up Docker Buildx
33 | uses: docker/setup-buildx-action@v1
34 |
35 | - name: Build and push Docker image
36 | uses: docker/build-push-action@v2
37 | with:
38 | context: .
39 | push: true
40 | tags: ghcr.io/${{ github.repository }}:latest
41 |
42 | - name: Set up SSH agent
43 | uses: webfactory/ssh-agent@v0.5.3
44 | with:
45 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
46 |
47 | - name: Copy Docker Compose file
48 | run: |
49 | scp -o StrictHostKeyChecking=no $DOCKER_COMPOSE_FILE $SSH_HOST:$WORKING_DIR/
50 |
51 | - name: Log in to GitHub Container Registry
52 | run: |
53 | ssh -o StrictHostKeyChecking=no $SSH_HOST "echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin"
54 |
55 | - name: Pull Docker images
56 | run: |
57 | ssh -o StrictHostKeyChecking=no $SSH_HOST "docker pull ghcr.io/${{ github.repository }}:latest"
58 |
59 | - name: Check and setup nginx-proxy
60 | run: |
61 | ssh -o StrictHostKeyChecking=no $SSH_HOST "
62 | if ! docker network inspect proxy-network >/dev/null 2>&1; then
63 | echo 'Creating proxy-network...'
64 | docker network create proxy-network
65 | fi
66 | if ! docker ps | grep -q nginx-proxy; then
67 | echo 'nginx-proxy not detected. Setting it up...'
68 | docker run -d -p 80:80 \
69 | --name nginx-proxy \
70 | --network proxy-network \
71 | --restart always \
72 | -v /var/run/docker.sock:/tmp/docker.sock:ro \
73 | jwilder/nginx-proxy
74 | else
75 | echo 'nginx-proxy already running. Skipping setup.'
76 | fi
77 | "
78 | - name: Run Docker Compose
79 | run: |
80 | ssh -o StrictHostKeyChecking=no $SSH_HOST "cd $WORKING_DIR && docker-compose -f $DOCKER_COMPOSE_FILE up -d"
81 |
--------------------------------------------------------------------------------
/apps/server/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 | import * as Joi from 'joi';
4 | import { AppController } from './app.controller';
5 | import { AppService } from './app.service';
6 | import { AuthModule } from './auth/auth.module';
7 | import { OpenaiModule } from './openai/openai.module';
8 | import { PrismaService } from './prisma/prisma.service';
9 | import { TrpcModule } from './trpc/trpc.module';
10 | import { UploadModule } from './upload/upload.module';
11 | import { UserModule } from './user/user.module';
12 |
13 | @Module({
14 | imports: [
15 | OpenaiModule,
16 | ConfigModule.forRoot({
17 | isGlobal: true,
18 | expandVariables: true,
19 | envFilePath: `${process.cwd()}/.env.${process.env.NODE_ENV ?? 'development'}`,
20 | validationSchema: Joi.object({
21 | // DO NOT update these directly, update them from the .env.x files,
22 | // server config
23 | NODE_ENV: Joi.string()
24 | .valid('development', 'production', 'test', 'provision')
25 | .default('development'),
26 | API_HOST: Joi.string(),
27 | APP_HOST: Joi.string(),
28 |
29 | // auth config
30 | JWT_SECRET: Joi.string(),
31 | JWT_EXPIRES_IN: Joi.string().default('365d'),
32 |
33 | // database config
34 | DB_URL: Joi.string().default(
35 | 'postgresql://postgres:password@localhost:5432/postgres?schema=public',
36 | ),
37 |
38 | // mailer config
39 | MAIL_SMTP_HOST: Joi.string().default('smtp-relay.brevo.com'),
40 | MAIL_SMTP_PORT: Joi.number().default(587),
41 | MAIL_SMTP_SECURE: Joi.boolean().default(true),
42 | MAIL_SMTP_USERNAME: Joi.string().default('user@gmail.com'),
43 | MAIL_SMTP_PASSWORD: Joi.string().default('password'),
44 | MAIL_DEFAULT_FROM: Joi.string().default('user@gmail.com'),
45 |
46 | // Google SSO config
47 | GOOGLE_CLIENT_ID: Joi.string(),
48 | GOOGLE_CLIENT_SECRET: Joi.string(),
49 | GOOGLE_CALLBACK_URL: Joi.string(),
50 |
51 | // OpenAI config
52 | OPENAI_API_KEY: Joi.string(),
53 |
54 | // Pinecone config
55 | PINECONE_API_KEY: Joi.string(),
56 |
57 | // AWS S3 config
58 | AWS_REGION: Joi.string().default('us-east-1'),
59 | AWS_S3_BUCKET: Joi.string().default('ult-uploads'),
60 | AWS_ACCESS_KEY_ID: Joi.string().optional(),
61 | AWS_SECRET_ACCESS_KEY: Joi.string().optional(),
62 | }),
63 | }),
64 | TrpcModule,
65 | UserModule,
66 | AuthModule,
67 | OpenaiModule,
68 | UploadModule,
69 | ],
70 | controllers: [AppController],
71 | providers: [AppService, PrismaService],
72 | })
73 | export class AppModule {}
74 |
--------------------------------------------------------------------------------
/apps/web/components/home/WeatherWidget.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 |
3 | interface WeatherData {
4 | name: string;
5 | main: {
6 | temp: number;
7 | temp_min: number;
8 | temp_max: number;
9 | humidity: number;
10 | };
11 | weather: {
12 | description: string;
13 | icon: string;
14 | }[];
15 | }
16 |
17 | const WeatherWidget: React.FC = () => {
18 | const [weatherData, setWeatherData] = useState(null);
19 | const [loading, setLoading] = useState(true);
20 | const [zipcode, setZipcode] = useState("90210");
21 | const [city, setCity] = useState("Los Angeles");
22 | const [error, setError] = useState(null);
23 |
24 | const fetchWeather = async () => {
25 | try {
26 | const response = await fetch(
27 | `https://api.openweathermap.org/data/2.5/weather?q=${city}&units=imperial&appid=${process.env.NEXT_PUBLIC_OPENWEATHERMAP_API_KEY}`
28 | );
29 | if (!response.ok) {
30 | throw new Error("Failed to fetch weather data");
31 | }
32 | const data: WeatherData = await response.json();
33 | setWeatherData(data);
34 | } catch (err) {
35 | setError((err as Error).message);
36 | } finally {
37 | setLoading(false);
38 | }
39 | };
40 |
41 | useEffect(() => {
42 | fetchWeather();
43 | }, [city]);
44 |
45 | if (loading) {
46 | return (
47 |
50 | );
51 | }
52 | if (error) return Error: {error}
;
53 |
54 | return (
55 |
56 |
Today's Weather
57 |
58 |
62 |
63 |
64 |
Current
65 |
{Math.round(weatherData?.main.temp || 0)}°F
66 |
67 |
68 |
69 | {Math.round(weatherData?.main.temp_min || 0)}°F /{" "}
70 | {Math.round(weatherData?.main.temp_max || 0)}°F
71 |
72 |
73 |
74 |
75 | setCity(e.target.value)}
78 | >
79 | Los Angeles
80 | San Francisco
81 | San Diego
82 | San Jose
83 | San Francisco
84 |
85 |
86 |
87 | );
88 | };
89 |
90 | export default WeatherWidget;
91 |
--------------------------------------------------------------------------------
/apps/web/components/common/alerts/Toast.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, useEffect, useState } from "react";
2 | import { Transition } from "@headlessui/react";
3 | import { CheckCircleIcon } from "@heroicons/react/24/outline";
4 | import { XMarkIcon } from "@heroicons/react/20/solid";
5 |
6 | export default function Toast(props: {
7 | color: "green" | "red" | "yellow" | "blue" | "purple" | "pink" | "gray";
8 | title: string;
9 | message: string;
10 | }) {
11 | const [showToast, setShowToast] = useState(true);
12 | useEffect(() => {
13 | setTimeout(() => {
14 | setShowToast(false);
15 | }, 3000);
16 | }, []);
17 | if (!showToast) {
18 | return <>>;
19 | }
20 | return (
21 | <>
22 | {/* Global notification live region, render this permanently at the end of the document */}
23 |
27 |
28 | {/* Notification panel, dynamically insert this into the live region when it needs to be displayed */}
29 |
39 |
40 |
41 |
42 |
43 |
47 |
48 |
49 |
50 | {props.title}
51 |
52 |
53 | {props.message}
54 |
55 |
56 |
57 | {
61 | setShowToast(false);
62 | }}
63 | >
64 | Close
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | >
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/apps/web/components/home/menus/MainMenu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Category } from "@prisma/client";
4 | import { isNew } from "@web/app/posts/PostList";
5 | import Link from "next/link";
6 | import { useEffect, useRef } from "react";
7 |
8 | export default function MainMenu({ categories }: { categories: Category[] }) {
9 | const scrollContainerRef = useRef(null);
10 |
11 | useEffect(() => {
12 | const scrollContainer = scrollContainerRef.current;
13 | if (scrollContainer) {
14 | let isDown = false;
15 | let startX: number;
16 | let scrollLeft: number;
17 |
18 | const handleMouseDown = (e: MouseEvent) => {
19 | isDown = true;
20 | startX = e.pageX - scrollContainer.offsetLeft;
21 | scrollLeft = scrollContainer.scrollLeft;
22 | };
23 |
24 | const handleMouseLeave = () => {
25 | isDown = false;
26 | };
27 |
28 | const handleMouseUp = () => {
29 | isDown = false;
30 | };
31 |
32 | const handleMouseMove = (e: MouseEvent) => {
33 | if (!isDown) return;
34 | e.preventDefault();
35 | const x = e.pageX - scrollContainer.offsetLeft;
36 | const walk = (x - startX) * 2;
37 | scrollContainer.scrollLeft = scrollLeft - walk;
38 | };
39 |
40 | scrollContainer.addEventListener("mousedown", handleMouseDown);
41 | scrollContainer.addEventListener("mouseleave", handleMouseLeave);
42 | scrollContainer.addEventListener("mouseup", handleMouseUp);
43 | scrollContainer.addEventListener("mousemove", handleMouseMove);
44 |
45 | return () => {
46 | scrollContainer.removeEventListener("mousedown", handleMouseDown);
47 | scrollContainer.removeEventListener("mouseleave", handleMouseLeave);
48 | scrollContainer.removeEventListener("mouseup", handleMouseUp);
49 | scrollContainer.removeEventListener("mousemove", handleMouseMove);
50 | };
51 | }
52 | }, []);
53 |
54 | return (
55 |
56 |
60 | {categories?.map((category) => (
61 |
62 |
70 | {category.name}
71 | {isNew(category.lastPostedAt) && (
72 |
73 |
78 |
79 | )}
80 |
81 |
82 | ))}
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/apps/server/src/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { JwtService } from '@nestjs/jwt';
4 | import { User } from '@prisma/client';
5 | import { AuthJwt } from '@server/auth/auth.types';
6 | import { PrismaService } from '@server/prisma/prisma.service';
7 | import bcrypt from 'bcryptjs';
8 |
9 | @Injectable()
10 | export class AuthService {
11 | constructor(
12 | private readonly configService: ConfigService,
13 | private readonly jwtService: JwtService,
14 | private readonly prismaService: PrismaService,
15 | ) {}
16 |
17 | async verifyPassword(password: string, encryptedPassword: string) {
18 | return bcrypt.compare(password, encryptedPassword);
19 | }
20 |
21 | async encryptPassword(password: string) {
22 | const rounds = 10;
23 | let encryptedPassword;
24 | // do not hash again if already hashed
25 | if (password.indexOf('$2a$') === 0 && password.length === 60) {
26 | encryptedPassword = password;
27 | } else {
28 | encryptedPassword = await bcrypt.hash(password, rounds);
29 | }
30 | return encryptedPassword;
31 | }
32 |
33 | getJwt(user: User): AuthJwt {
34 | const expiresIn =
35 | this.configService.get('JWT_EXPIRES_IN') || '365d';
36 | const payload = {
37 | username: user.email,
38 | sub: user.id,
39 | expiresIn,
40 | };
41 | const accessToken = this.jwtService.sign(payload);
42 | return { accessToken, expiresIn };
43 | }
44 |
45 | async decodeJwtToken(accessToken: string) {
46 | return this.jwtService.verifyAsync(accessToken, {
47 | secret: this.configService.get('JWT_SECRET'),
48 | });
49 | }
50 |
51 | async loginFromGoogle(req: any) {
52 | let user: User | null;
53 | if (req.user.email) {
54 | user = await this.prismaService.user.findFirst({
55 | where: {
56 | email: req.user.email,
57 | },
58 | });
59 | if (!user) {
60 | // assign a default User role
61 | let roleConnections: { id: number }[] = [];
62 | const role = await this.prismaService.role.findFirst({
63 | where: {
64 | name: 'User',
65 | },
66 | });
67 | if (role) {
68 | roleConnections = [{ id: role.id }];
69 | }
70 |
71 | user = await this.prismaService.user.create({
72 | data: {
73 | email: req.user.email,
74 | password: '',
75 | verifiedAt: new Date(),
76 | lastLoggedInAt: new Date(),
77 | firstName: req.user.firstName,
78 | lastName: req.user.lastName,
79 | profilePicUrl: req.user.picture,
80 | authType: 'google',
81 | roles: {
82 | connect: roleConnections,
83 | },
84 | },
85 | });
86 | } else {
87 | await this.prismaService.user.update({
88 | where: {
89 | id: user.id,
90 | },
91 | data: {
92 | lastLoggedInAt: new Date(),
93 | profilePicUrl: req.user.picture,
94 | },
95 | });
96 | }
97 | const jwt = this.getJwt(user);
98 | return { user, jwt };
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/apps/server/src/user/user.router.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, UseFilters } from '@nestjs/common';
2 | import { TrpcExceptionFilter } from '@server/trpc/trpc.exception-handler';
3 | import { TrpcService } from '@server/trpc/trpc.service';
4 | import { UserService } from '@server/user/user.service';
5 | import { Roles } from '@shared/interfaces';
6 | import {
7 | UserCreateDto,
8 | UserFindAllDto,
9 | UserFindByIdDto,
10 | UserLoginDto,
11 | UserRemoveDto,
12 | UserResetPasswordDto,
13 | UserSignupDto,
14 | UserUpdateDto,
15 | UserVerifyAccessTokenDto,
16 | } from './user.dto';
17 |
18 | @Injectable()
19 | @UseFilters(new TrpcExceptionFilter())
20 | export class UserRouter {
21 | constructor(
22 | private readonly trpcService: TrpcService,
23 | private readonly userService: UserService,
24 | ) {}
25 | apply() {
26 | const userRouter = this.trpcService.trpc.router({
27 | // login user
28 | login: this.trpcService
29 | .publicProcedure()
30 | .input(UserLoginDto)
31 | .mutation(async ({ input }) => {
32 | return this.userService.login(input);
33 | }),
34 |
35 | // signs up a user
36 | signup: this.trpcService
37 | .publicProcedure()
38 | .input(UserSignupDto)
39 | .mutation(async ({ input }) => {
40 | return this.userService.signup(input);
41 | }),
42 |
43 | // creates a user from dashboard
44 | create: this.trpcService
45 | .protectedProcedure([Roles.Admin])
46 | .input(UserCreateDto)
47 | .mutation(async ({ input }) => {
48 | return this.userService.create(input);
49 | }),
50 |
51 | // update user
52 | update: this.trpcService
53 | .protectedProcedure()
54 | .input(UserUpdateDto)
55 | .mutation(async ({ input, ctx }) => {
56 | return this.userService.update(input, ctx.user);
57 | }),
58 |
59 | // remove user
60 | remove: this.trpcService
61 | .protectedProcedure([Roles.Admin, Roles.User])
62 | .input(UserRemoveDto)
63 | .mutation(async ({ input, ctx }) => {
64 | return this.userService.remove(input.id, ctx.user);
65 | }),
66 |
67 | // get user by id
68 | findById: this.trpcService
69 | .protectedProcedure()
70 | .input(UserFindByIdDto)
71 | .query(async ({ input }) => {
72 | return this.userService.findById(input.id);
73 | }),
74 |
75 | // get all users
76 | findAll: this.trpcService
77 | .protectedProcedure([Roles.Admin])
78 | .input(UserFindAllDto)
79 | .query(async ({ input }) => {
80 | return this.userService.findAll(input);
81 | }),
82 |
83 | // get user by id
84 | verifyAccessToken: this.trpcService
85 | .publicProcedure()
86 | .input(UserVerifyAccessTokenDto)
87 | .query(async ({ input }) => {
88 | return this.userService.verifyAccessToken(input.accessToken);
89 | }),
90 |
91 | // reset user password
92 | resetPassword: this.trpcService
93 | .publicProcedure()
94 | .input(UserResetPasswordDto)
95 | .mutation(async ({ input }) => {
96 | return this.userService.resetPassword({ email: input.email });
97 | }),
98 | });
99 |
100 | return {
101 | userRouter,
102 | } as const;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/apps/web/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@web/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 | [role=checkbox]]:translate-y-[2px]",
77 | className
78 | )}
79 | {...props}
80 | />
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
--------------------------------------------------------------------------------
/apps/server/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | datasource db {
12 | provider = "postgresql"
13 | url = env("DATABASE_URL")
14 | }
15 |
16 | enum AuthType {
17 | email
18 | google
19 | }
20 |
21 | model User {
22 | id Int @id @default(autoincrement())
23 | firstName String?
24 | lastName String?
25 | username String?
26 | email String @unique
27 | password String
28 | status String @default("active")
29 | phone String?
30 | gender String?
31 | bio String?
32 | profilePicUrl String?
33 | createdAt DateTime @default(now())
34 | verifiedAt DateTime?
35 | updatedAt DateTime @updatedAt
36 | deletedAt DateTime?
37 | lastLoggedInAt DateTime?
38 | authType AuthType @default(email)
39 | roles Role[] @relation("UserRoles")
40 | posts Post[] @relation("UserPosts")
41 | postComments PostComment[]
42 | postReactions PostReaction[]
43 | }
44 |
45 | model Post {
46 | id Int @id @default(autoincrement())
47 | userId Int
48 | title String
49 | teaser String?
50 | description String?
51 | imageUrl String?
52 | createdAt DateTime @default(now())
53 | updatedAt DateTime @updatedAt
54 | deletedAt DateTime?
55 | status String? @default("published")
56 | user User @relation("UserPosts", fields: [userId], references: [id])
57 | postComments PostComment[]
58 | postReactions PostReaction[]
59 | category Category? @relation(fields: [categoryId], references: [id])
60 | categoryId Int?
61 | viewCount Int? @default(0)
62 | }
63 |
64 | model PostComment {
65 | id Int @id @default(autoincrement())
66 | postId Int
67 | userId Int
68 | message String
69 | createdAt DateTime @default(now())
70 | updatedAt DateTime @updatedAt
71 | deletedAt DateTime?
72 | user User @relation(fields: [userId], references: [id])
73 | post Post @relation(fields: [postId], references: [id])
74 | }
75 |
76 | enum PostReactionType {
77 | like
78 | dislike
79 | }
80 |
81 | model PostReaction {
82 | id Int @id @default(autoincrement())
83 | postId Int
84 | userId Int
85 | type PostReactionType @default(like)
86 | createdAt DateTime @default(now())
87 | user User @relation(fields: [userId], references: [id])
88 | post Post @relation(fields: [postId], references: [id])
89 | }
90 |
91 | model Role {
92 | id Int @id @default(autoincrement())
93 | name String @unique
94 | users User[] @relation("UserRoles")
95 | }
96 |
97 | model Category {
98 | id Int @id @default(autoincrement())
99 | parentId Int @default(0)
100 | name String @unique
101 | sortOrder Int @default(0)
102 | createdAt DateTime @default(now())
103 | posts Post[]
104 | template String?
105 | adminWriteOnly Boolean @default(true)
106 | singlePostOnly Boolean @default(false)
107 | lastPostedAt DateTime?
108 | }
109 |
--------------------------------------------------------------------------------
/apps/server/src/post/post.router.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, UseFilters } from '@nestjs/common';
2 | import { PostService } from '@server/post/post.service';
3 | import { TrpcExceptionFilter } from '@server/trpc/trpc.exception-handler';
4 | import { TrpcService } from '@server/trpc/trpc.service';
5 | import { Roles } from '@shared/interfaces';
6 | import {
7 | PostCommentCreateDto,
8 | PostCommentRemoveDto as PostCommentDeleteDto,
9 | PostCreateDto,
10 | PostFindAllDto,
11 | PostFindByIdDto,
12 | PostReactionCreateDto,
13 | PostRemoveDto as PostDeleteDto,
14 | PostUpdateDto,
15 | } from './post.dto';
16 |
17 | @Injectable()
18 | @UseFilters(new TrpcExceptionFilter())
19 | export class PostRouter {
20 | constructor(
21 | private readonly trpcService: TrpcService,
22 | private readonly postService: PostService,
23 | ) {}
24 | apply() {
25 | const postRouter = this.trpcService.trpc.router({
26 | // creates a post from dashboard
27 | create: this.trpcService
28 | .protectedProcedure()
29 | .input(PostCreateDto)
30 | .mutation(async ({ input, ctx }) => {
31 | if (ctx.user) {
32 | return this.postService.create(input, ctx.user);
33 | }
34 | }),
35 |
36 | // update post
37 | update: this.trpcService
38 | .protectedProcedure()
39 | .input(PostUpdateDto)
40 | .mutation(async ({ input, ctx }) => {
41 | if (ctx.user) {
42 | return this.postService.update(input, ctx.user);
43 | }
44 | }),
45 |
46 | // delete post
47 | delete: this.trpcService
48 | .protectedProcedure([Roles.Admin])
49 | .input(PostDeleteDto)
50 | .mutation(async ({ input, ctx }) => {
51 | if (ctx.user) {
52 | return this.postService.delete(input.id, ctx.user);
53 | }
54 | }),
55 |
56 | // get post by id
57 | findById: this.trpcService
58 | .publicProcedure()
59 | .input(PostFindByIdDto)
60 | .query(async ({ input }) => {
61 | return this.postService.findById(input.id);
62 | }),
63 |
64 | // get all posts
65 | findAll: this.trpcService
66 | .publicProcedure()
67 | .input(PostFindAllDto)
68 | .query(async ({ input }) => {
69 | return this.postService.findAll(input);
70 | }),
71 |
72 | // add post reaction
73 | createReaction: this.trpcService
74 | .protectedProcedure()
75 | .input(PostReactionCreateDto)
76 | .mutation(async ({ input, ctx }) => {
77 | if (ctx.user) {
78 | return this.postService.createReaction(input, ctx.user);
79 | }
80 | }),
81 |
82 | // add post comment
83 | createComment: this.trpcService
84 | .protectedProcedure()
85 | .input(PostCommentCreateDto)
86 | .mutation(async ({ input, ctx }) => {
87 | if (ctx.user) {
88 | return this.postService.createComment(input, ctx.user);
89 | }
90 | }),
91 |
92 | // delete post comment
93 | deleteComment: this.trpcService
94 | .protectedProcedure()
95 | .input(PostCommentDeleteDto)
96 | .mutation(async ({ input, ctx }) => {
97 | if (ctx.user) {
98 | return this.postService.deleteComment(input, ctx.user);
99 | }
100 | }),
101 | });
102 |
103 | return {
104 | postRouter,
105 | } as const;
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Tech Stack
6 |
7 | **Ult** is a full-stack TypeScript application using:
8 | - **Backend**: NestJS with Prisma ORM, tRPC for type-safe APIs
9 | - **Frontend**: Next.js 14 (App Router) with React 18, Tailwind CSS, Preline UI
10 | - **Database**: PostgreSQL
11 | - **Authentication**: JWT-based with Google OAuth support
12 | - **Monorepo**: pnpm workspaces with three packages (server, web, shared)
13 |
14 | ## Essential Commands
15 |
16 | ### Development
17 | ```bash
18 | # Install dependencies (uses pnpm)
19 | pnpm install
20 |
21 | # Start both servers (Next.js on :3000, NestJS on :3001)
22 | pnpm dev
23 |
24 | # Run specific app
25 | pnpm --filter=server dev
26 | pnpm --filter=web dev
27 | ```
28 |
29 | ### Database Operations
30 | ```bash
31 | # Run migrations (development)
32 | pnpm db:migrate
33 |
34 | # Create new migration
35 | pnpm db:migrate:new
36 |
37 | # Seed database
38 | pnpm db:seed
39 |
40 | # Generate Prisma client
41 | pnpm db:prisma:generate
42 | ```
43 |
44 | ### Testing
45 | ```bash
46 | # Run all tests
47 | pnpm test
48 |
49 | # Server tests
50 | pnpm --filter=server test
51 | pnpm --filter=server test:watch
52 | pnpm --filter=server test:cov
53 | ```
54 |
55 | ### Build & Production
56 | ```bash
57 | # Build all apps
58 | pnpm build
59 |
60 | # Production commands
61 | pnpm prod:server
62 | pnpm prod:web
63 | pnpm prod:db:migrate
64 | pnpm prod:db:seed
65 | ```
66 |
67 | ### Linting
68 | ```bash
69 | # Server linting
70 | pnpm --filter=server lint
71 |
72 | # Web linting
73 | pnpm --filter=web lint
74 | ```
75 |
76 | ## Architecture Overview
77 |
78 | ### tRPC Integration Pattern
79 | The application uses tRPC for end-to-end type safety between NestJS and Next.js:
80 |
81 | 1. **Server-side routers** (`apps/server/src/*/**.router.ts`) define tRPC procedures
82 | 2. **AppRouter type** exported from `apps/server/src/trpc/trpc.router.ts:37`
83 | 3. **Client consumes** via `apps/web/contexts/TrpcContext.tsx` using `@trpc/react-query`
84 | 4. **Shared types** in `apps/shared/interfaces.ts` ensure consistency
85 |
86 | ### Authentication Flow
87 | - JWT tokens stored in cookies (`jwtAccessToken`)
88 | - Auth service at `apps/server/src/auth/auth.service.ts`
89 | - Protected routes use `WithAuth` HOC (`apps/web/components/common/auth/WithAuth.tsx`)
90 | - Google OAuth integration via Passport strategies
91 |
92 | ### Database Schema
93 | - Prisma schema at `apps/server/prisma/schema.prisma`
94 | - Core models: User, Post, PostComment, PostReaction, Role, Category
95 | - User authentication supports email and Google OAuth
96 | - Role-based access control through User-Role many-to-many
97 |
98 | ### Module Structure
99 | Each feature module in the server follows this pattern:
100 | - `*.module.ts` - NestJS module definition
101 | - `*.service.ts` - Business logic
102 | - `*.router.ts` - tRPC router with procedures
103 | - `*.dto.ts` - Data transfer objects with Zod validation
104 |
105 | ### Frontend Organization
106 | - **App Router**: Pages in `apps/web/app/`
107 | - **Components**: Reusable UI in `apps/web/components/`
108 | - **Contexts**: Global state providers in `apps/web/contexts/`
109 | - **Hooks**: Custom React hooks in `apps/web/hooks/`
110 |
111 | ### Environment Configuration
112 | - Development: `apps/server/.env.development`
113 | - Production: `apps/server/.env.production`
114 | - Required: DATABASE_URL, JWT secrets, SMTP credentials for email
115 | - Optional: Google OAuth credentials, OpenAI API key
116 |
117 | ### Key Dependencies
118 | - **Validation**: Zod for runtime type checking
119 | - **Email**: @nestjs-modules/mailer with EJS templates
120 | - **File uploads**: Uppy with various adapters
121 | - **UI Components**: Preline, Headless UI, Hero Icons
122 | - **Forms**: react-hook-form with Zod resolver
--------------------------------------------------------------------------------
/apps/server/src/category/category.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { PrismaService } from '@server/prisma/prisma.service';
3 | import {
4 | CategoryCreateDtoType,
5 | CategoryFindAllDtoType,
6 | CategoryUpdateDtoType,
7 | } from './category.dto';
8 |
9 | @Injectable()
10 | /**
11 | * CategoryService handles CRUD operations for categories.
12 | *
13 | * Provides methods for creating, updating, finding, deleting categories.
14 | * Also includes methods for category reactions, comments, and categories.
15 | */
16 | export class CategoryService {
17 | constructor(private readonly prismaService: PrismaService) {}
18 |
19 | /**
20 | * Creates a new category.
21 | *
22 | * @param categoryCreateDto - The data for the new category.
23 | * @param requestUser - The user creating the category.
24 | * @returns The created category.
25 | */
26 | async create(categoryCreateDto: CategoryCreateDtoType) {
27 | const category = await this.prismaService.category.create({
28 | data: categoryCreateDto,
29 | });
30 | return category;
31 | }
32 |
33 | /**
34 | * Updates an existing category.
35 | *
36 | * @param categoryUpdateDto - The data to update the category with.
37 | * @returns The updated category.
38 | * @throws UnauthorizedException if the user is not allowed to update the category.
39 | * @throws InternalServerErrorException on unknown errors.
40 | */
41 | async update(categoryUpdateDto: CategoryUpdateDtoType) {
42 | const category = await this.findById(categoryUpdateDto.id);
43 | const updatedCategory = await this.prismaService.category.update({
44 | where: {
45 | id: category.id,
46 | },
47 | data: { ...categoryUpdateDto },
48 | });
49 | return updatedCategory;
50 | }
51 |
52 | /**
53 | * Finds all categories.
54 | *
55 | * @param opts - Options for pagination, filtering, etc.
56 | * @returns A paginated list of categories.
57 | */
58 | async findAll(opts: CategoryFindAllDtoType) {
59 | const name = opts.name;
60 |
61 | const records = await this.prismaService.category.findMany({
62 | skip: (opts.page - 1) * opts.perPage,
63 | take: opts.perPage,
64 | where: {
65 | name,
66 | parentId: opts.parentId ?? undefined,
67 | },
68 | orderBy: {
69 | sortOrder: 'asc',
70 | },
71 | });
72 | const total = await this.prismaService.category.count();
73 | const lastPage = Math.ceil(total / opts.perPage);
74 | return {
75 | records,
76 | total,
77 | currentPage: opts.page,
78 | lastPage,
79 | perPage: opts.perPage,
80 | };
81 | }
82 |
83 | /**
84 | * Finds a category by ID.
85 | *
86 | * @param id - The ID of the category to find.
87 | * @returns The category with the given ID.
88 | */
89 | async findById(id: number) {
90 | return this.prismaService.category.findFirstOrThrow({
91 | where: { id },
92 | });
93 | }
94 |
95 | /**
96 | * Removes one or more categories by ID.
97 | *
98 | * Accepts a single category ID or an array of category IDs.
99 | * Checks if the requesting user is allowed to delete the category.
100 | * Marks the categories as deleted by setting deletedAt.
101 | *
102 | * @param id - The ID or IDs of the categories to delete.
103 | * @returns The updated categories.
104 | */
105 | async remove(id: number | number[]) {
106 | let ids: number[] = [];
107 | if (id instanceof Array) {
108 | ids = id;
109 | } else if (typeof id === 'number') {
110 | ids = [id];
111 | }
112 | const output = [];
113 | for (const id of ids) {
114 | output.push(
115 | await this.prismaService.category.delete({
116 | where: {
117 | id,
118 | },
119 | }),
120 | );
121 | }
122 | return output;
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/apps/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "0.2.0",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "dev": "pnpm start:dev",
10 | "build": "nest build",
11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12 | "start": "nest start",
13 | "start:dev": "cp .env.development .env && nest start --watch",
14 | "start:debug": "nest start --debug --watch",
15 | "start:staging": "cp .env.staging .env && nest start --watch",
16 | "start:prod": "node dist/main",
17 | "prod": "pnpm start:prod",
18 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
19 | "test": "jest",
20 | "test:watch": "jest --watch",
21 | "test:cov": "jest --coverage",
22 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
23 | "test:e2e": "jest --config ./test/jest-e2e.json",
24 | "prestart": "rimraf dist && yarn run build",
25 | "prisma:generate": "npx prisma generate",
26 | "db:migrate:new": "npx prisma migrate dev --name",
27 | "db:migrate": "npx prisma migrate dev && npx prisma generate",
28 | "db:seed": "npx ts-node -r tsconfig-paths/register prisma/seed.ts",
29 | "db:reset": "npx prisma migrate reset; pnpm db:seed",
30 | "prepare": "ts-patch install -s"
31 | },
32 | "dependencies": {
33 | "@aws-sdk/client-s3": "^3.879.0",
34 | "@aws-sdk/s3-request-presigner": "^3.879.0",
35 | "@nestjs-modules/mailer": "2.0.2",
36 | "@nestjs/common": "^11.1.6",
37 | "@nestjs/config": "^4.0.2",
38 | "@nestjs/core": "^11.1.6",
39 | "@nestjs/jwt": "^11.0.0",
40 | "@nestjs/mapped-types": "^2.1.0",
41 | "@nestjs/passport": "^11.0.5",
42 | "@nestjs/platform-express": "^11.1.6",
43 | "@pinecone-database/pinecone": "^3.0.3",
44 | "@prisma/client": "5.20.0",
45 | "@trpc/server": "^10.45.1",
46 | "bcrypt": "^5.1.1",
47 | "bcryptjs": "^3.0.2",
48 | "class-validator": "^0.14.1",
49 | "cors": "^2.8.5",
50 | "date-fns": "^3.3.1",
51 | "ejs": "^3.1.9",
52 | "generate-password-ts": "^1.6.5",
53 | "glob": "^10.3.10",
54 | "joi": "^17.12.2",
55 | "lodash": "^4.17.21",
56 | "openai": "^4.67.3",
57 | "passport": "^0.7.0",
58 | "passport-google-oauth20": "^2.0.0",
59 | "passport-jwt": "^4.0.1",
60 | "pdf-parse": "^1.1.1",
61 | "pg": "^8.11.3",
62 | "random-avatar-generator": "^2.0.0",
63 | "reflect-metadata": "^0.2.1",
64 | "rxjs": "^7.8.1",
65 | "typeorm-naming-strategies": "^4.1.0",
66 | "zod": "^3.22.4"
67 | },
68 | "devDependencies": {
69 | "@faker-js/faker": "^8.4.1",
70 | "@nestjs/cli": "^11.0.10",
71 | "@nestjs/schematics": "^11.0.7",
72 | "@nestjs/swagger": "^11.2.0",
73 | "@nestjs/testing": "^11.1.6",
74 | "@swc/cli": "^0.3.14",
75 | "@swc/core": "^1.7.23",
76 | "@types/bcrypt": "^5.0.2",
77 | "@types/express": "^4.17.21",
78 | "@types/glob": "^8.1.0",
79 | "@types/jest": "^29.5.12",
80 | "@types/lodash": "^4.14.202",
81 | "@types/node": "^20.11.20",
82 | "@types/nodemailer": "^6.4.14",
83 | "@types/passport-google-oauth20": "^2.0.16",
84 | "@types/passport-jwt": "^4.0.1",
85 | "@types/pdf-parse": "^1.1.4",
86 | "@types/supertest": "^6.0.2",
87 | "@typescript-eslint/eslint-plugin": "^6.21.0",
88 | "@typescript-eslint/parser": "^6.21.0",
89 | "dotenv": "^16.4.5",
90 | "esbuild": "^0.25.0",
91 | "eslint": "^8.57.0",
92 | "eslint-config-prettier": "^9.1.0",
93 | "eslint-plugin-prettier": "^5.1.3",
94 | "jest": "^29.7.0",
95 | "prettier": "^3.2.5",
96 | "prisma": "^5.20.0",
97 | "source-map-support": "^0.5.21",
98 | "supertest": "^6.3.4",
99 | "ts-jest": "^29.1.2",
100 | "ts-loader": "^9.5.1",
101 | "ts-node": "^10.9.2",
102 | "tsconfig-paths": "^4.2.0",
103 | "typescript": "^5.3.3",
104 | "webpack": "^5.90.3"
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/apps/web/app/dashboard/roles/[id]/DashboardRoleView.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { RoleUpdateDto, RoleUpdateDtoType } from "@server/role/role.dto";
5 | import { useTrpc } from "@web/contexts/TrpcContext";
6 | import { useParams } from "next/navigation";
7 | import { useEffect, useState } from "react";
8 | import { useForm } from "react-hook-form";
9 | import "react-quill/dist/quill.snow.css";
10 | import { toast } from "react-toastify";
11 |
12 | export default function DashboardRoleView() {
13 | const { trpc } = useTrpc();
14 | const params = useParams();
15 | const role = trpc.roleRouter.findById.useQuery({ id: Number(params.id) });
16 | const updateRole = trpc.roleRouter.update.useMutation();
17 | const {
18 | register,
19 | formState: { errors },
20 | handleSubmit,
21 | reset,
22 | getValues,
23 | } = useForm({
24 | resolver: zodResolver(RoleUpdateDto),
25 | });
26 |
27 | const data = getValues(); // Gets all current form values
28 |
29 | // set default form values
30 | useEffect(() => {
31 | if (role.data) {
32 | const formData = {
33 | id: role.data.id,
34 | name: role.data.name,
35 | };
36 | reset(formData);
37 | }
38 | }, [role.data, reset]);
39 |
40 | return (
41 |
42 | {/* Card */}
43 |
44 |
45 |
46 | {data?.name}
47 |
48 |
49 | * means required
50 |
51 |
52 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/apps/web/app/forgot-password/ForgotPassword.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { UserLoginDtoType, UserResetPasswordDto } from "@server/user/user.dto";
5 | import { CircularProgress } from "@web/components/common/preloaders/CircularProgress";
6 | import { useTrpc } from "@web/contexts/TrpcContext";
7 | import { useRouter } from "next/navigation";
8 | import { useForm } from "react-hook-form";
9 |
10 | export default function ForgotPassword() {
11 | const router = useRouter();
12 | const { trpc } = useTrpc();
13 | const {
14 | register,
15 | formState: { errors },
16 | handleSubmit,
17 | } = useForm({
18 | resolver: zodResolver(UserResetPasswordDto),
19 | });
20 | const resetPassword = trpc.userRouter.resetPassword.useMutation();
21 |
22 | return (
23 | <>
24 |
25 |
26 |
27 | Forgot Password
28 |
29 |
30 | Find your password by email.
31 |
32 |
33 | {resetPassword.data && (
34 |
35 | Find your password by email.
36 |
37 | )}
38 |
39 | {!resetPassword.data && (
40 |
41 |
89 | {resetPassword.error && (
90 |
91 | {resetPassword.error.message}
92 |
93 | )}
94 |
95 | )}
96 |
97 |
98 | >
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/apps/web/app/user/UserContext.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { UserById } from "@shared/interfaces";
4 | import { useTrpc } from "@web/contexts/TrpcContext";
5 | import Cookies from "js-cookie";
6 | import React, {
7 | Dispatch,
8 | ReactNode,
9 | SetStateAction,
10 | createContext,
11 | useContext,
12 | useEffect,
13 | useRef,
14 | useState,
15 | } from "react";
16 |
17 | interface UserProviderProps {
18 | children: ReactNode; // More generic type to allow any valid React child/children
19 | }
20 |
21 | export interface UserContextState {
22 | currentUser: UserById | null; // Explicitly allow for user to be undefined or null
23 | setCurrentUser: Dispatch> | null; // Allow null here
24 | accessToken: string | null;
25 | setAccessToken: (accessToken: string, expiresIn: string) => void;
26 | logout: () => void;
27 | isAuthenticating: boolean;
28 | }
29 |
30 | // Define a default context value that matches the shape of UserContextState
31 | const defaultContextValue: UserContextState = {
32 | currentUser: null, // Default to null, but now it's explicitly part of the context type
33 | setCurrentUser: null,
34 | accessToken: "af",
35 | setAccessToken: (accessToken: string, expiresIn: string) => {},
36 | logout: () => {},
37 | isAuthenticating: true,
38 | };
39 |
40 | // Context creation with a default value that matches the expected shape
41 | const UserContext = createContext(defaultContextValue);
42 |
43 | // Provider Component
44 | export const UserProvider: React.FC = ({ children }) => {
45 | const [currentUser, setCurrentUser] = useState(null);
46 | const [token, setToken] = useState(null);
47 | const [isAuthenticating, setIsAuthenticating] = useState(true); // Initially true, assuming authentication check is in progress
48 | const { trpcAsync } = useTrpc();
49 | const trpcAsyncRef = useRef(trpcAsync);
50 |
51 | useEffect(() => {
52 | const fetchUser = async () => {
53 | const tokenFromCookie = Cookies.get("jwtAccessToken");
54 | if (tokenFromCookie) {
55 | setIsAuthenticating(true); // Begin authentication check only if token exists
56 | try {
57 | const userJwt =
58 | await trpcAsyncRef.current.userRouter.verifyAccessToken.query({
59 | accessToken: tokenFromCookie,
60 | });
61 | if (userJwt.user.verifiedAt) {
62 | setCurrentUser(userJwt.user);
63 | }
64 | } catch (error) {
65 | console.error("Error fetching user:", error);
66 | // Optionally, handle error state here
67 | }
68 | setIsAuthenticating(false); // End authentication check
69 | } else {
70 | // Immediately consider authentication check complete if no token
71 | setIsAuthenticating(false);
72 | }
73 | };
74 | fetchUser();
75 | }, [token, trpcAsyncRef]);
76 |
77 | // Provide both user and setUser to the context value
78 | const contextValue = {
79 | currentUser,
80 | setCurrentUser,
81 | accessToken: token,
82 | setAccessToken: (token: string, expiresIn: string) => {
83 | const expires = parseExpiresIn(expiresIn);
84 | Cookies.set("jwtAccessToken", token, {
85 | expires,
86 | secure: process.env.NODE_ENV === "production" ? true : false,
87 | sameSite: "Strict",
88 | });
89 | setToken(token);
90 | },
91 | logout: () => {
92 | setCurrentUser(null);
93 | setToken(null);
94 | Cookies.remove("jwtAccessToken", {
95 | secure: true,
96 | sameSite: "Strict",
97 | });
98 | },
99 | isAuthenticating,
100 | };
101 |
102 | return (
103 | {children}
104 | );
105 | };
106 |
107 | // Hook for easy context usage
108 | export const useUserContext = () => useContext(UserContext);
109 |
110 | function parseExpiresIn(expiresIn: string) {
111 | const unit = expiresIn.slice(-1);
112 | const value = parseInt(expiresIn.slice(0, -1), 10);
113 | switch (unit) {
114 | case "h": // hours
115 | return value / 24; // Convert hours to days
116 | case "d": // days
117 | return value;
118 | default:
119 | return undefined; // Default case, might be useful to handle 's' for seconds or other formats
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/apps/web/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@web/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
--------------------------------------------------------------------------------
/apps/web/app/forgot-password/ShadcnForgotPassword.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { UserLoginDtoType, UserResetPasswordDto } from "@server/user/user.dto";
5 | import { useTrpc } from "@web/contexts/TrpcContext";
6 | import { useRouter } from "next/navigation";
7 | import { useForm } from "react-hook-form";
8 | import { Button } from "@web/components/ui/button";
9 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@web/components/ui/card";
10 | import { Input } from "@web/components/ui/input";
11 | import { Label } from "@web/components/ui/label";
12 | import { Loader2, CheckCircle } from "lucide-react";
13 | import Link from "next/link";
14 |
15 | export default function ShadcnForgotPassword() {
16 | const router = useRouter();
17 | const { trpc } = useTrpc();
18 | const {
19 | register,
20 | formState: { errors },
21 | handleSubmit,
22 | } = useForm({
23 | resolver: zodResolver(UserResetPasswordDto),
24 | });
25 | const resetPassword = trpc.userRouter.resetPassword.useMutation();
26 |
27 | return (
28 |
29 |
30 |
31 |
32 | Reset password
33 |
34 | Enter your email address and we'll send you a link to reset your password
35 |
36 |
37 |
38 | {resetPassword.data ? (
39 |
40 |
41 |
42 | We've sent a password reset link to your email address.
43 | Please check your inbox and follow the instructions.
44 |
45 |
46 | Back to login
47 |
48 |
49 | ) : (
50 |
88 | )}
89 |
90 | {resetPassword.error && (
91 |
92 | {resetPassword.error.message}
93 |
94 | )}
95 |
96 | {!resetPassword.data && (
97 |
98 |
99 | Remember your password?{" "}
100 |
104 | Sign in
105 |
106 |
107 |
108 | )}
109 |
110 |
111 |
112 | );
113 | }
--------------------------------------------------------------------------------
/apps/web/app/dashboard/roles/[id]/DashboardRoleCreateModal.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { RoleCreateDto, RoleCreateDtoType } from "@server/role/role.dto";
3 | import { Dialog } from "@web/components/dashboard/Dialog";
4 | import { useTrpc } from "@web/contexts/TrpcContext";
5 | import { useRouter } from "next/navigation";
6 | import { useForm } from "react-hook-form";
7 | import { toast } from "react-toastify";
8 |
9 | type DashboardRoleCreateModalProps = {
10 | onClose: () => void;
11 | };
12 | export default function DashboardRoleCreateModal(
13 | props: DashboardRoleCreateModalProps
14 | ) {
15 | const { trpc } = useTrpc();
16 | const createRole = trpc.roleRouter.create.useMutation();
17 | const router = useRouter();
18 | const {
19 | register,
20 | formState: { errors },
21 | handleSubmit,
22 | } = useForm({
23 | resolver: zodResolver(RoleCreateDto),
24 | });
25 | return (
26 | {
28 | if (props.onClose) {
29 | props.onClose();
30 | }
31 | }}
32 | >
33 |
34 |
35 |
36 |
37 | Add a new role
38 |
39 |
40 | * means required
41 |
42 |
43 |
44 |
106 |
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/apps/web/app/verify-email/ShadcnVerifyEmail.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTrpc } from "@web/contexts/TrpcContext";
4 | import { useRouter } from "next/navigation";
5 | import { useEffect } from "react";
6 | import { StringParam, useQueryParam, withDefault } from "use-query-params";
7 | import { useUserContext } from "../user/UserContext";
8 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@web/components/ui/card";
9 | import { Button } from "@web/components/ui/button";
10 | import { Loader2, Mail, CheckCircle, XCircle } from "lucide-react";
11 | import Link from "next/link";
12 |
13 | export default function ShadcnVerifyEmail() {
14 | const { trpc } = useTrpc();
15 | const router = useRouter();
16 | const { setAccessToken, currentUser } = useUserContext();
17 | const [token] = useQueryParam("token", withDefault(StringParam, ""));
18 |
19 | const userJwt = trpc.userRouter.verifyAccessToken.useQuery({
20 | accessToken: token,
21 | });
22 |
23 | useEffect(() => {
24 | if (userJwt.data) {
25 | setAccessToken(token, userJwt.data.jwt.expiresIn);
26 | }
27 | }, [userJwt, setAccessToken, token]);
28 |
29 | useEffect(() => {
30 | if (currentUser) {
31 | setTimeout(() => {
32 | router.push("/");
33 | }, 2000);
34 | }
35 | }, [currentUser, router]);
36 |
37 | return (
38 |
39 |
40 |
41 |
42 | Email Verification
43 |
44 | {token ? "Verifying your email address" : "Check your email"}
45 |
46 |
47 |
48 | {token ? (
49 | <>
50 | {userJwt.isLoading && (
51 | <>
52 |
53 |
54 | Please wait while we verify your email...
55 |
56 | >
57 | )}
58 |
59 | {userJwt.data && currentUser && (
60 | <>
61 |
62 |
63 |
64 | Email verified successfully!
65 |
66 |
67 | Redirecting you to the homepage...
68 |
69 |
70 | >
71 | )}
72 |
73 | {userJwt.error && (
74 | <>
75 |
76 |
77 |
78 | Verification failed
79 |
80 |
81 | {userJwt.error.message || "The verification link may be invalid or expired."}
82 |
83 |
84 |
85 | Back to login
86 |
87 | >
88 | )}
89 | >
90 | ) : (
91 | <>
92 |
93 |
94 |
95 | Check your inbox
96 |
97 |
98 | We've sent a verification link to your email address.
99 | Please click the link to verify your account.
100 |
101 |
102 |
103 |
104 | Back to login
105 |
106 |
107 | >
108 | )}
109 |
110 |
111 |
112 |
113 | );
114 | }
--------------------------------------------------------------------------------
/apps/web/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | font-family: "Segoe UI", Helvetica, Arial,
7 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
8 |
9 | /* background-color: #ffdab9; */
10 | /* background-color: #EFFFFE; */
11 | /* background: linear-gradient(to bottom, #fefefe, #efefef); */
12 | }
13 |
14 | @keyframes fadeIn {
15 | from {
16 | opacity: 0;
17 | }
18 | to {
19 | opacity: 1;
20 | }
21 | }
22 |
23 | @keyframes slideDown {
24 | from {
25 | transform: translateY(-50px);
26 | opacity: 0;
27 | }
28 | to {
29 | transform: translateY(0);
30 | opacity: 1;
31 | }
32 | }
33 |
34 | .modal-content {
35 | animation: slideDown 0.2s ease-out forwards;
36 | }
37 |
38 | @layer utilities {
39 | .text-balance {
40 | text-wrap: balance;
41 | }
42 | .input {
43 | @apply block px-3 w-full rounded-md border-0 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6;
44 | }
45 |
46 | .dashboard-layout {
47 | .nav-menu {
48 | @apply w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600;
49 | }
50 | .nav-menu-active {
51 | @apply flex items-center gap-x-3.5 py-2 px-2.5 bg-gray-100 text-sm rounded-lg hover:bg-gray-100 dark:bg-gray-900 dark:text-white dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600;
52 | }
53 | }
54 |
55 | .rounded-card {
56 | @apply border border-gray-200 rounded-lg bg-white p-6;
57 | }
58 | }
59 |
60 |
61 | .embla {
62 | overflow: hidden;
63 | }
64 | .embla__container {
65 | display: flex;
66 | }
67 | .embla__slide {
68 | flex: 0 0 100%;
69 | min-width: 0;
70 | }
71 |
72 | @keyframes gentle-bounce {
73 | 0%, 100% { transform: translateY(0); }
74 | 50% { transform: translateY(-3px); }
75 | }
76 |
77 | .animate-gentle-bounce {
78 | animation: gentle-bounce 2s infinite;
79 | }
80 |
81 |
82 |
83 | @layer base {
84 | :root {
85 | --background: 0 0% 100%;
86 | --foreground: 0 0% 3.9%;
87 | --card: 0 0% 100%;
88 | --card-foreground: 0 0% 3.9%;
89 | --popover: 0 0% 100%;
90 | --popover-foreground: 0 0% 3.9%;
91 | --primary: 0 0% 9%;
92 | --primary-foreground: 0 0% 98%;
93 | --secondary: 0 0% 96.1%;
94 | --secondary-foreground: 0 0% 9%;
95 | --muted: 0 0% 96.1%;
96 | --muted-foreground: 0 0% 45.1%;
97 | --accent: 0 0% 96.1%;
98 | --accent-foreground: 0 0% 9%;
99 | --destructive: 0 84.2% 60.2%;
100 | --destructive-foreground: 0 0% 98%;
101 | --border: 0 0% 89.8%;
102 | --input: 0 0% 89.8%;
103 | --ring: 0 0% 3.9%;
104 | --chart-1: 12 76% 61%;
105 | --chart-2: 173 58% 39%;
106 | --chart-3: 197 37% 24%;
107 | --chart-4: 43 74% 66%;
108 | --chart-5: 27 87% 67%;
109 | --radius: 0.5rem;
110 | --sidebar-background: 0 0% 98%;
111 | --sidebar-foreground: 240 5.3% 26.1%;
112 | --sidebar-primary: 240 5.9% 10%;
113 | --sidebar-primary-foreground: 0 0% 98%;
114 | --sidebar-accent: 240 4.8% 95.9%;
115 | --sidebar-accent-foreground: 240 5.9% 10%;
116 | --sidebar-border: 220 13% 91%;
117 | --sidebar-ring: 217.2 91.2% 59.8%;
118 | }
119 | .dark {
120 | --background: 0 0% 3.9%;
121 | --foreground: 0 0% 98%;
122 | --card: 0 0% 3.9%;
123 | --card-foreground: 0 0% 98%;
124 | --popover: 0 0% 3.9%;
125 | --popover-foreground: 0 0% 98%;
126 | --primary: 0 0% 98%;
127 | --primary-foreground: 0 0% 9%;
128 | --secondary: 0 0% 14.9%;
129 | --secondary-foreground: 0 0% 98%;
130 | --muted: 0 0% 14.9%;
131 | --muted-foreground: 0 0% 63.9%;
132 | --accent: 0 0% 14.9%;
133 | --accent-foreground: 0 0% 98%;
134 | --destructive: 0 62.8% 30.6%;
135 | --destructive-foreground: 0 0% 98%;
136 | --border: 0 0% 14.9%;
137 | --input: 0 0% 14.9%;
138 | --ring: 0 0% 83.1%;
139 | --chart-1: 220 70% 50%;
140 | --chart-2: 160 60% 45%;
141 | --chart-3: 30 80% 55%;
142 | --chart-4: 280 65% 60%;
143 | --chart-5: 340 75% 55%;
144 | --sidebar-background: 240 5.9% 10%;
145 | --sidebar-foreground: 240 4.8% 95.9%;
146 | --sidebar-primary: 224.3 76.3% 48%;
147 | --sidebar-primary-foreground: 0 0% 100%;
148 | --sidebar-accent: 240 3.7% 15.9%;
149 | --sidebar-accent-foreground: 240 4.8% 95.9%;
150 | --sidebar-border: 240 3.7% 15.9%;
151 | --sidebar-ring: 217.2 91.2% 59.8%;
152 | }
153 | }
154 |
155 |
156 |
157 | @layer base {
158 | * {
159 | @apply border-border;
160 | }
161 | body {
162 | @apply bg-background text-foreground;
163 | }
164 | }
--------------------------------------------------------------------------------
/apps/web/app/dashboard/categories/[id]/DashboardCategoryCreateModal.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { CategoryCreateDto, CategoryCreateDtoType } from "@server/category/category.dto";
3 | import { Dialog } from "@web/components/dashboard/Dialog";
4 | import { useTrpc } from "@web/contexts/TrpcContext";
5 | import { useRouter } from "next/navigation";
6 | import { useForm } from "react-hook-form";
7 | import { toast } from "react-toastify";
8 |
9 | type DashboardCategoryCreateModalProps = {
10 | categoryId?: number;
11 | onClose: () => void;
12 | };
13 | export default function DashboardCategoryCreateModal(
14 | props: DashboardCategoryCreateModalProps
15 | ) {
16 | const { trpc } = useTrpc();
17 | const createCategory = trpc.categoryRouter.create.useMutation();
18 | const router = useRouter();
19 | const {
20 | register,
21 | formState: { errors },
22 | handleSubmit,
23 | } = useForm({
24 | resolver: zodResolver(CategoryCreateDto),
25 | });
26 | return (
27 | {
29 | if (props.onClose) {
30 | props.onClose();
31 | }
32 | }}
33 | >
34 |
35 |
36 |
37 |
38 | Add a new category
39 |
40 |
41 | * means required
42 |
43 |
44 |
45 |
107 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/apps/web/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@web/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
--------------------------------------------------------------------------------
/apps/server/prisma/migrations/20241028065201_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "AuthType" AS ENUM ('email', 'google');
3 |
4 | -- CreateEnum
5 | CREATE TYPE "PostReactionType" AS ENUM ('like', 'dislike');
6 |
7 | -- CreateTable
8 | CREATE TABLE "User" (
9 | "id" SERIAL NOT NULL,
10 | "firstName" TEXT,
11 | "lastName" TEXT,
12 | "username" TEXT,
13 | "email" TEXT NOT NULL,
14 | "password" TEXT NOT NULL,
15 | "status" TEXT NOT NULL DEFAULT 'active',
16 | "phone" TEXT,
17 | "gender" TEXT,
18 | "bio" TEXT,
19 | "profilePicUrl" TEXT,
20 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21 | "verifiedAt" TIMESTAMP(3),
22 | "updatedAt" TIMESTAMP(3) NOT NULL,
23 | "deletedAt" TIMESTAMP(3),
24 | "lastLoggedInAt" TIMESTAMP(3),
25 | "authType" "AuthType" NOT NULL DEFAULT 'email',
26 |
27 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
28 | );
29 |
30 | -- CreateTable
31 | CREATE TABLE "Post" (
32 | "id" SERIAL NOT NULL,
33 | "userId" INTEGER NOT NULL,
34 | "title" TEXT NOT NULL,
35 | "teaser" TEXT,
36 | "description" TEXT,
37 | "imageUrl" TEXT,
38 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
39 | "updatedAt" TIMESTAMP(3) NOT NULL,
40 | "deletedAt" TIMESTAMP(3),
41 | "status" TEXT DEFAULT 'published',
42 | "categoryId" INTEGER,
43 | "viewCount" INTEGER DEFAULT 0,
44 |
45 | CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
46 | );
47 |
48 | -- CreateTable
49 | CREATE TABLE "PostComment" (
50 | "id" SERIAL NOT NULL,
51 | "postId" INTEGER NOT NULL,
52 | "userId" INTEGER NOT NULL,
53 | "message" TEXT NOT NULL,
54 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
55 | "updatedAt" TIMESTAMP(3) NOT NULL,
56 | "deletedAt" TIMESTAMP(3),
57 |
58 | CONSTRAINT "PostComment_pkey" PRIMARY KEY ("id")
59 | );
60 |
61 | -- CreateTable
62 | CREATE TABLE "PostReaction" (
63 | "id" SERIAL NOT NULL,
64 | "postId" INTEGER NOT NULL,
65 | "userId" INTEGER NOT NULL,
66 | "type" "PostReactionType" NOT NULL DEFAULT 'like',
67 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
68 |
69 | CONSTRAINT "PostReaction_pkey" PRIMARY KEY ("id")
70 | );
71 |
72 | -- CreateTable
73 | CREATE TABLE "Role" (
74 | "id" SERIAL NOT NULL,
75 | "name" TEXT NOT NULL,
76 |
77 | CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
78 | );
79 |
80 | -- CreateTable
81 | CREATE TABLE "Category" (
82 | "id" SERIAL NOT NULL,
83 | "parentId" INTEGER NOT NULL DEFAULT 0,
84 | "name" TEXT NOT NULL,
85 | "sortOrder" INTEGER NOT NULL DEFAULT 0,
86 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
87 | "template" TEXT,
88 | "adminWriteOnly" BOOLEAN NOT NULL DEFAULT true,
89 | "singlePostOnly" BOOLEAN NOT NULL DEFAULT false,
90 | "lastPostedAt" TIMESTAMP(3),
91 |
92 | CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
93 | );
94 |
95 | -- CreateTable
96 | CREATE TABLE "_UserRoles" (
97 | "A" INTEGER NOT NULL,
98 | "B" INTEGER NOT NULL
99 | );
100 |
101 | -- CreateIndex
102 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
103 |
104 | -- CreateIndex
105 | CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
106 |
107 | -- CreateIndex
108 | CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
109 |
110 | -- CreateIndex
111 | CREATE UNIQUE INDEX "_UserRoles_AB_unique" ON "_UserRoles"("A", "B");
112 |
113 | -- CreateIndex
114 | CREATE INDEX "_UserRoles_B_index" ON "_UserRoles"("B");
115 |
116 | -- AddForeignKey
117 | ALTER TABLE "Post" ADD CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
118 |
119 | -- AddForeignKey
120 | ALTER TABLE "Post" ADD CONSTRAINT "Post_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
121 |
122 | -- AddForeignKey
123 | ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
124 |
125 | -- AddForeignKey
126 | ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
127 |
128 | -- AddForeignKey
129 | ALTER TABLE "PostReaction" ADD CONSTRAINT "PostReaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
130 |
131 | -- AddForeignKey
132 | ALTER TABLE "PostReaction" ADD CONSTRAINT "PostReaction_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
133 |
134 | -- AddForeignKey
135 | ALTER TABLE "_UserRoles" ADD CONSTRAINT "_UserRoles_A_fkey" FOREIGN KEY ("A") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
136 |
137 | -- AddForeignKey
138 | ALTER TABLE "_UserRoles" ADD CONSTRAINT "_UserRoles_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
139 |
--------------------------------------------------------------------------------