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:
parent
66582626a8
commit
4157138c2a
280
docs/superpowers/specs/2026-03-31-event-guests-design.md
Normal file
280
docs/superpowers/specs/2026-03-31-event-guests-design.md
Normal 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 |
|
||||||
Loading…
Reference in New Issue
Block a user