Dokumentacja wygenerowana automatycznie · maindrive v1.0.0 · api/v1
v1.0.0

Wprowadzenie

Witaj w dokumentacji REST API aplikacji maindrive. Poniżej znajdziesz wszystkie dostępne endpointy, formaty żądań i odpowiedzi.

Base URL https://maindrive.app/api/v1
Format

Wszystkie żądania i odpowiedzi są w formacie application/json, kodowanie UTF-8. Wersja API: 1.0.0.

Konwencja nazewnictwa

Request body: camelCase (np. employeeId, serialNumber, startDate).

Response: camelCase (od maja 2025; wcześniejsze wersje zwracały snake_case z DB). Pola w bazie SQLite pozostają w snake_case, konwersja zachodzi w warstwie API przez helper toCamel().

Pola pozostające w snake_case (do migracji): endpointy /waste/*, /financial/*, /departments/*.

Format odpowiedzi

Wszystkie endpointy v1 zwracają payload w polu data:

{ "data": {...} }   // pojedynczy obiekt
{ "data": [...] }   // lista
{ "data": { "deleted": true } }  // po DELETE
Format błędów

Błędy zwracane są w ujednoliconym formacie JSON z opcjonalnym kodem błędu:

"error": "Opis błędu po polsku",
"code":  "OPTIONAL_ERROR_CODE"

Używane kody HTTP: 200 OK • 201 Created • 400 Bad Request • 401 Unauthorized • 403 Forbidden • 404 Not Found • 409 Conflict • 500 Server Error

Autentykacja

API obsługuje dwa typy uwierzytelnienia: dla administratora i dla pracownika.

Admin

Token uzyskany przez /auth/admin/login. Przekazywany w nagłówku:

X-Admin-Token: eyJhbGciOiJIUzI1NiJ9...

Daje dostęp do wszystkich endpointów jako wirtualny superadmin.

Pracownik / Manager / org_admin

Osobisty token pracownika przekazywany jako Bearer token:

Authorization: Bearer AB12CD

Zakres danych zależny od roli (own / team / org / platform). Pracownicy z rolą org_admin lub manager mogą używać tego tokenu do logowania w panelu admina.

Integracja zewnętrzna (API key)

Dedykowany klucz dla mikrousług partnerskich. Format kk_live_…. Header:

X-API-Key: kk_live_aB3xY2…

Generowany w panelu admina (Integracje · API keys). Każdy klucz ma własne scope-y (np. vacations:read), rate limit (req/min) i audit log. Pełny klucz pokazany TYLKO przy generowaniu — w DB trzymamy sha256.

Scope-y dla X-API-Key

Płaska lista moduł:akcja. Akcja to read lub write:

employees:read, employees:write · vacations:read, vacations:write · equipment:read, equipment:write · lunch:read, lunch:write · support:read, support:write · waste:read, waste:write · surveys:read, surveys:write · departments:read, departments:write · financials:read

Operacje user-only (zawsze 403 dla X-API-Key bez względu na scope): financials:write, backups:manage, voting:approve, employees:delete, settings:manage.

Limit / błędy: przekroczenie rate limitu → 429 z headerem Retry-After: 60. Klucz cofnięty/wygasły → 401. Brak scope-a → 403 z polem detail mówiącym jaki scope wymagany.

Sync danych

Pełne listy przez GET /:moduł. Inkrementalny sync (?updatedAfter=…) i webhook delivery — planowane w v2 zależnie od potrzeb partnerów.

Priorytety uwierzytelnienia

Endpointy oznaczone Admin wymagają nagłówka X-Admin-Token. Endpointy oznaczone Pracownik akceptują Authorization: Bearer — zakres odpowiedzi zależy od roli. Endpointy oznaczone Publiczny nie wymagają autentykacji.

Dual-auth: middleware empAuth akceptuje oba typy — X-Admin-Token tworzy wirtualny kontekst superadmin, Bearer token tworzy kontekst pracownika z jego rolą.

System

GET /health Publiczny

Sprawdza stan serwera i zwraca podstawowe informacje o aplikacji.

Response 200
{
  "status":    "ok",
  "version":   "1.0.0",
  "employees": 8
}
Kody błędów
500Wewnętrzny błąd serwera

Autentykacja — Endpointy

POST /auth/admin/login Publiczny

Loguje administratora i zwraca token sesji.

Request Body
{
  "password": "moje-haslo-admina"
}
Response 200
{
  "data": {
    "token":    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4ifQ.abc123",
    "firstRun": false  // true gdy brak hasła — pierwsze uruchomienie
  }
}
Kody błędów
400Brak pola password 401Nieprawidłowe hasło
POST /auth/employee/verify Publiczny

Weryfikuje 6-znakowy token pracownika i zwraca dane identyfikacyjne.

Request Body
{
  "token": "AB12CD"
}
Response 200
{
  "data": {
    "token":      "AB12CD",
    "name":       "Marek Kowalski",
    "employeeId": "emp_01"
  }
}
Kody błędów
400Brak lub nieprawidłowy format tokenu 401Token nieznany lub wygasły

Pracownicy

GET /employees Admin

Zwraca listę wszystkich pracowników wraz z flagą czy oddali głos.

Response 200
{
  "data": [
    {
      "id":               "emp_01",
      "name":             "Marek Kowalski",
      "startDate":        "2021-03-15",
      "eligibleForRaise": true,
      "ownershipPct":     12.5,
      "token":            "AB12CD",
      "hasVoted":         true
    },
    {
      "id":               "emp_02",
      "name":             "Anna Wiśniewska",
      "startDate":        "2020-07-01",
      "eligibleForRaise": true,
      "ownershipPct":     20.0,
      "token":            "XY98ZW",
      "hasVoted":         false
    }
  ]
}
Kody błędów
401Brak lub nieprawidłowy X-Admin-Token
GET /employees/:id Admin

Zwraca szczegółowe dane pojedynczego pracownika.

Response 200
{
  "data": {
    "id":               "emp_01",
    "name":             "Marek Kowalski",
    "startDate":        "2021-03-15",
    "eligibleForRaise": true,
    "ownershipPct":     12.5,
    "token":            "AB12CD",
    "hasVoted":         true
  }
}
Kody błędów
401Brak autoryzacji 404Pracownik nie istnieje
POST /employees Admin

Tworzy nowego pracownika i generuje dla niego unikalny 6-znakowy token.

Request Body
{
  "name":             "Piotr Nowak",
  "startDate":        "2024-01-15",
  "eligibleForRaise": true,
  "ownershipPct":     5.0
}
Response 201
{
  "data": {
    "id":               "emp_09",
    "name":             "Piotr Nowak",
    "startDate":        "2024-01-15",
    "eligibleForRaise": true,
    "ownershipPct":     5.0,
    "token":            "QR34ST",
    "hasVoted":         false
  }
}
Kody błędów
400Brak wymaganych pól lub nieprawidłowy format daty 409Pracownik o podanej nazwie już istnieje
PUT /employees/:id Admin

Aktualizuje dane pracownika (pełna zamiana zasobu).

Request Body
{
  "name":             "Piotr Nowak",
  "startDate":        "2024-01-15",
  "eligibleForRaise": false,
  "ownershipPct":     7.5
}
Response 200
{
  "data": {
    "id":               "emp_09",
    "name":             "Piotr Nowak",
    "startDate":        "2024-01-15",
    "eligibleForRaise": false,
    "ownershipPct":     7.5,
    "token":            "QR34ST"
  }
}
Kody błędów
400Nieprawidłowe dane wejściowe 404Pracownik nie istnieje
DELETE /employees/:id Admin

Usuwa pracownika z systemu wraz z powiązanymi danymi.

Response 200
{
  "message": "Pracownik został usunięty"
}
Kody błędów
404Pracownik nie istnieje 409Nie można usunąć pracownika który ma aktywne zasoby
GET /employees/me Pracownik

Zwraca dane zalogowanego pracownika wraz z wynikami głosowania i informacją o podwyżce.

Response 200
{
  "data": {
    "id":               "emp_02",
    "name":             "Anna Wiśniewska",
    "startDate":        "2020-07-01",
    "eligibleForRaise": true,
    "ownershipPct":     20.0,
    "hasVoted":         true,
    "votingResults": {
      "available": true,
      "votes":     5,
      "rank":      2
    },
    "raiseAmount": 1200.00
  }
}
Kody błędów
401Nieprawidłowy token pracownika

Głosowanie

GET /voting/status Publiczny

Zwraca aktualny stan głosowania — czy jest otwarte, ile osób jest uprawnionych i ile już zagłosowało.

Response 200
{
  "open":     true,
  "eligible": 8,
  "voted":    3
}
Kody błędów
500Wewnętrzny błąd serwera
POST /voting/open Admin

Otwiera rundę głosowania — pracownicy mogą od tej chwili oddawać głosy.

Response 200
{
  "message": "Głosowanie zostało otwarte",
  "open":    true
}
Kody błędów
409Głosowanie jest już otwarte
POST /voting/close Admin

Zamyka głosowanie i finalizuje wyniki.

Response 200
{
  "message": "Głosowanie zostało zamknięte",
  "open":    false
}
Kody błędów
409Głosowanie jest już zamknięte
POST /voting/votes Pracownik

Oddaje głos pracownika — można wskazać maksymalnie 5 ID pracowników.

Request Body
{
  "votes": ["emp_01", "emp_03", "emp_05"]
}
Response 200
{
  "message": "Głos został zapisany",
  "votesCount": 3
}
Kody błędów
400Przekroczono limit 5 głosów lub nieprawidłowe ID pracowników 403Głosowanie jest zamknięte 409Pracownik już oddał głos
GET /voting/results Admin

Zwraca pełne wyniki głosowania posortowane malejąco według liczby głosów.

Response 200
{
  "data": [
    { "employeeId": "emp_01", "name": "Marek Kowalski",   "votes": 7, "rank": 1 },
    { "employeeId": "emp_02", "name": "Anna Wiśniewska",  "votes": 5, "rank": 2 },
    { "employeeId": "emp_03", "name": "Tomasz Jabłoński", "votes": 3, "rank": 3 }
  ],
  "totalVoters": 8,
  "voted": 6
}
Kody błędów
401Brak autoryzacji admin
POST /voting/tokens/regenerate Admin

Regeneruje tokeny dostępowe dla wszystkich pracowników (np. przed nową rundą głosowania).

Response 200
{
  "message":        "Tokeny zostały zregenerowane",
  "regenerated":    8
}
Kody błędów
409Nie można regenerować tokenów podczas otwartego głosowania

Dane Finansowe

GET /financial/years Admin

Zwraca listę wszystkich lat obrachunkowych z ich statusem i aktywnymi rekordami.

Response 200
{
  "data": [
    {
      "year":   2024,
      "closed": true,
      "records": 4
    },
    {
      "year":   2025,
      "closed": false,
      "records": 2
    }
  ]
}
GET /financial/pools Admin

Zwraca pule bonusowe i podwyżkowe wyliczone na podstawie aktywnych rekordów finansowych.

Response 200
{
  "data": {
    "bonusPool":     48750.00,
    "raisePool":     24000.00,
    "currency":      "PLN",
    "basedOnPeriod": "Q1 2025"
  }
}
POST /financial/years/:year/records Admin

Dodaje rekord finansowy dla danego roku i okresu rozliczeniowego.

Request Body
{
  "period": "Q1",
  "navifleet": {
    "revenueThisYear":  1250000.00,
    "revenuePrevYear":  980000.00,
    "netProfit":        187500.00
  },
  "buildibox": {
    "revenueThisYear":  340000.00,
    "revenuePrevYear":  210000.00,
    "netProfit":        62000.00
  }
}
Response 201
{
  "data": {
    "year":     2025,
    "period":   "Q1",
    "active":   false,
    "createdAt": "2025-04-05T10:30:00Z"
  }
}
Kody błędów
400Brak wymaganych pól finansowych 409Rekord dla tego okresu już istnieje
DELETE /financial/years/:year/records/:period Admin

Usuwa rekord finansowy dla wskazanego roku i okresu.

Response 200
{
  "message": "Rekord został usunięty"
}
Kody błędów
403Nie można usunąć aktywnego rekordu 404Rekord nie istnieje
PATCH /financial/years/:year/records/:period/activate Admin

Ustawia wskazany rekord jako aktywny (deaktywuje inne rekordy w tym roku).

Response 200
{
  "message": "Rekord Q1 2025 jest teraz aktywny",
  "active":  true
}
Kody błędów
404Rekord nie istnieje
POST /financial/years/:year/close Admin

Zamyka rok obrachunkowy i blokuje możliwość edycji jego rekordów.

Response 200
{
  "message": "Rok 2024 został zamknięty",
  "year":    2024,
  "closed":  true
}
Kody błędów
409Rok jest już zamknięty lub brak aktywnego rekordu

Urlopy

GET /vacations Admin

Zwraca wszystkie wnioski urlopowe wszystkich pracowników.

Response 200
{
  "data": [
    {
      "id":          "vac_01",
      "employeeId": "emp_02",
      "employeeName": "Anna Wiśniewska",
      "type":        "days",
      "startDate":   "2025-06-09",
      "endDate":     "2025-06-20",
      "note":        "Wyjazd rodzinny",
      "status":      "pending",
      "createdAt":   "2025-05-10T08:15:00Z"
    }
  ]
}
GET /vacations/me Pracownik

Zwraca wnioski urlopowe zalogowanego pracownika.

Response 200
{
  "data": [
    {
      "id":        "vac_01",
      "type":      "days",
      "startDate": "2025-06-09",
      "endDate":   "2025-06-20",
      "note":      "Wyjazd rodzinny",
      "status":    "pending"
    }
  ]
}
POST /vacations Pracownik

Składa wniosek urlopowy — na dni lub godziny.

Request Body
{
  "type":      "days",   // "days" | "hours"
  "startDate": "2025-06-09",
  "endDate":   "2025-06-20",
  "note":      "Wyjazd rodzinny"
}
Response 201
{
  "data": {
    "id":        "vac_02",
    "type":      "days",
    "startDate": "2025-06-09",
    "endDate":   "2025-06-20",
    "status":    "pending"
  }
}
Kody błędów
400Nieprawidłowe daty lub typ urlopu 409Koliduje z istniejącym wnioskiem
PATCH /vacations/:id/approve Admin

Zatwierdza wniosek urlopowy pracownika.

Response 200
{
  "message": "Wniosek urlopowy został zatwierdzony",
  "status":  "approved"
}
Kody błędów
404Wniosek nie istnieje 409Wniosek nie jest w statusie pending
PATCH /vacations/:id/reject Admin

Odrzuca wniosek urlopowy pracownika.

Response 200
{
  "message": "Wniosek urlopowy został odrzucony",
  "status":  "rejected"
}
Kody błędów
404Wniosek nie istnieje 409Wniosek nie jest w statusie pending
DELETE /vacations/:id Pracownik

Anuluje własny wniosek urlopowy — tylko jeśli jest w statusie pending.

Response 200
{
  "message": "Wniosek urlopowy został anulowany"
}
Kody błędów
403Brak uprawnień do tego wniosku lub nie jest pending 404Wniosek nie istnieje

Profil i hasła

POST /profile/password Zalogowany

Pracownik (lub admin) zmienia własne hasło. Wymaga potwierdzenia obecnym hasłem albo tokenem (jeśli hasło nie jest jeszcze ustawione, currentCredential jest opcjonalne). Po pomyślnej zmianie zerowana jest flaga must_change_password.

Request Body
{
  "newPassword":       "NoweHaslo2025!",    // min. 6 znaków
  "currentCredential": "StareHaslo"          // hasło lub token
}
Response 200
{ "data": { "ok": true } }
Kody błędów
400Hasło krótsze niż 6 znaków lub identyczne z tokenem 401Nieprawidłowe aktualne hasło 404Pracownik nie istnieje
POST /employees/:id/reset-password Admin

Admin ustawia tymczasowe hasło dla pracownika i ustawia flagę must_change_password = 1. Przy najbliższym logowaniu pracownik zobaczy blokujący modal wymuszający zmianę hasła zanim będzie mógł korzystać z aplikacji.

Request Body
{
  "newPassword": "Tymczasowe2025!"   // min. 6 znaków
}
Response 200
{ "data": { "ok": true, "mustChangeAtNextLogin": true } }
Kody błędów
400Hasło krótsze niż 6 znaków 404Pracownik nie istnieje
Powiązane: GET /employees/me zwraca dodatkowe flagi: passwordSet (czy hasło już istnieje) oraz mustChangePassword (czy klient powinien wymusić zmianę po zalogowaniu).

Filtry / Zapisane widoki

Mechanizm zapisywania filtrów / widoków per użytkownik per kontekst (np. kontekst "vacations" dla listy urlopów). Pozwala każdemu użytkownikowi mieć swoje zapisane konfiguracje filtrów + sortowania.

GET /filters?context=:context Zalogowany

Zwraca zapisane widoki dla zalogowanego użytkownika w danym kontekście. Sortowane od najnowszego.

Query params
context="vacations"  // nazwa kontekstu (string)
Response 200
{
  "data": [
    {
      "id":        "abc123-uuid",
      "name":      "Mój zespół",
      "context":   "vacations",
      "filter":    { // dowolny obiekt JSON },
      "createdAt": "2025-05-02T..."
    }
  ]
}
POST /filters Zalogowany

Zapisuje nowy widok dla zalogowanego użytkownika.

Request Body
{
  "context": "vacations",
  "name":    "Mój zespół",
  "filter":  { // dowolna struktura — będzie zwrócona 1:1  }
}
Response 201
{ "data": { "id", "name", "context", "filter" } }
Kody błędów
400Brak context, name lub filter
DELETE /filters/:id Zalogowany

Usuwa zapisany widok. Tylko właściciel może go usunąć — endpoint sprawdza user_id.

Response 200
{ "data": { "deleted": true } }
Kody błędów
404Filtr nie istnieje lub należy do innego użytkownika

Ustawienia globalne

GET /settings Zalogowany

Zwraca ustawienia globalne aplikacji (konfiguracja modułów). Dostępne dla wszystkich zalogowanych — używane przez frontend do dostosowania UI.

Response 200 (przykład)
{
  "data": {
    "vacationApprovalMode": "manager",    // "manager" lub "auto"
    "vacationDaysLimit":   26,
    "votingOpen":          false,
    "raisesApproved":      false,
    "bonusesApproved":     false
  }
}
PUT /settings Admin

Aktualizuje wybrane pola w ustawieniach. Body to dowolny obiekt z polami do nadpisania — pola pominięte pozostają niezmienione.

Request Body
{
  "vacationApprovalMode": "auto",
  "vacationDaysLimit":   28
}
Response 200
{ "data": { // pełny obiekt ustawień po update  } }

Głosowanie — operacje admina

GET /voting/history Admin

Zwraca archiwum zatwierdzonych głosowań pogrupowane po roku. Każdy rok zawiera snapshot wyników wszystkich pracowników.

Response 200
{
  "data": {
    "2024": [
      {
        "year":              2024,
        "employeeId":       "5dc3...",
        "employeeName":     "Marcin Smoleń",
        "bonus":             2690.12,
        "shareholderBonus": 0,
        "raiseMonthly":     189.45,
        "raiseAnnual":      2273.40,
        "votesReceived":    12,
        "voteShare":        0.0461,
        "approvedAt":       "2025-01-15T..."
      }
    ]
  }
}
POST /voting/approve Admin

Zatwierdza wyniki głosowania, robi snapshot wszystkich pracowników do tabeli result_snapshots (rok bieżący), zamyka głosowanie i ustawia flagi bonusesApproved.

Response 200
{ "data": { "approved": true, "year": 2025 } }
POST /voting/reset Admin

Resetuje aktualną rundę głosowania — usuwa wszystkie głosy z tabel votes, voted_employees, used_tokens, zamyka głosowanie. Niezatwierdzone wyniki przepadają.

Response 200
{ "data": { "reset": true } }

Sprzęt

Inwentarz sprzętu firmowego z przypisaniami do pracowników. Wszystkie endpointy admina wymagają autoryzacji x-admin-token lub Bearer (admin/manager/org_admin), endpoint /equipment/me wymaga sesji pracownika.

Wartości słownikowe
category: laptop, monitor, phone, headphones, keyboard, mouse, other
status: active, replace, returned, inactive
GET /equipment Admin

Zwraca kompletny inwentarz wraz z imieniem przypisanego pracownika (LEFT JOIN). Sortowane po nazwie.

Response 200
{
  "data": [
    {
      "id":            "a1f3...-uuid",
      "employeeId":   "5dc3e512-...",
      "employeeName": "Marcin Smoleń",
      "name":          "MacBook Pro 14",
      "category":      "laptop",
      "brand":         "Apple",
      "model":         "M3 Pro 16GB/512GB",
      "serialNumber": "FVFT2X3NXY12",
      "assignedAt":   "2024-03-12T08:00:00.000Z",
      "status":        "active",
      "notes":         null
    }
  ]
}
GET /equipment/me Pracownik

Zwraca sprzęt przypisany do zalogowanego pracownika (employeeId = ID z sesji). Brak pola employeeName bo użytkownik zna swoje imię.

Response 200
{
  "data": [
    {
      "id":            "a1f3...-uuid",
      "employeeId":   "5dc3e512-...",
      "name":          "MacBook Pro 14",
      "category":      "laptop",
      "brand":         "Apple",
      "model":         "M3 Pro 16GB/512GB",
      "serialNumber": "FVFT2X3NXY12",
      "assignedAt":   "2024-03-12T08:00:00.000Z",
      "status":        "active",
      "notes":         null
    }
  ]
}
POST /equipment Admin

Dodaje nowy sprzęt. Request używa camelCase, response snake_case (z DB). Jeśli przekazany employeeId, automatycznie ustawiana jest data assignedAt.

Request Body
{
  "name":         "MacBook Pro 14",        // wymagane
  "category":     "laptop",                 // default: "other"
  "brand":        "Apple",                  // opcjonalne
  "model":        "M3 Pro 16GB",            // opcjonalne
  "serialNumber": "FVFT2X3NXY12",           // opcjonalne
  "employeeId":   "5dc3e512-...",           // null = magazyn
  "status":       "active",                 // default: "active"
  "notes":        "Notatka"                 // opcjonalne
}
Response 201
{
  "data": {
    "id":            "a1f3...-uuid",
    "employeeId":   "5dc3e512-...",
    "name":          "MacBook Pro 14",
    "category":      "laptop",
    "brand":         "Apple",
    "model":         "M3 Pro 16GB",
    "serialNumber": "FVFT2X3NXY12",
    "assignedAt":   "2025-05-02T12:34:56.000Z",
    "status":        "active",
    "notes":         null
  }
}
Kody błędów
400Brak name
PUT /equipment/:id Admin

Aktualizuje pola sprzętu (oprócz przypisania — to robi PATCH /equipment/:id/assign). Pominięte pola pozostają niezmienione.

Request Body
{
  "name":         "MacBook Pro 14 (zaktualizowany)",
  "category":     "laptop",
  "brand":        "Apple",
  "model":        "M3 Max 32GB/1TB",
  "serialNumber": "FVFT2X3NXY12",
  "status":       "replace",
  "notes":        "Bateria do wymiany"
}
Response 200
{ "data": { // pełny obiekt sprzętu (snake_case)  } }
Kody błędów
404Sprzęt nie istnieje
PATCH /equipment/:id/assign Admin

Przypisuje sprzęt do pracownika lub odpisuje (employeeId: null). Jeśli przypisanie nowemu — automatycznie ustawia assignedAt na obecny czas.

Request Body
{
  "employeeId": "5dc3e512-..."  // null = oddziel od pracownika
}
Response 200
{ "data": { // pełny obiekt sprzętu po zmianie  } }
Kody błędów
404Sprzęt nie istnieje
DELETE /equipment/:id Admin

Usuwa sprzęt z inwentarza (twardo). Brak walidacji „sprzęt jest przypisany" — można usuwać sprzęt aktualnie u pracownika.

Response 200
{ "data": { "deleted": true } }
Kody błędów
404Sprzęt nie istnieje

Zgłoszenia sprzętowe

Zgłoszenia od pracowników: nowy sprzęt (type: "new"), usterka (type: "fault"), wymiana (type: "replacement"). Każdy zalogowany użytkownik może utworzyć zgłoszenie. Status modyfikuje superadmin / org_admin / manager (manager — tylko swojego działu). Wszystkie odpowiedzi w camelCase.

GET /equipment-requests Pracownik / Admin

Zwraca listę zgłoszeń. Scope zależy od roli: superadmin / org_admin — wszystkie; manager — zgłoszenia pracowników z jego działu; employee — własne. Sortowane po createdAt DESC. Lista zawiera employeeName i equipmentName (JOIN).

Response 200
{
  "data": [
    {
      "id": "a1b2c3...",
      "employeeId": "emp-uuid",
      "employeeName": "Anna Sokołowska",
      "type": "fault",                // new | fault | replacement
      "category": null,                // tylko dla type=new
      "equipmentId": "eq-uuid",         // tylko dla fault/replacement
      "equipmentName": "MacBook Pro 14\"",
      "title": "Laptop nie ładuje się",
      "description": "Po podłączeniu zasilacza...",
      "priority": "high",                // low | normal | high | urgent
      "status": "pending",              // pending | approved | rejected | in_progress | resolved
      "adminNote": null,
      "createdAt": "2026-05-03T10:15:00.000Z",
      "updatedAt": null,
      "resolvedAt": null,
      "resolvedBy": null
    }
  ]
}
GET /equipment-requests/me Pracownik

Alias filtrujący do własnych zgłoszeń (employeeId = ID z sesji). Pole employeeName nie jest zwracane (klient zna własne imię). Używane na zakładce „Twój sprzęt" / „Mój sprzęt".

Response 200
{ "data": [ // jak wyżej, bez pola employeeName ] }
POST /equipment-requests Pracownik

Tworzy nowe zgłoszenie. Pole employeeId brane z sesji — pracownik nie może wystawiać zgłoszeń w cudzym imieniu. Domyślny status = pending, domyślny priorytet = normal.

Request Body
{
  "type": "fault",                  // wymagane: new | fault | replacement
  "title": "Laptop nie ładuje się",    // wymagane (po trimie)
  "description": "…",                // opcjonalne
  "priority": "high",                  // opcjonalne, default normal
  "category": "laptop",                // wymagane gdy type=new
  "equipmentId": "eq-uuid"             // wymagane gdy type=fault | replacement; sprzęt MUSI należeć do zgłaszającego
}
Response 201
{ "data": { "id": "…", "status": "pending" } }
Kody błędów
400Niepoprawny typ / brak tytułu / brak kategorii (dla new) / brak equipmentId (dla fault|replacement) 403Próba zgłoszenia cudzego sprzętu 404Wskazany sprzęt nie istnieje
PATCH /equipment-requests/:id/status Admin / Manager

Aktualizuje status i/lub komentarz administratora. Dostępne dla superadmin, org_admin, manager. Manager może zmieniać tylko zgłoszenia pracowników ze swojego działu (po stronie serwera weryfikowane na podstawie departmentId). Przy przejściu w resolved lub rejected automatycznie ustawiane resolvedAt i resolvedBy (jeśli były null).

Request Body
{
  "status": "in_progress",    // opcjonalne, jedna z: pending | approved | rejected | in_progress | resolved
  "adminNote": "Zamówione, dostawa za 3 dni"  // opcjonalne; null = brak zmiany
}
Response 200
{ "data": { // pełne zgłoszenie po aktualizacji  } }
Kody błędów
400Niepoprawny status 403Manager: zgłoszenie spoza działu / Brak roli admin/manager 404Zgłoszenie nie istnieje
DELETE /equipment-requests/:id Pracownik / Admin

Usuwa zgłoszenie. Autor może usunąć tylko własne zgłoszenie i tylko jeśli ma status pending (anulowanie). Superadmin / org_admin mogą usunąć każde zgłoszenie w dowolnym stanie. Manager nie może usuwać.

Response 200
{ "data": { "deleted": true } }
Kody błędów
403Próba usunięcia cudzego zgłoszenia / nie-pending bez roli admina 404Zgłoszenie nie istnieje

Waste Radar

Zgłoszenia marnotrawstwa w 4 kategoriach: license (subskrypcje i licencje), purchase (zakupy i koszty), process (procesy), time (czas). Kwota roczna (estimatedCost) jest pierwszorzędna — UI ma sprzężone inputy miesięczne/roczne, wysyła zawsze konkretną liczbę. Skala (low/mid/high/huge) jest etykietą agregatu, auto-derived z kwoty wg progów <2k / <10k / <50k / ≥50k PLN. Legacy fallback: gdy nie poda się estimatedCost, serwer bierze z mapy WASTE_SCALE_COST (1k/5k/25k/75k). Wszystkie odpowiedzi w camelCase.

GET /waste?status=&type= Pracownik / Admin

Zwraca listę zgłoszeń. Scope wg roli (employee → swoje, manager → dział, org_admin → org, superadmin → wszystko). Lista zawiera reporterName i assigneeName (JOIN z employees).

Query Params
status: "active" | "in_progress" | "resolved"   (opcjonalny)
type:   "license" | "purchase" | "process" | "time"   (opcjonalny)
Response 200
{
  "data": [
    {
      "id":            "a1b2…",
      "employeeId":    "emp-uuid",
      "reporterName":  "Anna Sokołowska",
      "type":          "license",
      "title":         "5 nieużywanych licencji Figma",
      "description":   "Płacimy 75 USD/mies za miejsca…",
      "scale":         "mid",             // low | mid | high | huge
      "estimatedCost": 5000,              // roczna strata w PLN
      "status":        "active",
      "assignedTo":    null,
      "assigneeName":  null,
      "createdAt":     "2026-05-03T09:00:00Z",
      "resolvedAt":    null
    }
  ],
  "scope": "team"
}
GET /waste/stats Pracownik / Admin

Zwraca zagregowane statystyki marnotrawstwa. Honoruje scope wg roli — manager dostaje agregat tylko swojego działu. byType zawiera kategoryzację dla widoku admina (paski strat per kategoria).

Response 200
{
  "data": {
    "totalAnnual":     47200,    // suma estimatedCost dla otwartych zgłoszeń
    "openCount":       12,
    "resolvedCount":   5,
    "inProgressCount": 3,
    "bonusPerPerson":  589.5, // 20% z totalAnnual / liczba pracowników
    "byType": {
      "license":  { "count": 3, "estimatedAnnual": 12000 },
      "purchase": { "count": 2, "estimatedAnnual": 15000 },
      "process":  { "count": 5, "estimatedAnnual": 15200 },
      "time":     { "count": 2, "estimatedAnnual":  5000 }
    }
  }
}
POST /waste Pracownik

Zgłasza nowe marnotrawstwo. employeeId brane z sesji. estimatedCost (kwota roczna w PLN) jest pierwszorzędnym źródłem wartości — frontend od maja 2026 wysyła ją zawsze (UI ma sprzężone inputy mies./rok jak Revolut). scale jest opcjonalna, służy tylko jako etykieta agregatu (Mała/Średnia/Duża/Krytyczna) i może być auto-derivowana z kwoty po stronie klienta.

Request Body
{
  "type":          "license",    // wymagane: license | purchase | process | time
  "title":         "5 nieużywanych licencji Figma",  // wymagane (po trimie)
  "description":   "…",           // opcjonalne
  "estimatedCost": 6000,          // PLN/rok — preferowane, ma priorytet
  "scale":         "mid"           // opcjonalne, auto-derived z kwoty: <2k=low, <10k=mid, <50k=high, ≥50k=huge
}
Fallback (legacy)

Jeśli pominiesz estimatedCost a podasz scale, serwer wylicza koszt wg mapy: low=1000, mid=5000, high=25000, huge=75000 PLN/rok. Pominięcie obu → estimatedCost = 0 (zgłoszenie nie liczy się do agregatów strat).

Response 201
{ "data": { // pełny obiekt waste w camelCase  } }
Kody błędów
400Niepoprawna kategoria / brak tytułu / niepoprawna skala 403Moduł waste wyłączony / brak uprawnienia create
PATCH /waste/:id/assign Pracownik

Przypisuje pracownika do rozwiązania zgłoszenia (zmienia status na in_progress).

Response 200
{
  "message":    "Zgłoszenie zostało przypisane",
  "status":     "in_progress",
  "assignedTo": "emp_05"
}
Kody błędów
404Zgłoszenie nie istnieje 409Zgłoszenie jest już przypisane lub rozwiązane
PATCH /waste/:id/resolve Admin

Zatwierdza rozwiązanie zgłoszenia i zamyka je jako resolved.

Response 200
{
  "message": "Zgłoszenie zostało zamknięte jako rozwiązane",
  "status":  "resolved"
}
Kody błędów
404Zgłoszenie nie istnieje
DELETE /waste/:id Pracownik / Admin

Usuwa zgłoszenie. Autor może usunąć własne tylko jeśli nie jest resolved. Superadmin / org_admin mogą usunąć każde.

Response 200
{ "data": { "deleted": true } }
Kody błędów
403Próba usunięcia cudzego zgłoszenia / nie-rozwiązane bez roli admina 404Zgłoszenie nie istnieje

Obiady

Moduł obiadów. Model template'owy: admin tworzy menu z polami name, validFrom, validTo i listą pozycji pogrupowanych per dzień tygodnia (1=pon..5=pt). Tylko jedno menu może być aktywne (isActive=1) — jego items resolveują się na konkretne daty przez endpoint /lunch/effective. Pracownik wybiera 1 pozycję/dzień. O cutoff cron generuje zamówienie do restauracji. Urlop dzienny (approved + days/sick/other) wyklucza obiad. Wszystkie odpowiedzi w camelCase.

GET /lunch/settings Admin / Manager

Ustawienia modułu — cutoff, email restauracji, max dni do przodu.

Response 200
{
  "data": {
    "lunchModuleEnabled":     true,
    "lunchCutoffTime":        "09:00",
    "lunchCutoffDayOffset":   0,        // -1 / 0 / 1 dni względem dnia obiadu
    "lunchOrderDelayMinutes": 5,
    "lunchRestaurantName":    "Bistro Mama",
    "lunchRestaurantEmail":   "zamowienia@bistro.pl",
    "lunchMaxDaysAhead":      30
  }
}
PUT /lunch/settings Admin

Aktualizuje ustawienia. Można wysłać tylko zmieniane pola — pozostałe zostaną zachowane.

Request Body (wszystkie pola opcjonalne)
{
  "lunchModuleEnabled":     true,
  "lunchCutoffTime":        "09:00",         // HH:MM, 0-23:0-59
  "lunchCutoffDayOffset":   0,                // -1, 0, 1
  "lunchOrderDelayMinutes": 5,                // 0-240
  "lunchRestaurantName":    "Bistro Mama",
  "lunchRestaurantEmail":   "…",
  "lunchMaxDaysAhead":      30                // 1-90
}
Kody błędów
400Niepoprawny format godziny / poza zakresem
GET /lunch/menus Pracownik / Admin

Lista wszystkich menu (template'ów). Sortowane: aktywne pierwsze, potem wg validFrom DESC. Lekka odpowiedź — bez items, jedynie liczniki.

Response 200
{
  "data": [
    {
      "id":         "…",
      "name":       "Menu majowe 2026",
      "validFrom":  "2026-05-01",
      "validTo":    "2026-05-31",
      "isActive":   1,
      "createdAt":  "2026-04-28T10:15:00Z",
      "updatedAt":  null,
      "itemsCount": 25,    // łączna liczba pozycji we wszystkich dniach
      "dayCount":   5      // ile dni tygodnia (1..5) ma jakiekolwiek pozycje
    }
  ]
}
GET /lunch/menus/:id Pracownik / Admin

Pełen template z items pogrupowanymi per dzień tygodnia. Dla pracowników (rola employee) ceny ukryte (price = undefined).

Response 200
{
  "data": {
    "id": "…", "name": "…", "validFrom": "…", "validTo": "…", "isActive": 1,
    "itemsByDay": {
      "1": [{ "id": "…", "name": "Schabowy", "price": 28, "description": "Z surówką",
              "isVegetarian": 0, "isVegan": 0, "isGlutenFree": 0, "position": 0 }, …],
      "2": [...],   // wtorek
      "3": [...],   // środa
      "4": [...],   // czwartek
      "5": [...]    // piątek
    }
  }
}
GET /lunch/effective?from=&to= Pracownik / Admin

Resolveuje aktywne menu na konkretne daty w zadanym zakresie. Dla każdej daty (która: jest dniem roboczym i mieści się w validFromvalidTo aktywnego menu) zwraca items dla odpowiedniego day_of_week. Używane przez frontend pracownika do widoku kalendarzowego.

Query Params
from: "2026-05-01"   // wymagany, YYYY-MM-DD
to:   "2026-05-31"   // wymagany
Response 200
{
  "data": {
    "activeMenuId":   "…",
    "activeMenuName": "Menu majowe 2026",
    "validFrom":      "2026-05-01",
    "validTo":        "2026-05-31",
    "byDate": {
      "2026-05-04": {     // poniedziałek
        "menuId":   "…",
        "menuName": "Menu majowe 2026",
        "items":    [...],   // lista items dla day_of_week=1
        "locked":   false      // czy są pendingowe picks na tę datę
      },
      "2026-05-05": {...}    // wtorek
      // soboty/niedziele i daty poza zakresem aktywnego menu pominięte
    }
  }
}
Kody błędów
400Brak from/to lub niepoprawny format
POST /lunch/menus Admin

Tworzy template menu z items pogrupowanymi per dzień tygodnia. Wymaga przynajmniej 1 pozycji w dowolnym dniu. Jeśli isActive: true w body — automatycznie deaktywuje wszystkie inne menu (transakcja).

Request Body
{
  "name":      "Menu majowe 2026",
  "validFrom": "2026-05-01",
  "validTo":   "2026-05-31",
  "isActive":  false,                  // opcjonalne, default false
  "itemsByDay": {
    "1": [                                   // pon (może być pusta tablica)
      { "name": "Schabowy", "price": 28, "description": "Z surówką",
        "isVegetarian": false, "isVegan": false, "isGlutenFree": false }
    ],
    "2": [...], "3": [...], "4": [...], "5": [...]
  }
}
Kody błędów
400Brak name / validFrom > validTo / pusta tablica wszystkich dni / brak nazwy pozycji / ujemna cena
PUT /lunch/menus/:id Admin

Edycja template'a. Items z id updateowane, bez id → nowe. Jeśli edycja usunie pozycje na które są aktywne picks i body nie zawiera force: true → 409. Z force: true picks zostają oznaczone jako cancelled_admin.

Request Body
{
  "name":      "…",
  "validFrom": "…", "validTo": "…",
  "itemsByDay": { "1": [...], "2": [...], "3": [...], "4": [...], "5": [...] },
  "force":     true   // opcjonalne — wymuś gdy są aktywne picks
}
Kody błędów
400Brak wymaganego pola / niepoprawny zakres dat 409Edycja anuluje aktywne wybory pracowników (potrzebny force=true)
PATCH /lunch/menus/:id/activate Admin

Ustawia menu jako aktywne (isActive=1) i deaktywuje wszystkie pozostałe (transakcja). Tylko jedno menu może być aktywne naraz.

Response 200
{ "data": { "id": "…", "isActive": 1, … } }
PATCH /lunch/menus/:id/deactivate Admin

Deaktywuje menu. Po deaktywacji żadne menu nie jest aktywne — pracownicy nie mogą wybierać dopóki któreś nie zostanie ustawione przez /activate.

Response 200
{ "data": { "deactivated": true } }
DELETE /lunch/menus/:id Admin

Usuwa menu. Aktywnego menu nie można usunąć — najpierw je dezaktywuj. Jeśli istnieją historyczne picks na items tego menu — wymagany ?force=1 w query (usunie wraz z historią).

Response 200
{ "data": { "deleted": true } }
Kody błędów
404Menu nie istnieje 409Menu jest aktywne / Menu ma historię picków (potrzebny ?force=1)
GET /lunch/orders/me?from=&to= Pracownik

Lista własnych picków zalogowanego pracownika w zakresie dat. Każdy obiekt zawiera dodatkowo itemName, itemPrice, employeeName (JOIN).

Response 200
{
  "data": [
    {
      "id":                  "…",
      "employeeId":          "…",
      "date":                "2026-05-12",
      "menuItemId":          "…",
      "itemName":            "Schabowy z ziemniakami",
      "itemPrice":           28,
      "status":              "pending",    // pending | sent | cancelled_user | cancelled_vacation | cancelled_admin
      "createdAt":           "…",
      "cancellationReason":  null
    }
  ]
}
POST /lunch/orders Pracownik

Wybór obiadu — UPSERT (jeden pick na dzień per pracownik). Walidacje: nie weekend, nie po cutoffie, nie przed startDate, nie urlop dzienny, item musi należeć do aktywnego menu i pasować do day_of_week daty oraz mieścić się w validFromvalidTo.

Request Body
{
  "date":       "2026-05-12",
  "menuItemId": "abc-123"
}
Kody błędów
400Cutoff minął / urlop dzienny / przed startDate / weekend / poza maxDaysAhead / login hasłem (admin __admin__) próbuje zamówić 404Item nie należy do aktywnego menu na ten dzień
DELETE /lunch/orders/:date Pracownik

Anuluje własny pick na ten dzień. Tylko przed cutoffem (po cutoffie zamówienie poszło do restauracji — nie da się odwołać).

Response 200
{ "data": { "deleted": true } }
Kody błędów
400Cutoff minął 404Brak pendingowego picku na ten dzień
GET /lunch/admin/daily-summary?date=YYYY-MM-DD Admin / Manager

Dzienne podsumowanie zamówień — agregat per pozycja + lista pracowników.

Response 200
{
  "data": {
    "date": "2026-05-12",
    "totalAmount":    152,
    "activeCount":    6,
    "cancelledCount": 1,
    "byItem": [
      { "itemId": "…", "name": "Schabowy", "price": 28, "qty": 3,
        "employees": ["Anna", "Wojtek", "Krzysztof"], "cancelledQty": 0 }
    ]
  }
}
GET /lunch/admin/monthly-summary?year=&month= Admin / Manager

Miesięczne podsumowanie — agregat per dzień + per pracownik. Używane do weryfikacji rachunku z restauracji.

Response 200
{
  "data": {
    "year": 2026, "month": 5,
    "fromDate": "2026-05-01", "toDate": "2026-05-31",
    "totalAmount": 3250,         // suma w PLN
    "totalQty":    125,          // liczba obiadów
    "days": [
      { "date": "2026-05-04", "qty": 7, "amount": 182, "cancelledQty": 1 }, …
    ],
    "byEmployee": [
      { "employeeId": "…", "employeeName": "…", "qty": 18, "amount": 468 }, …
    ]
  }
}
GET /lunch/admin/restaurant-orders?from=&to= Admin

Historia wygenerowanych zamówień do restauracji (tabela lunch_restaurant_orders). Limit 200 najnowszych. Każdy wpis ma snapshot items, total amount, treść maila do skopiowania.

Response 200
{
  "data": [
    {
      "id": "…", "date": "2026-05-12",
      "generatedAt": "2026-05-12T09:05:00Z",
      "totalAmount": 182,
      "itemsJson": "[{...}]",        // JSON snapshot pozycji
      "recipients": "zamowienia@bistro.pl",
      "emailSubject": "…", "emailBody": "…",
      "status": "generated",         // generated | sent | failed
      "sentAt": null
    }
  ]
}
POST /lunch/admin/regenerate-order/:date Admin

Ręcznie generuje order do restauracji (zwykle robi to cron co minutę po cutoff+delay). Wykluczeni: pracownicy z urlopem dziennym. Picks pending → status sent. Zwraca pełen obiekt z emailBody do skopiowania / wysyłki.

Response 200
{
  "data": {
    "id": "…", "date": "…", "totalAmount": 152,
    "items": [...],
    "excluded": [{ "employeeName": "…", "itemName": "…", "reason": "urlop dzienny" }],
    "emailSubject": "…",
    "emailBody": "…",
    "recipients": "zamowienia@bistro.pl"
  }
}

Podwyżki

GET /raises/management Admin

Zwraca aktualne propozycje podwyżek dla wszystkich uprawnionych pracowników.

Response 200
{
  "data": {
    "emp_01": { "name": "Marek Kowalski",   "amount": 1500.00 },
    "emp_02": { "name": "Anna Wiśniewska",  "amount": 1200.00 },
    "emp_03": { "name": "Tomasz Jabłoński", "amount": 900.00  }
  },
  "raisePool": 24000.00,
  "allocated": 3600.00,
  "currency":  "PLN"
}
PUT /raises/management Admin

Ustawia propozycje podwyżek dla pracowników (klucz = employeeId, wartość = kwota PLN).

Request Body
{
  "emp_01": 1800.00,
  "emp_02": 1400.00,
  "emp_03": 1000.00
}
Response 200
{
  "message":   "Propozycje podwyżek zostały zapisane",
  "allocated": 4200.00
}
Kody błędów
400Kwoty przekraczają dostępną pulę podwyżek
GET /raises/results Admin

Zwraca zatwierdzone wyniki podwyżek po zamknięciu procesu przez zarząd.

Response 200
{
  "data": [
    { "employeeId": "emp_01", "name": "Marek Kowalski",   "amount": 1800.00, "approved": true },
    { "employeeId": "emp_02", "name": "Anna Wiśniewska",  "amount": 1400.00, "approved": true }
  ],
  "currency": "PLN"
}

Zatwierdzenia

GET /approval Admin

Zwraca aktualny status zatwierdzenia podwyżek i bonusów przez zarząd.

Response 200
{
  "raises":  false,
  "bonuses": true
}
PATCH /approval Admin

Aktualizuje status zatwierdzenia podwyżek i/lub bonusów przez zarząd.

Request Body
{
  "raises":  true,
  "bonuses": true
}
Response 200
{
  "message": "Status zatwierdzeń zaktualizowany",
  "raises":  true,
  "bonuses": true
}

Raporty

GET /reports/raises Admin

Pobiera raport podwyżek jako plik CSV gotowy do importu do kadr.

Response 200
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="raporty-podwyzki-2025.csv"

Imię i Nazwisko,Kwota Podwyżki (PLN),Status
Marek Kowalski,1800.00,Zatwierdzona
Anna Wiśniewska,1400.00,Zatwierdzona
Tomasz Jabłoński,1000.00,Zatwierdzona
Kody błędów
404Brak danych do wygenerowania raportu
GET /reports/summary Admin

Zwraca JSON z pełnym podsumowaniem roku — finansami, podwyżkami, bonusami i głosowaniem.

Response 200
{
  "year": 2025,
  "financial": {
    "activePeriod":    "Q1",
    "totalRevenue":    1590000.00,
    "totalNetProfit":  249500.00,
    "bonusPool":       48750.00,
    "raisePool":       24000.00
  },
  "raises": {
    "approved":    true,
    "totalAmount": 4200.00,
    "employees":   3
  },
  "bonuses": {
    "approved":        true,
    "perPerson":       6093.75,
    "wasteReduction":  5900.00
  },
  "voting": {
    "participation": 75,
    "winner": "Marek Kowalski"
  },
  "currency": "PLN",
  "generatedAt": "2025-05-01T12:00:00Z"
}

Kopie Zapasowe

GET /backups Admin

Zwraca listę dostępnych kopii zapasowych bazy danych.

Response 200
{
  "data": [
    {
      "name":      "backup_2025-04-30T22-00-00.db",
      "size":      "2.4 MB",
      "createdAt": "2025-04-30T22:00:00Z"
    },
    {
      "name":      "backup_2025-04-29T22-00-00.db",
      "size":      "2.3 MB",
      "createdAt": "2025-04-29T22:00:00Z"
    }
  ]
}
POST /backups Admin

Tworzy natychmiastową kopię zapasową bazy danych.

Response 201
{
  "message":   "Kopia zapasowa została utworzona",
  "name":      "backup_2025-05-01T10-15-30.db",
  "size":      "2.5 MB",
  "createdAt": "2025-05-01T10:15:30Z"
}
Kody błędów
500Błąd podczas tworzenia kopii zapasowej
POST /backups/:name/restore Admin

Przywraca bazę danych z wybranej kopii zapasowej — uwaga: operacja nieodwracalna.

Response 200
{
  "message":   "Baza danych przywrócona z backup_2025-04-30T22-00-00.db",
  "restoredAt": "2025-05-01T11:05:00Z"
}
Kody błędów
404Kopia zapasowa o podanej nazwie nie istnieje 500Błąd podczas przywracania kopii

Zgłoszenia (helpdesk)

Każdy zalogowany użytkownik tworzy ticket (tytuł, opis, opcjonalny screen). Wątek czat-style. Statusy: new → in_progress → waiting_user → resolved. Body limit 2 MB (screeny w base64).

GET /support/tickets Pracownik

Lista ticketów. Pracownik widzi swoje, admin (superadmin/org_admin) — wszystkie w tenancie. Query: ?status=pending|in_progress|waiting_user|resolved.

Response 200
{ "data": [ { "id", "title", "status", "messageCount", "lastMessage", "unread", ... } ] }
POST /support/tickets Pracownik

Tworzy nowy ticket z pierwszą wiadomością i opcjonalnym screenshotem (data URL).

Request Body
{
  "title":      "Nie ładuje się lista urlopów",
  "body":       "Po kliknięciu Zatwierdź dostaję 500…",
  "screenshot": "data:image/jpeg;base64,…",    // opcjonalne, max ~2 MB
  "route":      "#admin/vacations",            // kontekst dla admina
  "userAgent":  "Mozilla/5.0…",
  "viewport":   "1920x1080"
}
Kody błędów
400Tytuł 3-200 znaków, opis 5-5000 400Admin (X-Admin-Token) nie może tworzyć — tylko zalogowany user
GET /support/tickets/:id Pracownik

Pełen ticket z wątkiem (messages + attachments). Auto-mark-read przy odczycie. Pracownik nie-właściciel → 403.

POST /support/tickets/:id/messages Pracownik

Dodaje wiadomość do wątku. Auto-przejścia statusu: admin pierwsza odpowiedź → in_progress, druga → waiting_user, pracownik odpowiada → in_progress.

Request Body
{ "body": "Sprawdziłem, nie działa też po refresh.", "screenshot": "data:image/jpeg;base64,…" }
PATCH /support/tickets/:id/status Admin

Zmienia status (admin only). Wartości: new | in_progress | waiting_user | resolved.

GET /support/unread-count Pracownik

Dla badge'a w sidebarze. Admin: liczba pending/in_progress ze stale last_admin_read_at. Pracownik: jego tickety z nową aktywnością.

Response 200
{ "data": { "count": 3 } }
GET /support/uploads/:filename Pracownik

Auth-gated download screenu (image/jpeg|png|webp). Tylko właściciel ticketu lub admin. Path-traversal validation w filename.

Wnioski udziałowe (multi-sig governance)

Bezpośrednia edycja ownership_pct jest zablokowana po pierwszym przyznaniu udziałów (bootstrap). Każda zmiana wymaga proposal-a + zatwierdzenia przez wszystkich pozostałych obecnych udziałowców (unanimous-minus-proposer). Reject zamyka. 30 dni → expire.

GET /ownership-proposals Pracownik

Lista wniosków. Admin lub udziałowiec widzi pełną listę w tenancie. Query ?status=pending|applied|rejected|expired|cancelled.

Response 200
{ "data": [ { "id", "status", "changes": [{"employeeId", "oldPct", "newPct"}], "votes": [...], "progress": { "approveCount", "requiredVoters" }, ... } ] }
POST /ownership-proposals Admin

Tworzy wniosek (admin only — superadmin/org_admin). W trybie bootstrap (suma udziałów = 0) zmiana stosuje się natychmiast.

Request Body
{
  "changes": [
    { "employeeId": "emp_abc", "newPct": 5 },
    { "employeeId": "emp_xyz", "newPct": 45 }
  ],
  "description": "Anna dołącza jako udziałowiec — 5%"
}
Kody błędów
400Suma po zmianie > 100% 400No-op — wszystkie wartości == aktualne 400Pracownik nie istnieje 403Tylko admin może składać wnioski
GET /ownership-proposals/me Pracownik

Pending wnioski na które ja jako udziałowiec mogę głosować. Pusta lista jeśli ownership_pct = 0.

GET /ownership-proposals/:id Pracownik

Szczegóły z listą głosów + progress. Dostęp: admin lub udziałowiec.

POST /ownership-proposals/:id/vote Pracownik

Głos approve/reject (tylko obecni udziałowcy, nie wnioskodawca). Reject zamyka wniosek. Approve od ostatniego brakującego udziałowca → automatyczne applied.

Request Body
{ "vote": "approve", "comment": "OK z mojej strony" }
Kody błędów
400Wniosek już zamknięty (status != pending) 400Już głosowałeś 400Nie głosujesz na własny wniosek 403Tylko obecni udziałowcy mogą głosować
POST /ownership-proposals/:id/cancel Pracownik

Wnioskodawca odwołuje swój pending wniosek (status → cancelled).

API keys (zarządzanie integracjami)

Endpointy do zarządzania kluczami API dla mikrousług partnerskich. Tylko admin (superadmin/org_admin). Klucze auth-ują przez header X-API-Key: kk_live_…. Pełen klucz zwracany TYLKO przy create.

GET /api-keys Admin

Lista wszystkich kluczy w tenancie z metadata (bez pełnego klucza — tylko prefix dla identyfikacji).

Response 200
{
  "data": [ { "id", "name", "keyPrefix": "kk_live_aB3x", "scopes": [...], "rateLimit": 60, "lastUsedAt", "revokedAt" } ],
  "validScopes": [ "employees:read", "employees:write", ... ]
}
POST /api-keys Admin

Generuje nowy klucz. Pełny klucz zwracany RAZ — w DB tylko sha256.

Request Body
{
  "name":      "Partner-HR — bridge do BambooHR",
  "scopes":    ["employees:read", "vacations:read"],
  "rateLimit": 60,                    // req/min, 1-6000, default 60
  "expiresAt": "2026-12-31T23:59:59Z",    // opcjonalne
  "notes":     "Kontakt: john@partner.com"
}
Response 201
{
  "data": { "id", "name", "key": "kk_live_aB3xY2z…", "keyPrefix", "scopes", ... },
  "warning": "Skopiuj klucz teraz — nie będzie pokazany ponownie"
}
PATCH /api-keys/:id Admin

Update name / scopes / rateLimit / notes. Klucza samego (kk_live_…) nie da się zmienić — trzeba revoke + nowy.

DELETE /api-keys/:id Admin

Soft delete — ustawia revokedAt. Klucz natychmiast 401 przy następnym wywołaniu. Audit log zostaje.

GET /api-keys/:id/audit Admin

Ostatnie wywołania klucza. Query ?limit=100 (max 500).

Response 200
{ "data": [ { "method", "path", "status", "durationMs", "ip", "ts" } ] }
GET /api-keys/audit/recent Admin

Audit feed ze wszystkich kluczy w tenancie (overview dashboard).

GET /* (z X-API-Key) API key

Każdy endpoint v1 z middleware requirePerm akceptuje X-API-Key. Mapowanie: module:read → akcje read; module:write → wszystkie inne (create/update/delete/approve/manage/export).

Forbidden ops (zawsze 403): financials:write, backups:manage, voting:approve, employees:delete, settings:manage.

Kody błędów
401Brak / niepoprawny / cofnięty / wygasły klucz 403Brak wymaganego scope-a (detail wskazuje który) 403Operacja zarezerwowana dla użytkowników (forbidden op) 429Rate limit przekroczony (header Retry-After: 60)

Ankiety (Surveys)

CRUD ankiet z lifecycle draft → active → closed. Audience: all/department/custom. Anonimowe (privacy by design — bez employee_id w response) lub imienne. Anti-duplicate: jeden user = jedna odpowiedź.

GET/surveysPracownik

Lista wszystkich ankiet (admin: pełna; pracownik: tylko aktywne dla swojej audience). Query: status filter.

GET/surveys/mePracownik

Aktywne ankiety w których pracownik jest w audience i jeszcze nie odpowiedział.

GET/surveys/:idPracownik

Pełna ankieta z pytaniami. Pracownik widzi tylko jeśli active + audience-match. Admin widzi zawsze.

POST/surveysAdmin

Tworzy ankietę w stanie draft. Wymaga ≥1 pytania. Typy: text, longtext, single, multi, scale, nps.

Request Body
{
  "title":        "Satysfakcja Q1",
  "description":  "Krótka ankieta…",
  "isAnonymous":  false,
  "audience":     "all",                // "all" | "department" | "custom"
  "audienceIds":  ["emp_x", "emp_y"],   // dla "custom"
  "deadline":     "2026-12-31",
  "questions":    [
    {"type":"text", "question":"Co dobrego?", "required":true},
    {"type":"scale", "question":"Ocena 1-10", "scaleMin":1, "scaleMax":10},
    {"type":"single", "question":"Wybierz", "options":["A","B","C"]}
  ]
}
Kody błędów
400Brak tytułu / pytań / single bez 2+ opcji / niepoprawny typ
PUT/surveys/:idAdmin

Edytuj ankietę. Tylko w stanie draft — active/closed → 409.

POST/surveys/:id/activateAdmin

draft → active. Po tym pracownicy widzą + mogą odpowiadać.

POST/surveys/:id/closeAdmin

active → closed. Nowe odpowiedzi blokowane (409).

DELETE/surveys/:idAdmin

Usuwa ankietę + cascade odpowiedzi.

POST/surveys/:id/respondPracownik

Wypełnia ankietę. Anti-duplicate (409). Walidacja required. Anonimowa: employee_id = NULL w response, anti-duplicate przez osobną tabelę survey_responded (privacy by design).

Request Body
{
  "answers": [
    { "questionId": "q1", "text": "Dobra atmosfera" },
    { "questionId": "q2", "value": 8 },
    { "questionId": "q3", "value": ["A", "C"] }   // multi
  ]
}
GET/surveys/:id/responsesAdmin

Wszystkie odpowiedzi + statystyki agregowane (counts, średnie dla scale/nps). Anonimowe — bez employee_id.

GET/surveys/:id/exportAdmin

Eksport CSV odpowiedzi.

Działy (Departments)

Zarządzanie strukturą działów. Manager przypisany do działu auto-promote z employee. Scope: org_admin/superadmin = wszystkie, manager = tylko swój.

GET/departmentsPracownik

Lista działów ze scopem (response zawiera scope: 'org' | 'team').

POST/departmentsAdmin

Tworzy dział. Jeśli managerId wskazuje na employee → auto-promote do manager + przypisanie do nowego działu.

Request Body
{ "name": "Engineering", "managerId": "emp_xyz" }
PUT/departments/:idAdmin

Update name / managerId.

DELETE/departments/:idAdmin

Usuwa dział + odpina pracowników (department_id = NULL).

GET/departments/:id/membersPracownik

Członkowie działu. Manager może czytać tylko swój dział (403 dla cudzych).

Tenants (multi-tenant)

Multi-tenant infrastruktura. Każda organizacja ma własne moduły (feature flags) — np. {vacations, equipment, lunch}. Pracownicy z innego tenanta są niewidoczni.

GET/tenantsAdmin

Lista wszystkich organizacji (superadmin via X-Admin-Token).

POST/tenantsAdmin

Tworzy nową organizację. Slug auto-generowany z name jeśli nie podany.

Request Body
{ "name": "Acme Corp", "slug": "acme" }
Kody błędów
400Brak name 409Slug już istnieje
GET/tenants/:idPracownik

Pojedyncza organizacja. :id = "me" → tenant zalogowanego usera. Cudzą widzi tylko superadmin.

PATCH/tenants/:idPracownik (org_admin/superadmin)

Zmiana name. org_admin tylko własną organizację.

PATCH/tenants/:id/modulesPracownik (org_admin/superadmin)

Włącza/wyłącza moduły. Nieznane moduły są filtrowane. Wpływa na requireModule middleware (endpointy z wyłączonych modułów → 403).

Request Body
{ "modules": ["dashboard", "employees", "vacations", "lunch"] }

Employees — role & department

Zmiana roli i przypisania do działu. Wszystkie chronione przez requireNotEscalating() — org_admin nie może modyfikować superadmina.

PATCH/employees/:id/rolePracownik (org_admin/superadmin)

Zmiana roli. Tylko superadmin może nadać rolę superadmin. org_admin nie może zmieniać roli superadmina (anti-escalation).

Request Body
{ "role": "manager" }    // employee | manager | org_admin | superadmin
PATCH/employees/:id/departmentPracownik (manager+)

Przypisuje pracownika do działu. Manager może przypisywać tylko do własnego działu. Anti-escalation chroni superadmina.

Request Body
{ "departmentId": "dept_xyz" }    // null = odepnij
POST/auth/impersonate/:employeeIdPracownik (org_admin/superadmin)

Tryb testowy — admin "loguje się" jako pracownik (zwraca jego token + dane). org_admin nie może wcielić się w superadmina (anty-eskalacja).

Response 200
{
  "data": {
    "token": "AB12CD", "id", "name", "email", "role", "eligibleForRaise",
    "impersonatedBy": { "id", "name", "role" }
  }
}