docs: spec for event guest registration feature

Allows users to register accompanying guests (non-portal users) for events.
Covers data model, API endpoints, UI design, and migration plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-31 10:31:18 +02:00
parent 66582626a8
commit 4157138c2a

View File

@ -0,0 +1,280 @@
# Osoby towarzyszące na wydarzeniach (Event Guests)
**Data:** 2026-03-31
**Status:** Draft
**Kontekst:** Użytkownicy portalu chcą zapisywać na wydarzenia osoby towarzyszące — współpracowników, pracowników, osoby spoza Izby — które nie mają konta na portalu. Scenariusz zgłoszony przez prezesa Leszka Glazę: sam nie uczestniczy, ale chce zapisać kogoś w swoim imieniu.
---
## Wymagania
1. Zalogowany użytkownik może dodać jednego lub wielu gości na wydarzenie
2. Użytkownik nie musi sam być zapisany, żeby dodać gościa
3. Dane gościa (imię, nazwisko, firma/organizacja) są opcjonalne, ale minimum jedno pole musi być wypełnione
4. Goście wliczają się do limitu `max_attendees`
5. Goście są widoczni na liście uczestników z imieniem, nazwiskiem, firmą i informacją kto ich zapisał
6. Host może edytować i usuwać swoich gości
7. Admin (OFFICE_MANAGER+) może usuwać/edytować dowolnych gości
8. Nie dotyczy wydarzeń zewnętrznych (`is_external = True`) — tam rejestracja jest u organizatora
9. Max 5 gości per użytkownik per wydarzenie (stała aplikacyjna)
---
## Model danych
### Nowa tabela `event_guests`
| Kolumna | Typ | Nullable | Default | Opis |
|---------|-----|----------|---------|------|
| `id` | `Integer` PK | — | auto | — |
| `event_id` | `Integer` FK → `norda_events.id` | NOT NULL | — | CASCADE DELETE |
| `host_user_id` | `Integer` FK → `users.id` | NOT NULL | — | CASCADE DELETE |
| `first_name` | `String(100)` | TAK | `NULL` | Imię gościa |
| `last_name` | `String(100)` | TAK | `NULL` | Nazwisko gościa |
| `organization` | `String(255)` | TAK | `NULL` | Firma/organizacja |
| `created_at` | `DateTime` | NOT NULL | `now()` | Timestamp dodania |
**Indeksy:**
- `ix_event_guests_event_id` na `event_id`
- `ix_event_guests_host_user_id` na `host_user_id`
**Brak unique constraint** — użytkownik może zapisać osoby bez danych lub z powtarzającymi się danymi.
### Nowy model SQLAlchemy: `EventGuest`
```python
class EventGuest(db.Model):
__tablename__ = 'event_guests'
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('norda_events.id', ondelete='CASCADE'), nullable=False, index=True)
host_user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
first_name = db.Column(db.String(100), nullable=True)
last_name = db.Column(db.String(100), nullable=True)
organization = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
event = db.relationship('NordaEvent', backref=db.backref('guests', cascade='all, delete-orphan', lazy='dynamic'))
host = db.relationship('User', backref=db.backref('hosted_guests', lazy='dynamic'))
```
### Zmiany w `NordaEvent`
Nowa property:
```python
@property
def total_attendee_count(self):
"""Łączna liczba uczestników + gości (do sprawdzania limitu max_attendees)."""
return len(self.attendees) + self.guests.count()
```
Istniejąca `attendee_count` pozostaje bez zmian (kompatybilność wsteczna).
---
## API
Stała: `MAX_GUESTS_PER_USER = 5`
### POST `/kalendarz/<int:event_id>/guests`
Dodaje gościa na wydarzenie.
**Request body (JSON):**
```json
{
"first_name": "Jan",
"last_name": "Kowalski",
"organization": "Sigma Budownictwo"
}
```
Wszystkie pola opcjonalne, ale minimum jedno niepuste.
**Logika:**
1. `@login_required`
2. Sprawdź czy wydarzenie istnieje i nie jest przeszłe
3. Sprawdź `event.can_user_attend(current_user)`
4. Sprawdź `event.is_external == False`
5. Sprawdź limit gości: `EventGuest.query.filter_by(event_id=event_id, host_user_id=current_user.id).count() < MAX_GUESTS_PER_USER`
6. Sprawdź `max_attendees`: jeśli ustawiony, `event.total_attendee_count < event.max_attendees`
7. Walidacja: minimum jedno pole (first_name, last_name, organization) niepuste
8. Utwórz `EventGuest`, commit
9. Zwróć `201` z danymi gościa
**Odpowiedź (201):**
```json
{
"action": "added",
"guest": {
"id": 42,
"first_name": "Jan",
"last_name": "Kowalski",
"organization": "Sigma Budownictwo"
}
}
```
**Błędy:** 400 (walidacja), 403 (brak uprawnień), 409 (limit gości lub max_attendees)
### PATCH `/kalendarz/<int:event_id>/guests/<int:guest_id>`
Edytuje dane gościa.
**Request body (JSON):** jak POST, pola które mają się zmienić.
**Logika:**
1. `@login_required`
2. Sprawdź czy gość istnieje i należy do tego wydarzenia
3. Sprawdź czy `current_user` jest hostem gościa LUB ma rolę `OFFICE_MANAGER+`
4. Sprawdź czy wydarzenie nie jest przeszłe
5. Walidacja: po aktualizacji minimum jedno pole niepuste
6. Aktualizuj pola, commit
7. Zwróć `200` z zaktualizowanymi danymi
### DELETE `/kalendarz/<int:event_id>/guests/<int:guest_id>`
Usuwa gościa z wydarzenia.
**Logika:**
1. `@login_required`
2. Sprawdź czy gość istnieje i należy do tego wydarzenia
3. Sprawdź czy `current_user` jest hostem LUB ma rolę `OFFICE_MANAGER+`
4. Usuń rekord, commit
5. Zwróć `200` z `{"action": "removed"}`
### Zmiana w istniejącym RSVP
W route `calendar_rsvp`, zmiana sprawdzania limitu:
```python
# Było:
if event.max_attendees and event.attendee_count >= event.max_attendees:
# Jest:
if event.max_attendees and event.total_attendee_count >= event.max_attendees:
```
---
## UI — strona wydarzenia (`event.html`)
### Sekcja "Osoby towarzyszące"
Wyświetlana **poniżej** przycisku RSVP. Warunki widoczności:
- `event.can_user_attend(current_user)` = True
- Wydarzenie nie jest przeszłe
- Wydarzenie nie jest external
#### Przycisk
```
[+ Dodaj osobę towarzyszącą]
```
Po kliknięciu rozwija inline formularz (toggle, bez przeładowania strony).
#### Formularz (inline, rozwijany)
```
┌─────────────────────────────────────┐
│ Imię: [____________] │
│ Nazwisko: [____________] │
│ Firma/org.: [____________] │
│ │
│ [Dodaj] [Anuluj] │
└─────────────────────────────────────┘
```
- Tryb dodawania: przycisk "Dodaj", pola puste
- Tryb edycji: przycisk "Zapisz", pola wypełnione danymi gościa
- Walidacja frontend: minimum jedno pole niepuste, komunikat "Podaj przynajmniej imię, nazwisko lub firmę"
- Po sukcesie: formularz się czyści (tryb dodawania) lub zamyka (tryb edycji), lista gości się odświeża
- Komunikacja via fetch/JSON, spójnie z istniejącym RSVP
#### Lista gości bieżącego użytkownika
Pod formularzem, widoczna tylko jeśli użytkownik ma gości na tym wydarzeniu:
```
Twoi goście (2/5):
• Jan Kowalski (Sigma Budownictwo) [edytuj] [✕]
• Anna Nowak [edytuj] [✕]
```
- Kliknięcie `[edytuj]` otwiera formularz w trybie edycji
- Kliknięcie `[✕]` usuwa gościa (po potwierdzeniu `nordaConfirm()`)
- `(2/5)` — informacja o wykorzystanym limicie
### Lista uczestników (istniejąca sekcja, zmodyfikowana)
Goście wyświetlani pod swoim hostem z wcięciem:
```
Zapisani (7):
• Leszek Glaza — Sigma Budownictwo
└ gość: Jan Kowalski (Sigma Budownictwo)
└ gość: Anna Nowak
• Roman Wierciński — Sigma Budownictwo
• Maciej Pienczyn — InPi
└ gość: Tomek Zieliński (ARP)
```
Jeśli host nie jest zapisany sam (scenariusz Leszka), wyświetlany jest bez oznaczenia uczestnictwa:
```
• Leszek Glaza (nie uczestniczy) — Sigma Budownictwo
└ gość: Jan Kowalski (Sigma Budownictwo)
```
### Licznik
```
7 osób zapisanych (było: {{ event.attendee_count }}, jest: {{ event.total_attendee_count }})
```
---
## Migracja SQL
Plik: `database/migrations/091_event_guests.sql`
```sql
CREATE TABLE 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 ix_event_guests_event_id ON event_guests(event_id);
CREATE INDEX 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;
```
---
## Poza zakresem
- Powiadomienia email dla gości (brak konta = brak dostarczenia)
- Rejestracja gości na wydarzenia zewnętrzne
- Samodzielna rejestracja gościa (bez konta)
- Eksport listy uczestników do CSV/PDF
- Panel admin do zarządzania gośćmi (admin korzysta z widoku wydarzenia)
---
## Podsumowanie zmian
| Komponent | Zmiana |
|-----------|--------|
| `database.py` | Nowy model `EventGuest`, nowa property `total_attendee_count` na `NordaEvent` |
| `blueprints/community/calendar/routes.py` | 3 nowe endpointy (POST/PATCH/DELETE guests), zmiana sprawdzania limitu w RSVP |
| `templates/calendar/event.html` | Sekcja gości (formularz + lista), modyfikacja listy uczestników i licznika |
| `database/migrations/091_event_guests.sql` | Nowa tabela |