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.
@@ -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