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