404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
import express from 'express';
|
||
import { createServer as createViteServer } from 'vite';
|
||
import cors from 'cors';
|
||
import jwt from 'jsonwebtoken';
|
||
import bcrypt from 'bcryptjs';
|
||
import db, { initDb } from './server/db.ts';
|
||
import { GoogleGenAI, Type } from "@google/genai";
|
||
|
||
const JWT_SECRET = process.env.JWT_SECRET || 'mua-super-secret-key-2026';
|
||
|
||
let ai: GoogleGenAI | null = null;
|
||
function getAI() {
|
||
if (!ai) {
|
||
const key = process.env.GEMINI_API_KEY;
|
||
if (!key) throw new Error('GEMINI_API_KEY is required');
|
||
ai = new GoogleGenAI({ apiKey: key });
|
||
}
|
||
return ai;
|
||
}
|
||
|
||
async function startServer() {
|
||
const app = express();
|
||
const PORT = 3000;
|
||
|
||
app.use(cors());
|
||
app.use(express.json());
|
||
|
||
// Initialize DB
|
||
initDb();
|
||
|
||
// Authentication Middleware
|
||
const authenticate = (req: any, res: any, next: any) => {
|
||
const authHeader = req.headers.authorization;
|
||
if (!authHeader) return res.status(401).json({ error: 'Unauthorized' });
|
||
|
||
const token = authHeader.split(' ')[1];
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET);
|
||
req.user = decoded;
|
||
|
||
// Check if user still exists and has the same role/branch
|
||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get((req.user as any).id) as any;
|
||
if (!user) return res.status(401).json({ error: 'User not found' });
|
||
|
||
req.user.role = user.role;
|
||
req.user.branch_id = user.branch_id;
|
||
|
||
next();
|
||
} catch (err) {
|
||
return res.status(401).json({ error: 'Invalid token' });
|
||
}
|
||
};
|
||
|
||
const requireAdmin = (req: any, res: any, next: any) => {
|
||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden: Admin only' });
|
||
next();
|
||
};
|
||
|
||
// --- API Routes ---
|
||
|
||
// Login
|
||
app.post('/api/auth/login', (req, res) => {
|
||
const { email, password } = req.body;
|
||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any;
|
||
|
||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||
return res.status(401).json({ error: 'Invalid credentials' });
|
||
}
|
||
|
||
const token = jwt.sign({ id: user.id, role: user.role, branch_id: user.branch_id }, JWT_SECRET, { expiresIn: '12h' });
|
||
|
||
let branchName = null;
|
||
if (user.branch_id) {
|
||
const branch = db.prepare('SELECT name FROM branches WHERE id = ?').get(user.branch_id) as any;
|
||
branchName = branch ? branch.name : null;
|
||
}
|
||
|
||
res.json({ token, user: { id: user.id, email: user.email, role: user.role, branch_id: user.branch_id, branch_name: branchName } });
|
||
});
|
||
|
||
// Get current user info
|
||
app.get('/api/auth/me', authenticate, (req: any, res) => {
|
||
const user = db.prepare('SELECT id, email, role, branch_id FROM users WHERE id = ?').get(req.user.id) as any;
|
||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||
|
||
let branchName = null;
|
||
if (user.branch_id) {
|
||
const branch = db.prepare('SELECT name FROM branches WHERE id = ?').get(user.branch_id) as any;
|
||
branchName = branch ? branch.name : null;
|
||
}
|
||
|
||
res.json({ user: { ...user, branch_name: branchName } });
|
||
});
|
||
|
||
// Dashboard Data
|
||
app.get('/api/dashboard', authenticate, (req: any, res) => {
|
||
let branchId;
|
||
if (req.user.role === 'manager') {
|
||
branchId = req.user.branch_id;
|
||
} else if (req.user.role === 'admin') {
|
||
branchId = req.query.branch_id;
|
||
if (!branchId) return res.status(400).json({ error: 'Branch ID required for admin' });
|
||
} else {
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
}
|
||
|
||
const { start, end } = req.query; // timestamps
|
||
if (!start || !end) return res.status(400).json({ error: 'Missing start or end' });
|
||
|
||
// Get all employees for this branch
|
||
const employees = db.prepare('SELECT * FROM employees WHERE branch_id = ?').all(branchId) as any[];
|
||
|
||
// Get shifts for the week
|
||
const shifts = db.prepare('SELECT * FROM shifts WHERE branch_id = ? AND start_time >= ? AND start_time <= ?').all(branchId, start, end) as any[];
|
||
|
||
// Calculate total hours per employee
|
||
const employeeHours: Record<number, number> = {};
|
||
employees.forEach(emp => employeeHours[emp.id] = 0);
|
||
|
||
shifts.forEach(shift => {
|
||
const hours = (shift.end_time - shift.start_time) / (1000 * 60 * 60);
|
||
if (employeeHours[shift.employee_id] !== undefined) {
|
||
employeeHours[shift.employee_id] += hours;
|
||
}
|
||
});
|
||
|
||
const chartData = employees.map(emp => ({
|
||
name: emp.name,
|
||
hours: employeeHours[emp.id] || 0,
|
||
status: emp.status
|
||
}));
|
||
|
||
const totalHours = Object.values(employeeHours).reduce((a, b) => a + b, 0);
|
||
|
||
res.json({
|
||
totalHours,
|
||
chartData
|
||
});
|
||
});
|
||
|
||
// Shifts
|
||
app.get('/api/shifts', authenticate, (req: any, res) => {
|
||
const branchId = req.user.role === 'manager' ? req.user.branch_id : req.query.branch_id;
|
||
if (!branchId) return res.status(400).json({ error: 'Branch ID required' });
|
||
|
||
const { start, end } = req.query;
|
||
if (!start || !end) return res.status(400).json({ error: 'Missing start or end' });
|
||
|
||
const shifts = db.prepare('SELECT * FROM shifts WHERE branch_id = ? AND start_time >= ? AND start_time <= ?').all(branchId, start, end);
|
||
res.json(shifts);
|
||
});
|
||
|
||
app.post('/api/shifts/generate', authenticate, async (req: any, res) => {
|
||
let branchId;
|
||
if (req.user.role === 'manager') {
|
||
branchId = req.user.branch_id;
|
||
} else if (req.user.role === 'admin') {
|
||
branchId = req.body.branch_id;
|
||
if (!branchId) return res.status(400).json({ error: 'Branch ID required for admin' });
|
||
} else {
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
}
|
||
|
||
const { start, end } = req.body;
|
||
if (!start || !end) return res.status(400).json({ error: 'Missing start or end' });
|
||
|
||
try {
|
||
// Get employees
|
||
const employees = db.prepare('SELECT id, name FROM employees WHERE branch_id = ? AND status = ?').all(branchId, 'active') as any[];
|
||
if (employees.length === 0) {
|
||
return res.status(400).json({ error: 'No active employees found for this branch.' });
|
||
}
|
||
|
||
const prompt = `
|
||
You are an expert shift scheduling AI for a gelato shop.
|
||
Generate a weekly shift schedule for the following employees:
|
||
${JSON.stringify(employees)}
|
||
|
||
The week starts at timestamp ${start} and ends at ${end}.
|
||
|
||
Rules:
|
||
1. Store hours are roughly 08:00 to 22:00.
|
||
2. Each shift must be between 4 and 11 hours long.
|
||
3. Each employee should work around 30-45 hours this week. Do NOT exceed 45 hours per employee.
|
||
4. Ensure there is at least 1 person in the store at all times during store hours.
|
||
5. Return the start_time and end_time as Unix timestamps (milliseconds).
|
||
6. Distribute the shifts fairly.
|
||
`;
|
||
|
||
const response = await ai.models.generateContent({
|
||
model: "gemini-3.1-pro-preview",
|
||
contents: prompt,
|
||
config: {
|
||
responseMimeType: "application/json",
|
||
responseSchema: {
|
||
type: Type.ARRAY,
|
||
items: {
|
||
type: Type.OBJECT,
|
||
properties: {
|
||
employee_id: { type: Type.INTEGER },
|
||
start_time: { type: Type.NUMBER },
|
||
end_time: { type: Type.NUMBER }
|
||
},
|
||
required: ["employee_id", "start_time", "end_time"]
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
const generatedShifts = JSON.parse(response.text || '[]');
|
||
res.json(generatedShifts);
|
||
|
||
} catch (err: any) {
|
||
console.error('AI Generation Error:', err);
|
||
res.status(500).json({ error: 'Failed to generate shifts' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/shifts/bulk', authenticate, (req: any, res) => {
|
||
let branchId;
|
||
if (req.user.role === 'manager') {
|
||
branchId = req.user.branch_id;
|
||
} else if (req.user.role === 'admin') {
|
||
branchId = req.body.branch_id;
|
||
if (!branchId) return res.status(400).json({ error: 'Branch ID required for admin' });
|
||
} else {
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
}
|
||
|
||
const { shifts } = req.body;
|
||
if (!Array.isArray(shifts)) return res.status(400).json({ error: 'Shifts must be an array' });
|
||
|
||
try {
|
||
const insert = db.prepare('INSERT INTO shifts (employee_id, branch_id, start_time, end_time) VALUES (?, ?, ?, ?)');
|
||
const insertMany = db.transaction((shiftsToInsert) => {
|
||
for (const shift of shiftsToInsert) {
|
||
insert.run(shift.employee_id, branchId, shift.start_time, shift.end_time);
|
||
}
|
||
});
|
||
|
||
insertMany(shifts);
|
||
res.json({ success: true, count: shifts.length });
|
||
} catch (err: any) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/shifts', authenticate, (req: any, res) => {
|
||
let branchId;
|
||
if (req.user.role === 'manager') {
|
||
branchId = req.user.branch_id;
|
||
} else if (req.user.role === 'admin') {
|
||
branchId = req.body.branch_id;
|
||
if (!branchId) return res.status(400).json({ error: 'Branch ID required for admin' });
|
||
} else {
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
}
|
||
|
||
const { employee_id, start_time, end_time } = req.body;
|
||
|
||
if (!employee_id || !start_time || !end_time) return res.status(400).json({ error: 'Missing fields' });
|
||
|
||
// BR-01: Past edit restriction
|
||
const now = Date.now();
|
||
const startOfDay = new Date().setHours(0, 0, 0, 0);
|
||
if (start_time < startOfDay) {
|
||
return res.status(400).json({ error: 'Geçmiş günlere ait vardiya eklenemez veya değiştirilemez.' });
|
||
}
|
||
|
||
// AC 2.5: End time must be after start time
|
||
if (end_time <= start_time) {
|
||
return res.status(400).json({ error: 'Vardiya bitiş saati, başlangıç saatinden önce veya aynı olamaz.' });
|
||
}
|
||
|
||
// BR-02: Time constraint (4-11 hours)
|
||
const durationHours = (end_time - start_time) / (1000 * 60 * 60);
|
||
if (durationHours < 4 || durationHours > 11) {
|
||
return res.status(400).json({ error: 'Vardiya bloku minimum 4 saat, maksimum 11 saat olmalıdır.' });
|
||
}
|
||
|
||
// AC 2.6: Overlap check
|
||
const existingShift = db.prepare(`
|
||
SELECT 1 FROM shifts
|
||
WHERE employee_id = ?
|
||
AND ((start_time < ? AND end_time > ?) OR (start_time >= ? AND start_time < ?))
|
||
`).get(employee_id, end_time, start_time, start_time, end_time);
|
||
|
||
if (existingShift) {
|
||
return res.status(400).json({ error: 'Bu personel belirtilen saatlerde zaten planlanmış.' });
|
||
}
|
||
|
||
// Insert shift
|
||
const result = db.prepare('INSERT INTO shifts (employee_id, branch_id, start_time, end_time) VALUES (?, ?, ?, ?)').run(employee_id, branchId, start_time, end_time);
|
||
|
||
res.json({ id: result.lastInsertRowid, employee_id, branch_id: branchId, start_time, end_time });
|
||
});
|
||
|
||
// Employees
|
||
app.get('/api/employees', authenticate, (req: any, res) => {
|
||
const branchId = req.user.role === 'manager' ? req.user.branch_id : req.query.branch_id;
|
||
if (!branchId) return res.status(400).json({ error: 'Branch ID required' });
|
||
|
||
const employees = db.prepare('SELECT * FROM employees WHERE branch_id = ?').all(branchId);
|
||
res.json(employees);
|
||
});
|
||
|
||
// Admin Routes
|
||
app.get('/api/admin/summary', authenticate, requireAdmin, (req, res) => {
|
||
const { start, end } = req.query;
|
||
if (!start || !end) return res.status(400).json({ error: 'Missing start or end' });
|
||
|
||
try {
|
||
const branchesCount = db.prepare('SELECT COUNT(*) as count FROM branches').get() as { count: number };
|
||
const employeesCount = db.prepare('SELECT COUNT(*) as count FROM employees WHERE status = ?').get('active') as { count: number };
|
||
const shiftsCount = db.prepare('SELECT COUNT(*) as count FROM shifts WHERE start_time >= ? AND start_time <= ?').get(start, end) as { count: number };
|
||
|
||
res.json({
|
||
totalBranches: branchesCount.count,
|
||
totalActiveEmployees: employeesCount.count,
|
||
totalPlannedShifts: shiftsCount.count
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/admin/branches', authenticate, requireAdmin, (req, res) => {
|
||
const branches = db.prepare('SELECT * FROM branches').all();
|
||
res.json(branches);
|
||
});
|
||
|
||
app.post('/api/admin/branches', authenticate, requireAdmin, (req, res) => {
|
||
const { name } = req.body;
|
||
try {
|
||
const result = db.prepare('INSERT INTO branches (name) VALUES (?)').run(name);
|
||
res.json({ id: result.lastInsertRowid, name });
|
||
} catch (err: any) {
|
||
res.status(400).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/admin/users', authenticate, requireAdmin, (req, res) => {
|
||
const users = db.prepare('SELECT id, email, role, branch_id FROM users').all();
|
||
res.json(users);
|
||
});
|
||
|
||
app.post('/api/admin/users', authenticate, requireAdmin, (req, res) => {
|
||
const { email, password, role, branch_id } = req.body;
|
||
try {
|
||
const salt = bcrypt.genSaltSync(10);
|
||
const hash = bcrypt.hashSync(password, salt);
|
||
const result = db.prepare('INSERT INTO users (email, password, role, branch_id) VALUES (?, ?, ?, ?)').run(email, hash, role, branch_id || null);
|
||
res.json({ id: result.lastInsertRowid, email, role, branch_id });
|
||
} catch (err: any) {
|
||
res.status(400).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.put('/api/admin/users/:id', authenticate, requireAdmin, (req, res) => {
|
||
const { role, branch_id } = req.body;
|
||
try {
|
||
db.prepare('UPDATE users SET role = ?, branch_id = ? WHERE id = ?').run(role, branch_id || null, req.params.id);
|
||
res.json({ success: true });
|
||
} catch (err: any) {
|
||
res.status(400).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/admin/employees', authenticate, requireAdmin, (req, res) => {
|
||
const { name, branch_id } = req.body;
|
||
try {
|
||
const result = db.prepare('INSERT INTO employees (name, branch_id) VALUES (?, ?)').run(name, branch_id);
|
||
res.json({ id: result.lastInsertRowid, name, branch_id, status: 'active' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.put('/api/admin/employees/:id', authenticate, requireAdmin, (req, res) => {
|
||
const { status } = req.body;
|
||
try {
|
||
db.prepare('UPDATE employees SET status = ? WHERE id = ?').run(status, req.params.id);
|
||
res.json({ success: true });
|
||
} catch (err: any) {
|
||
res.status(400).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Vite middleware for development
|
||
if (process.env.NODE_ENV !== "production") {
|
||
const vite = await createViteServer({
|
||
server: { middlewareMode: true },
|
||
appType: "spa",
|
||
});
|
||
app.use(vite.middlewares);
|
||
}
|
||
|
||
app.listen(PORT, "0.0.0.0", () => {
|
||
console.log(`Server running on http://localhost:${PORT}`);
|
||
});
|
||
}
|
||
|
||
startServer();
|