feat(multi-company): Allow users to be associated with multiple companies
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Adds user_companies table with BEFORE/AFTER triggers to sync primary
company to users.company_id. Dashboard shows all user's companies with
edit buttons. Company edit routes accept optional company_id parameter.
Admin API endpoints for managing user-company associations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-06 19:13:10 +01:00
parent 1dbe3d2dfa
commit 2e6eca55e7
9 changed files with 469 additions and 45 deletions

View File

@ -17,7 +17,7 @@ from . import bp
from database import ( from database import (
SessionLocal, MembershipApplication, CompanyDataRequest, SessionLocal, MembershipApplication, CompanyDataRequest,
Company, Category, User, UserNotification, Person, CompanyPerson, CompanyPKD, Company, Category, User, UserNotification, Person, CompanyPerson, CompanyPKD,
SystemRole SystemRole, UserCompany
) )
from krs_api_service import get_company_from_krs from krs_api_service import get_company_from_krs
from utils.decorators import role_required from utils.decorators import role_required
@ -354,6 +354,16 @@ def admin_membership_approve(app_id):
user.company_id = company.id user.company_id = company.id
user.is_norda_member = True user.is_norda_member = True
# Create user-company association (multi-company support)
is_first_company = not db.query(UserCompany).filter_by(user_id=user.id).first()
user_company = UserCompany(
user_id=user.id,
company_id=company.id,
role=user.company_role or 'MANAGER',
is_primary=is_first_company,
)
db.add(user_company)
db.commit() db.commit()
logger.info( logger.info(

View File

@ -12,12 +12,13 @@ import re
import secrets import secrets
import string import string
import tempfile import tempfile
from datetime import datetime
from flask import jsonify, request from flask import jsonify, request
from flask_login import current_user, login_required from flask_login import current_user, login_required
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from database import SessionLocal, User, Company, SystemRole, CompanyRole, UserCompanyPermissions from database import SessionLocal, User, Company, SystemRole, CompanyRole, UserCompanyPermissions, UserCompany
from utils.decorators import role_required from utils.decorators import role_required
import gemini_service import gemini_service
from . import bp from . import bp
@ -349,6 +350,15 @@ def admin_users_change_role():
user.company_role = 'NONE' user.company_role = 'NONE'
# OFFICE_MANAGER and ADMIN keep their company_role unchanged # OFFICE_MANAGER and ADMIN keep their company_role unchanged
# Sync role to user_companies table (primary company)
if user.company_id and new_role in ['MANAGER', 'EMPLOYEE']:
uc = db.query(UserCompany).filter_by(
user_id=user.id, company_id=user.company_id
).first()
if uc:
uc.role = new_role
uc.updated_at = datetime.now()
# Create default permissions for EMPLOYEE if they have a company # Create default permissions for EMPLOYEE if they have a company
if new_role == 'EMPLOYEE' and user.company_id: if new_role == 'EMPLOYEE' and user.company_id:
existing_perms = db.query(UserCompanyPermissions).filter_by( existing_perms = db.query(UserCompanyPermissions).filter_by(
@ -399,3 +409,162 @@ def admin_users_get_roles():
] ]
return jsonify({'success': True, 'roles': roles}) return jsonify({'success': True, 'roles': roles})
# ============================================================
# USER-COMPANY ASSOCIATIONS (Multi-company support)
# ============================================================
@bp.route('/users-api/<int:user_id>/companies', methods=['GET'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_user_companies_list(user_id):
"""List all companies associated with a user."""
db = SessionLocal()
try:
user = db.query(User).get(user_id)
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
associations = db.query(UserCompany).filter_by(user_id=user_id).all()
result = []
for uc in associations:
company = db.query(Company).get(uc.company_id)
result.append({
'id': uc.id,
'company_id': uc.company_id,
'company_name': company.name if company else '(usunięta)',
'role': uc.role,
'is_primary': uc.is_primary,
'created_at': uc.created_at.isoformat() if uc.created_at else None,
})
return jsonify({'success': True, 'companies': result})
finally:
db.close()
@bp.route('/users-api/<int:user_id>/companies', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_user_company_add(user_id):
"""Add a company association to a user."""
db = SessionLocal()
try:
user = db.query(User).get(user_id)
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
data = request.get_json()
company_id = data.get('company_id')
role = data.get('role', 'MANAGER')
if not company_id:
return jsonify({'success': False, 'error': 'Brak company_id'}), 400
company = db.query(Company).get(company_id)
if not company:
return jsonify({'success': False, 'error': 'Firma nie znaleziona'}), 404
# Check if already associated
existing = db.query(UserCompany).filter_by(
user_id=user_id, company_id=company_id
).first()
if existing:
return jsonify({'success': False, 'error': 'Użytkownik jest już powiązany z tą firmą'}), 409
# If user has no companies yet, make this the primary
has_companies = db.query(UserCompany).filter_by(user_id=user_id).first()
is_primary = not has_companies
uc = UserCompany(
user_id=user_id,
company_id=company_id,
role=role,
is_primary=is_primary,
)
db.add(uc)
db.commit()
logger.info(f"Admin {current_user.email} added company {company_id} ({company.name}) to user {user.email} with role {role}")
return jsonify({
'success': True,
'message': f'Firma {company.name} przypisana do użytkownika',
'association_id': uc.id,
'is_primary': is_primary,
})
except Exception as e:
db.rollback()
logger.error(f"Error adding company to user: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/users-api/<int:user_id>/companies/<int:company_id>', methods=['DELETE'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_user_company_remove(user_id, company_id):
"""Remove a company association from a user."""
db = SessionLocal()
try:
uc = db.query(UserCompany).filter_by(
user_id=user_id, company_id=company_id
).first()
if not uc:
return jsonify({'success': False, 'error': 'Powiązanie nie znalezione'}), 404
company_name = ''
company = db.query(Company).get(company_id)
if company:
company_name = company.name
db.delete(uc)
db.commit()
logger.info(f"Admin {current_user.email} removed company {company_id} ({company_name}) from user {user_id}")
return jsonify({
'success': True,
'message': f'Usunięto powiązanie z firmą {company_name}',
})
except Exception as e:
db.rollback()
logger.error(f"Error removing company from user: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/users-api/<int:user_id>/companies/<int:company_id>/primary', methods=['PUT'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_user_company_set_primary(user_id, company_id):
"""Set a company as the user's primary company."""
db = SessionLocal()
try:
uc = db.query(UserCompany).filter_by(
user_id=user_id, company_id=company_id
).first()
if not uc:
return jsonify({'success': False, 'error': 'Powiązanie nie znalezione'}), 404
uc.is_primary = True
uc.updated_at = datetime.now()
# Trigger will handle clearing other primaries and syncing users.company_id
db.commit()
company = db.query(Company).get(company_id)
logger.info(f"Admin {current_user.email} set company {company_id} as primary for user {user_id}")
return jsonify({
'success': True,
'message': f'Firma {company.name if company else company_id} ustawiona jako główna',
})
except Exception as e:
db.rollback()
logger.error(f"Error setting primary company: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()

View File

@ -44,6 +44,7 @@ from database import (
ForumTopic, ForumTopic,
Classified, Classified,
UserNotification, UserNotification,
UserCompany,
) )
from utils.helpers import sanitize_input from utils.helpers import sanitize_input
from extensions import limiter from extensions import limiter
@ -594,6 +595,17 @@ def dashboard():
except Exception: except Exception:
pass # MembershipApplication table may not exist yet pass # MembershipApplication table may not exist yet
# Load user's company associations (multi-company support)
from sqlalchemy.orm import joinedload
user_companies = db.query(UserCompany).options(
joinedload(UserCompany.company)
).filter_by(
user_id=current_user.id
).order_by(UserCompany.is_primary.desc(), UserCompany.created_at.asc()).all()
# Force-load company names before session closes
for uc in user_companies:
_ = uc.company.name if uc.company else None
# Widget 1: Upcoming events (3 nearest future events) # Widget 1: Upcoming events (3 nearest future events)
upcoming_events = db.query(NordaEvent).filter( upcoming_events = db.query(NordaEvent).filter(
NordaEvent.event_date >= date.today() NordaEvent.event_date >= date.today()
@ -641,6 +653,7 @@ def dashboard():
has_pending_application=has_pending_application, has_pending_application=has_pending_application,
has_draft_application=has_draft_application, has_draft_application=has_draft_application,
pending_application=pending_application, pending_application=pending_application,
user_companies=user_companies,
unread_notifications=unread_notifications, unread_notifications=unread_notifications,
upcoming_events_count=upcoming_events_count, upcoming_events_count=upcoming_events_count,
user_forum_topics_count=user_forum_topics_count, user_forum_topics_count=user_forum_topics_count,

View File

@ -22,16 +22,19 @@ EDITABLE_SOURCES = [None, 'manual_edit', 'manual']
@bp.route('/firma/edytuj') @bp.route('/firma/edytuj')
@bp.route('/firma/edytuj/<int:company_id>')
@login_required @login_required
def company_edit(): def company_edit(company_id=None):
"""Display the company profile edit form.""" """Display the company profile edit form."""
if not current_user.can_edit_company(): target_company_id = company_id or current_user.company_id
if not target_company_id or not current_user.can_edit_company(target_company_id):
flash('Nie masz uprawnień do edycji profilu firmy.', 'error') flash('Nie masz uprawnień do edycji profilu firmy.', 'error')
return redirect(url_for('public.dashboard')) return redirect(url_for('public.dashboard'))
db = SessionLocal() db = SessionLocal()
try: try:
company = db.query(Company).get(current_user.company_id) company = db.query(Company).get(target_company_id)
if not company: if not company:
flash('Nie znaleziono firmy.', 'error') flash('Nie znaleziono firmy.', 'error')
return redirect(url_for('public.dashboard')) return redirect(url_for('public.dashboard'))
@ -50,10 +53,10 @@ def company_edit():
).all() ).all()
permissions = { permissions = {
'description': current_user.can_edit_company_field('description'), 'description': current_user.can_edit_company_field('description', company_id=company.id),
'services': current_user.can_edit_company_field('services'), 'services': current_user.can_edit_company_field('services', company_id=company.id),
'contacts': current_user.can_edit_company_field('contacts'), 'contacts': current_user.can_edit_company_field('contacts', company_id=company.id),
'social': current_user.can_edit_company_field('social'), 'social': current_user.can_edit_company_field('social', company_id=company.id),
} }
editable_contacts = [c for c in contacts if c.source in EDITABLE_SOURCES] editable_contacts = [c for c in contacts if c.source in EDITABLE_SOURCES]
@ -72,32 +75,35 @@ def company_edit():
@bp.route('/firma/edytuj', methods=['POST']) @bp.route('/firma/edytuj', methods=['POST'])
@bp.route('/firma/edytuj/<int:company_id>', methods=['POST'])
@login_required @login_required
def company_edit_save(): def company_edit_save(company_id=None):
"""Save company profile edits.""" """Save company profile edits."""
if not current_user.can_edit_company(): target_company_id = company_id or current_user.company_id
if not target_company_id or not current_user.can_edit_company(target_company_id):
flash('Nie masz uprawnień do edycji profilu firmy.', 'error') flash('Nie masz uprawnień do edycji profilu firmy.', 'error')
return redirect(url_for('public.dashboard')) return redirect(url_for('public.dashboard'))
db = SessionLocal() db = SessionLocal()
try: try:
company = db.query(Company).get(current_user.company_id) company = db.query(Company).get(target_company_id)
if not company: if not company:
flash('Nie znaleziono firmy.', 'error') flash('Nie znaleziono firmy.', 'error')
return redirect(url_for('public.dashboard')) return redirect(url_for('public.dashboard'))
active_tab = request.form.get('active_tab', 'description') active_tab = request.form.get('active_tab', 'description')
if active_tab == 'description' and current_user.can_edit_company_field('description'): if active_tab == 'description' and current_user.can_edit_company_field('description', company_id=company.id):
_save_description(db, company) _save_description(db, company)
elif active_tab == 'services' and current_user.can_edit_company_field('services'): elif active_tab == 'services' and current_user.can_edit_company_field('services', company_id=company.id):
_save_services(company) _save_services(company)
elif active_tab == 'contacts' and current_user.can_edit_company_field('contacts'): elif active_tab == 'contacts' and current_user.can_edit_company_field('contacts', company_id=company.id):
_save_contacts(db, company) _save_contacts(db, company)
elif active_tab == 'social' and current_user.can_edit_company_field('social'): elif active_tab == 'social' and current_user.can_edit_company_field('social', company_id=company.id):
_save_social_media(db, company) _save_social_media(db, company)
db.commit() db.commit()
@ -106,9 +112,9 @@ def company_edit_save():
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f"Error saving company edit for company_id={current_user.company_id}: {e}") logger.error(f"Error saving company edit for company_id={target_company_id}: {e}")
flash('Wystąpił błąd podczas zapisywania zmian. Spróbuj ponownie.', 'error') flash('Wystąpił błąd podczas zapisywania zmian. Spróbuj ponownie.', 'error')
return redirect(url_for('public.company_edit')) return redirect(url_for('public.company_edit', company_id=company_id))
finally: finally:
db.close() db.close()

View File

@ -377,12 +377,52 @@ class User(Base, UserMixin):
""" """
return self.has_role(SystemRole.MEMBER) return self.has_role(SystemRole.MEMBER)
def get_companies(self, session=None):
"""
Get all companies associated with this user.
Returns:
List of UserCompany objects (uses relationship if loaded, else queries).
"""
if self.company_associations:
return self.company_associations
if session:
return session.query(UserCompany).filter_by(user_id=self.id).all()
return []
def get_company_role(self, company_id: int, session=None) -> 'CompanyRole':
"""
Get user's role for a specific company from user_companies table.
Args:
company_id: The company to check.
session: SQLAlchemy session (optional, uses relationship if loaded).
Returns:
CompanyRole enum value, or CompanyRole.NONE if not associated.
"""
# Check loaded relationships first
for assoc in (self.company_associations or []):
if assoc.company_id == company_id:
return assoc.role_enum
# Fallback to query
if session:
assoc = session.query(UserCompany).filter_by(
user_id=self.id, company_id=company_id
).first()
if assoc:
return assoc.role_enum
# Legacy fallback: if company_id matches primary, use company_role
if self.company_id == company_id:
return self.company_role_enum
return CompanyRole.NONE
def can_edit_company(self, company_id: int = None) -> bool: def can_edit_company(self, company_id: int = None) -> bool:
""" """
Check if user can edit a company's profile. Check if user can edit a company's profile.
Args: Args:
company_id: Company to check. If None, checks user's own company. company_id: Company to check. If None, checks user's primary company.
Returns: Returns:
True if user can edit the company. True if user can edit the company.
@ -391,20 +431,20 @@ class User(Base, UserMixin):
if self.has_role(SystemRole.OFFICE_MANAGER): if self.has_role(SystemRole.OFFICE_MANAGER):
return True return True
# Check user's own company
target_company = company_id or self.company_id target_company = company_id or self.company_id
if not target_company or self.company_id != target_company: if not target_company:
return False return False
# EMPLOYEE or MANAGER of the company can edit # Check role via user_companies (supports multi-company)
return self.company_role_enum >= CompanyRole.EMPLOYEE role = self.get_company_role(target_company)
return role >= CompanyRole.EMPLOYEE
def can_manage_company(self, company_id: int = None) -> bool: def can_manage_company(self, company_id: int = None) -> bool:
""" """
Check if user can manage a company (including user management). Check if user can manage a company (including user management).
Args: Args:
company_id: Company to check. If None, checks user's own company. company_id: Company to check. If None, checks user's primary company.
Returns: Returns:
True if user has full management rights. True if user has full management rights.
@ -413,13 +453,13 @@ class User(Base, UserMixin):
if self.has_role(SystemRole.ADMIN): if self.has_role(SystemRole.ADMIN):
return True return True
# Check user's own company
target_company = company_id or self.company_id target_company = company_id or self.company_id
if not target_company or self.company_id != target_company: if not target_company:
return False return False
# Only MANAGER of the company can manage users # Check role via user_companies (supports multi-company)
return self.company_role_enum >= CompanyRole.MANAGER role = self.get_company_role(target_company)
return role >= CompanyRole.MANAGER
def can_manage_users(self) -> bool: def can_manage_users(self) -> bool:
""" """
@ -442,9 +482,9 @@ class User(Base, UserMixin):
""" """
return self.has_role(SystemRole.OFFICE_MANAGER) return self.has_role(SystemRole.OFFICE_MANAGER)
def has_delegated_permission(self, permission: str, session=None) -> bool: def has_delegated_permission(self, permission: str, company_id: int = None, session=None) -> bool:
""" """
Check if user has a specific delegated permission for their company. Check if user has a specific delegated permission for a company.
This checks UserCompanyPermissions table for fine-grained permissions This checks UserCompanyPermissions table for fine-grained permissions
granted by a MANAGER. granted by a MANAGER.
@ -452,30 +492,35 @@ class User(Base, UserMixin):
Args: Args:
permission: One of: 'edit_description', 'edit_services', 'edit_contacts', permission: One of: 'edit_description', 'edit_services', 'edit_contacts',
'edit_social', 'manage_classifieds', 'post_forum', 'view_analytics' 'edit_social', 'manage_classifieds', 'post_forum', 'view_analytics'
company_id: Company to check. If None, checks user's primary company.
session: SQLAlchemy session (optional, uses relationship if available) session: SQLAlchemy session (optional, uses relationship if available)
Returns: Returns:
True if user has this specific permission True if user has this specific permission
""" """
target_company = company_id or self.company_id
# Managers have all permissions by default # Managers have all permissions by default
if self.company_role_enum >= CompanyRole.MANAGER: role = self.get_company_role(target_company) if target_company else self.company_role_enum
if role >= CompanyRole.MANAGER:
return True return True
# Check delegated permissions # Check delegated permissions
if self.company_permissions: if self.company_permissions:
for perm in self.company_permissions: for perm in self.company_permissions:
if perm.company_id == self.company_id: if perm.company_id == target_company:
attr_name = f'can_{permission}' attr_name = f'can_{permission}'
return getattr(perm, attr_name, False) return getattr(perm, attr_name, False)
return False return False
def can_edit_company_field(self, field_category: str) -> bool: def can_edit_company_field(self, field_category: str, company_id: int = None) -> bool:
""" """
Check if user can edit a specific category of company fields. Check if user can edit a specific category of company fields.
Args: Args:
field_category: 'description', 'services', 'contacts', or 'social' field_category: 'description', 'services', 'contacts', or 'social'
company_id: Company to check. If None, checks user's primary company.
Returns: Returns:
True if user can edit fields in this category True if user can edit fields in this category
@ -484,17 +529,20 @@ class User(Base, UserMixin):
if self.has_role(SystemRole.OFFICE_MANAGER): if self.has_role(SystemRole.OFFICE_MANAGER):
return True return True
# Must have company target_company = company_id or self.company_id
if not self.company_id: if not target_company:
return False return False
# Check role via user_companies (supports multi-company)
role = self.get_company_role(target_company)
# Managers can edit everything in their company # Managers can edit everything in their company
if self.company_role_enum >= CompanyRole.MANAGER: if role >= CompanyRole.MANAGER:
return True return True
# Employees need delegated permission # Employees need delegated permission
if self.company_role_enum >= CompanyRole.EMPLOYEE: if role >= CompanyRole.EMPLOYEE:
return self.has_delegated_permission(f'edit_{field_category}') return self.has_delegated_permission(f'edit_{field_category}', company_id=target_company)
return False return False
@ -579,6 +627,41 @@ class UserCompanyPermissions(Base):
return perms return perms
class UserCompany(Base):
"""
Association between users and companies (multi-company support).
Allows a user to be linked to multiple companies with different roles.
One association per user can be marked as is_primary, which syncs
to users.company_id via database trigger.
"""
__tablename__ = 'user_companies'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
role = Column(String(20), nullable=False, default='MANAGER')
is_primary = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
user = relationship('User', backref='company_associations')
company = relationship('Company', backref='user_associations')
__table_args__ = (
UniqueConstraint('user_id', 'company_id', name='uq_user_company'),
)
@property
def role_enum(self) -> CompanyRole:
"""Get the CompanyRole enum value."""
return CompanyRole.from_string(self.role or 'NONE')
def __repr__(self):
return f'<UserCompany user={self.user_id} company={self.company_id} role={self.role} primary={self.is_primary}>'
# ============================================================ # ============================================================
# COMPANY DIRECTORY (existing schema from SQL) # COMPANY DIRECTORY (existing schema from SQL)
# ============================================================ # ============================================================

View File

@ -0,0 +1,108 @@
-- Migration 050: User-Company associations (multi-company support)
-- Allows one user to be associated with multiple companies
-- Maintains backward compatibility via trigger syncing users.company_id
BEGIN;
-- 1. Create user_companies table
CREATE TABLE IF NOT EXISTS user_companies (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'MANAGER',
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, company_id)
);
-- 2. Indexes
CREATE INDEX IF NOT EXISTS idx_user_companies_user_id ON user_companies(user_id);
CREATE INDEX IF NOT EXISTS idx_user_companies_company_id ON user_companies(company_id);
-- No partial unique index on is_primary - enforced by BEFORE trigger instead
-- (AFTER trigger can't clear old primary before unique constraint check)
-- 3. Migrate existing data from users table
INSERT INTO user_companies (user_id, company_id, role, is_primary, created_at)
SELECT id, company_id,
COALESCE(NULLIF(company_role, 'NONE'), 'MANAGER'),
TRUE,
COALESCE(created_at, NOW())
FROM users
WHERE company_id IS NOT NULL
ON CONFLICT (user_id, company_id) DO NOTHING;
-- 4. BEFORE trigger: enforce single primary + clear others
CREATE OR REPLACE FUNCTION sync_user_primary_company()
RETURNS TRIGGER AS $$
BEGIN
-- On INSERT or UPDATE with is_primary = TRUE
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.is_primary = TRUE THEN
-- Clear other primary flags for this user BEFORE the row is written
UPDATE user_companies
SET is_primary = FALSE, updated_at = NOW()
WHERE user_id = NEW.user_id
AND id != NEW.id
AND is_primary = TRUE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_sync_user_primary_company ON user_companies;
CREATE TRIGGER trg_sync_user_primary_company
BEFORE INSERT OR UPDATE ON user_companies
FOR EACH ROW
EXECUTE FUNCTION sync_user_primary_company();
-- 5. AFTER trigger: sync to users table
CREATE OR REPLACE FUNCTION sync_user_primary_to_users()
RETURNS TRIGGER AS $$
BEGIN
-- On INSERT or UPDATE with is_primary = TRUE -> sync to users
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.is_primary = TRUE THEN
UPDATE users
SET company_id = NEW.company_id,
company_role = NEW.role
WHERE id = NEW.user_id;
END IF;
-- On DELETE of primary association
IF TG_OP = 'DELETE' AND OLD.is_primary = TRUE THEN
-- Try to promote another association to primary
UPDATE user_companies
SET is_primary = TRUE, updated_at = NOW()
WHERE id = (
SELECT id FROM user_companies
WHERE user_id = OLD.user_id AND id != OLD.id
ORDER BY created_at ASC
LIMIT 1
);
-- If no other association exists, clear users table
IF NOT FOUND THEN
UPDATE users
SET company_id = NULL, company_role = 'NONE'
WHERE id = OLD.user_id;
END IF;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_sync_user_primary_to_users ON user_companies;
CREATE TRIGGER trg_sync_user_primary_to_users
AFTER INSERT OR UPDATE OR DELETE ON user_companies
FOR EACH ROW
EXECUTE FUNCTION sync_user_primary_to_users();
-- 6. Permissions
GRANT ALL ON TABLE user_companies TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE user_companies_id_seq TO nordabiz_app;
COMMIT;

View File

@ -615,7 +615,7 @@
<!-- AI Enrichment Button + Edit Profile --> <!-- AI Enrichment Button + Edit Profile -->
<div style="margin: var(--spacing-md) 0; display: flex; gap: var(--spacing-sm); flex-wrap: wrap; align-items: center;"> <div style="margin: var(--spacing-md) 0; display: flex; gap: var(--spacing-sm); flex-wrap: wrap; align-items: center;">
{% if can_enrich %} {% if can_enrich %}
<a href="{{ url_for('company_edit') }}" class="ai-enrich-btn" style="text-decoration: none;"> <a href="{{ url_for('company_edit', company_id=company.id) }}" class="ai-enrich-btn" style="text-decoration: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/> <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>

View File

@ -345,7 +345,7 @@
</button> </button>
</div> </div>
<form method="POST" action="{{ url_for('public.company_edit_save') }}" id="companyEditForm"> <form method="POST" action="{{ url_for('public.company_edit_save', company_id=company.id) }}" id="companyEditForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="active_tab" id="activeTabInput" value="description"> <input type="hidden" name="active_tab" id="activeTabInput" value="description">

View File

@ -520,13 +520,13 @@
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/> <path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg> </svg>
</div> </div>
<h5>Twoja firma</h5> <h5>{{ 'Twoje firmy' if user_companies|length > 1 else 'Twoja firma' }}</h5>
{% if current_user.company_id %} {% if user_companies %}
<h3 style="color: var(--primary); font-size: 1rem;">{{ current_user.company.name if current_user.company else 'Przypisana' }}</h3> <h3 style="color: var(--primary); font-size: 1rem;">{{ user_companies|length }}</h3>
{% else %} {% else %}
<h3 style="color: var(--text-secondary); font-size: 1rem;">Brak</h3> <h3 style="color: var(--text-secondary); font-size: 1rem;">Brak</h3>
{% endif %} {% endif %}
<small>Profil firmowy</small> <small>{{ 'Profile firmowe' if user_companies|length > 1 else 'Profil firmowy' }}</small>
</div> </div>
</div> </div>
@ -570,8 +570,43 @@
</div> </div>
</div> </div>
{# User's companies section #}
{% if user_companies %}
<div class="dashboard-card" style="margin-bottom: var(--spacing-lg, 20px);">
<div style="padding: var(--spacing-lg, 20px);">
<h4 style="margin: 0 0 var(--spacing-md, 16px) 0; display: flex; align-items: center; gap: 8px;">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
{{ 'Twoje firmy' if user_companies|length > 1 else 'Twoja firma' }}
</h4>
{% for uc in user_companies %}
<div style="display: flex; align-items: center; justify-content: space-between; padding: var(--spacing-sm, 8px) 0; {% if not loop.last %}border-bottom: 1px solid var(--border-color, #e5e7eb);{% endif %}">
<div style="display: flex; align-items: center; gap: var(--spacing-sm, 8px);">
<span style="font-weight: 500;">{{ uc.company.name if uc.company else '(firma usunięta)' }}</span>
{% if uc.is_primary and user_companies|length > 1 %}
<span style="background: var(--primary); color: white; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px;">Główna</span>
{% endif %}
<span style="color: var(--text-secondary); font-size: 0.85rem;">
{% if uc.role == 'MANAGER' %}Zarządzający{% elif uc.role == 'EMPLOYEE' %}Pracownik{% elif uc.role == 'VIEWER' %}Podgląd{% endif %}
</span>
</div>
<div style="display: flex; gap: var(--spacing-xs, 4px);">
{% if uc.company %}
<a href="{{ url_for('public.company_detail', company_id=uc.company_id) }}" class="btn btn-sm btn-outline" style="font-size: 0.8rem; padding: 4px 10px;">Profil</a>
{% if current_user.can_edit_company(uc.company_id) %}
<a href="{{ url_for('public.company_edit', company_id=uc.company_id) }}" class="btn btn-sm btn-primary" style="font-size: 0.8rem; padding: 4px 10px;">Edytuj</a>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Membership CTA for users without company #} {# Membership CTA for users without company #}
{% if not current_user.company_id %} {% if not user_companies %}
{% if has_pending_application %} {% if has_pending_application %}
<div class="membership-status"> <div class="membership-status">
<div class="membership-status-content"> <div class="membership-status-content">