msaas-waitlist 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msaas_waitlist-0.1.0/.gitignore +23 -0
- msaas_waitlist-0.1.0/PKG-INFO +16 -0
- msaas_waitlist-0.1.0/pyproject.toml +35 -0
- msaas_waitlist-0.1.0/src/waitlist/__init__.py +65 -0
- msaas_waitlist-0.1.0/src/waitlist/config.py +64 -0
- msaas_waitlist-0.1.0/src/waitlist/models.py +131 -0
- msaas_waitlist-0.1.0/src/waitlist/notifications.py +63 -0
- msaas_waitlist-0.1.0/src/waitlist/router.py +141 -0
- msaas_waitlist-0.1.0/src/waitlist/service.py +328 -0
- msaas_waitlist-0.1.0/src/waitlist/store.py +130 -0
- msaas_waitlist-0.1.0/tests/conftest.py +61 -0
- msaas_waitlist-0.1.0/tests/test_models.py +250 -0
- msaas_waitlist-0.1.0/tests/test_notifications.py +149 -0
- msaas_waitlist-0.1.0/tests/test_router.py +240 -0
- msaas_waitlist-0.1.0/tests/test_service.py +318 -0
- msaas_waitlist-0.1.0/tests/test_store.py +226 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
.turbo/
|
|
5
|
+
*.pyc
|
|
6
|
+
__pycache__/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.env
|
|
12
|
+
.env.*
|
|
13
|
+
!.env.example
|
|
14
|
+
!.env.*.example
|
|
15
|
+
!.env.*.template
|
|
16
|
+
.DS_Store
|
|
17
|
+
coverage/
|
|
18
|
+
|
|
19
|
+
# Runtime artifacts
|
|
20
|
+
logs_llm/
|
|
21
|
+
vectors.db
|
|
22
|
+
vectors.db-shm
|
|
23
|
+
vectors.db-wal
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msaas-waitlist
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Waitlist and early access management for SaaS launches
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: msaas-api-core
|
|
7
|
+
Requires-Dist: msaas-errors
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: fastapi>=0.110.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
15
|
+
Provides-Extra: fastapi
|
|
16
|
+
Requires-Dist: fastapi>=0.110.0; extra == 'fastapi'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "msaas-waitlist"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Waitlist and early access management for SaaS launches"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"msaas-api-core",
|
|
8
|
+
"msaas-errors","pydantic>=2.0"
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
fastapi = ["fastapi>=0.110.0"]
|
|
13
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "httpx>=0.27", "fastapi>=0.110.0", "ruff>=0.8"]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/waitlist"]
|
|
21
|
+
|
|
22
|
+
[tool.ruff]
|
|
23
|
+
target-version = "py312"
|
|
24
|
+
line-length = 100
|
|
25
|
+
|
|
26
|
+
[tool.ruff.lint]
|
|
27
|
+
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
|
|
33
|
+
[tool.uv.sources]
|
|
34
|
+
msaas-api-core = { workspace = true }
|
|
35
|
+
msaas-errors = { workspace = true }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Waitlist -- early access and waitlist management for SaaS launches."""
|
|
2
|
+
|
|
3
|
+
from waitlist.config import get_waitlist, init_waitlist, reset
|
|
4
|
+
from waitlist.models import (
|
|
5
|
+
BatchApproveRequest,
|
|
6
|
+
EntryFilter,
|
|
7
|
+
EntryStatus,
|
|
8
|
+
JoinRequest,
|
|
9
|
+
PositionResponse,
|
|
10
|
+
WaitlistConfig,
|
|
11
|
+
WaitlistEntry,
|
|
12
|
+
WaitlistInvite,
|
|
13
|
+
WaitlistStats,
|
|
14
|
+
)
|
|
15
|
+
from waitlist.notifications import (
|
|
16
|
+
LoggingNotificationHandler,
|
|
17
|
+
NoopNotificationHandler,
|
|
18
|
+
NotificationHandler,
|
|
19
|
+
)
|
|
20
|
+
from waitlist.router import create_waitlist_router
|
|
21
|
+
from waitlist.service import (
|
|
22
|
+
CapacityReachedError,
|
|
23
|
+
DuplicateEntryError,
|
|
24
|
+
EntryNotFoundError,
|
|
25
|
+
InvalidStateError,
|
|
26
|
+
InviteError,
|
|
27
|
+
WaitlistError,
|
|
28
|
+
WaitlistService,
|
|
29
|
+
)
|
|
30
|
+
from waitlist.store import InMemoryStore, WaitlistStore
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Config
|
|
34
|
+
"get_waitlist",
|
|
35
|
+
"init_waitlist",
|
|
36
|
+
"reset",
|
|
37
|
+
# Models
|
|
38
|
+
"BatchApproveRequest",
|
|
39
|
+
"EntryFilter",
|
|
40
|
+
"EntryStatus",
|
|
41
|
+
"JoinRequest",
|
|
42
|
+
"PositionResponse",
|
|
43
|
+
"WaitlistConfig",
|
|
44
|
+
"WaitlistEntry",
|
|
45
|
+
"WaitlistInvite",
|
|
46
|
+
"WaitlistStats",
|
|
47
|
+
# Service
|
|
48
|
+
"WaitlistService",
|
|
49
|
+
# Errors
|
|
50
|
+
"CapacityReachedError",
|
|
51
|
+
"DuplicateEntryError",
|
|
52
|
+
"EntryNotFoundError",
|
|
53
|
+
"InvalidStateError",
|
|
54
|
+
"InviteError",
|
|
55
|
+
"WaitlistError",
|
|
56
|
+
# Store
|
|
57
|
+
"InMemoryStore",
|
|
58
|
+
"WaitlistStore",
|
|
59
|
+
# Notifications
|
|
60
|
+
"LoggingNotificationHandler",
|
|
61
|
+
"NoopNotificationHandler",
|
|
62
|
+
"NotificationHandler",
|
|
63
|
+
# Router
|
|
64
|
+
"create_waitlist_router",
|
|
65
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Global waitlist configuration and service singleton."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from waitlist.models import WaitlistConfig
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from waitlist.service import WaitlistService
|
|
11
|
+
|
|
12
|
+
_service: WaitlistService | None = None
|
|
13
|
+
_config: WaitlistConfig | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def init_waitlist(config: WaitlistConfig | None = None) -> WaitlistConfig:
|
|
17
|
+
"""Initialise the waitlist module with the given (or default) config.
|
|
18
|
+
|
|
19
|
+
This stores the config globally so that ``get_waitlist()`` can build
|
|
20
|
+
the service lazily.
|
|
21
|
+
"""
|
|
22
|
+
global _config, _service
|
|
23
|
+
_config = config or WaitlistConfig()
|
|
24
|
+
_service = None # reset so next get_waitlist() rebuilds
|
|
25
|
+
return _config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_config() -> WaitlistConfig:
|
|
29
|
+
"""Return the current config, raising if ``init_waitlist`` was never called."""
|
|
30
|
+
if _config is None:
|
|
31
|
+
raise RuntimeError("Waitlist not initialised. Call init_waitlist() first.")
|
|
32
|
+
return _config
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_waitlist() -> WaitlistService:
|
|
36
|
+
"""Return the global WaitlistService singleton.
|
|
37
|
+
|
|
38
|
+
Creates one on first call using the stored config and the default
|
|
39
|
+
InMemoryStore + LoggingNotificationHandler.
|
|
40
|
+
"""
|
|
41
|
+
global _service
|
|
42
|
+
if _service is not None:
|
|
43
|
+
return _service
|
|
44
|
+
|
|
45
|
+
cfg = get_config()
|
|
46
|
+
|
|
47
|
+
# Lazy imports to avoid circular dependencies
|
|
48
|
+
from waitlist.notifications import LoggingNotificationHandler
|
|
49
|
+
from waitlist.service import WaitlistService
|
|
50
|
+
from waitlist.store import InMemoryStore
|
|
51
|
+
|
|
52
|
+
_service = WaitlistService(
|
|
53
|
+
store=InMemoryStore(),
|
|
54
|
+
config=cfg,
|
|
55
|
+
notification_handler=LoggingNotificationHandler(),
|
|
56
|
+
)
|
|
57
|
+
return _service
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def reset() -> None:
|
|
61
|
+
"""Reset global state -- useful in tests."""
|
|
62
|
+
global _service, _config
|
|
63
|
+
_service = None
|
|
64
|
+
_config = None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Waitlist domain models built on Pydantic v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EntryStatus(StrEnum):
|
|
14
|
+
"""Lifecycle states for a waitlist entry."""
|
|
15
|
+
|
|
16
|
+
WAITING = "waiting"
|
|
17
|
+
APPROVED = "approved"
|
|
18
|
+
INVITED = "invited"
|
|
19
|
+
JOINED = "joined"
|
|
20
|
+
REJECTED = "rejected"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Configuration
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WaitlistConfig(BaseModel):
|
|
29
|
+
"""Settings that control waitlist behaviour."""
|
|
30
|
+
|
|
31
|
+
auto_approve: bool = Field(default=False, description="Automatically approve new entries")
|
|
32
|
+
max_capacity: int | None = Field(
|
|
33
|
+
default=None, description="Maximum number of entries (None = unlimited)"
|
|
34
|
+
)
|
|
35
|
+
require_email_verification: bool = Field(
|
|
36
|
+
default=False, description="Require email verification before approval"
|
|
37
|
+
)
|
|
38
|
+
priority_fields: list[str] = Field(
|
|
39
|
+
default_factory=list,
|
|
40
|
+
description="Metadata keys that increase priority score",
|
|
41
|
+
)
|
|
42
|
+
invite_expiry_hours: int = Field(default=72, description="Hours before an invite token expires")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Core domain objects
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WaitlistEntry(BaseModel):
|
|
51
|
+
"""A single person on the waitlist."""
|
|
52
|
+
|
|
53
|
+
id: UUID = Field(default_factory=uuid4)
|
|
54
|
+
email: str
|
|
55
|
+
name: str = ""
|
|
56
|
+
status: EntryStatus = EntryStatus.WAITING
|
|
57
|
+
position: int = 0
|
|
58
|
+
priority_score: float = 0.0
|
|
59
|
+
metadata: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
|
60
|
+
referral_code: str | None = None
|
|
61
|
+
referred_by: str | None = None
|
|
62
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
63
|
+
approved_at: datetime | None = None
|
|
64
|
+
invited_at: datetime | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WaitlistInvite(BaseModel):
|
|
68
|
+
"""An invitation token linked to an approved entry."""
|
|
69
|
+
|
|
70
|
+
id: UUID = Field(default_factory=uuid4)
|
|
71
|
+
entry_id: UUID
|
|
72
|
+
token: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
|
73
|
+
expires_at: datetime
|
|
74
|
+
accepted_at: datetime | None = None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_expired(self) -> bool:
|
|
78
|
+
return datetime.now(UTC) > self.expires_at
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def is_accepted(self) -> bool:
|
|
82
|
+
return self.accepted_at is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WaitlistStats(BaseModel):
|
|
86
|
+
"""Aggregate statistics for the waitlist."""
|
|
87
|
+
|
|
88
|
+
total_entries: int = 0
|
|
89
|
+
waiting: int = 0
|
|
90
|
+
approved: int = 0
|
|
91
|
+
invited: int = 0
|
|
92
|
+
joined: int = 0
|
|
93
|
+
rejected: int = 0
|
|
94
|
+
avg_wait_time_hours: float = 0.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# API request / response helpers
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class JoinRequest(BaseModel):
|
|
103
|
+
"""Payload for joining the waitlist."""
|
|
104
|
+
|
|
105
|
+
email: str
|
|
106
|
+
name: str = ""
|
|
107
|
+
metadata: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
|
108
|
+
referral_code: str | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class BatchApproveRequest(BaseModel):
|
|
112
|
+
"""Payload for batch-approving entries."""
|
|
113
|
+
|
|
114
|
+
count: int = Field(ge=1, le=1000)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class EntryFilter(BaseModel):
|
|
118
|
+
"""Query parameters for listing entries."""
|
|
119
|
+
|
|
120
|
+
status: EntryStatus | None = None
|
|
121
|
+
limit: int = Field(default=50, ge=1, le=500)
|
|
122
|
+
offset: int = Field(default=0, ge=0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PositionResponse(BaseModel):
|
|
126
|
+
"""Position information returned to a waitlist user."""
|
|
127
|
+
|
|
128
|
+
entry_id: UUID
|
|
129
|
+
position: int
|
|
130
|
+
total_ahead: int
|
|
131
|
+
status: EntryStatus
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Notification handlers for waitlist lifecycle events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from waitlist.models import WaitlistEntry, WaitlistInvite
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class NotificationHandler(Protocol):
|
|
15
|
+
"""Protocol that any notification backend must satisfy."""
|
|
16
|
+
|
|
17
|
+
async def send_waitlist_confirmation(self, entry: WaitlistEntry) -> None: ...
|
|
18
|
+
async def send_approval(self, entry: WaitlistEntry) -> None: ...
|
|
19
|
+
async def send_invite(self, entry: WaitlistEntry, invite: WaitlistInvite) -> None: ...
|
|
20
|
+
async def send_rejection(self, entry: WaitlistEntry) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LoggingNotificationHandler:
|
|
24
|
+
"""Default handler that simply logs events -- no external I/O."""
|
|
25
|
+
|
|
26
|
+
async def send_waitlist_confirmation(self, entry: WaitlistEntry) -> None:
|
|
27
|
+
logger.info(
|
|
28
|
+
"Waitlist confirmation for %s (%s) at position %d",
|
|
29
|
+
entry.email,
|
|
30
|
+
entry.id,
|
|
31
|
+
entry.position,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def send_approval(self, entry: WaitlistEntry) -> None:
|
|
35
|
+
logger.info("Approval notification for %s (%s)", entry.email, entry.id)
|
|
36
|
+
|
|
37
|
+
async def send_invite(self, entry: WaitlistEntry, invite: WaitlistInvite) -> None:
|
|
38
|
+
logger.info(
|
|
39
|
+
"Invite sent to %s (%s), token=%s, expires=%s",
|
|
40
|
+
entry.email,
|
|
41
|
+
entry.id,
|
|
42
|
+
invite.token,
|
|
43
|
+
invite.expires_at.isoformat(),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def send_rejection(self, entry: WaitlistEntry) -> None:
|
|
47
|
+
logger.info("Rejection notification for %s (%s)", entry.email, entry.id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NoopNotificationHandler:
|
|
51
|
+
"""Handler that does nothing -- useful when notifications are disabled."""
|
|
52
|
+
|
|
53
|
+
async def send_waitlist_confirmation(self, entry: WaitlistEntry) -> None:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
async def send_approval(self, entry: WaitlistEntry) -> None:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
async def send_invite(self, entry: WaitlistEntry, invite: WaitlistInvite) -> None:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
async def send_rejection(self, entry: WaitlistEntry) -> None:
|
|
63
|
+
pass
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""FastAPI router for waitlist HTTP endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from errors import (
|
|
8
|
+
ConflictError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
from fastapi import APIRouter, status
|
|
13
|
+
|
|
14
|
+
from waitlist.models import (
|
|
15
|
+
BatchApproveRequest,
|
|
16
|
+
EntryFilter,
|
|
17
|
+
EntryStatus,
|
|
18
|
+
JoinRequest,
|
|
19
|
+
PositionResponse,
|
|
20
|
+
WaitlistEntry,
|
|
21
|
+
WaitlistInvite,
|
|
22
|
+
WaitlistStats,
|
|
23
|
+
)
|
|
24
|
+
from waitlist.service import (
|
|
25
|
+
CapacityReachedError,
|
|
26
|
+
DuplicateEntryError,
|
|
27
|
+
EntryNotFoundError,
|
|
28
|
+
InvalidStateError,
|
|
29
|
+
InviteError,
|
|
30
|
+
WaitlistService,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_waitlist_router(service: WaitlistService | None = None) -> APIRouter:
|
|
35
|
+
"""Build and return a FastAPI APIRouter wired to the given service.
|
|
36
|
+
|
|
37
|
+
If *service* is ``None``, falls back to the global singleton via
|
|
38
|
+
``get_waitlist()``.
|
|
39
|
+
"""
|
|
40
|
+
router = APIRouter(prefix="/waitlist", tags=["waitlist"])
|
|
41
|
+
|
|
42
|
+
def _svc() -> WaitlistService:
|
|
43
|
+
if service is not None:
|
|
44
|
+
return service
|
|
45
|
+
from waitlist.config import get_waitlist
|
|
46
|
+
|
|
47
|
+
return get_waitlist()
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Join
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
@router.post("/join", response_model=WaitlistEntry, status_code=status.HTTP_201_CREATED)
|
|
54
|
+
async def join_waitlist(body: JoinRequest) -> WaitlistEntry:
|
|
55
|
+
try:
|
|
56
|
+
return await _svc().join(
|
|
57
|
+
email=body.email,
|
|
58
|
+
name=body.name,
|
|
59
|
+
metadata=body.metadata,
|
|
60
|
+
referral_code=body.referral_code,
|
|
61
|
+
)
|
|
62
|
+
except DuplicateEntryError as exc:
|
|
63
|
+
raise ConflictError(str(exc)) from exc
|
|
64
|
+
except CapacityReachedError as exc:
|
|
65
|
+
raise ValidationError(str(exc)) from exc
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Position
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@router.get("/position/{entry_id}", response_model=PositionResponse)
|
|
72
|
+
async def get_position(entry_id: UUID) -> PositionResponse:
|
|
73
|
+
try:
|
|
74
|
+
return await _svc().get_position(entry_id)
|
|
75
|
+
except EntryNotFoundError as exc:
|
|
76
|
+
raise NotFoundError(str(exc)) from exc
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Moderation
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
@router.post("/approve/{entry_id}", response_model=WaitlistEntry)
|
|
83
|
+
async def approve_entry(entry_id: UUID) -> WaitlistEntry:
|
|
84
|
+
try:
|
|
85
|
+
return await _svc().approve(entry_id)
|
|
86
|
+
except EntryNotFoundError as exc:
|
|
87
|
+
raise NotFoundError(str(exc)) from exc
|
|
88
|
+
except InvalidStateError as exc:
|
|
89
|
+
raise ValidationError(str(exc)) from exc
|
|
90
|
+
|
|
91
|
+
@router.post("/reject/{entry_id}", response_model=WaitlistEntry)
|
|
92
|
+
async def reject_entry(entry_id: UUID) -> WaitlistEntry:
|
|
93
|
+
try:
|
|
94
|
+
return await _svc().reject(entry_id)
|
|
95
|
+
except EntryNotFoundError as exc:
|
|
96
|
+
raise NotFoundError(str(exc)) from exc
|
|
97
|
+
except InvalidStateError as exc:
|
|
98
|
+
raise ValidationError(str(exc)) from exc
|
|
99
|
+
|
|
100
|
+
@router.post("/approve-batch", response_model=list[WaitlistEntry])
|
|
101
|
+
async def approve_batch(body: BatchApproveRequest) -> list[WaitlistEntry]:
|
|
102
|
+
return await _svc().approve_batch(body.count)
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Invitations
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
@router.post("/invite/{entry_id}", response_model=WaitlistInvite)
|
|
109
|
+
async def invite_entry(entry_id: UUID) -> WaitlistInvite:
|
|
110
|
+
try:
|
|
111
|
+
return await _svc().invite(entry_id)
|
|
112
|
+
except EntryNotFoundError as exc:
|
|
113
|
+
raise NotFoundError(str(exc)) from exc
|
|
114
|
+
except InvalidStateError as exc:
|
|
115
|
+
raise ValidationError(str(exc)) from exc
|
|
116
|
+
|
|
117
|
+
@router.post("/accept/{token}", response_model=WaitlistEntry)
|
|
118
|
+
async def accept_invite(token: str) -> WaitlistEntry:
|
|
119
|
+
try:
|
|
120
|
+
return await _svc().accept_invite(token)
|
|
121
|
+
except InviteError as exc:
|
|
122
|
+
raise ValidationError(str(exc)) from exc
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Queries
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
@router.get("/stats", response_model=WaitlistStats)
|
|
129
|
+
async def get_stats() -> WaitlistStats:
|
|
130
|
+
return await _svc().get_stats()
|
|
131
|
+
|
|
132
|
+
@router.get("/entries", response_model=list[WaitlistEntry])
|
|
133
|
+
async def list_entries(
|
|
134
|
+
status_filter: EntryStatus | None = None,
|
|
135
|
+
limit: int = 50,
|
|
136
|
+
offset: int = 0,
|
|
137
|
+
) -> list[WaitlistEntry]:
|
|
138
|
+
filt = EntryFilter(status=status_filter, limit=limit, offset=offset)
|
|
139
|
+
return await _svc().list_entries(filt)
|
|
140
|
+
|
|
141
|
+
return router
|