Skip to content

Commit ce750ec

Browse files
authored
feat: domain discovery and email event listeners (#237)
Implements a plugin to automatically discover "domains" added in the domain folder. This will automatically register routes, controllers, listeners, and update the signature namespace.
1 parent 8b637ce commit ce750ec

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1722
-1010
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ repos:
2525
- id: mixed-line-ending
2626
- id: trailing-whitespace
2727
- repo: https://github.com/charliermarsh/ruff-pre-commit
28-
rev: "v0.14.10"
28+
rev: "v0.14.11"
2929
hooks:
3030
# Run the linter.
3131
- id: ruff

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
".static": true,
2727
"dist": false,
2828
".dmypy.json": true,
29-
".claude": false,
29+
".claude": true,
3030
"htmlcov": true
3131
},
3232
"mypy-type-checker.importStrategy": "fromEnvironment",

Makefile

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ MAKEFLAGS += --no-print-directory
1212
COMPOSE_DIR := tools/deploy/docker
1313
COMPOSE_INFRA := $(COMPOSE_DIR)/docker-compose.infra.yml
1414
COMPOSE_APP := $(COMPOSE_DIR)/docker-compose.yml
15-
COMPOSE_DEV := $(COMPOSE_DIR)/docker-compose.dev.yml
15+
16+
# Define project name for docker-compose
17+
COMPOSE_PROJECT_NAME := fullstack-spa
1618

1719
# Define colors and formatting
1820
BLUE := $(shell printf "\033[1;34m")
@@ -295,28 +297,28 @@ docker-logs: ## Tail production Docker sta
295297
.PHONY: start-all-docker-dev
296298
start-all-docker-dev: ## Start development Docker stack (with hot-reload)
297299
@echo "${INFO} Building and starting development Docker stack... 🐳"
298-
@docker compose -f $(COMPOSE_DEV) up -d --build --force-recreate
300+
@docker compose -f $(COMPOSE_APP) -f $(COMPOSE_DIR)/docker-compose.override.yml up -d --build --force-recreate
299301
@echo "${OK} Development Docker stack is running"
300302

301303
.PHONY: stop-all-docker-dev
302304
stop-all-docker-dev: ## Stop development Docker stack
303305
@echo "${INFO} Stopping development Docker stack... 🛑"
304-
@docker compose -f $(COMPOSE_DEV) down
306+
@docker compose -f $(COMPOSE_APP) -f $(COMPOSE_DIR)/docker-compose.override.yml down
305307
@echo "${OK} Development Docker stack stopped"
306308

307309
.PHONY: wipe-all-docker-dev
308310
wipe-all-docker-dev: ## Remove development Docker stack, images, and volumes
309311
@echo "${INFO} Wiping development Docker stack... 🧹"
310-
@docker compose -f $(COMPOSE_DEV) down -v --remove-orphans --rmi local
312+
@docker compose -f $(COMPOSE_APP) -f $(COMPOSE_DIR)/docker-compose.override.yml down -v --remove-orphans --rmi local
311313
@echo "${OK} Development Docker stack wiped clean"
312314

313315
.PHONY: docker-dev-logs
314316
docker-dev-logs: ## Tail development Docker stack logs
315-
@docker compose -f $(COMPOSE_DEV) logs -f
317+
@docker compose -f $(COMPOSE_APP) -f $(COMPOSE_DIR)/docker-compose.override.yml logs -f
316318

317319
.PHONY: docker-shell
318320
docker-shell: ## Open a shell in the app container
319-
@docker compose -f $(COMPOSE_DEV) exec app /bin/bash || docker compose -f $(COMPOSE_DEV) exec app /bin/sh
321+
@docker compose -f $(COMPOSE_APP) -f $(COMPOSE_DIR)/docker-compose.override.yml exec app /bin/bash || docker compose -f $(COMPOSE_APP) -f $(COMPOSE_DIR)/docker-compose.override.yml exec app /bin/sh
320322

321323

322324
# =============================================================================

src/js/web/bun.lock

Lines changed: 127 additions & 143 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/js/web/package.json

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,45 +24,45 @@
2424
"@radix-ui/react-tooltip": "^1.2.8",
2525
"@tailwindcss/postcss": "^4.1.18",
2626
"@tailwindcss/vite": "^4.1.18",
27-
"@tanstack/react-query": "^5.90.16",
27+
"@tanstack/react-query": "^5.90.17",
2828
"@tanstack/react-query-devtools": "^5.91.2",
29-
"@tanstack/react-router": "^1.145.11",
30-
"@tanstack/react-router-devtools": "^1.145.11",
29+
"@tanstack/react-router": "^1.149.3",
30+
"@tanstack/react-router-devtools": "^1.149.3",
3131
"@tanstack/react-table": "^8.21.3",
32-
"@tanstack/router-vite-plugin": "^1.145.11",
32+
"@tanstack/router-vite-plugin": "^1.149.3",
3333
"axios": "^1.13.2",
3434
"class-variance-authority": "^0.7.1",
3535
"clsx": "^2.1.1",
36-
"framer-motion": "^12.24.10",
37-
"litestar-vite-plugin": "^0.15.0",
38-
"lucide-react": "^0.507.0",
36+
"framer-motion": "^12.26.2",
37+
"litestar-vite-plugin": "^0.16.4",
38+
"lucide-react": "^0.562.0",
3939
"react": "^19.2.3",
4040
"react-dom": "^19.2.3",
41-
"react-hook-form": "^7.70.0",
41+
"react-hook-form": "^7.71.1",
4242
"sonner": "^2.0.7",
4343
"tailwind-merge": "^3.4.0",
4444
"tailwindcss": "^4.1.18",
45-
"zod": "^3.25.76",
46-
"zustand": "^5.0.9"
45+
"zod": "^4.3.5",
46+
"zustand": "^5.0.10"
4747
},
4848
"devDependencies": {
4949
"@biomejs/biome": "^2.3.11",
50-
"@hey-api/openapi-ts": "^0.90.2",
51-
"@tanstack/router-plugin": "^1.145.11",
50+
"@hey-api/openapi-ts": "^0.90.3",
51+
"@tanstack/router-plugin": "^1.149.3",
5252
"@testing-library/dom": "^10.4.1",
5353
"@testing-library/react": "^16.3.1",
54-
"@types/node": "^22.19.3",
55-
"@types/react": "^19.2.7",
54+
"@types/node": "^25.0.8",
55+
"@types/react": "^19.2.8",
5656
"@types/react-dom": "^19.2.3",
57-
"@vitejs/plugin-react": "^4.7.0",
57+
"@vitejs/plugin-react": "^5.1.2",
5858
"autoprefixer": "^10.4.23",
59-
"jsdom": "^26.1.0",
59+
"jsdom": "^27.4.0",
6060
"postcss": "^8.5.6",
6161
"tsx": "^4.21.0",
6262
"tw-animate-css": "^1.4.0",
6363
"typescript": "^5.9.3",
6464
"vite": "^7.3.1",
65-
"vitest": "^3.2.4",
66-
"web-vitals": "^4.2.4"
65+
"vitest": "^4.0.17",
66+
"web-vitals": "^5.1.0"
6767
}
6868
}

src/js/web/src/lib/generated/openapi.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,7 +1247,7 @@
12471247
"type": "string"
12481248
},
12491249
"version": {
1250-
"default": "0.2.0",
1250+
"default": "0.3.0",
12511251
"type": "string"
12521252
}
12531253
},
@@ -1932,7 +1932,7 @@
19321932
},
19331933
"info": {
19341934
"title": "Litestar Fullstack Template",
1935-
"version": "0.2.0"
1935+
"version": "0.3.0"
19361936
},
19371937
"openapi": "3.1.0",
19381938
"paths": {

src/py/app/__main__.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,10 @@ def fixed_format_help(self: Any, ctx: Any, formatter: Any) -> None:
3333
self: The LitestarExtensionGroup instance
3434
ctx: The click Context
3535
formatter: The help formatter
36-
37-
Returns:
38-
None
3936
"""
40-
self._prepare(ctx) # Force plugin loading
37+
self._prepare(ctx)
4138
return original_format_help(self, ctx, formatter)
4239

43-
# Type ignore needed for monkey-patching
4440
setattr(LitestarExtensionGroup, "format_help", fixed_format_help)
4541

4642

src/py/app/domain/accounts/controllers/_access.py

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6-
from typing import TYPE_CHECKING, Annotated, Any, cast
6+
from typing import TYPE_CHECKING, Annotated, Any
77

88
from advanced_alchemy.exceptions import DuplicateKeyError
99
from advanced_alchemy.utils.text import slugify
@@ -47,13 +47,11 @@
4747
from litestar.security.jwt import OAuth2Login, Token
4848

4949
from app.domain.accounts.services import (
50-
EmailVerificationTokenService,
5150
PasswordResetService,
5251
RoleService,
5352
UserService,
5453
)
5554
from app.lib.email import AppEmailService
56-
from app.lib.email.service import UserProtocol
5755
from app.lib.settings import AppSettings
5856

5957
logger = logging.getLogger(__name__)
@@ -397,19 +395,20 @@ async def signup(
397395
request: Request[m.User, Token, Any],
398396
users_service: UserService,
399397
roles_service: RoleService,
400-
verification_service: EmailVerificationTokenService,
401-
app_email_service: AppEmailService,
402398
data: AccountRegister,
399+
app_mailer: AppEmailService,
403400
) -> User:
404401
"""User Signup.
405402
406403
Args:
407404
request: Request
408405
users_service: User Service
409406
roles_service: Role Service
410-
verification_service: Email Verification Service
411-
app_email_service: Email service for sending verification emails
412407
data: Account Register Data
408+
app_mailer: Email service for sending notifications
409+
410+
Raises:
411+
ClientException: If user with this email already exists
413412
414413
Returns:
415414
User
@@ -426,21 +425,18 @@ async def signup(
426425
user = await users_service.create(user_data)
427426
except DuplicateKeyError as exc:
428427
raise ClientException(detail="User with this email already exists", status_code=409) from exc
429-
request.app.emit(event_id="user_created", user_id=user.id)
430-
431-
_, verification_token = await verification_service.create_verification_token(user_id=user.id, email=user.email)
432-
await app_email_service.send_verification_email(cast("UserProtocol", user), verification_token)
428+
request.app.emit(event_id="user_created", user_id=user.id, mailer=app_mailer)
433429

434430
return users_service.to_schema(user, schema_type=User)
435431

436432
@post(operation_id="ForgotPassword", path="/api/access/forgot-password", exclude_from_auth=True, security=[])
437433
async def forgot_password(
438434
self,
439-
data: ForgotPasswordRequest,
440-
request: Request[m.User, Token, Any],
441435
users_service: UserService,
442436
password_reset_service: PasswordResetService,
443-
app_email_service: AppEmailService,
437+
app_mailer: AppEmailService,
438+
request: Request[m.User, Token, Any],
439+
data: ForgotPasswordRequest,
444440
) -> PasswordResetSent:
445441
"""Initiate password reset flow.
446442
@@ -449,15 +445,11 @@ async def forgot_password(
449445
request: HTTP request object
450446
users_service: User service
451447
password_reset_service: Password reset service
452-
app_email_service: Email service for sending reset emails
448+
app_mailer: Email service for sending notifications
453449
454450
Returns:
455451
Response indicating reset email status
456452
"""
457-
458-
ip_address = request.client.host if request.client else "unknown"
459-
user_agent = request.headers.get("user-agent", "unknown")
460-
461453
user = await users_service.get_one_or_none(email=data.email)
462454

463455
if user is None or not user.is_active:
@@ -470,15 +462,7 @@ async def forgot_password(
470462
message="Too many password reset requests. Please try again later", expires_in_minutes=60
471463
)
472464

473-
_, reset_token = await password_reset_service.create_reset_token(
474-
user_id=user.id, ip_address=ip_address, user_agent=user_agent
475-
)
476-
477-
await app_email_service.send_password_reset_email(
478-
user=cast("UserProtocol", user),
479-
reset_token=reset_token,
480-
expires_in_minutes=60,
481-
)
465+
request.app.emit(event_id="password_reset_requested", user_id=user.id, mailer=app_mailer)
482466

483467
return PasswordResetSent(
484468
message="If the email exists, a password reset link has been sent", expires_in_minutes=60
@@ -516,15 +500,17 @@ async def reset_password_with_token(
516500
data: ResetPasswordRequest,
517501
users_service: UserService,
518502
password_reset_service: PasswordResetService,
519-
app_email_service: AppEmailService,
503+
request: Request[m.User, Token, Any],
504+
app_mailer: AppEmailService,
520505
) -> PasswordResetComplete:
521506
"""Complete password reset with token.
522507
523508
Args:
524509
data: Password reset request data
525510
users_service: User service
526511
password_reset_service: Password reset service
527-
app_email_service: Email service for sending confirmation emails
512+
request: HTTP request object
513+
app_mailer: Email service for sending notifications
528514
529515
Returns:
530516
Password reset confirmation
@@ -545,6 +531,6 @@ async def reset_password_with_token(
545531

546532
user = await users_service.reset_password_with_token(user_id=reset_token.user_id, new_password=data.password)
547533

548-
await app_email_service.send_password_reset_confirmation_email(cast("UserProtocol", user))
534+
request.app.emit(event_id="password_reset_completed", user_id=user.id, mailer=app_mailer)
549535

550536
return PasswordResetComplete(message="Password has been successfully reset", user_id=user.id)

src/py/app/domain/accounts/controllers/_email_verification.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, cast
5+
from typing import TYPE_CHECKING, Any
66

7-
from litestar import Controller, get, post
7+
from litestar import Controller, Request, get, post
88
from litestar.di import Provide
99
from litestar.status_codes import HTTP_200_OK, HTTP_201_CREATED
1010

@@ -20,9 +20,11 @@
2020
if TYPE_CHECKING:
2121
from uuid import UUID
2222

23+
from litestar.security.jwt import Token
24+
25+
from app.db import models as m
2326
from app.domain.accounts.services import EmailVerificationTokenService, UserService
2427
from app.lib.email import AppEmailService
25-
from app.lib.email.service import UserProtocol
2628

2729

2830
class EmailVerificationController(Controller):
@@ -38,10 +40,10 @@ class EmailVerificationController(Controller):
3840
@post("/request", status_code=HTTP_201_CREATED)
3941
async def request_verification(
4042
self,
41-
data: EmailVerificationRequest,
4243
users_service: UserService,
43-
verification_service: EmailVerificationTokenService,
44-
app_email_service: AppEmailService,
44+
app_mailer: AppEmailService,
45+
request: Request[m.User, Token, Any],
46+
data: EmailVerificationRequest,
4547
) -> EmailVerificationSent:
4648
"""Request email verification for a user."""
4749

@@ -52,11 +54,9 @@ async def request_verification(
5254
if user.is_verified:
5355
return EmailVerificationSent(message="Email is already verified")
5456

55-
_, token = await verification_service.create_verification_token(user_id=user.id, email=user.email)
56-
57-
await app_email_service.send_verification_email(cast("UserProtocol", user), token)
57+
request.app.emit(event_id="verification_requested", user_id=user.id, mailer=app_mailer)
5858

59-
return EmailVerificationSent(message="Verification email sent", token=token)
59+
return EmailVerificationSent(message="Verification email sent")
6060

6161
@post("/verify", status_code=HTTP_200_OK)
6262
async def verify_email(

src/py/app/domain/accounts/controllers/_oauth.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ async def google_authorize(
159159
client_secret=settings.GOOGLE_OAUTH2_CLIENT_SECRET,
160160
)
161161

162-
frontend_callback = redirect_url or f"{settings.URL}/auth/google/callback"
162+
frontend_callback = redirect_url or "/auth/google/callback"
163163

164164
state = create_oauth_state(
165165
provider="google",
@@ -192,7 +192,7 @@ async def google_callback(
192192
oauth_error: str | None = Parameter(query="error", required=False),
193193
) -> Redirect:
194194
"""Handle Google OAuth callback for login and account linking."""
195-
default_callback = f"{settings.URL}/auth/google/callback"
195+
default_callback = "/auth/google/callback"
196196
redirect_path = build_oauth_error_redirect(default_callback, "oauth_failed", "Missing state parameter")
197197

198198
if not oauth_state:
@@ -302,7 +302,7 @@ async def github_authorize(
302302
client_secret=settings.GITHUB_OAUTH2_CLIENT_SECRET,
303303
)
304304

305-
frontend_callback = redirect_url or f"{settings.URL}/auth/github/callback"
305+
frontend_callback = redirect_url or "/auth/github/callback"
306306

307307
state = create_oauth_state(
308308
provider="github",
@@ -336,7 +336,7 @@ async def github_callback(
336336
oauth_error_description: str | None = Parameter(query="error_description", required=False),
337337
) -> Redirect:
338338
"""Handle GitHub OAuth callback for login and account linking."""
339-
default_callback = f"{settings.URL}/auth/github/callback"
339+
default_callback = "/auth/github/callback"
340340
redirect_path = build_oauth_error_redirect(default_callback, "oauth_failed", "Missing state parameter")
341341

342342
if not oauth_state:

0 commit comments

Comments
 (0)