nordabiz/docs/superpowers/plans/2026-03-31-event-guests.md
Maciej Pienczyn 110d971dca
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
feat: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:53 +02:00

29 KiB
Raw Blame History

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 22922305) 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

-- 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
# 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
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
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):

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:

    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:

    @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
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
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:

from database import SessionLocal, NordaEvent, EventAttendee

To:

from database import SessionLocal, NordaEvent, EventAttendee, EventGuest
  • Step 2: Update max_attendees check in RSVP (line 347)

Change:

            if not is_ext and event.max_attendees and event.attendee_count >= event.max_attendees:

To:

            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:

        # 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 301307):

        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)
MAX_GUESTS_PER_USER = 5


@bp.route('/<int:event_id>/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
@bp.route('/<int:event_id>/guests/<int:guest_id>', 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
@bp.route('/<int:event_id>/guests/<int:guest_id>', 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
python3 -m py_compile blueprints/community/calendar/routes.py && echo "OK"

Expected: OK

  • Step 8: Commit
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 584616)
  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 </div> of rsvp-section), before {% elif event.access_level == 'rada_only' %} at line 566:

            {# --- Guest management section --- #}
            {% if not event.is_external %}
            <div class="guest-section" style="margin-top: 16px;">
                <div id="user-guests-list">
                    {% if user_guests %}
                    <p style="margin: 0 0 8px; font-weight: 600; font-size: 0.9em; color: var(--text-secondary);">
                        Twoi goście ({{ user_guests|length }}/5):
                    </p>
                    {% for guest in user_guests %}
                    <div class="guest-item" data-guest-id="{{ guest.id }}" style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--surface); border-radius: var(--radius); margin-bottom: 4px; font-size: 0.9em;">
                        <span class="guest-display" style="flex: 1;">
                            {{ guest.display_name }}{% if guest.organization %} <span style="color: var(--text-secondary);">({{ guest.organization }})</span>{% endif %}
                        </span>
                        <button onclick="editGuest({{ guest.id }}, '{{ guest.first_name or '' }}', '{{ guest.last_name or '' }}', '{{ guest.organization or '' }}')" style="background: none; border: none; cursor: pointer; color: var(--primary); font-size: 0.85em; padding: 2px 6px;">edytuj</button>
                        <button onclick="deleteGuest({{ guest.id }})" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1.1em; padding: 2px 6px;" title="Usuń gościa">&times;</button>
                    </div>
                    {% endfor %}
                    {% endif %}
                </div>

                {% if user_guests|length < 5 %}
                <button id="add-guest-btn" onclick="toggleGuestForm()" class="btn btn-outline" style="margin-top: 8px; font-size: 0.9em;">
                    + Dodaj osobę towarzyszącą
                </button>
                {% endif %}

                <div id="guest-form" style="display: none; margin-top: 12px; padding: 16px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border);">
                    <input type="hidden" id="guest-edit-id" value="">
                    <div style="display: flex; flex-direction: column; gap: 10px;">
                        <div>
                            <label for="guest-first-name" style="font-size: 0.85em; color: var(--text-secondary);">Imię</label>
                            <input type="text" id="guest-first-name" maxlength="100" class="form-control" style="margin-top: 2px;">
                        </div>
                        <div>
                            <label for="guest-last-name" style="font-size: 0.85em; color: var(--text-secondary);">Nazwisko</label>
                            <input type="text" id="guest-last-name" maxlength="100" class="form-control" style="margin-top: 2px;">
                        </div>
                        <div>
                            <label for="guest-org" style="font-size: 0.85em; color: var(--text-secondary);">Firma / organizacja</label>
                            <input type="text" id="guest-org" maxlength="255" class="form-control" style="margin-top: 2px;">
                        </div>
                        <div style="display: flex; gap: 8px;">
                            <button id="guest-submit-btn" onclick="submitGuest()" class="btn btn-primary" style="font-size: 0.9em;">Dodaj</button>
                            <button onclick="cancelGuestForm()" class="btn btn-outline" style="font-size: 0.9em;">Anuluj</button>
                        </div>
                        <p id="guest-form-error" style="display: none; color: var(--error); font-size: 0.85em; margin: 0;"></p>
                    </div>
                </div>
            </div>
            {% endif %}
  • Step 2: Modify attendee list to include guests (lines 584616)

Replace the entire attendees section (lines 584616) with:

{% if (event.attendees or event.guests) and event.can_user_see_attendees(current_user) %}
<div class="attendees-section">
    <h2>{{ 'Zainteresowani' if event.is_external else 'Uczestnicy' }} ({{ event.total_attendee_count }})</h2>
    <div class="attendees-list">
        {# --- Regular attendees with their guests --- #}
        {% set shown_hosts = [] %}
        {% for attendee in event.attendees|sort(attribute='user.name') %}
        <div class="attendee-badge">
            {% if attendee.user.person_id %}
            <a href="{{ url_for('public.person_detail', person_id=attendee.user.person_id) }}" class="attendee-name verified">
            {% else %}
            <a href="{{ url_for('public.user_profile', user_id=attendee.user.id) }}" class="attendee-name verified">
            {% endif %}
                <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
                    <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
                    <circle cx="12" cy="7" r="4"></circle>
                </svg>
                {{ attendee.user.name or attendee.user.email.split('@')[0] }}
            </a>

            {% if attendee.user.company %}
            <a href="{{ url_for('public.company_detail_by_slug', slug=attendee.user.company.slug) }}" class="attendee-company">
                <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
                    <path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5m-4 0h4"></path>
                </svg>
                {{ attendee.user.company.name }}
            </a>
            {% endif %}
        </div>
        {# Guests of this attendee #}
        {% for guest in event.guests if guest.host_user_id == attendee.user.id %}
        <div class="attendee-badge" style="margin-left: 28px; font-size: 0.9em;">
            <span class="attendee-name" style="color: var(--text-secondary);">
                <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width: 14px; height: 14px;">
                    <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
                    <circle cx="12" cy="7" r="4"></circle>
                </svg>
                gość: {{ guest.display_name }}{% if guest.organization %} ({{ guest.organization }}){% endif %}
            </span>
        </div>
        {% 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 %}
        <div class="attendee-badge" style="opacity: 0.7;">
            <span class="attendee-name" style="color: var(--text-secondary);">
                <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
                    <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
                    <circle cx="12" cy="7" r="4"></circle>
                </svg>
                {{ guest.host.name or 'Użytkownik' }} <em style="font-size: 0.85em;">(nie uczestniczy)</em>
            </span>
        </div>
        {% for g in event.guests if g.host_user_id == guest.host_user_id %}
        <div class="attendee-badge" style="margin-left: 28px; font-size: 0.9em;">
            <span class="attendee-name" style="color: var(--text-secondary);">
                <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width: 14px; height: 14px;">
                    <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
                    <circle cx="12" cy="7" r="4"></circle>
                </svg>
                gość: {{ g.display_name }}{% if g.organization %} ({{ g.organization }}){% endif %}
            </span>
        </div>
        {% endfor %}
        {% endif %}
        {% endfor %}
    </div>
</div>
{% endif %}
  • Step 3: Update attendee count in RSVP section (lines 551 and 559)

Line 551 — change:

<p class="text-muted" style="margin: 0;">{{ event.attendee_count }} osób zainteresowanych z Izby</p>

To:

<p class="text-muted" style="margin: 0;">{{ event.total_attendee_count }} osób zainteresowanych z Izby</p>

Line 559 — change:

<p class="text-muted" style="margin: 0;">{{ event.attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}</p>

To:

<p class="text-muted" style="margin: 0;">{{ event.total_attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}</p>
  • Step 4: Add JavaScript guest functions (after toggleRSVP function, in {% block extra_js %})

Add after the toggleRSVP function (after line 679):

/* --- 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
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
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
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)
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
git push origin master && git push inpi master
  • Step 2: Deploy to staging
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
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
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
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull"
  • Step 2: Run migration on production
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
ssh maciejpi@57.128.200.27 "sudo systemctl reload nordabiznes"
  • Step 4: Verify production
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.