diff --git a/README.md b/README.md new file mode 100644 index 0000000..444b89b --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# how to set up secret key: +python -c "import secrets; print(secrets.token_hex(16))" + +example: f7faaa9ab2e1b3145a527a573f3c7489 + +then > set FLASK_SECRET_KEY=f7faaa9ab2e1b3145a527a573f3c7489 OR $env:FLASK_SECRET_KEY=f7faaa9ab2e1b3145a527a573f3c7489 if on PSH + +for now you can use: $env:FLASK_SECRET_KEY="a-secret-key-that-is-long-and-random" +once set up, python main.py + + +super admin login: +testemail@gmail.com +testpassword + +inv URL: http://localhost:8000/api/invitations +inv URL with token: http://localhost:3000/accept-invitation?token=TOKENHERE + +Known issue: +password encryption does not work as intended, will fix this asap (backend) \ No newline at end of file diff --git a/backend/app/assignment_routes.py b/backend/app/assignment_routes.py index 69ff72d..b72888d 100644 --- a/backend/app/assignment_routes.py +++ b/backend/app/assignment_routes.py @@ -2,7 +2,8 @@ import sqlite3 from flask import request, jsonify, session import os -from .auth import role_required +from .auth import role_required, get_current_user +from datetime import datetime DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'database.db') @@ -15,15 +16,14 @@ def init_assignment_routes(app): os.makedirs(UPLOAD_FOLDER) app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER - def _get_employee_ids_for_assignment(c, assignment_id): - c.execute('SELECT user_id FROM user_assignments WHERE assignment_id = ?', (assignment_id,)) - rows = c.fetchall() - return [r[0] for r in rows] - # ---------------- CREATE ASSIGNMENT ---------------- @app.route('/api/assignments', methods=['POST']) - @role_required(['org_admin', 'team_manager']) + @role_required(['super_admin', 'org_admin', 'team_manager']) def create_assignment(): + user_id, user_role, user_organization_id = get_current_user() + if not user_id: + return jsonify({'message': 'Unauthorized'}), 401 + data = request.get_json() or {} title = data.get('title') description = data.get('description') @@ -34,25 +34,44 @@ def create_assignment(): if not title: return jsonify({'message': 'Title is required'}), 400 + if due_date: + try: + due_date = datetime.fromisoformat(due_date) + except ValueError: + return jsonify({'message': 'Invalid due date format. Use ISO 8601 format.'}), 400 + conn = _connect() c = conn.cursor() - c.execute('SELECT id, role FROM users WHERE email = ?', (session.get('email'),)) - user = c.fetchone() - if not user: - conn.close() - return jsonify({'message': 'User not found'}), 404 - - user_id, user_role = user[0], user[1] - # team manager validation (if team_id present) - if team_id and user_role == 'team_manager': - c.execute('SELECT manager_id FROM teams WHERE id = ?', (team_id,)) - manager = c.fetchone() - if not manager or manager[0] != user_id: + if user_role == 'org_admin': + # Org admin can't assign to a team, only to employees in their org + if team_id: + c.execute('SELECT organization_id FROM teams WHERE id = ?', (team_id,)) + team_org = c.fetchone() + if not team_org or team_org[0] != user_organization_id: + conn.close() + return jsonify({'message': 'Unauthorized: You can only assign to teams in your own organization'}), 403 + for emp_id in employee_ids: + c.execute('SELECT organization_id FROM users WHERE id = ?', (emp_id,)) + emp_org = c.fetchone() + if not emp_org or emp_org[0] != user_organization_id: + conn.close() + return jsonify({'message': f'Unauthorized: User {emp_id} is not in your organization'}), 403 + + elif user_role == 'team_manager': + c.execute('SELECT organization_id, manager_id FROM teams WHERE id = ?', (team_id,)) + team_info = c.fetchone() + if not team_info or team_info[1] != user_id: conn.close() return jsonify({'message': 'Unauthorized: Not manager of this team'}), 403 - - # if manager assigns to individual employees, optionally check membership (omitted here for brevity) + + # Check if all employees are in the manager's team + for emp_id in employee_ids: + c.execute('SELECT team_id FROM team_members WHERE user_id = ? AND team_id = ?', (emp_id, team_id)) + member = c.fetchone() + if not member: + conn.close() + return jsonify({'message': f'Unauthorized: User {emp_id} is not in your team'}), 403 is_general = 1 if (not employee_ids and not team_id) else 0 @@ -62,18 +81,8 @@ def create_assignment(): if employee_ids: for emp_id in employee_ids: - try: - emp_int = int(emp_id) - except Exception: - continue - # ensure user exists - c.execute('SELECT id FROM users WHERE id = ?', (emp_int,)) - if not c.fetchone(): - conn.rollback() - conn.close() - return jsonify({'message': f'Employee id {emp_int} not found'}), 400 c.execute('INSERT OR IGNORE INTO user_assignments (user_id, assignment_id) VALUES (?, ?)', - (emp_int, assignment_id)) + (emp_id, assignment_id)) conn.commit() conn.close() @@ -81,51 +90,46 @@ def create_assignment(): # ---------------- GET ALL ASSIGNMENTS ---------------- @app.route('/api/assignments', methods=['GET']) + @role_required(['super_admin', 'org_admin', 'team_manager', 'employee']) def get_assignments(): - if 'email' not in session: + user_id, user_role, user_organization_id = get_current_user() + if not user_id: return jsonify({'message': 'Unauthorized'}), 401 conn = _connect() c = conn.cursor() - c.execute('SELECT id, role FROM users WHERE email = ?', (session.get('email'),)) - user = c.fetchone() - if not user: - conn.close() - return jsonify({'message': 'User not found'}), 404 - - user_id, role = user[0], user[1] - if role == 'org_admin': - c.execute('SELECT * FROM assignments') - assignments = c.fetchall() - elif role == 'team_manager': + if user_role == 'super_admin': + c.execute('SELECT a.* FROM assignments a') + elif user_role == 'org_admin': + c.execute(''' + SELECT a.* FROM assignments a + JOIN users u ON a.created_by_id = u.id + WHERE u.organization_id = ? + ''', (user_organization_id,)) + elif user_role == 'team_manager': c.execute(''' SELECT DISTINCT a.* FROM assignments a LEFT JOIN teams t ON a.team_id = t.id - WHERE a.is_general = 1 OR t.manager_id = ? + WHERE t.manager_id = ? OR a.is_general = 1 ''', (user_id,)) - assignments = c.fetchall() - else: + else: # employee c.execute(''' SELECT DISTINCT a.* FROM assignments a LEFT JOIN user_assignments ua ON a.id = ua.assignment_id LEFT JOIN team_members tm ON a.team_id = tm.team_id - WHERE a.is_general = 1 OR ua.user_id = ? OR tm.user_id = ? + WHERE ua.user_id = ? OR tm.user_id = ? OR a.is_general = 1 ''', (user_id, user_id)) - assignments = c.fetchall() - + + assignments = c.fetchall() assignments_list = [] for a in assignments: aid = a[0] - employee_ids = _get_employee_ids_for_assignment(c, aid) + c.execute('SELECT user_id FROM user_assignments WHERE assignment_id = ?', (aid,)) + employee_ids = [row[0] for row in c.fetchall()] assignments_list.append({ - 'id': aid, - 'title': a[1], - 'description': a[2], - 'due_date': a[3], - 'is_general': a[4], - 'team_id': a[5], - 'created_by_id': a[6] if len(a) > 6 else None, + 'id': aid, 'title': a[1], 'description': a[2], 'due_date': a[3], + 'is_general': a[4], 'team_id': a[5], 'created_by_id': a[6], 'employee_ids': employee_ids }) @@ -134,58 +138,44 @@ def get_assignments(): # ---------------- GET SINGLE ASSIGNMENT ---------------- @app.route('/api/assignments/', methods=['GET']) + @role_required(['super_admin', 'org_admin', 'team_manager', 'employee']) def get_assignment(assignment_id): - if 'email' not in session: + user_id, user_role, user_organization_id = get_current_user() + if not user_id: return jsonify({'message': 'Unauthorized'}), 401 conn = _connect() c = conn.cursor() - c.execute('SELECT id, role FROM users WHERE email = ?', (session.get('email'),)) - user = c.fetchone() - if not user: - conn.close() - return jsonify({'message': 'User not found'}), 404 - - user_id, role = user[0], user[1] - c.execute('SELECT * FROM assignments WHERE id = ?', (assignment_id,)) + c.execute('SELECT a.*, u.organization_id FROM assignments a JOIN users u ON a.created_by_id = u.id WHERE a.id = ?', (assignment_id,)) assignment = c.fetchone() + if not assignment: conn.close() return jsonify({'message': 'Assignment not found'}), 404 + + if user_role != 'super_admin' and assignment[7] != user_organization_id: + conn.close() + return jsonify({'message': 'Unauthorized: You can only view assignments in your own organization'}), 403 - is_general = assignment[4] - team_id = assignment[5] - - # Employee access check - if role == 'employee': - c.execute('SELECT 1 FROM user_assignments WHERE user_id = ? AND assignment_id = ?', (user_id, assignment_id)) - ua = c.fetchone() - c.execute('SELECT 1 FROM team_members WHERE user_id = ? AND team_id = ?', (user_id, team_id)) - tm = c.fetchone() - if not is_general and not ua and not tm: - conn.close() - return jsonify({'message': 'Unauthorized: Cannot access this assignment'}), 403 - - # include employee_ids - employee_ids = _get_employee_ids_for_assignment(c, assignment_id) + c.execute('SELECT user_id FROM user_assignments WHERE assignment_id = ?', (assignment_id,)) + employee_ids = [row[0] for row in c.fetchall()] conn.close() return jsonify({'assignment': { - 'id': assignment[0], - 'title': assignment[1], - 'description': assignment[2], - 'due_date': assignment[3], - 'is_general': assignment[4], - 'team_id': assignment[5], - 'created_by_id': assignment[6] if len(assignment) > 6 else None, - 'employee_ids': employee_ids + 'id': assignment[0], 'title': assignment[1], 'description': assignment[2], + 'due_date': assignment[3], 'is_general': assignment[4], 'team_id': assignment[5], + 'created_by_id': assignment[6], 'employee_ids': employee_ids }}), 200 # ---------------- UPDATE ASSIGNMENT ---------------- @app.route('/api/assignments/', methods=['PUT']) - @role_required(['org_admin', 'team_manager']) + @role_required(['super_admin', 'org_admin', 'team_manager']) def update_assignment(assignment_id): + user_id, user_role, user_organization_id = get_current_user() + if not user_id: + return jsonify({'message': 'Unauthorized'}), 401 + data = request.get_json() or {} title = data.get('title') description = data.get('description') @@ -193,57 +183,39 @@ def update_assignment(assignment_id): employee_ids = data.get('employee_ids', []) or [] team_id = data.get('team_id', None) - is_general = 1 if (not employee_ids and not team_id) else 0 + if due_date: + try: + due_date = datetime.fromisoformat(due_date) + except ValueError: + return jsonify({'message': 'Invalid due date format. Use ISO 8601 format.'}), 400 conn = _connect() c = conn.cursor() - c.execute('SELECT created_by_id, team_id FROM assignments WHERE id = ?', (assignment_id,)) - existing = c.fetchone() - if not existing: + + c.execute('SELECT a.*, u.organization_id FROM assignments a JOIN users u ON a.created_by_id = u.id WHERE a.id = ?', (assignment_id,)) + assignment = c.fetchone() + if not assignment: conn.close() return jsonify({'message': 'Assignment not found'}), 404 - created_by_id, existing_team_id = existing[0], existing[1] - - c.execute('SELECT id, role FROM users WHERE email = ?', (session.get('email'),)) - user = c.fetchone() - if not user: + if user_role == 'org_admin' and assignment[7] != user_organization_id: conn.close() - return jsonify({'message': 'User not found'}), 404 - user_id, user_role = user[0], user[1] - - if user_role == 'team_manager': - c.execute('SELECT manager_id FROM teams WHERE id = ?', (existing_team_id,)) - t = c.fetchone() - manager_id = t[0] if t else None - if created_by_id != user_id and manager_id != user_id: + return jsonify({'message': 'Unauthorized: Not in your organization'}), 403 + elif user_role == 'team_manager': + c.execute('SELECT manager_id FROM teams WHERE id = ?', (assignment[5],)) + manager = c.fetchone() + if not manager or manager[0] != user_id: conn.close() - return jsonify({'message': 'Unauthorized: cannot update this assignment'}, 403) - - if team_id: - c.execute('SELECT manager_id FROM teams WHERE id = ?', (team_id,)) - m = c.fetchone() - if not m or m[0] != user_id: - conn.close() - return jsonify({'message': 'Unauthorized: cannot assign to a team you do not manage'}, 403) + return jsonify({'message': 'Unauthorized: Not manager of this team'}), 403 - c.execute('UPDATE assignments SET title = ?, description = ?, due_date = ?, is_general = ?, team_id = ? WHERE id = ?', - (title, description, due_date, is_general, team_id, assignment_id)) + c.execute('UPDATE assignments SET title = ?, description = ?, due_date = ?, team_id = ? WHERE id = ?', + (title, description, due_date, team_id, assignment_id)) c.execute('DELETE FROM user_assignments WHERE assignment_id = ?', (assignment_id,)) if employee_ids: for emp_id in employee_ids: - try: - emp_int = int(emp_id) - except Exception: - continue - c.execute('SELECT id FROM users WHERE id = ?', (emp_int,)) - if not c.fetchone(): - conn.rollback() - conn.close() - return jsonify({'message': f'Employee id {emp_int} not found'}), 400 c.execute('INSERT OR IGNORE INTO user_assignments (user_id, assignment_id) VALUES (?, ?)', - (emp_int, assignment_id)) + (emp_id, assignment_id)) conn.commit() conn.close() @@ -251,36 +223,33 @@ def update_assignment(assignment_id): # ---------------- DELETE ASSIGNMENT ---------------- @app.route('/api/assignments/', methods=['DELETE']) - @role_required(['org_admin', 'team_manager']) + @role_required(['super_admin', 'org_admin', 'team_manager']) def delete_assignment(assignment_id): + user_id, user_role, user_organization_id = get_current_user() + if not user_id: + return jsonify({'message': 'Unauthorized'}), 401 + conn = _connect() c = conn.cursor() - c.execute('SELECT created_by_id, team_id FROM assignments WHERE id = ?', (assignment_id,)) + + c.execute('SELECT a.*, u.organization_id FROM assignments a JOIN users u ON a.created_by_id = u.id WHERE a.id = ?', (assignment_id,)) assignment = c.fetchone() if not assignment: conn.close() return jsonify({'message': 'Assignment not found'}), 404 - created_by_id, assignment_team_id = assignment[0], assignment[1] - - c.execute('SELECT id, role FROM users WHERE email = ?', (session.get('email'),)) - user = c.fetchone() - if not user: + if user_role == 'org_admin' and assignment[7] != user_organization_id: conn.close() - return jsonify({'message': 'User not found'}), 404 - user_id, user_role = user[0], user[1] - - if user_role == 'team_manager': - c.execute('SELECT manager_id FROM teams WHERE id = ?', (assignment_team_id,)) - t = c.fetchone() - manager_id = t[0] if t else None - if created_by_id != user_id and manager_id != user_id: + return jsonify({'message': 'Unauthorized: Not in your organization'}), 403 + elif user_role == 'team_manager': + c.execute('SELECT manager_id FROM teams WHERE id = ?', (assignment[5],)) + manager = c.fetchone() + if not manager or manager[0] != user_id: conn.close() - return jsonify({'message': 'Unauthorized: cannot delete this assignment'}, 403) + return jsonify({'message': 'Unauthorized: Not manager of this team'}), 403 - c.execute('DELETE FROM submissions WHERE assignment_id = ?', (assignment_id,)) - c.execute('DELETE FROM user_assignments WHERE assignment_id = ?', (assignment_id,)) c.execute('DELETE FROM assignments WHERE id = ?', (assignment_id,)) + c.execute('DELETE FROM user_assignments WHERE assignment_id = ?', (assignment_id,)) conn.commit() conn.close() - return jsonify({'message': 'Assignment deleted successfully!'}), 200 + return jsonify({'message': 'Assignment deleted successfully!'}), 200 \ No newline at end of file diff --git a/backend/app/auth.py b/backend/app/auth.py index b6a4405..f7145ed 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,6 +1,22 @@ +import sqlite3 from functools import wraps from flask import session, jsonify +def get_current_user(): + if 'email' not in session: + return None, None, None + + conn = sqlite3.connect('database.db') + c = conn.cursor() + c.execute('SELECT id, role, organization_id FROM users WHERE email = ?', (session['email'],)) + user = c.fetchone() + conn.close() + + if not user: + return None, None, None + + return user[0], user[1], user[2] + def role_required(allowed_roles): def decorator(f): @wraps(f) @@ -14,4 +30,4 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function - return decorator + return decorator \ No newline at end of file diff --git a/backend/app/db_migrations.py b/backend/app/db_migrations.py deleted file mode 100644 index e9f206c..0000000 --- a/backend/app/db_migrations.py +++ /dev/null @@ -1,24 +0,0 @@ -import sqlite3 -import os - -DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'database.db') - -def add_status_column_to_submissions(): - conn = sqlite3.connect(DB_PATH) - c = conn.cursor() - - # Check if 'status' column exists - c.execute("PRAGMA table_info(submissions)") - columns = [col[1] for col in c.fetchall()] - - if 'status' not in columns: - c.execute("ALTER TABLE submissions ADD COLUMN status TEXT DEFAULT 'pending'") - print("✅ Column 'status' added to submissions table.") - else: - print("ℹ️ Column 'status' already exists, skipping.") - - conn.commit() - conn.close() - -if __name__ == "__main__": - add_status_column_to_submissions() diff --git a/backend/app/db_setup.py b/backend/app/db_setup.py index c4afc8c..a11945d 100644 --- a/backend/app/db_setup.py +++ b/backend/app/db_setup.py @@ -1,3 +1,5 @@ +#backend/app/db_setup.py + import sqlite3 import os @@ -96,6 +98,61 @@ def initialize_database(): FOREIGN KEY (graded_by) REFERENCES users (id) )''') + # --- Invitations --- + c.execute(''' + CREATE TABLE IF NOT EXISTS invitations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + role TEXT NOT NULL, + organization_id INTEGER NOT NULL, + created_by INTEGER, + expires_at TIMESTAMP NOT NULL, + is_used INTEGER DEFAULT 0, + FOREIGN KEY (organization_id) REFERENCES organizations (id), + FOREIGN KEY (created_by) REFERENCES users (id) +) +''') + + # --- Messaging Tables --- + c.execute('''CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + is_group_chat INTEGER NOT NULL DEFAULT 0, + created_by_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by_id) REFERENCES users (id) + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS conversation_participants ( + user_id INTEGER NOT NULL, + conversation_id INTEGER NOT NULL, + last_read_timestamp TIMESTAMP, + PRIMARY KEY (user_id, conversation_id), + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (conversation_id) REFERENCES conversations (id) + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL, + sender_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + is_deleted INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (conversation_id) REFERENCES conversations (id), + FOREIGN KEY (sender_id) REFERENCES users (id) + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS message_read_status ( + message_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + PRIMARY KEY (message_id, user_id), + FOREIGN KEY (message_id) REFERENCES messages (id), + FOREIGN KEY (user_id) REFERENCES users (id) + )''') + conn.commit() conn.close() print(f"Database initialized at {DB_PATH}") diff --git a/backend/app/init.py b/backend/app/init.py index 2432cbd..a54f2c9 100644 --- a/backend/app/init.py +++ b/backend/app/init.py @@ -9,7 +9,7 @@ def create_app(): app = Flask(__name__) app.secret_key = "supersecretkey" - CORS(app, supports_credentials=True) + CORS(app, supports_credentials=True, origins="http://localhost:3000") # Initialize DB (creates default org + default admin) initialize_database() diff --git a/backend/app/insert_test_data.py b/backend/app/insert_test_data.py index 737bd2d..b2a327e 100644 --- a/backend/app/insert_test_data.py +++ b/backend/app/insert_test_data.py @@ -1,9 +1,16 @@ # backend/app/insert_test_data.py import sqlite3 import bcrypt +import os +import sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from app.db_setup import initialize_database + +DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'database.db') def insert_test_data(): - conn = sqlite3.connect('database.db') + initialize_database() + conn = sqlite3.connect(DB_PATH) c = conn.cursor() # --- Create a test organization --- @@ -66,6 +73,21 @@ def insert_test_data(): """, (3, "Personal Assignment", "Visible to employee1@test.com only", user_ids["admin@test.com"], 0)) c.execute("INSERT OR IGNORE INTO user_assignments (user_id, assignment_id) VALUES (?, ?)", (employee1_id, 3)) + # --- Create a test conversation --- + c.execute("INSERT OR IGNORE INTO conversations (id, name, is_group_chat, created_by_id) VALUES (?, ?, ?, ?)", + (1, "Test Group Chat", 1, user_ids["manager@test.com"])) + c.execute("INSERT OR IGNORE INTO conversation_participants (conversation_id, user_id) VALUES (?, ?)", (1, user_ids["manager@test.com"])) + c.execute("INSERT OR IGNORE INTO conversation_participants (conversation_id, user_id) VALUES (?, ?)", (1, user_ids["employee1@test.com"])) + c.execute("INSERT OR IGNORE INTO conversation_participants (conversation_id, user_id) VALUES (?, ?)", (1, user_ids["employee2@test.com"])) + + # --- Create test messages --- + c.execute("INSERT OR IGNORE INTO messages (conversation_id, sender_id, content) VALUES (?, ?, ?)", + (1, user_ids["manager@test.com"], "Hello team!")) + c.execute("INSERT OR IGNORE INTO messages (conversation_id, sender_id, content) VALUES (?, ?, ?)", + (1, user_ids["employee1@test.com"], "Hello manager!")) + c.execute("INSERT OR IGNORE INTO messages (conversation_id, sender_id, content) VALUES (?, ?, ?)", + (1, user_ids["employee2@test.com"], "Hello!")) + conn.commit() conn.close() diff --git a/backend/app/invitation_routes.py b/backend/app/invitation_routes.py new file mode 100644 index 0000000..20aa234 --- /dev/null +++ b/backend/app/invitation_routes.py @@ -0,0 +1,174 @@ +#backend/app/invitation_routes.py +import sqlite3 +import secrets +from datetime import datetime, timedelta +from flask import request, jsonify, session +import bcrypt +from app.auth import role_required + +# ------------------------- +# Helper DB Connection +# ------------------------- +def get_db_connection(): + conn = sqlite3.connect("database.db") + conn.row_factory = sqlite3.Row + return conn + +# ------------------------- +# Initialize Invitation Routes +# ------------------------- +def init_invitation_routes(app): + + # ------------------------- + # CORS PREFLIGHT (OPTIONS) + # ------------------------- + @app.route('/api/invitations', methods=['OPTIONS']) + @app.route('/api/invitations/', methods=['OPTIONS']) + def invitations_options(invitation_id=None): + return jsonify({}), 200 + + # ------------------------- + # CREATE INVITATION + # ------------------------- + @app.route('/api/invitations', methods=['POST']) + @role_required(['super_admin']) + def create_invitation(): + data = request.get_json() + email = data.get('email') + role = data.get('role') + organization_id = data.get('organization_id') + + if not all([email, role, organization_id]): + return jsonify({'message': 'Email, role, and organization ID are required'}), 400 + + token = secrets.token_urlsafe(16) + expires_at = datetime.now() + timedelta(days=7) + created_by = session.get('user_id', 0) + + conn = get_db_connection() + c = conn.cursor() + c.execute(""" + INSERT INTO invitations + (email, token, role, organization_id, created_by, expires_at, is_used) + VALUES (?, ?, ?, ?, ?, ?, 0) + """, (email, token, role, organization_id, created_by, expires_at)) + conn.commit() + conn.close() + + invite_link = f"http://localhost:3000/accept-invitation?token={token}" + + return jsonify({ + "message": "Invitation created successfully", + "token": token, + "invite_link": invite_link + }), 201 + + # ------------------------- + # GET ALL INVITATIONS + # ------------------------- + @app.route('/api/invitations', methods=['GET']) + @role_required(['super_admin']) + def get_invitations(): + conn = get_db_connection() + c = conn.cursor() + c.execute('SELECT id, email, role, organization_id, expires_at, is_used FROM invitations ORDER BY id DESC') + invitations = [dict(row) for row in c.fetchall()] + conn.close() + return jsonify({'invitations': invitations}), 200 + + # ------------------------- + # DELETE INVITATION + # ------------------------- + @app.route('/api/invitations/', methods=['DELETE']) + @role_required(['super_admin']) + def delete_invitation(invitation_id): + conn = get_db_connection() + c = conn.cursor() + + # Check if invitation exists + c.execute('SELECT id FROM invitations WHERE id = ?', (invitation_id,)) + inv = c.fetchone() + if not inv: + conn.close() + return jsonify({'message': 'Invitation not found'}), 404 + + # Delete the invitation + c.execute('DELETE FROM invitations WHERE id = ?', (invitation_id,)) + conn.commit() + conn.close() + + return jsonify({'message': 'Invitation deleted successfully'}), 200 + + # ------------------------- + # VERIFY INVITATION TOKEN + # ------------------------- + @app.route('/api/invitations/', methods=['GET']) + def verify_invitation(token): + conn = get_db_connection() + c = conn.cursor() + c.execute('SELECT email, role, organization_id, expires_at, is_used FROM invitations WHERE token = ?', (token,)) + invitation = c.fetchone() + conn.close() + + if not invitation: + return jsonify({'message': 'Invalid token'}), 404 + + expires_at = invitation['expires_at'] + if isinstance(expires_at, str): + expires_at = datetime.strptime(expires_at, '%Y-%m-%d %H:%M:%S.%f') + + if expires_at < datetime.now(): + return jsonify({'message': 'Token has expired'}), 400 + + if invitation['is_used']: + return jsonify({'message': 'Token has already been used'}), 400 + + return jsonify({ + 'email': invitation['email'], + 'role': invitation['role'], + 'organization_id': invitation['organization_id'] + }), 200 + + # ------------------------- + # REGISTER USER FROM INVITATION + # ------------------------- + @app.route('/api/register', methods=['POST']) + def register_from_invitation(): + data = request.get_json() + email = data.get("email") + password = data.get("password") + role = data.get("role") + organization_id = data.get("organization_id") + token = data.get("token") + + if not all([email, password, role, organization_id, token]): + return jsonify({"message": "All fields are required"}), 400 + + conn = get_db_connection() + c = conn.cursor() + + # Check invitation + c.execute("SELECT is_used FROM invitations WHERE token = ?", (token,)) + inv = c.fetchone() + if not inv: + conn.close() + return jsonify({"message": "Invalid invitation token"}), 404 + if inv['is_used']: + conn.close() + return jsonify({"message": "Invitation already used"}), 400 + + # Hash password + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + + # Create user + c.execute( + "INSERT INTO users (email, password, role, organization_id) VALUES (?, ?, ?, ?)", + (email, hashed_password, role, organization_id) + ) + + # Mark invitation as used + c.execute("UPDATE invitations SET is_used = 1 WHERE token = ?", (token,)) + conn.commit() + conn.close() + + return jsonify({"message": "User registered successfully"}), 201 \ No newline at end of file diff --git a/backend/app/messaging_routes.py b/backend/app/messaging_routes.py new file mode 100644 index 0000000..0482442 --- /dev/null +++ b/backend/app/messaging_routes.py @@ -0,0 +1,384 @@ +from flask import request, jsonify, session +from flask_socketio import emit, join_room, leave_room +from app.auth import role_required +import sqlite3 +import os +from datetime import datetime + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "database.db") + + +def init_messaging_routes(app, socketio): + + # ========================= + # CREATE CONVERSATION (DIRECT OR GROUP) + # ========================= + @app.route("/api/conversations", methods=["POST"]) + @role_required(["employee", "manager", "admin", "super_admin"]) + def create_conversation(): + data = request.get_json() or {} + participant_ids = data.get("participant_ids", []) + user_id = session.get("user_id") + role = session.get("role") + + if not participant_ids: + return jsonify({"message": "Participants required"}), 400 + + participant_ids.append(user_id) + participant_ids = list(set(participant_ids)) + + is_group_chat = len(participant_ids) > 2 + + if is_group_chat and role not in ["manager", "admin", "super_admin"]: + return jsonify({"message": "Permission denied"}), 403 + + name = data.get("name") if is_group_chat else None + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + c.execute( + """ + INSERT INTO conversations (name, is_group_chat, created_by_id) + VALUES (?, ?, ?) + """, + (name, int(is_group_chat), user_id), + ) + conversation_id = c.lastrowid + + for pid in participant_ids: + c.execute( + """ + INSERT INTO conversation_participants (conversation_id, user_id) + VALUES (?, ?) + """, + (conversation_id, pid), + ) + + conn.commit() + conn.close() + + return jsonify({"conversation_id": conversation_id}), 201 + + # ========================= + # GET USER CONVERSATIONS + # ========================= + @app.route("/api/conversations", methods=["GET"]) + @role_required(["employee", "manager", "admin", "super_admin"]) + def get_conversations(): + user_id = session.get("user_id") + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + c.execute( + """ + SELECT DISTINCT c.id, c.name, c.is_group_chat, um.unread_count + FROM conversations c + JOIN conversation_participants cp ON cp.conversation_id = c.id + LEFT JOIN unread_messages um + ON um.conversation_id = c.id AND um.user_id = ? + WHERE cp.user_id = ? + ORDER BY c.last_message_at DESC + """, + (user_id, user_id), + ) + + conversations = [] + + for row in c.fetchall(): + c.execute( + """ + SELECT u.id, u.email + FROM users u + JOIN conversation_participants cp + ON u.id = cp.user_id + WHERE cp.conversation_id = ? + """, + (row["id"],), + ) + + participants = [ + {"id": p["id"], "email": p["email"]} + for p in c.fetchall() + ] + + name = row["name"] + if not row["is_group_chat"]: + for p in participants: + if p["id"] != user_id: + name = p["email"] + break + + conversations.append( + { + "id": row["id"], + "name": name, + "is_group_chat": bool(row["is_group_chat"]), + "participants": participants, + "unread_count": row["unread_count"] or 0, + } + ) + + conn.close() + return jsonify(conversations), 200 + + # ========================= + # GET CONVERSATION PARTICIPANTS (GROUP DETAILS) + # ========================= + @app.route("/api/conversations//participants", methods=["GET"]) + @role_required(["employee", "manager", "admin", "super_admin"]) + def get_participants(conversation_id): + user_id = session.get("user_id") + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + # Ensure requester is in conversation + c.execute( + """ + SELECT 1 FROM conversation_participants + WHERE conversation_id = ? AND user_id = ? + """, + (conversation_id, user_id), + ) + + if not c.fetchone(): + conn.close() + return jsonify({"message": "Forbidden"}), 403 + + c.execute( + """ + SELECT u.id, u.email, u.role + FROM users u + JOIN conversation_participants cp ON cp.user_id = u.id + WHERE cp.conversation_id = ? + """, + (conversation_id,), + ) + + participants = [ + {"id": row["id"], "email": row["email"], "role": row["role"]} + for row in c.fetchall() + ] + + conn.close() + return jsonify(participants), 200 + + # ========================= + # REMOVE PARTICIPANT (SUPER ADMIN) + # ========================= + @app.route( + "/api/conversations//participants/", + methods=["DELETE"], + ) + @role_required(["super_admin"]) + def remove_participant(conversation_id, user_id): + requester_id = session.get("user_id") + + if requester_id == user_id: + return jsonify({"message": "Cannot remove yourself"}), 400 + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + c.execute( + """ + SELECT 1 FROM conversation_participants + WHERE conversation_id = ? AND user_id = ? + """, + (conversation_id, user_id), + ) + + if not c.fetchone(): + conn.close() + return jsonify({"message": "User not in conversation"}), 404 + + c.execute( + """ + DELETE FROM conversation_participants + WHERE conversation_id = ? AND user_id = ? + """, + (conversation_id, user_id), + ) + + conn.commit() + conn.close() + + return jsonify({"success": True}), 200 + + # ========================= + # DIRECT CHAT (SAFE) + # ========================= + @app.route("/api/conversations/direct", methods=["POST"]) + @role_required(["employee", "manager", "admin", "super_admin"]) + def create_direct_conversation(): + other_user_id = request.json.get("user_id") + user_id = session.get("user_id") + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + c.execute( + """ + SELECT c.id + FROM conversations c + JOIN conversation_participants a ON c.id = a.conversation_id + JOIN conversation_participants b ON c.id = b.conversation_id + WHERE c.is_group_chat = 0 + AND a.user_id = ? + AND b.user_id = ? + """, + (user_id, other_user_id), + ) + + existing = c.fetchone() + if existing: + conn.close() + return jsonify({"conversation_id": existing[0]}), 200 + + c.execute( + """ + INSERT INTO conversations (is_group_chat, created_by_id) + VALUES (0, ?) + """, + (user_id,), + ) + convo_id = c.lastrowid + + c.executemany( + """ + INSERT INTO conversation_participants (conversation_id, user_id) + VALUES (?, ?) + """, + [(convo_id, user_id), (convo_id, other_user_id)], + ) + + conn.commit() + conn.close() + + return jsonify({"conversation_id": convo_id}), 201 + + # ========================= + # DELETE CONVERSATION (SUPER ADMIN) + # ========================= + @app.route("/api/conversations/", methods=["DELETE"]) + @role_required(["super_admin"]) + def delete_conversation(conversation_id): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + c.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)) + c.execute("DELETE FROM conversation_participants WHERE conversation_id = ?", (conversation_id,)) + c.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,)) + + conn.commit() + conn.close() + + return jsonify({"message": "Conversation deleted"}), 200 + + # ========================= + # MESSAGES + # ========================= + @app.route("/api/conversations//messages", methods=["GET"]) + @role_required(["employee", "manager", "admin", "super_admin"]) + def get_messages(conversation_id): + user_id = session.get("user_id") + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + c.execute( + """ + SELECT 1 FROM conversation_participants + WHERE conversation_id = ? AND user_id = ? + """, + (conversation_id, user_id), + ) + + if not c.fetchone(): + conn.close() + return jsonify({"message": "Forbidden"}), 403 + + c.execute( + """ + SELECT m.id, m.content, m.created_at, u.email, m.status + FROM messages m + JOIN users u ON m.sender_id = u.id + WHERE m.conversation_id = ? + ORDER BY m.created_at ASC + """, + (conversation_id,), + ) + + messages = [] + for row in c.fetchall(): + messages.append( + { + "id": row[0], + "content": row[1], + "created_at": row[2], + "sender_email": row[3], + "status": row[4], + } + ) + + conn.close() + return jsonify(messages), 200 + + @app.route("/api/conversations//messages", methods=["POST"]) + @role_required(["employee", "manager", "admin", "super_admin"]) + def send_message(conversation_id): + content = request.json.get("content") + user_id = session.get("user_id") + + if not content: + return jsonify({"message": "Empty message"}), 400 + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + c.execute( + "INSERT INTO messages (conversation_id, sender_id, content) VALUES (?, ?, ?)", + (conversation_id, user_id, content), + ) + msg_id = c.lastrowid + + c.execute( + "UPDATE conversations SET last_message_at = ? WHERE id = ?", + (datetime.utcnow(), conversation_id), + ) + + c.execute("SELECT email FROM users WHERE id = ?", (user_id,)) + email = c.fetchone()[0] + + conn.commit() + conn.close() + + socketio.emit( + "new_message", + { + "conversation_id": conversation_id, + "message": { + "id": msg_id, + "content": content, + "sender_email": email, + }, + }, + room=f"conversation_{conversation_id}", + ) + + return jsonify({"message_id": msg_id}), 201 + + # ========================= + # SOCKET EVENTS + # ========================= + @socketio.on("join") + def on_join(data): + join_room(f"conversation_{data['conversation_id']}") + + @socketio.on("leave") + def on_leave(data): + leave_room(f"conversation_{data['conversation_id']}") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..b71e765 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,16 @@ +#backend/app/models.py + +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from .database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, nullable=False, index=True) + password = Column(String, nullable=False) + role = Column(String, nullable=False) + token_hash = Column(String, nullable=False) + expires_at = Column(DateTime, nullable=False) + used = Column(Boolean, default=False) diff --git a/backend/app/org_routes.py b/backend/app/org_routes.py index 9f4a416..6ce9eb5 100644 --- a/backend/app/org_routes.py +++ b/backend/app/org_routes.py @@ -1,70 +1,66 @@ +#backend/app/org_routes.py + import sqlite3 from flask import request, jsonify, session +from .auth import role_required, get_current_user def init_org_routes(app): @app.route('/api/organizations', methods=['POST']) + @role_required(['super_admin']) def create_organization(): - # In a real-world scenario, you'd want to protect this route, - # possibly making it accessible only to a Super Admin. data = request.get_json() name = data.get('name') +from flask import request, jsonify +import sqlite3 +from .role_required import role_required - if not name: - return jsonify({'message': 'Organization name is required'}), 400 +DB_PATH = "database.db" - try: - conn = sqlite3.connect('database.db') - c = conn.cursor() - c.execute('INSERT INTO organizations (name) VALUES (?)', (name,)) - org_id = c.lastrowid - conn.commit() - conn.close() - return jsonify({'message': 'Organization created successfully!', 'organization_id': org_id}), 201 - except sqlite3.IntegrityError: - return jsonify({'message': 'Organization name already exists'}), 400 +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn - @app.route('/api/teams', methods=['POST']) - def create_team(): - if not session.get('is_admin'): # For now, we'll assume Org Admins are the ones creating teams - return jsonify({'message': 'Unauthorized: Admins only'}), 403 +def init_org_routes(app): - data = request.get_json() - name = data.get('name') - organization_id = data.get('organization_id') + # ---------------- CREATE ORG ---------------- + @app.route("/api/orgs", methods=["POST"]) + @role_required(["super_admin"]) + def create_org(): + data = request.get_json() or {} + name = data.get("name") - if not name or not organization_id: - return jsonify({'message': 'Team name and organization ID are required'}), 400 + if not name: + return jsonify({"message": "Organization name is required"}), 400 - conn = sqlite3.connect('database.db') + conn = get_db() c = conn.cursor() - c.execute('INSERT INTO teams (name, organization_id) VALUES (?, ?)', (name, organization_id)) - team_id = c.lastrowid + c.execute("INSERT INTO organizations (name) VALUES (?)", (name,)) conn.commit() conn.close() - - return jsonify({'message': 'Team created successfully!', 'team_id': team_id}), 201 - @app.route('/api/teams//members', methods=['POST']) - def add_team_member(team_id): - if not session.get('is_admin'): - return jsonify({'message': 'Unauthorized: Admins only'}), 403 + return jsonify({"message": "Organization created"}), 201 - data = request.get_json() - user_id = data.get('user_id') - - if not user_id: - return jsonify({'message': 'User ID is required'}), 400 + # ---------------- GET ORGS ---------------- + @app.route("/api/orgs", methods=["GET"]) + @role_required(["super_admin"]) + def get_orgs(): + conn = get_db() + c = conn.cursor() + c.execute("SELECT id, name FROM organizations ORDER BY id DESC") + orgs = [dict(row) for row in c.fetchall()] + conn.close() + return jsonify(orgs), 200 - conn = sqlite3.connect('database.db') + # ---------------- DELETE ORG ---------------- + @app.route("/api/orgs/", methods=["DELETE"]) + @role_required(["super_admin"]) + def delete_org(org_id): + conn = get_db() c = conn.cursor() - try: - c.execute('INSERT INTO team_members (user_id, team_id) VALUES (?, ?)', (user_id, team_id)) - conn.commit() - except sqlite3.IntegrityError: - conn.close() - return jsonify({'message': 'User is already in this team'}), 400 - finally: - conn.close() + c.execute("DELETE FROM organizations WHERE id = ?", (org_id,)) + conn.commit() + conn.close() - return jsonify({'message': 'User added to team successfully!'}), 201 + return jsonify({"message": "Organization deleted"}), 200 diff --git a/backend/app/role_required.py b/backend/app/role_required.py new file mode 100644 index 0000000..b1cff88 --- /dev/null +++ b/backend/app/role_required.py @@ -0,0 +1,13 @@ +from functools import wraps +from flask import session, jsonify + +def role_required(roles): + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + role = session.get("role") + if role not in roles: + return jsonify({"message": "Access denied"}), 403 + return f(*args, **kwargs) + return wrapper + return decorator diff --git a/backend/app/routes.py b/backend/app/routes.py index 7a2710d..358e0ca 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -1,115 +1,192 @@ # backend/app/routes.py + import sqlite3 import bcrypt from flask import request, jsonify, session +from datetime import datetime +from marshmallow import ValidationError +from .schemas import SignupSchema, LoginSchema, EmployeeSchema + +DB_PATH = "database.db" + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + def init_routes(app): + + # ========================= + # AUTH + # ========================= @app.route('/api/signup', methods=['POST']) def signup(): - data = request.get_json() - email = data.get('email') + try: + data = SignupSchema().load(request.get_json()) + except ValidationError as err: + return jsonify(err.messages), 400 + password = data.get('password') - organization_id = data.get('organization_id') # Users must join an organization + token = data.get('token') - if not all([email, password, organization_id]): - return jsonify({'message': 'Email, password, and organization ID are required'}), 400 + conn = get_db() + c = conn.cursor() + + c.execute( + ''' + SELECT email, role, organization_id, expires_at, is_used + FROM invitations + WHERE token = ? + ''', + (token,) + ) + invitation = c.fetchone() + + if not invitation: + conn.close() + return jsonify({'message': 'Invalid token'}), 404 + + if invitation['is_used']: + conn.close() + return jsonify({'message': 'Token already used'}), 400 + + if datetime.strptime( + invitation['expires_at'], '%Y-%m-%d %H:%M:%S.%f' + ) < datetime.now(): + conn.close() + return jsonify({'message': 'Token expired'}), 400 - hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + hashed_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()) try: - conn = sqlite3.connect('database.db') - c = conn.cursor() - # By default, new users are 'employees'. Admins can change this later. c.execute( - 'INSERT INTO users (email, password, organization_id, role) VALUES (?, ?, ?, ?)', - (email, hashed_password, organization_id, 'employee') + ''' + INSERT INTO users (email, password, role, organization_id) + VALUES (?, ?, ?, ?) + ''', + ( + invitation['email'], + hashed_password, + invitation['role'], + invitation['organization_id'] + ) + ) + c.execute( + 'UPDATE invitations SET is_used = 1 WHERE token = ?', + (token,) ) conn.commit() conn.close() - return jsonify({'message': 'Signup successful!'}), 201 + return jsonify({'message': 'Signup successful'}), 201 except sqlite3.IntegrityError: + conn.close() return jsonify({'message': 'Email already exists'}), 400 + # ========================= + # LOGIN + # ========================= @app.route('/api/login', methods=['POST']) def login(): - data = request.get_json() - email = data.get('email') - password = data.get('password') - - if not email or not password: - return jsonify({'message': 'Email and password are required'}), 400 + try: + data = LoginSchema().load(request.get_json()) + except ValidationError as err: + return jsonify(err.messages), 400 - conn = sqlite3.connect('database.db') + conn = get_db() c = conn.cursor() - c.execute('SELECT id, password, role, organization_id FROM users WHERE email = ?', (email,)) + c.execute( + ''' + SELECT id, email, password, role, organization_id + FROM users + WHERE email = ? + ''', + (data['email'],) + ) user = c.fetchone() conn.close() - if not user: - return jsonify({'message': 'Invalid email or password'}), 401 - - user_id, hashed_password, role, organization_id = user - - if not bcrypt.checkpw(password.encode('utf-8'), hashed_password): - return jsonify({'message': 'Invalid email or password'}), 401 + if not user or not bcrypt.checkpw( + data['password'].encode(), user['password'] + ): + return jsonify({'message': 'Invalid credentials'}), 401 - # Save user info in session - session['user_id'] = user_id - session['email'] = email - session['role'] = role - session['organization_id'] = organization_id - session['is_admin'] = role in ['org_admin', 'super_admin'] + session.update({ + 'user_id': user['id'], + 'email': user['email'], + 'role': user['role'], + 'organization_id': user['organization_id'], + 'is_admin': user['role'] in ['org_admin', 'super_admin'] + }) return jsonify({ - 'id': user_id, - 'email': email, - 'role': role, - 'organization_id': organization_id + 'id': user['id'], + 'email': user['email'], + 'role': user['role'], + 'organization_id': user['organization_id'] }), 200 + # ========================= + # CURRENT USER + # ========================= @app.route('/api/user', methods=['GET']) def get_current_user(): - if 'email' in session: - return jsonify({ - 'id': session.get('user_id'), - 'email': session['email'], - 'role': session.get('role'), - 'organization_id': session.get('organization_id'), - 'is_admin': session.get('is_admin', False) - }) - else: - return jsonify({'email': None, 'is_admin': False}) + if 'user_id' not in session: + return jsonify({'user': None}), 401 + return jsonify({ + 'id': session['user_id'], + 'email': session['email'], + 'role': session['role'], + 'organization_id': session['organization_id'], + 'is_admin': session['is_admin'] + }) + + # ========================= + # EMPLOYEES (ADMIN ONLY) + # ========================= @app.route('/api/employees', methods=['GET']) def get_employees(): - conn = sqlite3.connect('database.db') + if not session.get('is_admin'): + return jsonify({'message': 'Admins only'}), 403 + + conn = get_db() c = conn.cursor() c.execute("SELECT * FROM employees") - employees = c.fetchall() + rows = c.fetchall() conn.close() - return jsonify(employees), 200 + + return jsonify([dict(r) for r in rows]) @app.route('/api/employees', methods=['POST']) def add_employee(): if not session.get('is_admin'): - return jsonify({'message': 'Unauthorized: Admins only'}), 403 + return jsonify({'message': 'Admins only'}), 403 - data = request.get_json() - email = data.get('email') - first_name = data.get('first_name') - last_name = data.get('last_name') - position = data.get('position') - department = data.get('department') - phone = data.get('phone') + try: + data = EmployeeSchema().load(request.get_json()) + except ValidationError as err: + return jsonify(err.messages), 400 - conn = sqlite3.connect('database.db') + conn = get_db() c = conn.cursor() - c.execute(''' - INSERT INTO employees (first_name, last_name, email, position, department, phone) + c.execute( + ''' + INSERT INTO employees + (first_name, last_name, email, position, department, phone) VALUES (?, ?, ?, ?, ?, ?) - ''', (first_name, last_name, email, position, department, phone)) - + ''', + ( + data['first_name'], + data.get('last_name'), + data['email'], + data.get('position'), + data.get('department'), + data.get('phone') + ) + ) conn.commit() conn.close() - return jsonify({'message': 'Employee added successfully!'}), 201 + return jsonify({'message': 'Employee added'}), 201 diff --git a/backend/app/routes/invite_routes.py b/backend/app/routes/invite_routes.py new file mode 100644 index 0000000..8ea0be8 --- /dev/null +++ b/backend/app/routes/invite_routes.py @@ -0,0 +1,51 @@ +import os +import secrets +from datetime import datetime, timedelta +from flask import Blueprint, request, jsonify +from werkzeug.security import generate_password_hash + +from app.database import db +from app.models import UserInvite + +invite_bp = Blueprint("invite", __name__) + +FRONTEND_URL = os.getenv("FRONTEND_URL") + +@invite_bp.route("/invite", methods=["POST"]) +def invite_user(): + if not FRONTEND_URL: + return jsonify({"error": "FRONTEND_URL not set"}), 500 + + data = request.get_json() + email = data.get("email") + role = data.get("role") + + if not email or not role: + return jsonify({"error": "Email and role are required"}), 400 + + # 1️⃣ generate secure token + raw_token = secrets.token_urlsafe(32) + + # 2️⃣ hash token for storage + token_hash = generate_password_hash(raw_token) + + # 3️⃣ create invite record + invite = UserInvite( + email=email, + role=role, + token_hash=token_hash, + expires_at=datetime.utcnow() + timedelta(hours=24), + used=False + ) + + db.session.add(invite) + db.session.commit() + + # 4️⃣ build FULL invite link + invite_link = f"{FRONTEND_URL}/accept-invitation?token={raw_token}" + + # 5️⃣ return link instead of token + return jsonify({ + "message": "Invitation created", + "invite_link": invite_link + }), 201 diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..d7115ba --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,17 @@ +from marshmallow import Schema, fields, validate + +class SignupSchema(Schema): + password = fields.Str(required=True, validate=validate.Length(min=8)) + token = fields.Str(required=True) + +class LoginSchema(Schema): + email = fields.Email(required=True) + password = fields.Str(required=True) + +class EmployeeSchema(Schema): + email = fields.Email(required=True) + first_name = fields.Str(required=True, validate=validate.Length(min=1)) + last_name = fields.Str(required=True, validate=validate.Length(min=1)) + position = fields.Str(required=True) + department = fields.Str(required=True) + phone = fields.Str(required=True) diff --git a/backend/app/user_routes.py b/backend/app/user_routes.py new file mode 100644 index 0000000..fe8aab5 --- /dev/null +++ b/backend/app/user_routes.py @@ -0,0 +1,93 @@ +#backend/app/user_routes.py + +from flask import jsonify, session +from app.auth import role_required +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "database.db") + + +def init_user_routes(app): + + # ========================= + # LIST USERS (FOR MESSAGING) + # ========================= + @app.route("/api/users", methods=["GET"]) + @role_required(["employee", "manager", "admin", "super_admin"]) + def list_users(): + org_id = session.get("organization_id") + user_id = session.get("user_id") + role = session.get("role") + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + if role == "super_admin": + c.execute(""" + SELECT id, email, role + FROM users + WHERE id != ? + """, (user_id,)) + elif role == "admin": + c.execute(""" + SELECT id, email, role + FROM users + WHERE (organization_id = ? OR role = 'admin') + AND id != ? + """, (org_id, user_id)) + elif role == "manager": + c.execute(""" + SELECT id, email, role + FROM users + WHERE (organization_id = ? OR role = 'manager') + AND id != ? + """, (org_id, user_id)) + else: # employee + c.execute(""" + SELECT id, email, role + FROM users + WHERE organization_id = ? + AND id != ? + """, (org_id, user_id)) + + users = [ + {"id": u[0], "email": u[1], "role": u[2]} + for u in c.fetchall() + ] + + conn.close() + return jsonify(users), 200 + + + # ========================= + # GET USER PROFILE + # ========================= + @app.route("/api/users/", methods=["GET"]) + @role_required(["employee", "manager", "admin", "super_admin"]) + def get_user_profile(user_id): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + c.execute(""" + SELECT u.id, u.email, u.role, + e.first_name, e.last_name, e.position + FROM users u + LEFT JOIN employees e ON u.id = e.user_id + WHERE u.id = ? + """, (user_id,)) + + user = c.fetchone() + conn.close() + + if not user: + return jsonify({"message": "User not found"}), 404 + + return jsonify({ + "id": user[0], + "email": user[1], + "role": user[2], + "first_name": user[3], + "last_name": user[4], + "position": user[5], + }), 200 \ No newline at end of file diff --git a/backend/database.db b/backend/database.db index b9a9214..3f1622c 100644 Binary files a/backend/database.db and b/backend/database.db differ diff --git a/backend/main.py b/backend/main.py index 6031b21..0595c06 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,32 +1,64 @@ -from flask import Flask +#backend/main.py + +from flask import Flask, session from flask_cors import CORS +from flask_socketio import SocketIO +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_talisman import Talisman +import os + +from app.db_setup import initialize_database from app.routes import init_routes -from app.assignment_routes import init_assignment_routes from app.org_routes import init_org_routes +from app.assignment_routes import init_assignment_routes +from app.messaging_routes import init_messaging_routes +from app.user_routes import init_user_routes from app.submission_routes import init_submission_routes -from app.db_setup import initialize_database +from app.invitation_routes import init_invitation_routes -# Ensure DB is initialized first +# Initialize the database first initialize_database() app = Flask(__name__) -app.secret_key = 'f3d9b1c2e7a54d1f8b3c9e4d0a67f821' +app.secret_key = os.environ.get('FLASK_SECRET_KEY') +if not app.secret_key: + raise ValueError("No FLASK_SECRET_KEY set for Flask application") +CORS(app, supports_credentials=True) -# CORS setup for React frontend -CORS( +# Initialize Talisman for security headers +Talisman(app) + +def get_user_id(): + if 'user_id' in session: + return session['user_id'] + return get_remote_address + +# Initialize Rate Limiter +# Rate limits are applied to selected routes in app/routes.py +limiter = None +# limiter = Limiter( +# key_func=get_user_id, +# app=app, +# default_limits=["200 per day", "50 per hour"] +# ) + +# Initialize SocketIO +socketio = SocketIO( app, - origins=["http://localhost:3000"], - supports_credentials=True, - methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allow_headers=["Content-Type", "Authorization"], + cors_allowed_origins="http://localhost:3000", + manage_session=False, + async_mode="threading" ) -# Register API routes +# Initialize routes init_routes(app) -init_assignment_routes(app) init_org_routes(app) +init_assignment_routes(app) +init_messaging_routes(app, socketio) +init_user_routes(app) init_submission_routes(app) +init_invitation_routes(app) if __name__ == '__main__': - print("Starting backend on http://0.0.0.0:8000") - app.run(debug=True, host='0.0.0.0', port=8000) + socketio.run(app, debug=True, port=8000) \ No newline at end of file diff --git a/backend/make_super_admin.py b/backend/make_super_admin.py new file mode 100644 index 0000000..5f7af9b --- /dev/null +++ b/backend/make_super_admin.py @@ -0,0 +1,34 @@ + +import sqlite3 +import bcrypt +import argparse +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), 'database.db') + +def make_super_admin(email, password): + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + try: + c.execute( + 'INSERT INTO users (email, password, role) VALUES (?, ?, ?)', + (email, hashed_password, 'super_admin') + ) + conn.commit() + print(f"Successfully created super admin: {email}") + except sqlite3.IntegrityError: + print(f"Error: User with email {email} already exists.") + finally: + conn.close() + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Create a new super admin user.') + parser.add_argument('email', type=str, help='Email of the super admin') + parser.add_argument('password', type=str, help='Password for the super admin') + + args = parser.parse_args() + + make_super_admin(args.email, args.password) diff --git a/backend/migration_1.py b/backend/migration_1.py new file mode 100644 index 0000000..cfc10ab --- /dev/null +++ b/backend/migration_1.py @@ -0,0 +1,36 @@ +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "database.db") + +def migrate(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # Add last_message_at to conversations table + try: + c.execute("ALTER TABLE conversations ADD COLUMN last_message_at DATETIME") + except sqlite3.OperationalError as e: + if "duplicate column name" in str(e): + print("Column last_message_at already exists in conversations table.") + else: + raise + + # Create unread_messages table + c.execute(""" + CREATE TABLE IF NOT EXISTS unread_messages ( + user_id INTEGER NOT NULL, + conversation_id INTEGER NOT NULL, + unread_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, conversation_id), + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (conversation_id) REFERENCES conversations (id) + ) + """) + + conn.commit() + conn.close() + print("Migration 1 applied successfully.") + +if __name__ == "__main__": + migrate() diff --git a/backend/requirements.txt b/backend/requirements.txt index df78244..8897b0d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,7 @@ -flask==2.3.3 -flask-cors==4.0.1 # For handling CORS (cross-origin requests from frontend) -bcrypt==4.2.0 # For password hashing \ No newline at end of file +Flask==3.0.0 +Flask-Cors==3.0.10 +Flask-SocketIO==5.3.6 +Flask-Limiter==3.5.0 +python-dotenv==1.0.1 +marshmallow==3.19.0 +Flask-Talisman==1.1.0 diff --git a/database.db b/database.db new file mode 100644 index 0000000..e69de29 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c97b15a..3a7ffaf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "react": "^19.1.0", "react-dom": "19.1.0", "react-icons": "^5.5.0", + "socket.io-client": "^4.8.3", "zod": "^4.0.5" }, "devDependencies": { @@ -677,6 +678,12 @@ "node": ">= 10" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -1086,6 +1093,23 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -1096,6 +1120,28 @@ "node": ">=8" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", @@ -1422,6 +1468,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1674,6 +1726,34 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1768,6 +1848,35 @@ "dev": true, "license": "MIT" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 89d8759..dfc888c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "react": "^19.1.0", "react-dom": "19.1.0", "react-icons": "^5.5.0", + "socket.io-client": "^4.8.3", "zod": "^4.0.5" }, "devDependencies": { diff --git a/frontend/src/api/submissionApi.js b/frontend/src/api/submissionApi.js index 0634ce3..1ed2c60 100644 --- a/frontend/src/api/submissionApi.js +++ b/frontend/src/api/submissionApi.js @@ -1,6 +1,6 @@ import axios from "axios"; -const API_BASE = "http://localhost:8000/api"; +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL; export const fetchAssignments = async () => { const res = await axios.get(`${API_BASE}/assignments`, { withCredentials: true }); diff --git a/frontend/src/app/accept-invitation/page.tsx b/frontend/src/app/accept-invitation/page.tsx new file mode 100644 index 0000000..3e6f0cd --- /dev/null +++ b/frontend/src/app/accept-invitation/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function AcceptInvitationPage() { + const router = useRouter(); + const [token, setToken] = useState(""); + const [invitation, setInvitation] = useState(null); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const t = urlParams.get("token"); + if (!t) { + setError("No token provided"); + setLoading(false); + return; + } + setToken(t); + + fetch(`http://localhost:8000/api/invitations/${t}`, { + credentials: "include", + }) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.message || "Invalid token"); + setInvitation(data); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, []); + + const handleRegister = async () => { + setError(""); + setSuccess(""); + + if (!password || !confirmPassword) { + setError("Please fill both password fields"); + return; + } + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + try { + const res = await fetch("http://localhost:8000/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + email: invitation.email, + password, + role: invitation.role, + organization_id: invitation.organization_id, + token, + }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.message); + + setSuccess("Account created successfully! Redirecting to login..."); + setTimeout(() => router.push("/login"), 2000); + } catch (err: any) { + setError(err.message || "Failed to register"); + } + }; + + if (loading) return

Loading...

; + if (error) return

{error}

; + + return ( +
+

Accept Invitation

+ +
+

Email: {invitation.email}

+

Role: {invitation.role}

+

Organization ID: {invitation.organization_id}

+ + {error &&

{error}

} + {success &&

{success}

} + + setPassword(e.target.value)} /> + setConfirmPassword(e.target.value)} /> + + +
+
+ ); +} diff --git a/frontend/src/app/admin/invitations/page.tsx b/frontend/src/app/admin/invitations/page.tsx new file mode 100644 index 0000000..8ff5882 --- /dev/null +++ b/frontend/src/app/admin/invitations/page.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "../../../context/AuthContext"; +import { useRouter } from "next/navigation"; + +type Invitation = { + id: number; + email: string; + role: string; + organization_id: number; + expires_at: string; + is_used: boolean; + token?: string; // include token for generating link +}; + +export default function AdminInvitationsPage() { + const { user } = useAuth(); + const router = useRouter(); + + const [invitations, setInvitations] = useState([]); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("employee"); + const [organizationId, setOrganizationId] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [successLink, setSuccessLink] = useState(""); + + /* 🔐 Protect route */ + useEffect(() => { + if (user && user.role !== "super_admin") { + router.push("/login"); + } + }, [user]); + + /* 📥 Load invitations */ + useEffect(() => { + if (user?.role === "super_admin") { + fetchInvitations(); + } + }, [user]); + + async function fetchInvitations() { + try { + const res = await fetch("http://localhost:8000/api/invitations", { + credentials: "include", + }); + const data = await res.json(); + setInvitations(data.invitations || []); + } catch { + setError("Failed to load invitations"); + } + } + + async function createInvitation() { + setError(""); + setSuccess(""); + setSuccessLink(""); + + if (!email || !organizationId) { + setError("Email and Organization ID are required"); + return; + } + + try { + const res = await fetch("http://localhost:8000/api/invitations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + email, + role, + organization_id: Number(organizationId), + }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.message); + + // Create clickable link + const link = `http://localhost:3000/accept-invitation?token=${data.token}`; + setSuccess("Invitation created successfully."); + setSuccessLink(link); + + setEmail(""); + setOrganizationId(""); + fetchInvitations(); + } catch (err: any) { + setError(err.message || "Failed to create invitation"); + } + } + + async function deleteInvitation(id: number) { + if (!confirm("Delete this invitation?")) return; + + try { + const res = await fetch(`http://localhost:8000/api/invitations/${id}`, { + method: "DELETE", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.message || "Failed to delete invitation"); + + fetchInvitations(); + } catch (err: any) { + setError(err.message || "Failed to delete invitation"); + } + } + + if (!user) return

Loading...

; + + return ( +
+

Admin – Invitations

+ + {/* CREATE INVITATION */} +
+

Create Invitation

+ {error &&

{error}

} + {success && ( +

+ {success} +
+ {successLink && ( + + {successLink} + + )} +

+ )} + +
+ setEmail(e.target.value)} + /> + + setOrganizationId(e.target.value)} + /> +
+ + +
+ + {/* INVITATION LIST */} +
+

Existing Invitations

+ {invitations.length === 0 ? ( +

No invitations found.

+ ) : ( +
    + {invitations.map((inv) => ( +
  • +
    +
    {inv.email}
    +
    + Role: {inv.role} | Org: {inv.organization_id} +
    +
    + Expires: {inv.expires_at} | Used: {inv.is_used ? "Yes" : "No"} +
    + {inv.token && ( + + http://localhost:3000/accept-invitation?token={inv.token} + + )} +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/assignments/[id]/page.tsx b/frontend/src/app/assignments/[id]/page.tsx index beb307f..bdbf619 100644 --- a/frontend/src/app/assignments/[id]/page.tsx +++ b/frontend/src/app/assignments/[id]/page.tsx @@ -1,9 +1,13 @@ +//frontend/src/app/assignments/[id]/page.tsx + "use client"; import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { useAuth } from "../../../context/AuthContext"; +/* ================= TYPES ================= */ + type Assignment = { id: number; title: string; @@ -24,11 +28,20 @@ type Submission = { employee_email?: string; }; +/* ================= PAGE ================= */ + export default function AssignmentDetailPage() { const { user } = useAuth(); const currentUser = user ?? null; - const { id } = useParams(); + const params = useParams(); + + // ✅ SAFELY extract id + const assignmentId = + params && typeof params.id === "string" + ? Number(params.id) + : null; + const [assignment, setAssignment] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); @@ -38,19 +51,24 @@ export default function AssignmentDetailPage() { const [uploading, setUploading] = useState(false); const [message, setMessage] = useState(""); + /* ================= LOAD ASSIGNMENT ================= */ + useEffect(() => { - if (!currentUser) return; + if (!currentUser || assignmentId === null) return; async function load() { setLoading(true); try { - const res = await fetch(`http://localhost:8000/api/assignments/${id}`, { - credentials: "include", - }); + const res = await fetch( + `http://localhost:8000/api/assignments/${assignmentId}`, + { credentials: "include" } + ); + if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.message || "Failed to load assignment"); } + const data = await res.json(); setAssignment(data.assignment); } catch (err: any) { @@ -61,22 +79,27 @@ export default function AssignmentDetailPage() { } load(); - }, [id, currentUser?.id, currentUser?.role]); + }, [assignmentId, currentUser]); + + /* ================= LOAD SUBMISSIONS ================= */ useEffect(() => { - if (!currentUser) return; + if (!currentUser || assignmentId === null) return; fetchSubmissions(); - }, [id, currentUser]); + }, [assignmentId, currentUser]); async function fetchSubmissions() { try { - const res = await fetch(`http://localhost:8000/api/submissions/${id}`, { - credentials: "include", - }); + const res = await fetch( + `http://localhost:8000/api/submissions/${assignmentId}`, + { credentials: "include" } + ); + if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.message || "Failed to load submissions"); } + const data = await res.json(); setSubmissions(data.submissions ?? []); } catch (err: any) { @@ -84,33 +107,41 @@ export default function AssignmentDetailPage() { } } + /* ================= HELPERS ================= */ + function alreadySubmitted(): boolean { if (!currentUser) return false; return submissions.some((s) => s.employee_id === currentUser.id); } + /* ================= UPLOAD ================= */ + async function handleUpload(e: React.FormEvent) { e.preventDefault(); - if (!uploadFile) { + + if (!uploadFile || assignmentId === null) { setMessage("Please choose a file before uploading."); return; } + setUploading(true); setMessage(""); + try { const fd = new FormData(); fd.append("file", uploadFile); - const res = await fetch(`http://localhost:8000/api/submissions/${id}`, { - method: "POST", - credentials: "include", - body: fd, - }); + const res = await fetch( + `http://localhost:8000/api/submissions/${assignmentId}`, + { + method: "POST", + credentials: "include", + body: fd, + } + ); const data = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(data.message || "Upload failed"); - } + if (!res.ok) throw new Error(data.message || "Upload failed"); setMessage("Uploaded successfully."); setUploadFile(null); @@ -122,14 +153,18 @@ export default function AssignmentDetailPage() { } } + /* ================= ADMIN ACTIONS ================= */ + async function handleAccept(submissionId: number) { try { const res = await fetch( `http://localhost:8000/api/submissions/${submissionId}/accept`, { method: "POST", credentials: "include" } ); + const data = await res.json(); if (!res.ok) throw new Error(data.message || "Accept failed"); + await fetchSubmissions(); } catch (err: any) { console.error("Accept error:", err); @@ -142,8 +177,10 @@ export default function AssignmentDetailPage() { `http://localhost:8000/api/submissions/delete/${submissionId}`, { method: "DELETE", credentials: "include" } ); + const data = await res.json(); if (!res.ok) throw new Error(data.message || "Delete failed"); + await fetchSubmissions(); } catch (err: any) { console.error("Delete error:", err); @@ -156,7 +193,14 @@ export default function AssignmentDetailPage() { )}`; } - if (!currentUser) return

Please log in to see this assignment.

; + /* ================= RENDER ================= */ + + if (!currentUser) + return

Please log in to see this assignment.

; + + if (assignmentId === null) + return

Invalid assignment ID.

; + if (loading) return

Loading…

; if (error) return

{error}

; if (!assignment) return

Assignment not found.

; @@ -166,34 +210,49 @@ export default function AssignmentDetailPage() { {/* Assignment Header */}

{assignment.title}

- {assignment.due_date &&

Due: {assignment.due_date}

} + {assignment.due_date && ( +

Due: {assignment.due_date}

+ )}

{assignment.description}

{/* Upload Section */}
-

Upload Your Submission

+

+ Upload Your Submission +

+

Status:{" "} {alreadySubmitted() ? ( - Already submitted ✅ + + Already submitted ✅ + ) : ( Not submitted ❌ )}

-
+ + setUploadFile(e.target.files ? e.target.files[0] : null)} + onChange={(e) => + setUploadFile(e.target.files ? e.target.files[0] : null) + } className="border p-2 rounded w-full md:w-auto" /> + +
+ {message &&

{message}

}
- {/* Submissions List */} + {/* Submissions */}
-

Submissions

+

+ Submissions +

+ {submissions.length === 0 ? (

No submissions yet 😢

) : ( @@ -218,31 +281,24 @@ export default function AssignmentDetailPage() { {submissions.map((s) => (
  • -
    {s.employee_email ?? `User ${s.employee_id}`}
    +
    + {s.employee_email ?? `User ${s.employee_id}`} +
    {new Date(s.submitted_at).toLocaleString()} | Status:{" "} - - {s.status.toUpperCase()} - + {s.status}
    +
    Download @@ -253,13 +309,13 @@ export default function AssignmentDetailPage() { <> diff --git a/frontend/src/app/assignments/[id]/submit/page.tsx b/frontend/src/app/assignments/[id]/submit/page.tsx index 7b6b19f..6d506ed 100644 --- a/frontend/src/app/assignments/[id]/submit/page.tsx +++ b/frontend/src/app/assignments/[id]/submit/page.tsx @@ -1,36 +1,64 @@ +//frontend/src/app/assignements/[id]/submit/page.tsx + "use client"; import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import AssignmentUploader from "../../../../components/AssignmentUploader"; +/* ================= PAGE ================= */ + export default function SubmitAssignmentPage() { - const { id } = useParams(); - const assignmentId = Number(id); + const params = useParams(); + + // ✅ SAFE extraction of id + const assignmentId = + params && typeof params.id === "string" + ? Number(params.id) + : null; const [loading, setLoading] = useState(true); const [assignment, setAssignment] = useState(null); + const [error, setError] = useState(""); + + /* ================= LOAD ASSIGNMENT ================= */ useEffect(() => { + if (assignmentId === null) return; + const loadAssignment = async () => { - const res = await fetch(`/api/assignments/${assignmentId}`); - const data = await res.json(); + try { + const res = await fetch(`/api/assignments/${assignmentId}`); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error(data.message || "Failed to load assignment"); + } - if (res.ok) { setAssignment(data.assignment); + } catch (err: any) { + setError(err.message || "Error loading assignment"); + } finally { + setLoading(false); } - - setLoading(false); }; loadAssignment(); }, [assignmentId]); - if (loading) return

    Loading...

    ; - if (!assignment) return

    Assignment not found.

    ; + /* ================= RENDER ================= */ + + if (assignmentId === null) { + return

    Invalid assignment ID.

    ; + } + + if (loading) return

    Loading...

    ; + if (error) return

    {error}

    ; + if (!assignment) return

    Assignment not found.

    ; return ( -
    +
    {/* -------- ASSIGNMENT HEADER -------- */}

    {assignment.title}

    @@ -38,13 +66,15 @@ export default function SubmitAssignmentPage() { {assignment.due_date && (

    - Due Date: {assignment.due_date} + Due Date:{" "} + {assignment.due_date}

    )} {/* -------- FILE UPLOADER -------- */} - - +
    + +
    ); } diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 967f27f..148982b 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -12,17 +12,18 @@ const schema = z.object({ password: z.string().min(6, "Password must be at least 6 characters"), }); +type LoginForm = z.infer; + export default function LoginPage() { const { login } = useAuth(); const router = useRouter(); - const [serverMessage, setServerMessage] = useState(""); const [error, setError] = useState(""); - const { register, handleSubmit, formState: { errors } } = useForm({ + const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema), }); - const onSubmit = async (data: any) => { + const onSubmit = async (data: LoginForm) => { setError(""); try { const res = await fetch("http://localhost:8000/api/login", { @@ -39,12 +40,15 @@ export default function LoginPage() { return; } + // login into context login(result.email, result.role, result.id); + // redirect based on role if (result.role === "super_admin") router.push("/super/dashboard"); else if (result.role === "org_admin") router.push("/org/dashboard"); else if (result.role === "team_manager") router.push("/manager/dashboard"); else router.push("/assignments"); + } catch (err) { console.error(err); setError("Server error. Please try again later."); @@ -54,17 +58,11 @@ export default function LoginPage() { return (
    - - {/* Title */}

    Login

    - {/* Messages */} {error &&

    {error}

    } - {serverMessage &&

    {serverMessage}

    }
    - - {/* Email */}
    {errors.email.message}

    }
    - {/* Password */}
    {errors.password.message}

    }
    - {/* Button */}
    - - -

    - Already have an account?{" "} - Login -

    - -
    -
    - ); -} diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx new file mode 100644 index 0000000..770083d --- /dev/null +++ b/frontend/src/components/ChatWindow.tsx @@ -0,0 +1,286 @@ +//frontend/src/components/ChatWindow.tsx + +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useSocket } from "../context/SocketContext"; +import MessageInput from "./MessageInput"; +import { Conversation } from "../types/conversation"; + +type Message = { + id: number; + sender_email: string; + content: string; + created_at: string; + status?: string; + read_by?: string[]; +}; + +type Participant = { + id: number; + email: string; + role: string; +}; + +const API_BASE = "http://localhost:8000"; + +export default function ChatWindow({ + conversation, + currentUser, + onDeleted, +}: { + conversation: Conversation; + currentUser: { id: number; email: string; role: string }; + onDeleted: () => void; +}) { + const socket = useSocket(); + const bottomRef = useRef(null); + + const [messages, setMessages] = useState([]); + const [participants, setParticipants] = useState([]); + const [showDetails, setShowDetails] = useState(false); + const [typingUser, setTypingUser] = useState(null); + + const conversationId = conversation.id; + const isGroup = Boolean(conversation.is_group_chat); + const isSuperAdmin = currentUser.role === "super_admin"; + + /* ================= LOAD MESSAGES ================= */ + useEffect(() => { + fetch(`${API_BASE}/api/conversations/${conversationId}/messages`, { + credentials: "include", + }) + .then(res => res.json()) + .then(setMessages); + + socket.emit("join", { conversation_id: conversationId }); + + socket.on("new_message", (data: any) => { + if (data.conversation_id === conversationId) { + setMessages(prev => [...prev, data.message]); + } + }); + + socket.on("user_typing", (data: any) => { + setTypingUser(data.user_email); + }); + + socket.on("user_stopped_typing", () => { + setTypingUser(null); + }); + + socket.on("message_status_updated", (data: any) => { + setMessages(prev => + prev.map(m => + m.id === data.message_id + ? { + ...m, + status: "read", + read_by: [...(m.read_by || []), data.read_by], + } + : m + ) + ); + }); + + return () => { + socket.emit("leave", { conversation_id: conversationId }); + socket.off(); + }; + }, [conversationId, socket]); + + /* ================= AUTO SCROLL ================= */ + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, typingUser]); + + /* ================= MARK AS READ ================= */ + useEffect(() => { + messages.forEach(msg => { + if ( + msg.sender_email !== currentUser.email && + (!msg.read_by || !msg.read_by.includes(currentUser.email)) + ) { + socket.emit("mark_as_read", { + conversation_id: conversationId, + message_id: msg.id, + }); + } + }); + + socket.emit("mark_conversation_as_read", { + conversation_id: conversationId, + }); + }, [messages, socket, conversationId, currentUser.email]); + + /* ================= LOAD PARTICIPANTS ================= */ + useEffect(() => { + if (!showDetails) return; + + fetch(`${API_BASE}/api/conversations/${conversationId}/participants`, { + credentials: "include", + }) + .then(res => res.json()) + .then(setParticipants); + }, [showDetails, conversationId]); + + /* ================= REMOVE MEMBER ================= */ + async function handleRemoveMember(userId: number) { + await fetch( + `${API_BASE}/api/conversations/${conversationId}/participants/${userId}`, + { + method: "DELETE", + credentials: "include", + } + ); + + setParticipants(prev => prev.filter(p => p.id !== userId)); + } + + /* ================= DELETE / LEAVE ================= */ + async function handleDeleteOrLeave() { + if (isSuperAdmin) { + await fetch(`${API_BASE}/api/conversations/${conversationId}`, { + method: "DELETE", + credentials: "include", + }); + } else { + await fetch(`${API_BASE}/api/conversations/${conversationId}/leave`, { + method: "POST", + credentials: "include", + }); + } + + onDeleted(); + } + + const displayName = isGroup + ? conversation.name + : conversation.participants?.find( + p => p.email !== currentUser.email + )?.email; + + return ( +
    + {/* HEADER */} +
    +
    + {displayName || "Conversation"} +
    + + +
    + + {/* MESSAGES */} +
    + {messages.map(msg => ( +
    +
    + {msg.content} + + {msg.sender_email === currentUser.email && + msg.status === "read" && ( +
    + Seen +
    + )} +
    +
    + ))} + + {typingUser && ( +
    + {typingUser} is typing… +
    + )} + +
    +
    + + {/* INPUT */} + + + {/* DETAILS MODAL */} + {showDetails && ( +
    +
    +

    Conversation Details

    + + {isGroup ? ( + <> +
    Members
    +
      + {participants.map(p => ( +
    • + + {p.email} + + ({p.role}) + + + + {isSuperAdmin && + p.email !== currentUser.email && ( + + )} +
    • + ))} +
    + + ) : ( +
    + This is a direct message. +
    + )} + +
    + + + +
    +
    +
    + )} +
    + ); +} diff --git a/frontend/src/components/ConversationList.tsx b/frontend/src/components/ConversationList.tsx new file mode 100644 index 0000000..8b04741 --- /dev/null +++ b/frontend/src/components/ConversationList.tsx @@ -0,0 +1,284 @@ +//frontend/src/components/ConversationList.tsx + +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useAuth } from "../context/AuthContext"; +import { Conversation } from "../types/conversation"; + +type UserOption = { + id: number; + email: string; +}; + +const API_BASE = "http://localhost:8000"; + +async function safeFetchJson(url: string, options?: RequestInit) { + try { + const res = await fetch(url, options); + const text = await res.text(); + return JSON.parse(text); + } catch { + return null; + } +} + +export default function ConversationList({ + conversations, + activeConversationId, + onSelect, + onConversationCreated, +}: { + conversations: Conversation[]; + activeConversationId: number | null; + onSelect: (id: number | null) => void; + onConversationCreated: (c: Conversation) => void; +}) { + const { user } = useAuth(); + + const [users, setUsers] = useState([]); + const [search, setSearch] = useState(""); + + const [showDirect, setShowDirect] = useState(false); + const [showGroup, setShowGroup] = useState(false); + + const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [groupName, setGroupName] = useState(""); + + /* ================= LOAD USERS ================= */ + useEffect(() => { + if (!user) return; + + safeFetchJson(`${API_BASE}/api/users`, { + credentials: "include", + }).then(data => { + if (Array.isArray(data)) { + setUsers(data.filter(u => u.id !== user.id)); + } + }); + }, [user]); + + /* ================= DIRECT CHAT ================= */ + async function startDirectChat() { + if (!user || !selectedUserId) return; + + const other = users.find(u => u.id === selectedUserId); + if (!other) return; + + const data = await safeFetchJson( + `${API_BASE}/api/conversations/direct`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: selectedUserId }), + } + ); + + if (!data?.conversation_id) return; + + onConversationCreated({ + id: data.conversation_id, + name: null, + is_group_chat: false, + participants: [ + { id: user.id, email: user.email }, + { id: other.id, email: other.email }, + ], + }); + + reset(); + } + + /* ================= GROUP CHAT ================= */ + async function createGroupChat() { + if (!user || user.role !== "super_admin") return; + if (!groupName || selectedUserIds.length === 0) return; + + const data = await safeFetchJson(`${API_BASE}/api/conversations`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: groupName, + participant_ids: selectedUserIds, + }), + }); + + if (!data?.conversation_id) return; + + const participants = users.filter(u => + selectedUserIds.includes(u.id) + ); + participants.push({ id: user.id, email: user.email }); + + onConversationCreated({ + id: data.conversation_id, + name: groupName, + is_group_chat: true, + participants, + }); + + reset(); + } + + function reset() { + setShowDirect(false); + setShowGroup(false); + setSelectedUserId(null); + setSelectedUserIds([]); + setGroupName(""); + setSearch(""); + } + + /* ================= FILTER USERS ================= */ + const filteredUsers = useMemo(() => { + if (!search) return users; + return users.filter(u => + u.email.toLowerCase().includes(search.toLowerCase()) + ); + }, [search, users]); + + function getConversationName(c: Conversation) { + if (c.is_group_chat && c.name) return c.name; + const other = c.participants.find(p => p.email !== user?.email); + return other?.email ?? "Direct Message"; + } + + /* ================= RENDER ================= */ + return ( + + ); +} + diff --git a/frontend/src/components/CreateGroupModal.tsx b/frontend/src/components/CreateGroupModal.tsx new file mode 100644 index 0000000..92d736d --- /dev/null +++ b/frontend/src/components/CreateGroupModal.tsx @@ -0,0 +1,92 @@ +//frontend/src/components/CreateGroupModal.tsx + + +"use client"; + +import { useEffect, useState } from "react"; + +type User = { + id: number; + email: string; +}; + +export default function CreateGroupModal({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: () => void; +}) { + const [users, setUsers] = useState([]); + const [selected, setSelected] = useState([]); + const [name, setName] = useState(""); + + useEffect(() => { + fetch("http://localhost:8000/api/users", { + credentials: "include", + }) + .then(res => res.json()) + .then(setUsers); + }, []); + + function toggle(id: number) { + setSelected(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] + ); + } + + async function createGroup() { + if (!name || selected.length === 0) return; + + await fetch("http://localhost:8000/api/conversations/group", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + participants: selected, + }), + }); + + onCreated(); + onClose(); + } + + return ( +
    +
    +

    New Group

    + + setName(e.target.value)} + /> + +
    + {users.map(u => ( + + ))} +
    + +
    + + +
    +
    +
    + ); +} diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx new file mode 100644 index 0000000..4c9a8c2 --- /dev/null +++ b/frontend/src/components/MessageBubble.tsx @@ -0,0 +1,11 @@ +export default function MessageBubble({ message }: any) { + return ( +
    +
    {message.sender_email}
    +
    + {message.content} +
    +
    + ); + } + \ No newline at end of file diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx new file mode 100644 index 0000000..3b353a5 --- /dev/null +++ b/frontend/src/components/MessageInput.tsx @@ -0,0 +1,59 @@ +//frontend/src/components/MessageInput.tsx + +"use client"; + +import { useState } from "react"; +import { useSocket } from "../context/SocketContext"; + +export default function MessageInput({ + conversationId, +}: { + conversationId: number; +}) { + const socket = useSocket(); + const [content, setContent] = useState(""); + + async function send() { + if (!content.trim()) return; + + await fetch( + `http://localhost:8000/api/conversations/${conversationId}/messages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ content }), + } + ); + + setContent(""); + socket.emit("stop_typing", { conversation_id: conversationId }); + } + + function handleTyping(e: React.ChangeEvent) { + setContent(e.target.value); + socket.emit("typing", { conversation_id: conversationId }); + } + + return ( +
    + e.key === "Enter" && send()} + onBlur={() => + socket.emit("stop_typing", { conversation_id: conversationId }) + } + className="flex-1 border rounded-full px-4 py-2 text-sm focus:outline-none focus:ring" + placeholder="Type a message…" + spellCheck="true" + /> + +
    + ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 0cb583c..7901cc0 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuth } from '../context/AuthContext'; -import { FaUserTie } from 'react-icons/fa'; +import { FaUserTie, FaComments } from 'react-icons/fa'; export default function Navbar() { const router = useRouter(); @@ -16,22 +16,48 @@ export default function Navbar() { return (