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.
This commit is contained in:
2025-05-23 21:35:43 +02:00
parent 97908db3e0
commit 26e3b76b11
102 changed files with 14233 additions and 668 deletions
+6
View File
@@ -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=="],
+1 -1
View File
@@ -1,2 +1,2 @@
// Import and run the Koa.js application
import './src/app';
import "./src/app";
+4 -2
View File
@@ -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",
+40 -29
View File
@@ -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;
+3 -2
View File
@@ -1,3 +1,4 @@
export default {
adminKey: process.env.ADMIN_KEY || 'admin-secret-key-change-this-in-production'
}
adminKey:
process.env.ADMIN_KEY || "admin-secret-key-change-this-in-production",
};
+7 -6
View File
@@ -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",
},
};
+2 -2
View File
@@ -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
};
+208 -190
View File
@@ -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<User>(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<User>(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<User>(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<User>(
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<User>(
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<User>(
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,
};
}
},
};
+405 -157
View File
@@ -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<Category>(dbConfig.collections.categories);
try {
const categoriesCollection = db.getCollection<Category>(
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<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
};
}
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,
};
}
},
// 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<Category>(
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<Category>(
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<Category>(
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<Category>(
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<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,
};
}
},
// Middleware function for admin routes
requireAdmin,
};
+43 -21
View File
@@ -31,7 +31,7 @@ export default {
try {
const transactionsCollection = db.getCollection<Transaction>(
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<Transaction>(
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,
+102
View File
@@ -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 };
}
}
};
+19 -23
View File
@@ -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;
}
}
};
+3 -3
View File
@@ -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;
+37 -14
View File
@@ -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;
+34 -32
View File
@@ -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<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;
}
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);
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');
}
async disconnect(): Promise<void> {
if (this.client) {
await this.client.close();
console.log("Disconnected from MongoDB");
}
}
}
export default DatabaseService;