# Event Guests — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Allow portal users to register accompanying guests (non-portal users) for events. **Architecture:** New `event_guests` table with FK to events and host users. Three new JSON API endpoints (POST/PATCH/DELETE) in the calendar blueprint. Inline form on the event detail page with AJAX interactions matching existing RSVP pattern. **Tech Stack:** Flask, SQLAlchemy, PostgreSQL, Vanilla JS (fetch API), Jinja2 **Spec:** `docs/superpowers/specs/2026-03-31-event-guests-design.md` **Deployment:** Staging first (`10.22.68.248`), user tests, then production (`57.128.200.27`). --- ## File Map | File | Action | Responsibility | |------|--------|----------------| | `database.py` | Modify (lines 2292–2305) | Add `EventGuest` model, add `total_attendee_count` property to `NordaEvent` | | `blueprints/community/calendar/routes.py` | Modify | Add 3 guest endpoints, update RSVP max_attendees check, pass guest data to template | | `templates/calendar/event.html` | Modify | Guest form, guest list, updated attendee list with guests | | `database/migrations/096_event_guests.sql` | Create | DDL for `event_guests` table | --- ### Task 1: Database Migration **Files:** - Create: `database/migrations/096_event_guests.sql` - [ ] **Step 1: Create migration file** ```sql -- Migration 096: Event Guests -- Allows users to register accompanying guests for events CREATE TABLE IF NOT EXISTS event_guests ( id SERIAL PRIMARY KEY, event_id INTEGER NOT NULL REFERENCES norda_events(id) ON DELETE CASCADE, host_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, first_name VARCHAR(100), last_name VARCHAR(100), organization VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS ix_event_guests_event_id ON event_guests(event_id); CREATE INDEX IF NOT EXISTS ix_event_guests_host_user_id ON event_guests(host_user_id); GRANT ALL ON TABLE event_guests TO nordabiz_app; GRANT USAGE, SELECT ON SEQUENCE event_guests_id_seq TO nordabiz_app; ``` - [ ] **Step 2: Run migration on local dev** ```bash # Docker dev DB on localhost:5433 PGPASSWORD=nordabiz psql -h localhost -p 5433 -U nordabiz -d nordabiz -f database/migrations/096_event_guests.sql ``` Expected: `CREATE TABLE`, `CREATE INDEX` x2, `GRANT` x2 - [ ] **Step 3: Verify table exists** ```bash PGPASSWORD=nordabiz psql -h localhost -p 5433 -U nordabiz -d nordabiz -c "\d event_guests" ``` Expected: table with 7 columns (id, event_id, host_user_id, first_name, last_name, organization, created_at) - [ ] **Step 4: Commit** ```bash git add database/migrations/096_event_guests.sql git commit -m "feat(calendar): add event_guests migration 096" ``` --- ### Task 2: SQLAlchemy Model **Files:** - Modify: `database.py` (after line 2305, after `EventAttendee` class) - Modify: `database.py` (line 2207, `NordaEvent.attendees` relationship area) - [ ] **Step 1: Add `EventGuest` model after `EventAttendee` (after line 2305)** Add this code after the `EventAttendee` class (after line 2305): ```python class EventGuest(Base): """Osoby towarzyszące na wydarzeniach (bez konta na portalu)""" __tablename__ = 'event_guests' id = Column(Integer, primary_key=True) event_id = Column(Integer, ForeignKey('norda_events.id', ondelete='CASCADE'), nullable=False, index=True) host_user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True) first_name = Column(String(100)) last_name = Column(String(100)) organization = Column(String(255)) created_at = Column(DateTime, default=datetime.now, nullable=False) event = relationship('NordaEvent', back_populates='guests') host = relationship('User') @property def display_name(self): """Nazwa wyświetlana gościa — łączy dostępne pola.""" parts = [] if self.first_name: parts.append(self.first_name) if self.last_name: parts.append(self.last_name) return ' '.join(parts) if parts else '(brak danych)' ``` - [ ] **Step 2: Add `guests` relationship to `NordaEvent` (line ~2207, after `attendees` relationship)** In `NordaEvent` class, after line 2207 (`attendees = relationship(...)`), add: ```python guests = relationship('EventGuest', back_populates='event', cascade='all, delete-orphan') ``` - [ ] **Step 3: Add `total_attendee_count` property to `NordaEvent` (after `attendee_count`, line ~2211)** After the existing `attendee_count` property: ```python @property def total_attendee_count(self): """Łączna liczba uczestników + gości (do sprawdzania limitu max_attendees).""" return len(self.attendees) + len(self.guests) ``` - [ ] **Step 4: Verify app starts** ```bash cd /Users/maciejpi/claude/projects/active/nordabiz && python3 -c "from database import EventGuest, NordaEvent; print('OK:', EventGuest.__tablename__); e = NordaEvent(); print('total_attendee_count:', e.total_attendee_count)" ``` Expected: `OK: event_guests` and `total_attendee_count: 0` - [ ] **Step 5: Commit** ```bash git add database.py git commit -m "feat(calendar): add EventGuest model and total_attendee_count property" ``` --- ### Task 3: Guest API Endpoints **Files:** - Modify: `blueprints/community/calendar/routes.py` - [ ] **Step 1: Update import line (line 14)** Change: ```python from database import SessionLocal, NordaEvent, EventAttendee ``` To: ```python from database import SessionLocal, NordaEvent, EventAttendee, EventGuest ``` - [ ] **Step 2: Update max_attendees check in RSVP (line 347)** Change: ```python if not is_ext and event.max_attendees and event.attendee_count >= event.max_attendees: ``` To: ```python if not is_ext and event.max_attendees and event.total_attendee_count >= event.max_attendees: ``` - [ ] **Step 3: Pass guest data to event template (after line 275 in `event()` route)** After the `user_attending` query (line 275), add: ```python # Pobierz gości bieżącego użytkownika na to wydarzenie user_guests = db.query(EventGuest).filter( EventGuest.event_id == event_id, EventGuest.host_user_id == current_user.id ).order_by(EventGuest.created_at.asc()).all() ``` And add `user_guests=user_guests` to the `render_template` call (line 301–307): ```python return render_template('calendar/event.html', event=event, user_attending=user_attending, user_guests=user_guests, speaker_user_id=speaker_user_id, speaker_company_slug=speaker_company_slug, enriched_description=enriched_description, ) ``` - [ ] **Step 4: Add POST endpoint for adding guests (after RSVP route, after line 364)** ```python MAX_GUESTS_PER_USER = 5 @bp.route('//guests', methods=['POST'], endpoint='calendar_add_guest') @login_required def add_guest(event_id): """Dodaj osobę towarzyszącą na wydarzenie""" db = SessionLocal() try: event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first() if not event: return jsonify({'success': False, 'error': 'Wydarzenie nie istnieje'}), 404 if event.is_past: return jsonify({'success': False, 'error': 'Wydarzenie już się odbyło'}), 400 if not event.can_user_attend(current_user): return jsonify({'success': False, 'error': 'Nie masz uprawnień'}), 403 if getattr(event, 'is_external', False): return jsonify({'success': False, 'error': 'Rejestracja gości niedostępna dla wydarzeń zewnętrznych'}), 400 # Sprawdź limit gości per użytkownik guest_count = db.query(EventGuest).filter( EventGuest.event_id == event_id, EventGuest.host_user_id == current_user.id ).count() if guest_count >= MAX_GUESTS_PER_USER: return jsonify({'success': False, 'error': f'Maksymalnie {MAX_GUESTS_PER_USER} gości na wydarzenie'}), 400 # Sprawdź limit miejsc if event.max_attendees and event.total_attendee_count >= event.max_attendees: return jsonify({'success': False, 'error': 'Brak wolnych miejsc'}), 400 data = request.get_json() or {} first_name = (data.get('first_name') or '').strip() last_name = (data.get('last_name') or '').strip() organization = (data.get('organization') or '').strip() # Minimum jedno pole if not first_name and not last_name and not organization: return jsonify({'success': False, 'error': 'Podaj przynajmniej imię, nazwisko lub firmę'}), 400 guest = EventGuest( event_id=event_id, host_user_id=current_user.id, first_name=first_name or None, last_name=last_name or None, organization=organization or None, ) db.add(guest) db.commit() return jsonify({ 'success': True, 'action': 'added', 'guest': { 'id': guest.id, 'first_name': guest.first_name, 'last_name': guest.last_name, 'organization': guest.organization, 'display_name': guest.display_name, } }), 201 finally: db.close() ``` - [ ] **Step 5: Add PATCH endpoint for editing guests** ```python @bp.route('//guests/', methods=['PATCH'], endpoint='calendar_edit_guest') @login_required def edit_guest(event_id, guest_id): """Edytuj dane osoby towarzyszącej""" db = SessionLocal() try: guest = db.query(EventGuest).filter( EventGuest.id == guest_id, EventGuest.event_id == event_id ).first() if not guest: return jsonify({'success': False, 'error': 'Gość nie znaleziony'}), 404 # Tylko host lub admin from database import SystemRole if guest.host_user_id != current_user.id and not current_user.has_role(SystemRole.OFFICE_MANAGER): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first() if event and event.is_past: return jsonify({'success': False, 'error': 'Wydarzenie już się odbyło'}), 400 data = request.get_json() or {} first_name = (data.get('first_name') or '').strip() last_name = (data.get('last_name') or '').strip() organization = (data.get('organization') or '').strip() if not first_name and not last_name and not organization: return jsonify({'success': False, 'error': 'Podaj przynajmniej imię, nazwisko lub firmę'}), 400 guest.first_name = first_name or None guest.last_name = last_name or None guest.organization = organization or None db.commit() return jsonify({ 'success': True, 'action': 'updated', 'guest': { 'id': guest.id, 'first_name': guest.first_name, 'last_name': guest.last_name, 'organization': guest.organization, 'display_name': guest.display_name, } }) finally: db.close() ``` - [ ] **Step 6: Add DELETE endpoint for removing guests** ```python @bp.route('//guests/', methods=['DELETE'], endpoint='calendar_delete_guest') @login_required def delete_guest(event_id, guest_id): """Usuń osobę towarzyszącą z wydarzenia""" db = SessionLocal() try: guest = db.query(EventGuest).filter( EventGuest.id == guest_id, EventGuest.event_id == event_id ).first() if not guest: return jsonify({'success': False, 'error': 'Gość nie znaleziony'}), 404 # Tylko host lub admin from database import SystemRole if guest.host_user_id != current_user.id and not current_user.has_role(SystemRole.OFFICE_MANAGER): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 db.delete(guest) db.commit() return jsonify({'success': True, 'action': 'removed'}) finally: db.close() ``` - [ ] **Step 7: Verify syntax** ```bash python3 -m py_compile blueprints/community/calendar/routes.py && echo "OK" ``` Expected: `OK` - [ ] **Step 8: Commit** ```bash git add blueprints/community/calendar/routes.py git commit -m "feat(calendar): add guest API endpoints (POST/PATCH/DELETE)" ``` --- ### Task 4: Event Template — Guest Form and Guest List **Files:** - Modify: `templates/calendar/event.html` This task modifies three areas of `event.html`: 1. **Guest management section** (new, after RSVP section ~line 564) 2. **Attendee list** (modify existing, lines 584–616) 3. **JavaScript** (add guest functions, after `toggleRSVP` ~line 679) - [ ] **Step 1: Add guest management section after RSVP (after line 564, before `{% endif %}` at line 576)** Insert after line 564 (closing `` of rsvp-section), before `{% elif event.access_level == 'rada_only' %}` at line 566: ```html {# --- Guest management section --- #} {% if not event.is_external %}
{% if user_guests %}

Twoi goście ({{ user_guests|length }}/5):

{% for guest in user_guests %}
{{ guest.display_name }}{% if guest.organization %} ({{ guest.organization }}){% endif %}
{% endfor %} {% endif %}
{% if user_guests|length < 5 %} {% endif %}
{% endif %} ``` - [ ] **Step 2: Modify attendee list to include guests (lines 584–616)** Replace the entire attendees section (lines 584–616) with: ```html {% if (event.attendees or event.guests) and event.can_user_see_attendees(current_user) %}

{{ 'Zainteresowani' if event.is_external else 'Uczestnicy' }} ({{ event.total_attendee_count }})

{# --- Regular attendees with their guests --- #} {% set shown_hosts = [] %} {% for attendee in event.attendees|sort(attribute='user.name') %} {# Guests of this attendee #} {% for guest in event.guests if guest.host_user_id == attendee.user.id %}
gość: {{ guest.display_name }}{% if guest.organization %} ({{ guest.organization }}){% endif %}
{% endfor %} {% if shown_hosts.append(attendee.user.id) %}{% endif %} {% endfor %} {# --- Hosts who are NOT attending but have guests --- #} {% for guest in event.guests %} {% if guest.host_user_id not in shown_hosts %} {% if shown_hosts.append(guest.host_user_id) %}{% endif %}
{{ guest.host.name or 'Użytkownik' }} (nie uczestniczy)
{% for g in event.guests if g.host_user_id == guest.host_user_id %}
gość: {{ g.display_name }}{% if g.organization %} ({{ g.organization }}){% endif %}
{% endfor %} {% endif %} {% endfor %}
{% endif %} ``` - [ ] **Step 3: Update attendee count in RSVP section (lines 551 and 559)** Line 551 — change: ```html

{{ event.attendee_count }} osób zainteresowanych z Izby

``` To: ```html

{{ event.total_attendee_count }} osób zainteresowanych z Izby

``` Line 559 — change: ```html

{{ event.attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}

``` To: ```html

{{ event.total_attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}

``` - [ ] **Step 4: Add JavaScript guest functions (after `toggleRSVP` function, in `{% block extra_js %}`)** Add after the `toggleRSVP` function (after line 679): ```javascript /* --- Guest management --- */ const MAX_GUESTS = 5; function toggleGuestForm() { const form = document.getElementById('guest-form'); const btn = document.getElementById('add-guest-btn'); if (form.style.display === 'none') { document.getElementById('guest-edit-id').value = ''; document.getElementById('guest-first-name').value = ''; document.getElementById('guest-last-name').value = ''; document.getElementById('guest-org').value = ''; document.getElementById('guest-submit-btn').textContent = 'Dodaj'; document.getElementById('guest-form-error').style.display = 'none'; form.style.display = 'block'; btn.style.display = 'none'; document.getElementById('guest-first-name').focus(); } else { cancelGuestForm(); } } function cancelGuestForm() { document.getElementById('guest-form').style.display = 'none'; const btn = document.getElementById('add-guest-btn'); if (btn) btn.style.display = ''; } function editGuest(guestId, firstName, lastName, org) { document.getElementById('guest-edit-id').value = guestId; document.getElementById('guest-first-name').value = firstName; document.getElementById('guest-last-name').value = lastName; document.getElementById('guest-org').value = org; document.getElementById('guest-submit-btn').textContent = 'Zapisz'; document.getElementById('guest-form-error').style.display = 'none'; document.getElementById('guest-form').style.display = 'block'; const btn = document.getElementById('add-guest-btn'); if (btn) btn.style.display = 'none'; document.getElementById('guest-first-name').focus(); } async function submitGuest() { const editId = document.getElementById('guest-edit-id').value; const firstName = document.getElementById('guest-first-name').value.trim(); const lastName = document.getElementById('guest-last-name').value.trim(); const org = document.getElementById('guest-org').value.trim(); const errEl = document.getElementById('guest-form-error'); if (!firstName && !lastName && !org) { errEl.textContent = 'Podaj przynajmniej imię, nazwisko lub firmę'; errEl.style.display = 'block'; return; } errEl.style.display = 'none'; const url = editId ? `/kalendarz/{{ event.id }}/guests/${editId}` : '/kalendarz/{{ event.id }}/guests'; const method = editId ? 'PATCH' : 'POST'; try { const resp = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ first_name: firstName, last_name: lastName, organization: org }) }); const data = await resp.json(); if (data.success) { showToast(editId ? 'Dane gościa zaktualizowane' : 'Dodano osobę towarzyszącą', 'success'); setTimeout(() => location.reload(), 800); } else { errEl.textContent = data.error || 'Wystąpił błąd'; errEl.style.display = 'block'; } } catch (e) { errEl.textContent = 'Błąd połączenia'; errEl.style.display = 'block'; } } async function deleteGuest(guestId) { if (typeof nordaConfirm === 'function') { nordaConfirm('Czy na pewno chcesz usunąć tę osobę towarzyszącą?', async () => { await doDeleteGuest(guestId); }); } else { await doDeleteGuest(guestId); } } async function doDeleteGuest(guestId) { try { const resp = await fetch(`/kalendarz/{{ event.id }}/guests/${guestId}`, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken } }); const data = await resp.json(); if (data.success) { showToast('Usunięto osobę towarzyszącą', 'info'); setTimeout(() => location.reload(), 800); } else { showToast(data.error || 'Wystąpił błąd', 'error'); } } catch (e) { showToast('Błąd połączenia', 'error'); } } ``` - [ ] **Step 5: Verify template syntax** ```bash python3 -c " from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader('templates')) env.get_template('calendar/event.html') print('Template syntax OK') " ``` Expected: `Template syntax OK` - [ ] **Step 6: Commit** ```bash git add templates/calendar/event.html git commit -m "feat(calendar): add guest form and updated attendee list in event template" ``` --- ### Task 5: Local Testing - [ ] **Step 1: Start local dev server** ```bash python3 app.py ``` Verify app starts without errors on port 5000/5001. - [ ] **Step 2: Test in browser** Open a future event page (e.g., http://localhost:5001/kalendarz/7). Verify: 1. "Dodaj osobę towarzyszącą" button is visible 2. Clicking it opens the inline form 3. Adding a guest with only first name works 4. Guest appears in "Twoi goście" list 5. Editing guest data works 6. Deleting guest works (with confirmation dialog) 7. Guest appears in attendee list with "gość:" prefix under host 8. Counter shows total (attendees + guests) - [ ] **Step 3: Commit all remaining changes (if any)** ```bash git add -A && git status # Only commit if there are changes git commit -m "fix(calendar): polish event guests after local testing" ``` --- ### Task 6: Deploy to Staging - [ ] **Step 1: Push to both remotes** ```bash git push origin master && git push inpi master ``` - [ ] **Step 2: Deploy to staging** ```bash ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl reload nordabiznes" ``` - [ ] **Step 3: Run migration on staging** ```bash ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/096_event_guests.sql" ``` - [ ] **Step 4: Verify staging** ```bash curl -sI https://staging.nordabiznes.pl/health | head -3 ``` Expected: `HTTP/2 200` - [ ] **Step 5: STOP — User tests on staging** 🛑 **CZEKAJ NA AKCEPTACJĘ UŻYTKOWNIKA** — user testuje na https://staging.nordabiznes.pl/kalendarz, weryfikuje dodawanie/edycję/usuwanie gości. --- ### Task 7: Deploy to Production **⚠️ Wykonaj DOPIERO po akceptacji użytkownika na staging!** - [ ] **Step 1: Deploy to production** ```bash ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull" ``` - [ ] **Step 2: Run migration on production** ```bash ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/096_event_guests.sql" ``` - [ ] **Step 3: Reload service** ```bash ssh maciejpi@57.128.200.27 "sudo systemctl reload nordabiznes" ``` - [ ] **Step 4: Verify production** ```bash curl -sI https://nordabiznes.pl/health | head -3 ``` Expected: `HTTP/2 200` - [ ] **Step 5: Smoke test on production** Open https://nordabiznes.pl/kalendarz — verify event pages load, guest section visible.