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>
29 KiB
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
-- 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, afterEventAttendeeclass) -
Modify:
database.py(line 2207,NordaEvent.attendeesrelationship area) -
Step 1: Add
EventGuestmodel afterEventAttendee(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
guestsrelationship toNordaEvent(line ~2207, afterattendeesrelationship)
In NordaEvent class, after line 2207 (attendees = relationship(...)), add:
guests = relationship('EventGuest', back_populates='event', cascade='all, delete-orphan')
- Step 3: Add
total_attendee_countproperty toNordaEvent(afterattendee_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 301–307):
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:
- Guest management section (new, after RSVP section ~line 564)
- Attendee list (modify existing, lines 584–616)
- 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">×</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 584–616)
Replace the entire attendees section (lines 584–616) 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
toggleRSVPfunction, 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:
- "Dodaj osobę towarzyszącą" button is visible
- Clicking it opens the inline form
- Adding a guest with only first name works
- Guest appears in "Twoi goście" list
- Editing guest data works
- Deleting guest works (with confirmation dialog)
- Guest appears in attendee list with "gość:" prefix under host
- 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.