feat: initialize frontend with Vite and PWA support

- Add index.html as the main entry point for the application.
- Create package.json to manage dependencies and scripts for development.
- Include favicon.svg for the application icon.
- Configure PWA assets generation with pwa-assets.config.js.
- Implement counter functionality in counter.js with notification on reaching 5.
- Add JavaScript logo SVG for branding.
- Set up main.js to render the application and handle user interactions.
- Create notification.js to manage browser notifications.
- Implement PWA registration and update handling in pwa.js.
- Style the application with a new style.css file.
- Configure Vite with PWA plugin in vite.config.js for service worker and manifest settings.
This commit is contained in:
2025-05-23 16:18:52 +02:00
commit 0394a7aceb
32 changed files with 2615 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import router from './routes';
import { authenticate, handleJwtError } from './middleware/auth';
import DatabaseService from './services/database';
const app = new Koa();
const PORT = process.env.PORT || 3000;
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);
});
// Middlewares
app.use(bodyParser());
app.use(handleJwtError);
app.use(authenticate);
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'
};
});
// Start server
app.listen(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);
}
});
export default app;
+9
View File
@@ -0,0 +1,9 @@
export default {
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017',
dbName: process.env.MONGODB_DB_NAME || 'budget-manager',
collections: {
users: 'users',
transactions: 'transactions',
categories: 'categories'
}
};
+4
View File
@@ -0,0 +1,4 @@
export default {
secret: process.env.JWT_SECRET || 'your-secret-key-change-this-in-production',
expiresIn: '1d', // Token expires in 1 day
};
+85
View File
@@ -0,0 +1,85 @@
import type { Context } from 'koa';
import bcrypt from 'bcrypt';
import jwt, { type Secret } from 'jsonwebtoken';
import jwtConfig from '../config/jwt';
// Mock user database (in a real app, use a proper database)
const users = new Map();
export default {
// User registration
async register(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;
}
// Check if user already exists
if (users.has(username)) {
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);
// Save user to database
users.set(username, {
username,
password: hashedPassword
});
ctx.status = 201;
ctx.body = {
success: true,
message: 'User registered successfully'
};
},
// 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;
}
// Check if user exists
const user = users.get(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(
{ username: user.username },
jwtConfig.secret as Secret,
{ expiresIn: jwtConfig.expiresIn }
);
ctx.body = {
success: true,
message: 'Login successful',
token
};
}
};
+166
View File
@@ -0,0 +1,166 @@
import type { Context } from 'koa';
import DatabaseService from '../services/database';
import dbConfig from '../config/database';
import type { Category } from '../models';
import { ObjectId } from 'mongodb';
const db = DatabaseService.getInstance();
export default {
// 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<Category>(dbConfig.collections.categories);
const categories = await categoriesCollection.find({
userId: new ObjectId(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<Category>(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<Category>(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<Category> = {
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
};
}
}
};
+110
View File
@@ -0,0 +1,110 @@
import type { Context } from 'koa';
import DatabaseService from '../services/database';
import dbConfig from '../config/database';
import type { Transaction } from '../models';
import { ObjectId } from 'mongodb';
const db = DatabaseService.getInstance();
export default {
// Protected endpoint that requires authentication
async getProtectedData(ctx: Context) {
// The user property is added by koa-jwt middleware
const user = (ctx.state as any).user;
ctx.body = {
success: true,
message: 'You have access to protected data',
data: {
secret: 'This is protected data',
timestamp: new Date().toISOString(),
user: user.username,
userId: user.id
}
};
},
// Get user's transactions
async getUserTransactions(ctx: Context) {
const user = (ctx.state as any).user;
const userId = user.id;
try {
const transactionsCollection = db.getCollection<Transaction>(dbConfig.collections.transactions);
// Get the latest 10 transactions for the user
const transactions = await transactionsCollection.find({
userId: new ObjectId(userId)
})
.sort({ date: -1 })
.limit(10)
.toArray();
ctx.body = {
success: true,
data: transactions
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: 'Error retrieving transactions',
error: (error as Error).message
};
}
},
// Add new transaction
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 transactionsCollection = db.getCollection<Transaction>(dbConfig.collections.transactions);
const now = new Date();
const newTransaction: Transaction = {
userId: new ObjectId(userId),
amount,
type,
categoryId: new ObjectId(categoryId),
description: description || '',
date: now,
createdAt: now,
updatedAt: now
};
const result = await transactionsCollection.insertOne(newTransaction);
ctx.status = 201;
ctx.body = {
success: true,
message: 'Transaction added successfully',
transactionId: result.insertedId
};
} catch (error) {
ctx.status = 500;
ctx.body = {
success: false,
message: 'Error adding transaction',
error: (error as Error).message
};
}
}
};
+32
View File
@@ -0,0 +1,32 @@
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
}).unless({
// 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 {
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;
}
}
};
+33
View File
@@ -0,0 +1,33 @@
import { ObjectId } from 'mongodb';
export interface User {
_id?: ObjectId;
username: string;
password: string;
email?: string;
role?: 'user' | 'admin';
createdAt: Date;
updatedAt: Date;
}
export interface Transaction {
_id?: ObjectId;
userId: ObjectId;
amount: number;
type: 'income' | 'expense';
categoryId: ObjectId;
description: string;
date: Date;
createdAt: Date;
updatedAt: Date;
}
export interface Category {
_id?: ObjectId;
name: string;
userId: ObjectId;
icon?: string;
color?: string;
createdAt: Date;
updatedAt: Date;
}
+25
View File
@@ -0,0 +1,25 @@
import Router from 'koa-router';
import authController from '../controllers/auth';
import protectedController from '../controllers/protected';
import categoriesController from '../controllers/categories';
const router = new Router({ prefix: '/api' });
// Auth routes
router.post('/auth/register', authController.register);
router.post('/auth/register-admin', authController.registerAdmin);
router.post('/auth/login', authController.login);
// Protected routes
router.get('/protected', protectedController.getProtectedData);
// Transaction routes
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);
export default router;
+46
View File
@@ -0,0 +1,46 @@
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 constructor() {
this.client = new MongoClient(dbConfig.uri);
}
static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
}
async connect(): Promise<void> {
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<T extends Document>(collectionName: string): Collection<T> {
if (!this.db) {
throw new Error('Database connection not established. Call connect() first.');
}
return this.db.collection<T>(collectionName);
}
async disconnect(): Promise<void> {
if (this.client) {
await this.client.close();
console.log('Disconnected from MongoDB');
}
}
}
export default DatabaseService;