From 26e3b76b11232c7ee34828593d8d61f8cf574b39 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Fri, 23 May 2025 21:35:43 +0200 Subject: [PATCH] feat: add global styles and transitions for dark mode support - Created style.css for base styles and color variables for light and dark themes. - Added tailwind.css for Tailwind CSS integration and custom theme variables. - Introduced transitions.css to manage global transitions and reduce motion preferences. - Updated tsconfig.app.json to include path aliases for easier imports. - Modified tsconfig.json to set baseUrl and paths for module resolution. - Added typed-router.d.ts for auto-generated route types. - Enhanced vite.config.ts with Vue Router plugin, Tailwind CSS integration, and server proxy settings. --- backend/bun.lock | 6 + backend/index.ts | 2 +- backend/package.json | 6 +- backend/src/app.ts | 69 +- backend/src/config/app.ts | 5 +- backend/src/config/database.ts | 13 +- backend/src/config/jwt.ts | 4 +- backend/src/controllers/auth.ts | 398 +- backend/src/controllers/categories.ts | 562 ++- backend/src/controllers/protected.ts | 64 +- backend/src/controllers/push.ts | 102 + backend/src/middleware/auth.ts | 42 +- backend/src/models/index.ts | 6 +- backend/src/routes/index.ts | 51 +- backend/src/services/database.ts | 66 +- compose.yml | 2 +- frontend/.prettierrc | 7 + frontend/.vscode/extensions.json | 3 - frontend/README.md | 11 +- frontend/bun.lock | 478 ++- frontend/components.json | 20 + frontend/dev-dist/registerSW.js | 1 + frontend/dev-dist/sw.js | 92 + frontend/dev-dist/workbox-54d0af47.js | 3391 +++++++++++++++++ frontend/index.html | 27 +- frontend/newbie-tour.md | 290 ++ frontend/package.json | 30 +- frontend/public/icons/icon-192px.jpg | Bin 0 -> 5006 bytes frontend/public/icons/icon-512px.jpg | Bin 0 -> 28203 bytes frontend/public/manifest.json | 59 + frontend/public/sw.js | 215 ++ frontend/src/App.vue | 89 +- frontend/src/components/BalanceSummary.vue | 170 + frontend/src/components/HelloWorld.vue | 41 - frontend/src/components/LoggedInUser.vue | 65 + frontend/src/components/Navigation.vue | 150 + frontend/src/components/PageLayout.vue | 101 + frontend/src/components/ThemeToggle.vue | 127 + frontend/src/components/ui/avatar/Avatar.vue | 22 + .../components/ui/avatar/AvatarFallback.vue | 26 + .../src/components/ui/avatar/AvatarImage.vue | 15 + frontend/src/components/ui/avatar/index.ts | 3 + frontend/src/components/ui/button/Button.vue | 26 + frontend/src/components/ui/button/index.ts | 36 + frontend/src/components/ui/card/Card.vue | 21 + .../src/components/ui/card/CardAction.vue | 21 + .../src/components/ui/card/CardContent.vue | 14 + .../components/ui/card/CardDescription.vue | 16 + .../src/components/ui/card/CardFooter.vue | 16 + .../src/components/ui/card/CardHeader.vue | 21 + frontend/src/components/ui/card/CardTitle.vue | 16 + frontend/src/components/ui/card/index.ts | 7 + .../ui/dropdown-menu/DropdownMenu.vue | 19 + .../DropdownMenuCheckboxItem.vue | 42 + .../ui/dropdown-menu/DropdownMenuContent.vue | 42 + .../ui/dropdown-menu/DropdownMenuGroup.vue | 11 + .../ui/dropdown-menu/DropdownMenuItem.vue | 43 + .../ui/dropdown-menu/DropdownMenuLabel.vue | 32 + .../dropdown-menu/DropdownMenuRadioGroup.vue | 21 + .../dropdown-menu/DropdownMenuRadioItem.vue | 43 + .../dropdown-menu/DropdownMenuSeparator.vue | 24 + .../ui/dropdown-menu/DropdownMenuShortcut.vue | 18 + .../ui/dropdown-menu/DropdownMenuSub.vue | 19 + .../dropdown-menu/DropdownMenuSubContent.vue | 34 + .../dropdown-menu/DropdownMenuSubTrigger.vue | 36 + .../ui/dropdown-menu/DropdownMenuTrigger.vue | 19 + .../src/components/ui/dropdown-menu/index.ts | 16 + frontend/src/components/ui/input/Input.vue | 34 + frontend/src/components/ui/input/index.ts | 1 + frontend/src/components/ui/label/Label.vue | 24 + frontend/src/components/ui/label/index.ts | 1 + .../src/components/ui/loading/Loading.vue | 72 + frontend/src/components/ui/loading/index.ts | 4 + .../src/components/ui/separator/Separator.vue | 28 + frontend/src/components/ui/separator/index.ts | 1 + frontend/src/lib/pwa-utils.ts | 100 + frontend/src/lib/utils.ts | 86 + frontend/src/main.ts | 104 +- frontend/src/pages/About.vue | 96 + frontend/src/pages/AddTransaction.vue | 224 ++ frontend/src/pages/AdminCategories.vue | 444 +++ frontend/src/pages/Categories.vue | 567 +++ frontend/src/pages/Dashboard.vue | 139 + frontend/src/pages/EditTransaction.vue | 284 ++ frontend/src/pages/Login.vue | 196 + frontend/src/pages/Reports.vue | 397 ++ frontend/src/pages/Settings.vue | 323 ++ frontend/src/pages/Transactions.vue | 275 ++ frontend/src/store.ts | 5 + frontend/src/stores/auth.ts | 123 + frontend/src/stores/categories.ts | 422 ++ frontend/src/stores/sync.ts | 214 ++ frontend/src/stores/transactions.ts | 313 ++ frontend/src/style.css | 79 - frontend/styles/matcha.css | 2523 ++++++++++++ frontend/styles/style.css | 62 + frontend/styles/tailwind.css | 224 ++ frontend/styles/transitions.css | 9 + frontend/tsconfig.app.json | 12 +- frontend/tsconfig.json | 16 +- frontend/typed-router.d.ts | 32 + frontend/vite.config.ts | 23 + 102 files changed, 14233 insertions(+), 668 deletions(-) create mode 100644 backend/src/controllers/push.ts create mode 100644 frontend/.prettierrc delete mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/components.json create mode 100644 frontend/dev-dist/registerSW.js create mode 100644 frontend/dev-dist/sw.js create mode 100644 frontend/dev-dist/workbox-54d0af47.js create mode 100644 frontend/newbie-tour.md create mode 100644 frontend/public/icons/icon-192px.jpg create mode 100644 frontend/public/icons/icon-512px.jpg create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/components/BalanceSummary.vue delete mode 100644 frontend/src/components/HelloWorld.vue create mode 100644 frontend/src/components/LoggedInUser.vue create mode 100644 frontend/src/components/Navigation.vue create mode 100644 frontend/src/components/PageLayout.vue create mode 100644 frontend/src/components/ThemeToggle.vue create mode 100644 frontend/src/components/ui/avatar/Avatar.vue create mode 100644 frontend/src/components/ui/avatar/AvatarFallback.vue create mode 100644 frontend/src/components/ui/avatar/AvatarImage.vue create mode 100644 frontend/src/components/ui/avatar/index.ts create mode 100644 frontend/src/components/ui/button/Button.vue create mode 100644 frontend/src/components/ui/button/index.ts create mode 100644 frontend/src/components/ui/card/Card.vue create mode 100644 frontend/src/components/ui/card/CardAction.vue create mode 100644 frontend/src/components/ui/card/CardContent.vue create mode 100644 frontend/src/components/ui/card/CardDescription.vue create mode 100644 frontend/src/components/ui/card/CardFooter.vue create mode 100644 frontend/src/components/ui/card/CardHeader.vue create mode 100644 frontend/src/components/ui/card/CardTitle.vue create mode 100644 frontend/src/components/ui/card/index.ts create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenu.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuContent.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuGroup.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuItem.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuLabel.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue create mode 100644 frontend/src/components/ui/dropdown-menu/index.ts create mode 100644 frontend/src/components/ui/input/Input.vue create mode 100644 frontend/src/components/ui/input/index.ts create mode 100644 frontend/src/components/ui/label/Label.vue create mode 100644 frontend/src/components/ui/label/index.ts create mode 100644 frontend/src/components/ui/loading/Loading.vue create mode 100644 frontend/src/components/ui/loading/index.ts create mode 100644 frontend/src/components/ui/separator/Separator.vue create mode 100644 frontend/src/components/ui/separator/index.ts create mode 100644 frontend/src/lib/pwa-utils.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/pages/About.vue create mode 100644 frontend/src/pages/AddTransaction.vue create mode 100644 frontend/src/pages/AdminCategories.vue create mode 100644 frontend/src/pages/Categories.vue create mode 100644 frontend/src/pages/Dashboard.vue create mode 100644 frontend/src/pages/EditTransaction.vue create mode 100644 frontend/src/pages/Login.vue create mode 100644 frontend/src/pages/Reports.vue create mode 100644 frontend/src/pages/Settings.vue create mode 100644 frontend/src/pages/Transactions.vue create mode 100644 frontend/src/store.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/categories.ts create mode 100644 frontend/src/stores/sync.ts create mode 100644 frontend/src/stores/transactions.ts delete mode 100644 frontend/src/style.css create mode 100644 frontend/styles/matcha.css create mode 100644 frontend/styles/style.css create mode 100644 frontend/styles/tailwind.css create mode 100644 frontend/styles/transitions.css create mode 100644 frontend/typed-router.d.ts diff --git a/backend/bun.lock b/backend/bun.lock index 30c8307..08a5da7 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -4,6 +4,7 @@ "": { "name": "backend", "dependencies": { + "@koa/cors": "^5.0.0", "bcrypt": "^6.0.0", "jsonwebtoken": "^9.0.2", "koa": "^3.0.0", @@ -19,6 +20,7 @@ "@types/koa": "^2.15.0", "@types/koa-bodyparser": "^4.3.12", "@types/koa-router": "^7.4.8", + "@types/koa__cors": "^5.0.0", }, "peerDependencies": { "typescript": "^5", @@ -28,6 +30,8 @@ "packages": { "@hapi/bourne": ["@hapi/bourne@3.0.0", "", {}, "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w=="], + "@koa/cors": ["@koa/cors@5.0.0", "", { "dependencies": { "vary": "^1.1.2" } }, "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw=="], + "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.2.2", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA=="], "@types/accepts": ["@types/accepts@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ=="], @@ -64,6 +68,8 @@ "@types/koa-router": ["@types/koa-router@7.4.8", "", { "dependencies": { "@types/koa": "*" } }, "sha512-SkWlv4F9f+l3WqYNQHnWjYnyTxYthqt8W9az2RTdQW7Ay8bc00iRZcrb8MC75iEfPqnGcg2csEl8tTG1NQPD4A=="], + "@types/koa__cors": ["@types/koa__cors@5.0.0", "", { "dependencies": { "@types/koa": "*" } }, "sha512-LCk/n25Obq5qlernGOK/2LUwa/2YJb2lxHUkkvYFDOpLXlVI6tKcdfCHRBQnOY4LwH6el5WOLs6PD/a8Uzau6g=="], + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], diff --git a/backend/index.ts b/backend/index.ts index 2557de1..a7d0939 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -1,2 +1,2 @@ // Import and run the Koa.js application -import './src/app'; \ No newline at end of file +import "./src/app"; diff --git a/backend/package.json b/backend/package.json index 0cf0a12..469b6ad 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { - "name": "backend", + "name": "budget-manager-pwa-backend", "module": "index.ts", "type": "module", "private": true, @@ -9,12 +9,14 @@ "@types/jsonwebtoken": "^9.0.9", "@types/koa": "^2.15.0", "@types/koa-bodyparser": "^4.3.12", - "@types/koa-router": "^7.4.8" + "@types/koa-router": "^7.4.8", + "@types/koa__cors": "^5.0.0" }, "peerDependencies": { "typescript": "^5" }, "dependencies": { + "@koa/cors": "^5.0.0", "bcrypt": "^6.0.0", "jsonwebtoken": "^9.0.2", "koa": "^3.0.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index bebf609..c1cc30c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,8 +1,9 @@ -import Koa from 'koa'; -import bodyParser from 'koa-bodyparser'; -import router from './routes'; -import { authenticate, handleJwtError } from './middleware/auth'; -import DatabaseService from './services/database'; +import Koa from "koa"; +import bodyParser from "koa-bodyparser"; +import router from "./routes"; +import { authenticate, handleJwtError } from "./middleware/auth"; +import DatabaseService from "./services/database"; +import cors from "@koa/cors"; const app = new Koa(); const PORT = process.env.PORT || 3000; @@ -10,15 +11,25 @@ const db = DatabaseService.getInstance(); // Connect to database db.connect() - .then(() => { - console.log('Database connection established'); - }) - .catch(err => { - console.error('Database connection failed:', err); - process.exit(1); - }); + .then(() => { + console.log("Database connection established"); + }) + .catch((err) => { + console.error("Database connection failed:", err); + process.exit(1); + }); // Middlewares +app.use( + cors({ + origin: "*", + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization", "Accept"], + credentials: true, + exposeHeaders: ["WWW-Authenticate", "Server-Authorization"], + maxAge: 86400, // 1 day + }), +); app.use(bodyParser()); app.use(handleJwtError); app.use(authenticate); @@ -26,30 +37,30 @@ app.use(router.routes()); app.use(router.allowedMethods()); // Error handling -app.on('error', (err, ctx) => { - console.error('Server error:', err); - ctx.status = err.status || 500; - ctx.body = { - success: false, - message: err.message || 'Internal server error' - }; +app.on("error", (err, ctx) => { + console.error("Server error:", err); + ctx.status = err.status || 500; + ctx.body = { + success: false, + message: err.message || "Internal server error", + }; }); // Start server app.listen(PORT, () => { - console.log(`Server running on http://localhost:${PORT}`); + console.log(`Server running on http://localhost:${PORT}`); }); // Handle graceful shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - try { - await db.disconnect(); - process.exit(0); - } catch (error) { - console.error('Error during shutdown:', error); - process.exit(1); - } +process.on("SIGINT", async () => { + console.log("Shutting down server..."); + try { + await db.disconnect(); + process.exit(0); + } catch (error) { + console.error("Error during shutdown:", error); + process.exit(1); + } }); export default app; diff --git a/backend/src/config/app.ts b/backend/src/config/app.ts index 7252683..4b4e610 100644 --- a/backend/src/config/app.ts +++ b/backend/src/config/app.ts @@ -1,3 +1,4 @@ export default { - adminKey: process.env.ADMIN_KEY || 'admin-secret-key-change-this-in-production' -} \ No newline at end of file + adminKey: + process.env.ADMIN_KEY || "admin-secret-key-change-this-in-production", +}; diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index c512a19..caa4113 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -1,9 +1,10 @@ export default { - uri: process.env.MONGODB_URI || 'mongodb://localhost:27017', - dbName: process.env.MONGODB_DB_NAME || 'budget-manager', + uri: process.env.MONGODB_URI || "mongodb://localhost:27017", + dbName: process.env.MONGODB_DB_NAME || "budget-manager", collections: { - users: 'users', - transactions: 'transactions', - categories: 'categories' - } + users: "users", + transactions: "transactions", + categories: "categories", + pushSubscriptions: "push_subscriptions", + }, }; diff --git a/backend/src/config/jwt.ts b/backend/src/config/jwt.ts index d4501af..25ae044 100644 --- a/backend/src/config/jwt.ts +++ b/backend/src/config/jwt.ts @@ -1,4 +1,4 @@ export default { - secret: process.env.JWT_SECRET || 'your-secret-key-change-this-in-production', - expiresIn: '1d', // Token expires in 1 day + secret: process.env.JWT_SECRET || "your-secret-key-change-this-in-production", + expiresIn: "1d", // Token expires in 1 day }; diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 43bfe0f..9b887c4 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -1,197 +1,215 @@ -import bcrypt from 'bcrypt'; -import jwt, { type SignOptions } from 'jsonwebtoken'; -import type { Context } from 'koa'; -import appConfig from '../config/app'; -import dbConfig from '../config/database'; -import jwtConfig from '../config/jwt'; -import type { User } from '../models'; -import DatabaseService from '../services/database'; +import bcrypt from "bcrypt"; +import jwt, { type SignOptions } from "jsonwebtoken"; +import type { Context } from "koa"; +import appConfig from "../config/app"; +import dbConfig from "../config/database"; +import jwtConfig from "../config/jwt"; +import type { User } from "../models"; +import DatabaseService from "../services/database"; const db = DatabaseService.getInstance(); export default { - // User registration - async register(ctx: Context) { - const { username, password, email } = ctx.request.body as { - username: string, - password: string, - email?: string - }; + // User registration + async register(ctx: Context) { + const { username, password, email } = ctx.request.body as { + username: string; + password: string; + email?: string; + }; - // Validate input - if (!username || !password) { - ctx.status = 400; - ctx.body = { success: false, message: 'Username and password are required' }; - return; - } - - try { - const usersCollection = db.getCollection(dbConfig.collections.users); - - // Check if user already exists - const existingUser = await usersCollection.findOne({ username }); - if (existingUser) { - ctx.status = 409; - ctx.body = { success: false, message: 'Username already exists' }; - return; - } - - // Hash password before saving - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(password, salt); - - // Create new user - const now = new Date(); - const newUser: User = { - username, - password: hashedPassword, - email, - createdAt: now, - updatedAt: now - }; - - // Save user to database - const result = await usersCollection.insertOne(newUser); - - ctx.status = 201; - ctx.body = { - success: true, - message: 'User registered successfully', - userId: result.insertedId - }; - } catch (error) { - ctx.status = 500; - ctx.body = { - success: false, - message: 'Error registering user', - error: (error as Error).message - }; - } - }, - - // Admin registration - async registerAdmin(ctx: Context) { - const { username, password, email, adminKey } = ctx.request.body as { - username: string, - password: string, - email?: string, - adminKey: string - }; - - // Validate input - if (!username || !password) { - ctx.status = 400; - ctx.body = { success: false, message: 'Username and password are required' }; - return; - } - - // Validate admin key - const validAdminKey = appConfig.adminKey; - if (adminKey !== validAdminKey) { - ctx.status = 403; - ctx.body = { success: false, message: 'Invalid admin key' }; - return; - } - - try { - const usersCollection = db.getCollection(dbConfig.collections.users); - - // Check if user already exists - const existingUser = await usersCollection.findOne({ username }); - if (existingUser) { - ctx.status = 409; - ctx.body = { success: false, message: 'Username already exists' }; - return; - } - - // Hash password before saving - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(password, salt); - - // Create new admin user - const now = new Date(); - const newUser: User = { - username, - password: hashedPassword, - email, - role: 'admin', // Add admin role - createdAt: now, - updatedAt: now - }; - - // Save user to database - const result = await usersCollection.insertOne(newUser); - - ctx.status = 201; - ctx.body = { - success: true, - message: 'Admin registered successfully', - userId: result.insertedId - }; - } catch (error) { - ctx.status = 500; - ctx.body = { - success: false, - message: 'Error registering admin', - error: (error as Error).message - }; - } - }, - - // User login - async login(ctx: Context) { - const { username, password } = ctx.request.body as { username: string, password: string }; - - // Validate input - if (!username || !password) { - ctx.status = 400; - ctx.body = { success: false, message: 'Username and password are required' }; - return; - } - - try { - const usersCollection = db.getCollection(dbConfig.collections.users); - - // Check if user exists - const user = await usersCollection.findOne({ username }); - if (!user) { - ctx.status = 401; - ctx.body = { success: false, message: 'Invalid credentials' }; - return; - } - - // Verify password - const isPasswordValid = await bcrypt.compare(password, user.password); - if (!isPasswordValid) { - ctx.status = 401; - ctx.body = { success: false, message: 'Invalid credentials' }; - return; - } - - // Generate JWT token - const token = jwt.sign( - { - id: user._id?.toString(), - username: user.username - }, - jwtConfig.secret, - { expiresIn: jwtConfig.expiresIn } as SignOptions - ); - - ctx.body = { - success: true, - message: 'Login successful', - userId: user._id, - username: user.username, - token - }; - } catch (error) { - ctx.status = 500; - ctx.body = { - success: false, - message: 'Error during login', - error: (error as Error).message - }; - } + // Validate input + if (!username || !password) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Username and password are required", + }; + return; } + + try { + const usersCollection = db.getCollection( + dbConfig.collections.users, + ); + + // Check if user already exists + const existingUser = await usersCollection.findOne({ username }); + if (existingUser) { + ctx.status = 409; + ctx.body = { success: false, message: "Username already exists" }; + return; + } + + // Hash password before saving + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + // Create new user + const now = new Date(); + const newUser: User = { + username, + password: hashedPassword, + email, + createdAt: now, + updatedAt: now, + }; + + // Save user to database + const result = await usersCollection.insertOne(newUser); + + ctx.status = 201; + ctx.body = { + success: true, + message: "User registered successfully", + userId: result.insertedId, + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error registering user", + error: (error as Error).message, + }; + } + }, + + // Admin registration + async registerAdmin(ctx: Context) { + const { username, password, email, adminKey } = ctx.request.body as { + username: string; + password: string; + email?: string; + adminKey: string; + }; + + // Validate input + if (!username || !password) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Username and password are required", + }; + return; + } + + // Validate admin key + const validAdminKey = appConfig.adminKey; + if (adminKey !== validAdminKey) { + ctx.status = 403; + ctx.body = { success: false, message: "Invalid admin key" }; + return; + } + + try { + const usersCollection = db.getCollection( + dbConfig.collections.users, + ); + + // Check if user already exists + const existingUser = await usersCollection.findOne({ username }); + if (existingUser) { + ctx.status = 409; + ctx.body = { success: false, message: "Username already exists" }; + return; + } + + // Hash password before saving + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + // Create new admin user + const now = new Date(); + const newUser: User = { + username, + password: hashedPassword, + email, + role: "admin", // Add admin role + createdAt: now, + updatedAt: now, + }; + + // Save user to database + const result = await usersCollection.insertOne(newUser); + + ctx.status = 201; + ctx.body = { + success: true, + message: "Admin registered successfully", + userId: result.insertedId, + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error registering admin", + error: (error as Error).message, + }; + } + }, + + // User login + async login(ctx: Context) { + const { username, password } = ctx.request.body as { + username: string; + password: string; + }; + + // Validate input + if (!username || !password) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Username and password are required", + }; + return; + } + + try { + const usersCollection = db.getCollection( + dbConfig.collections.users, + ); + + // Check if user exists + const user = await usersCollection.findOne({ username }); + if (!user) { + ctx.status = 401; + ctx.body = { success: false, message: "Invalid credentials" }; + return; + } + + // Verify password + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + ctx.status = 401; + ctx.body = { success: false, message: "Invalid credentials" }; + return; + } + + // Generate JWT token + const token = jwt.sign( + { + id: user._id?.toString(), + username: user.username, + }, + jwtConfig.secret, + { expiresIn: jwtConfig.expiresIn } as SignOptions, + ); + + ctx.body = { + success: true, + message: "Login successful", + userId: user._id, + username: user.username, + token, + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error during login", + error: (error as Error).message, + }; + } + }, }; diff --git a/backend/src/controllers/categories.ts b/backend/src/controllers/categories.ts index 4395e13..a88fd91 100644 --- a/backend/src/controllers/categories.ts +++ b/backend/src/controllers/categories.ts @@ -1,166 +1,414 @@ -import type { Context } from 'koa'; -import { ObjectId } from 'mongodb'; -import dbConfig from '../config/database'; -import type { Category } from '../models'; -import DatabaseService from '../services/database'; +import type { Context } from "koa"; +import { ObjectId } from "mongodb"; +import dbConfig from "../config/database"; +import type { Category } from "../models"; +import DatabaseService from "../services/database"; const db = DatabaseService.getInstance(); +// Middleware to check if user is admin +function requireAdmin(ctx: Context, next: Function) { + const user = (ctx.state as any).user; + if (user.role !== "admin") { + ctx.status = 403; + ctx.body = { + success: false, + message: "Admin access required", + }; + return; + } + return next(); +} + export default { - // Get categories for a user - async getUserCategories(ctx: Context) { - const user = (ctx.state as any).user; - const userId = user.id; + // Get categories for a user + async getUserCategories(ctx: Context) { + const user = (ctx.state as any).user; + const userId = user.id; - try { - const categoriesCollection = db.getCollection(dbConfig.collections.categories); + try { + const categoriesCollection = db.getCollection( + dbConfig.collections.categories, + ); - const categories = await categoriesCollection.find({ - userId: new ObjectId(userId) - }).toArray(); + const categories = await categoriesCollection + .find({ + userId: new ObjectId(String(userId)), + }) + .toArray(); - ctx.body = { - success: true, - data: categories - }; - } catch (error) { - ctx.status = 500; - ctx.body = { - success: false, - message: 'Error retrieving categories', - error: (error as Error).message - }; - } - }, - - // Add new category - async addCategory(ctx: Context) { - const user = (ctx.state as any).user; - const userId = user.id; - const { name, icon, color } = ctx.request.body as { - name: string; - icon?: string; - color?: string; - }; - - if (!name) { - ctx.status = 400; - ctx.body = { - success: false, - message: 'Category name is required' - }; - return; - } - - try { - const categoriesCollection = db.getCollection(dbConfig.collections.categories); - - // Check if category already exists - const existingCategory = await categoriesCollection.findOne({ - userId: new ObjectId(userId), - name - }); - - if (existingCategory) { - ctx.status = 409; - ctx.body = { - success: false, - message: 'Category already exists' - }; - return; - } - - const now = new Date(); - const newCategory: Category = { - userId: new ObjectId(userId), - name, - icon, - color, - createdAt: now, - updatedAt: now - }; - - const result = await categoriesCollection.insertOne(newCategory); - - ctx.status = 201; - ctx.body = { - success: true, - message: 'Category added successfully', - categoryId: result.insertedId - }; - } catch (error) { - ctx.status = 500; - ctx.body = { - success: false, - message: 'Error adding category', - error: (error as Error).message - }; - } - }, - - // Update category - async updateCategory(ctx: Context) { - const user = (ctx.state as any).user; - const userId = user.id; - const categoryId = ctx.params.id; - - const { name, icon, color } = ctx.request.body as { - name?: string; - icon?: string; - color?: string; - }; - - if (!categoryId || !ObjectId.isValid(categoryId)) { - ctx.status = 400; - ctx.body = { - success: false, - message: 'Valid category ID is required' - }; - return; - } - - try { - const categoriesCollection = db.getCollection(dbConfig.collections.categories); - - // Make sure category belongs to user - const category = await categoriesCollection.findOne({ - _id: new ObjectId(categoryId), - userId: new ObjectId(userId) - }); - - if (!category) { - ctx.status = 404; - ctx.body = { - success: false, - message: 'Category not found' - }; - return; - } - - const updateData: Partial = { - updatedAt: new Date() - }; - - if (name) updateData.name = name; - if (icon) updateData.icon = icon; - if (color) updateData.color = color; - - await categoriesCollection.updateOne( - { _id: new ObjectId(categoryId) }, - { $set: updateData } - ); - - ctx.body = { - success: true, - message: 'Category updated successfully' - }; - } catch (error) { - ctx.status = 500; - ctx.body = { - success: false, - message: 'Error updating category', - error: (error as Error).message - }; - } + ctx.body = { + success: true, + data: categories, + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error retrieving categories", + error: (error as Error).message, + }; } + }, + + // Add new category + async addCategory(ctx: Context) { + const user = (ctx.state as any).user; + const userId = user.id; + const { name, icon, color } = ctx.request.body as { + name: string; + icon?: string; + color?: string; + }; + + if (!name) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Category name is required", + }; + return; + } + + try { + const categoriesCollection = db.getCollection( + dbConfig.collections.categories, + ); + + // Check if category already exists + const existingCategory = await categoriesCollection.findOne({ + userId: new ObjectId(userId), + name, + }); + + if (existingCategory) { + ctx.status = 409; + ctx.body = { + success: false, + message: "Category already exists", + }; + return; + } + + const now = new Date(); + const newCategory: Category = { + userId: new ObjectId(userId), + name, + icon, + color, + createdAt: now, + updatedAt: now, + }; + + const result = await categoriesCollection.insertOne(newCategory); + + ctx.status = 201; + ctx.body = { + success: true, + message: "Category added successfully", + categoryId: result.insertedId, + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error adding category", + error: (error as Error).message, + }; + } + }, + + // Update category + async updateCategory(ctx: Context) { + const user = (ctx.state as any).user; + const userId = user.id; + const categoryId = ctx.params.id; + + const { name, icon, color } = ctx.request.body as { + name?: string; + icon?: string; + color?: string; + }; + + if (!categoryId || !ObjectId.isValid(categoryId)) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Valid category ID is required", + }; + return; + } + + try { + const categoriesCollection = db.getCollection( + dbConfig.collections.categories, + ); + + // Make sure category belongs to user + const category = await categoriesCollection.findOne({ + _id: new ObjectId(categoryId), + userId: new ObjectId(userId), + }); + + if (!category) { + ctx.status = 404; + ctx.body = { + success: false, + message: "Category not found", + }; + return; + } + + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (name) updateData.name = name; + if (icon) updateData.icon = icon; + if (color) updateData.color = color; + + await categoriesCollection.updateOne( + { _id: new ObjectId(categoryId) }, + { $set: updateData }, + ); + + ctx.body = { + success: true, + message: "Category updated successfully", + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error updating category", + error: (error as Error).message, + }; + } + }, + + // Delete category (Admin only) + async deleteCategory(ctx: Context) { + const user = (ctx.state as any).user; + const categoryId = ctx.params.id; + + if (!categoryId || !ObjectId.isValid(categoryId)) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Valid category ID is required", + }; + return; + } + + try { + const categoriesCollection = db.getCollection( + dbConfig.collections.categories, + ); + + // Check if category exists + const category = await categoriesCollection.findOne({ + _id: new ObjectId(categoryId), + }); + + if (!category) { + ctx.status = 404; + ctx.body = { + success: false, + message: "Category not found", + }; + return; + } + + // Check if category is being used in transactions + const transactionsCollection = db.getCollection( + dbConfig.collections.transactions, + ); + + const transactionCount = await transactionsCollection.countDocuments({ + categoryId: new ObjectId(categoryId), + }); + + if (transactionCount > 0) { + ctx.status = 409; + ctx.body = { + success: false, + message: `Cannot delete category. It is used in ${transactionCount} transaction(s).`, + }; + return; + } + + await categoriesCollection.deleteOne({ + _id: new ObjectId(categoryId), + }); + + ctx.body = { + success: true, + message: "Category deleted successfully", + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error deleting category", + error: (error as Error).message, + }; + } + }, + + // Get all categories (Admin only) + async getAllCategories(ctx: Context) { + try { + const categoriesCollection = db.getCollection( + dbConfig.collections.categories, + ); + + const categories = await categoriesCollection + .find({}) + .sort({ createdAt: -1 }) + .toArray(); + + ctx.body = { + success: true, + data: categories, + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error retrieving categories", + error: (error as Error).message, + }; + } + }, + + // Create global category (Admin only) + async createGlobalCategory(ctx: Context) { + const { name, icon, color } = ctx.request.body as { + name: string; + icon?: string; + color?: string; + }; + + if (!name) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Category name is required", + }; + return; + } + + try { + const categoriesCollection = db.getCollection( + dbConfig.collections.categories, + ); + + // Check if global category already exists + const existingCategory = await categoriesCollection.findOne({ + name, + userId: null, // Global categories have no userId + }); + + if (existingCategory) { + ctx.status = 409; + ctx.body = { + success: false, + message: "Global category already exists", + }; + return; + } + + const now = new Date(); + const newCategory: any = { + userId: null, // Global category + name, + icon, + color, + createdAt: now, + updatedAt: now, + }; + + const result = await categoriesCollection.insertOne(newCategory); + + ctx.status = 201; + ctx.body = { + success: true, + message: "Global category created successfully", + categoryId: result.insertedId, + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error creating global category", + error: (error as Error).message, + }; + } + }, + + // Update any category (Admin only) + async updateAnyCategory(ctx: Context) { + const categoryId = ctx.params.id; + const { name, icon, color } = ctx.request.body as { + name?: string; + icon?: string; + color?: string; + }; + + if (!categoryId || !ObjectId.isValid(categoryId)) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Valid category ID is required", + }; + return; + } + + try { + const categoriesCollection = db.getCollection( + dbConfig.collections.categories, + ); + + const category = await categoriesCollection.findOne({ + _id: new ObjectId(categoryId), + }); + + if (!category) { + ctx.status = 404; + ctx.body = { + success: false, + message: "Category not found", + }; + return; + } + + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (name) updateData.name = name; + if (icon) updateData.icon = icon; + if (color) updateData.color = color; + + await categoriesCollection.updateOne( + { _id: new ObjectId(categoryId) }, + { $set: updateData }, + ); + + ctx.body = { + success: true, + message: "Category updated successfully", + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error updating category", + error: (error as Error).message, + }; + } + }, + + // Middleware function for admin routes + requireAdmin, }; diff --git a/backend/src/controllers/protected.ts b/backend/src/controllers/protected.ts index ff4b562..b800cd1 100644 --- a/backend/src/controllers/protected.ts +++ b/backend/src/controllers/protected.ts @@ -31,7 +31,7 @@ export default { try { const transactionsCollection = db.getCollection( - dbConfig.collections.transactions + dbConfig.collections.transactions, ); // Get the latest 10 transactions for the user @@ -61,40 +61,61 @@ export default { async addTransaction(ctx: Context) { const user = (ctx.state as any).user; const userId = user.id; - const { amount, type, categoryId, description } = ctx.request.body as { - amount: number; - type: "income" | "expense"; - categoryId: string; - description: string; - }; - - // Validate input - if (!amount || !type || !categoryId) { - ctx.status = 400; - ctx.body = { - success: false, - message: "Amount, type and category are required", - }; - return; - } try { + const { amount, type, categoryId, description, date } = ctx.request + .body as { + amount: number; + type: "income" | "expense"; + categoryId: string; + description: string; + date?: string; + }; + + console.log("Received transaction data:", ctx.request.body); + + // Validate input + if (!amount || !type || !categoryId) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Amount, type and category are required", + }; + return; + } + const transactionsCollection = db.getCollection( - dbConfig.collections.transactions + dbConfig.collections.transactions, ); const now = new Date(); + const transactionDate = date ? new Date(date) : now; + + // Check if categoryId is a valid ObjectId + let categoryObjectId; + try { + categoryObjectId = new ObjectId(categoryId); + } catch (err) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Invalid category ID format", + }; + return; + } + const newTransaction: Transaction = { userId: new ObjectId(userId), - amount, + amount: Number(amount), type, - categoryId: new ObjectId(categoryId), + categoryId: categoryObjectId, description: description || "", - date: now, + date: transactionDate, createdAt: now, updatedAt: now, }; + console.log("Inserting transaction:", newTransaction); const result = await transactionsCollection.insertOne(newTransaction); ctx.status = 201; @@ -104,6 +125,7 @@ export default { transactionId: result.insertedId, }; } catch (error) { + console.error("Transaction error:", error); ctx.status = 500; ctx.body = { success: false, diff --git a/backend/src/controllers/push.ts b/backend/src/controllers/push.ts new file mode 100644 index 0000000..0b10b43 --- /dev/null +++ b/backend/src/controllers/push.ts @@ -0,0 +1,102 @@ +import type { Context } from "koa"; +import { ObjectId } from "mongodb"; +import dbConfig from "../config/database"; +import DatabaseService from "../services/database"; +import webpush from "web-push"; + +// Configure web-push with VAPID keys +const vapidKeys = { + publicKey: process.env.VAPID_PUBLIC_KEY || 'your-public-key', + privateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key' +}; + +webpush.setVapidDetails( + 'mailto:support@budgetmanager.com', + vapidKeys.publicKey, + vapidKeys.privateKey +); + +const db = DatabaseService.getInstance(); + +export default { + // Get VAPID public key + async getVapidPublicKey(ctx: Context) { + ctx.body = vapidKeys.publicKey; + }, + + // Register a new push subscription + async registerPushSubscription(ctx: Context) { + const user = (ctx.state as any).user; + const userId = user.id; + const { subscription } = ctx.request.body; + + if (!subscription) { + ctx.status = 400; + ctx.body = { + success: false, + message: "Subscription data is required" + }; + return; + } + + try { + const subscriptionsCollection = db.getCollection( + dbConfig.collections.pushSubscriptions + ); + + // Store subscription with user ID + await subscriptionsCollection.updateOne( + { userId: new ObjectId(String(userId)) }, + { $set: { subscription, updatedAt: new Date() } }, + { upsert: true } + ); + + ctx.body = { + success: true, + message: "Push subscription registered successfully" + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: "Error registering push subscription", + error: (error as Error).message + }; + } + }, + + // Send notification for a new transaction + async sendTransactionNotification(userId: string, transaction: any) { + try { + const subscriptionsCollection = db.getCollection( + dbConfig.collections.pushSubscriptions + ); + + // Find user's subscription + const record = await subscriptionsCollection.findOne({ + userId: new ObjectId(String(userId)) + }); + + if (!record || !record.subscription) { + return { success: false, message: "No subscription found" }; + } + + // Prepare notification payload + const payload = JSON.stringify({ + message: `New ${transaction.type}: ${transaction.description || 'Transaction'} - ${transaction.amount}`, + additionalData: { + transactionId: transaction._id.toString(), + type: transaction.type + } + }); + + // Send push notification + await webpush.sendNotification(record.subscription, payload); + + return { success: true }; + } catch (error) { + console.error("Error sending push notification:", error); + return { success: false, error: (error as Error).message }; + } + } +}; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index a2d78a2..adf25cf 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,33 +1,29 @@ -import type { Middleware } from 'koa'; -import jwt from 'koa-jwt'; -import jwtConfig from '../config/jwt'; +import type { Middleware } from "koa"; +import jwt from "koa-jwt"; +import jwtConfig from "../config/jwt"; // JWT authentication middleware export const authenticate: Middleware = jwt({ - secret: jwtConfig.secret + secret: jwtConfig.secret, }).unless({ - // Paths that don't require authentication - path: [ - '/api/auth/login', - '/api/auth/register', - '/api/auth/register-admin' - ] + // Paths that don't require authentication + path: ["/api/auth/login", "/api/auth/register", "/api/auth/register-admin"], }); // Error handling middleware for JWT authentication export const handleJwtError: Middleware = async (ctx, next) => { - try { - // Next middleware have to be jwt call. - await next(); - } catch (err: any) { - if (err.status === 401) { - ctx.status = 401; - ctx.body = { - success: false, - message: 'Unauthorized - Invalid or expired token' - }; - } else { - throw err; - } + try { + // Next middleware have to be jwt call. + await next(); + } catch (err: any) { + if (err.status === 401) { + ctx.status = 401; + ctx.body = { + success: false, + message: "Unauthorized - Invalid or expired token", + }; + } else { + throw err; } + } }; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 4a95880..ffc407b 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -1,11 +1,11 @@ -import { ObjectId } from 'mongodb'; +import { ObjectId } from "mongodb"; export interface User { _id?: ObjectId; username: string; password: string; email?: string; - role?: 'user' | 'admin'; + role?: "user" | "admin"; createdAt: Date; updatedAt: Date; } @@ -14,7 +14,7 @@ export interface Transaction { _id?: ObjectId; userId: ObjectId; amount: number; - type: 'income' | 'expense'; + type: "income" | "expense"; categoryId: ObjectId; description: string; date: Date; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 837fec0..eee3e56 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -1,9 +1,10 @@ -import Router from 'koa-router'; -import authController from '../controllers/auth'; -import protectedController from '../controllers/protected'; -import categoriesController from '../controllers/categories'; +import Router from "koa-router"; +import authController from "../controllers/auth"; +import protectedController from "../controllers/protected"; +import categoriesController from "../controllers/categories"; +import pushController from "../controllers/push"; -const router = new Router({ prefix: '/api' }); +const router = new Router({ prefix: "/api" }); /* Middleware requires authentication for all routes. @@ -13,21 +14,43 @@ const router = new Router({ prefix: '/api' }); // --- Public routes --- // Auth routes -router.post('/auth/register', authController.register); -router.post('/auth/register-admin', authController.registerAdmin); -router.post('/auth/login', authController.login); +router.post("/auth/register", authController.register); +router.post("/auth/register-admin", authController.registerAdmin); +router.post("/auth/login", authController.login); // --- All below routes require authentication ---- // Protected routes -router.get('/protected', protectedController.getProtectedData); +router.get("/protected", protectedController.getProtectedData); // Transaction routes -router.get('/transactions', protectedController.getUserTransactions); -router.post('/transactions', protectedController.addTransaction); +router.get("/transactions", protectedController.getUserTransactions); +router.post("/transactions", protectedController.addTransaction); // Category routes -router.get('/categories', categoriesController.getUserCategories); -router.post('/categories', categoriesController.addCategory); -router.put('/categories/:id', categoriesController.updateCategory); +router.get("/categories", categoriesController.getUserCategories); +router.post("/categories", categoriesController.addCategory); +router.put("/categories/:id", categoriesController.updateCategory); + +// Admin-only category routes +router.get( + "/admin/categories", + categoriesController.requireAdmin, + categoriesController.getAllCategories, +); +router.post( + "/admin/categories", + categoriesController.requireAdmin, + categoriesController.createGlobalCategory, +); +router.put( + "/admin/categories/:id", + categoriesController.requireAdmin, + categoriesController.updateAnyCategory, +); +router.delete( + "/admin/categories/:id", + categoriesController.requireAdmin, + categoriesController.deleteCategory, +); export default router; diff --git a/backend/src/services/database.ts b/backend/src/services/database.ts index 584ee4b..e5ff14e 100644 --- a/backend/src/services/database.ts +++ b/backend/src/services/database.ts @@ -1,46 +1,48 @@ -import { Collection, Db, MongoClient, type Document } from 'mongodb'; -import dbConfig from '../config/database'; +import { Collection, Db, MongoClient, type Document } from "mongodb"; +import dbConfig from "../config/database"; class DatabaseService { - private client: MongoClient; - private db: Db | null = null; - private static instance: DatabaseService; + private client: MongoClient; + private db: Db | null = null; + private static instance: DatabaseService; - private constructor() { - this.client = new MongoClient(dbConfig.uri); - } + private constructor() { + this.client = new MongoClient(dbConfig.uri); + } - static getInstance(): DatabaseService { - if (!DatabaseService.instance) { - DatabaseService.instance = new DatabaseService(); - } - return DatabaseService.instance; + static getInstance(): DatabaseService { + if (!DatabaseService.instance) { + DatabaseService.instance = new DatabaseService(); } + return DatabaseService.instance; + } - async connect(): Promise { - try { - await this.client.connect(); - this.db = this.client.db(dbConfig.dbName); - console.log('Connected to MongoDB successfully'); - } catch (error) { - console.error('Failed to connect to MongoDB', error); - throw error; - } + async connect(): Promise { + try { + await this.client.connect(); + this.db = this.client.db(dbConfig.dbName); + console.log("Connected to MongoDB successfully"); + } catch (error) { + console.error("Failed to connect to MongoDB", error); + throw error; } + } - getCollection(collectionName: string): Collection { - if (!this.db) { - throw new Error('Database connection not established. Call connect() first.'); - } - return this.db.collection(collectionName); + getCollection(collectionName: string): Collection { + if (!this.db) { + throw new Error( + "Database connection not established. Call connect() first.", + ); } + return this.db.collection(collectionName); + } - async disconnect(): Promise { - if (this.client) { - await this.client.close(); - console.log('Disconnected from MongoDB'); - } + async disconnect(): Promise { + if (this.client) { + await this.client.close(); + console.log("Disconnected from MongoDB"); } + } } export default DatabaseService; diff --git a/compose.yml b/compose.yml index 59a0388..908ef87 100644 --- a/compose.yml +++ b/compose.yml @@ -6,4 +6,4 @@ services: - 27017:27017 # environment: # MONGO_INITDB_ROOT_USERNAME: root - # MONGO_INITDB_ROOT_PASSWORD: example \ No newline at end of file + # MONGO_INITDB_ROOT_PASSWORD: example diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..790c822 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,7 @@ +{ + "tabWidth": 2, + "useTabs": false, + "printWidth": 80, + "vueIndentScriptAndStyle": true, + "bracketSameLine": true +} diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json deleted file mode 100644 index a7cea0b..0000000 --- a/frontend/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["Vue.volar"] -} diff --git a/frontend/README.md b/frontend/README.md index 33895ab..cfeb958 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,5 +1,10 @@ -# Vue 3 + TypeScript + Vite +# Frontend -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/frontend/newbie-tour.md b/frontend/newbie-tour.md new file mode 100644 index 0000000..3075a1d --- /dev/null +++ b/frontend/newbie-tour.md @@ -0,0 +1,290 @@ +# Vue.js for React Developers: A Guided Tour + +Welcome to the LuPa Plant Manager Cockpit's Vue.js frontend! This guide is designed for developers familiar with React who are new to Vue.js. We'll explore the key concepts, syntax differences, and mental models needed to understand and work with this codebase. + +## Table of Contents + +1. [Project Structure](#project-structure) +2. [Component System](#component-system) +3. [Reactivity & State Management](#reactivity--state-management) +4. [Template Syntax vs JSX](#template-syntax-vs-jsx) +5. [Routing](#routing) +6. [Lifecycle Hooks](#lifecycle-hooks) +7. [Styling & UI Components](#styling--ui-components) +8. [Additional Resources](#additional-resources) + +## Project Structure + +The Vue project structure is similar to React with some key differences: + +``` +frontend-vue/ +├── src/ # Source code directory (same as React) +│ ├── App.vue # Root component (like App.jsx in React) +│ ├── main.ts # Application entry point (like index.js) +│ ├── components/ # Reusable components (similar to React) +│ │ └── ui/ # UI component library (similar to shadcn/ui in React) +│ ├── pages/ # Page components (similar to pages/ in Next.js) +│ └── stores/ # State management (similar to Redux or Context) +├── styles/ # Global styles +└── public/ # Static assets (same as React) +``` + +The main difference: Vue uses Single File Components (SFC) with `.vue` extension that contain template, script, and style in one file, versus separate `.jsx/.tsx` and `.css` files in React. + +## Component System + +### Single File Components + +Vue's Single File Components (SFCs) combine template, script, and style in one `.vue` file: + +```vue + + + + + +``` + +The closest React equivalent would be combining JSX with CSS modules or styled-components. + +### Example Components + +Explore our commented examples: + +- [App.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/App.vue) - Root component with router setup +- [PageLayout.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/PageLayout.vue) - Layout component with responsive behavior +- [Navigation.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/components/Navigation.vue) - Navigation component with router links + +### Component Composition + +Vue components can be composed similarly to React: + +```vue + +``` + +This is conceptually similar to React's component composition: + +```jsx +function MyComponent() { + return ( + + + Hello world! + + + ); +} +``` + +## Reactivity & State Management + +### Reactivity Fundamentals + +Vue's reactivity system is more granular than React's: + +```vue + +``` + +See our [Login.vue](/home/biggy/Documents/Coding/lupa-plant-manager-cockpit/frontend-vue/src/pages/Login.vue) for practical examples of state management. + +### Key Differences from React + +1. **Direct mutations**: In Vue, you directly mutate state with `count.value++`, whereas React requires `setCount(count + 1)` +2. **Automatic tracking**: Vue automatically tracks dependencies, whereas React requires explicit dependency arrays +3. **Computed properties**: Vue provides `computed()` for derived state, which is cleaner than React's `useMemo()` + +## Template Syntax vs JSX + +### Vue Templates vs React JSX + +Vue uses an HTML-based template syntax, while React uses JSX: + +| Concept | Vue | React | +| ----------- | ------------------------------- | -------------------------------- | +| Attributes | `
` | `
` | +| Events | `