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

778 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**
```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 301307):
```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('/<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**
```python
@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**
```python
@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**
```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 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:
```html
{# --- 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:
```html
{% 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:
```html
<p class="text-muted" style="margin: 0;">{{ event.attendee_count }} osób zainteresowanych z Izby</p>
```
To:
```html
<p class="text-muted" style="margin: 0;">{{ event.total_attendee_count }} osób zainteresowanych z Izby</p>
```
Line 559 — change:
```html
<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:
```html
<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):
```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.