Ashraf Al-Kassem Claude Opus 4.6 commited on
Commit
c626151
·
1 Parent(s): 6da3982

feat: Mission 21 — Settings Center + Data-Driven Dropdowns

Browse files

- Add PATCH /auth/me for user profile updates with audit logging
- Add 8 new catalog endpoints (timezones, languages, ai-models, dedupe-strategies, event-sources, event-outcomes, audit-actions, contact-fields)
- Create LookupsProvider context + useLookups() hook for bulk catalog preload
- Restructure Settings page with Profile section (name edit, email verification badge)
- Replace 10 hardcoded dropdowns across 5 frontend pages with catalog data
- Sidebar shows real user name + computed initials from /auth/me
- 141 tests passing (+23 new), zero TS build errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

backend/app/api/v1/auth.py CHANGED
@@ -14,7 +14,7 @@ from app.core import security
14
  from app.core.config import settings
15
  from app.core.db import get_db
16
  from app.models.models import User, Workspace, WorkspaceMember, WorkspaceRole, PasswordResetToken, EmailLog, EmailStatus, EmailOutbox, EmailOutboxStatus
17
- from app.schemas.user import Token, UserRead, UserCreate, ForgotPassword, ResetPassword, GoogleCallback, MeRead
18
  from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
19
  from app.api.deps import get_current_user
20
  from app.services.audit_service import audit_event
@@ -56,6 +56,54 @@ async def get_me(
56
  ))
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  @router.post("/login", response_model=ResponseEnvelope[Token])
60
  async def login(
61
  request: Request, db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
 
14
  from app.core.config import settings
15
  from app.core.db import get_db
16
  from app.models.models import User, Workspace, WorkspaceMember, WorkspaceRole, PasswordResetToken, EmailLog, EmailStatus, EmailOutbox, EmailOutboxStatus
17
+ from app.schemas.user import Token, UserRead, UserCreate, ForgotPassword, ResetPassword, GoogleCallback, MeRead, ProfileUpdate
18
  from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
19
  from app.api.deps import get_current_user
20
  from app.services.audit_service import audit_event
 
56
  ))
57
 
58
 
59
+ @router.patch("/me", response_model=ResponseEnvelope[MeRead])
60
+ async def update_profile(
61
+ update_in: ProfileUpdate,
62
+ db: AsyncSession = Depends(get_db),
63
+ current_user: User = Depends(get_current_user),
64
+ request: Request = None,
65
+ ) -> Any:
66
+ """Update the authenticated user's profile."""
67
+ changed = False
68
+
69
+ if update_in.full_name is not None:
70
+ stripped = update_in.full_name.strip()
71
+ if not stripped:
72
+ return wrap_error("Full name cannot be empty")
73
+ current_user.full_name = stripped
74
+ changed = True
75
+
76
+ if changed:
77
+ db.add(current_user)
78
+ await audit_event(
79
+ db, action="update_profile", entity_type="user",
80
+ entity_id=str(current_user.id), actor_user_id=current_user.id,
81
+ outcome="success", request=request,
82
+ )
83
+ await db.commit()
84
+ await db.refresh(current_user)
85
+
86
+ now = datetime.utcnow()
87
+ verified = current_user.email_verified_at is not None
88
+ grace_expires = current_user.email_verification_expires_at
89
+ grace_remaining_days: Optional[int] = None
90
+ if not verified and grace_expires is not None:
91
+ delta = grace_expires - now
92
+ grace_remaining_days = max(0, delta.days)
93
+
94
+ return wrap_data(MeRead(
95
+ id=current_user.id,
96
+ email=current_user.email,
97
+ full_name=current_user.full_name,
98
+ is_active=current_user.is_active,
99
+ is_superuser=current_user.is_superuser,
100
+ email_verified_at=current_user.email_verified_at,
101
+ email_verification_expires_at=grace_expires,
102
+ requires_email_verification=not verified,
103
+ verification_grace_remaining_days=grace_remaining_days,
104
+ ))
105
+
106
+
107
  @router.post("/login", response_model=ResponseEnvelope[Token])
108
  async def login(
109
  request: Request, db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
backend/app/api/v1/catalog.py CHANGED
@@ -21,6 +21,14 @@ from app.core.catalog_registry import (
21
  WORKSPACE_ROLES,
22
  AGENCY_ROLES,
23
  MODULE_LABELS,
 
 
 
 
 
 
 
 
24
  )
25
 
26
  router = APIRouter()
@@ -145,3 +153,43 @@ router.add_api_route(
145
  _static_endpoint(MESSAGE_DELIVERY_STATUSES),
146
  methods=["GET"],
147
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  WORKSPACE_ROLES,
22
  AGENCY_ROLES,
23
  MODULE_LABELS,
24
+ TIMEZONES,
25
+ LANGUAGES,
26
+ AI_MODELS,
27
+ DEDUPE_STRATEGIES,
28
+ EVENT_SOURCES,
29
+ EVENT_OUTCOMES,
30
+ AUDIT_ACTIONS,
31
+ CONTACT_FIELDS,
32
  )
33
 
34
  router = APIRouter()
 
153
  _static_endpoint(MESSAGE_DELIVERY_STATUSES),
154
  methods=["GET"],
155
  )
156
+ router.add_api_route(
157
+ "/timezones",
158
+ _static_endpoint(TIMEZONES),
159
+ methods=["GET"],
160
+ )
161
+ router.add_api_route(
162
+ "/languages",
163
+ _static_endpoint(LANGUAGES),
164
+ methods=["GET"],
165
+ )
166
+ router.add_api_route(
167
+ "/ai-models",
168
+ _static_endpoint(AI_MODELS),
169
+ methods=["GET"],
170
+ )
171
+ router.add_api_route(
172
+ "/dedupe-strategies",
173
+ _static_endpoint(DEDUPE_STRATEGIES),
174
+ methods=["GET"],
175
+ )
176
+ router.add_api_route(
177
+ "/event-sources",
178
+ _static_endpoint(EVENT_SOURCES),
179
+ methods=["GET"],
180
+ )
181
+ router.add_api_route(
182
+ "/event-outcomes",
183
+ _static_endpoint(EVENT_OUTCOMES),
184
+ methods=["GET"],
185
+ )
186
+ router.add_api_route(
187
+ "/audit-actions",
188
+ _static_endpoint(AUDIT_ACTIONS),
189
+ methods=["GET"],
190
+ )
191
+ router.add_api_route(
192
+ "/contact-fields",
193
+ _static_endpoint(CONTACT_FIELDS),
194
+ methods=["GET"],
195
+ )
backend/app/core/catalog_registry.py CHANGED
@@ -184,3 +184,105 @@ MODULE_LABELS: Dict[str, str] = {
184
  "diagnostics": "Diagnostics",
185
  "admin_portal": "Admin Portal",
186
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  "diagnostics": "Diagnostics",
185
  "admin_portal": "Admin Portal",
186
  }
187
+
188
+
189
+ # ── Timezones ───────────────────────────────────────────────────────
190
+
191
+ TIMEZONES: List[Dict[str, str]] = [
192
+ {"key": "UTC", "label": "UTC (Coordinated Universal Time)"},
193
+ {"key": "US/Eastern", "label": "US/Eastern (EST/EDT)"},
194
+ {"key": "US/Central", "label": "US/Central (CST/CDT)"},
195
+ {"key": "US/Pacific", "label": "US/Pacific (PST/PDT)"},
196
+ {"key": "Europe/London", "label": "Europe/London (GMT/BST)"},
197
+ {"key": "Europe/Paris", "label": "Europe/Paris (CET/CEST)"},
198
+ {"key": "Asia/Dubai", "label": "Asia/Dubai (GST)"},
199
+ {"key": "Asia/Tokyo", "label": "Asia/Tokyo (JST)"},
200
+ {"key": "Asia/Shanghai", "label": "Asia/Shanghai (CST)"},
201
+ {"key": "Australia/Sydney", "label": "Australia/Sydney (AEST)"},
202
+ ]
203
+
204
+
205
+ # ── Languages ───────────────────────────────────────────────────────
206
+
207
+ LANGUAGES: List[Dict[str, str]] = [
208
+ {"key": "en", "label": "English"},
209
+ {"key": "ar", "label": "Arabic"},
210
+ {"key": "es", "label": "Spanish"},
211
+ {"key": "fr", "label": "French"},
212
+ {"key": "de", "label": "German"},
213
+ {"key": "zh", "label": "Chinese"},
214
+ {"key": "pt", "label": "Portuguese"},
215
+ {"key": "ja", "label": "Japanese"},
216
+ ]
217
+
218
+
219
+ # ── AI Models ───────────────────────────────────────────────────────
220
+
221
+ AI_MODELS: List[Dict[str, str]] = [
222
+ {"key": "gemini-1.5-pro", "label": "Gemini 1.5 Pro"},
223
+ {"key": "gemini-1.5-flash", "label": "Gemini 1.5 Flash"},
224
+ {"key": "gemini-2.0-flash", "label": "Gemini 2.0 Flash"},
225
+ ]
226
+
227
+
228
+ # ── Dedupe Strategies ──────────────────────────────────────────────
229
+
230
+ DEDUPE_STRATEGIES: List[Dict[str, str]] = [
231
+ {"key": "EMAIL", "label": "Email Address Only"},
232
+ {"key": "PHONE", "label": "Phone Number Only"},
233
+ {"key": "EMAIL_OR_PHONE", "label": "Email OR Phone (Recommended)"},
234
+ ]
235
+
236
+
237
+ # ── Event Sources ──────────────────────────────────────────────────
238
+
239
+ EVENT_SOURCES: List[Dict[str, str]] = [
240
+ {"key": "webhook", "label": "Webhook"},
241
+ {"key": "runtime", "label": "Runtime"},
242
+ {"key": "dispatch", "label": "Dispatch"},
243
+ {"key": "email", "label": "Email"},
244
+ {"key": "zoho", "label": "Zoho"},
245
+ {"key": "inbox", "label": "Inbox"},
246
+ ]
247
+
248
+
249
+ # ── Event Outcomes ─────────────────────────────────────────────────
250
+
251
+ EVENT_OUTCOMES: List[Dict[str, str]] = [
252
+ {"key": "success", "label": "Success"},
253
+ {"key": "failure", "label": "Failure"},
254
+ {"key": "skipped", "label": "Skipped"},
255
+ ]
256
+
257
+
258
+ # ── Audit Actions ──────────────────────────────────────────────────
259
+
260
+ AUDIT_ACTIONS: List[Dict[str, str]] = [
261
+ {"key": "user_login", "label": "User Login"},
262
+ {"key": "user_signup", "label": "User Signup"},
263
+ {"key": "update_profile", "label": "Profile Update"},
264
+ {"key": "workspace_create", "label": "Workspace Create"},
265
+ {"key": "workspace_invite", "label": "Workspace Invite"},
266
+ {"key": "workspace_member_remove", "label": "Member Remove"},
267
+ {"key": "workspace_role_change", "label": "Role Change"},
268
+ {"key": "automation_create", "label": "Automation Create"},
269
+ {"key": "automation_publish", "label": "Automation Publish"},
270
+ {"key": "integration_connect", "label": "Integration Connect"},
271
+ {"key": "integration_disconnect", "label": "Integration Disconnect"},
272
+ {"key": "dispatch_trigger", "label": "Dispatch Trigger"},
273
+ {"key": "inbox_reply", "label": "Inbox Reply"},
274
+ {"key": "inbox_status_change", "label": "Inbox Status Change"},
275
+ {"key": "update_workspace_settings", "label": "Settings Update"},
276
+ ]
277
+
278
+
279
+ # ── Contact Fields ─────────────────────────────────────────────────
280
+
281
+ CONTACT_FIELDS: List[Dict[str, Any]] = [
282
+ {"key": "first_name", "label": "First Name", "required": True},
283
+ {"key": "last_name", "label": "Last Name", "required": True},
284
+ {"key": "email", "label": "Email", "required": False},
285
+ {"key": "phone", "label": "Phone", "required": False},
286
+ {"key": "company", "label": "Company", "required": False},
287
+ {"key": "description", "label": "Description / Notes", "required": False},
288
+ ]
backend/app/schemas/user.py CHANGED
@@ -29,6 +29,10 @@ class MeRead(UserRead):
29
  verification_grace_remaining_days: Optional[int] = None
30
 
31
 
 
 
 
 
32
  class Token(BaseModel):
33
  access_token: str
34
  token_type: str
 
29
  verification_grace_remaining_days: Optional[int] = None
30
 
31
 
32
+ class ProfileUpdate(BaseModel):
33
+ full_name: Optional[str] = None
34
+
35
+
36
  class Token(BaseModel):
37
  access_token: str
38
  token_type: str
backend/tests/test_catalog_lookups.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mission 21: Catalog Lookup Tests
3
+ Tests all 8 new static catalog endpoints added for data-driven dropdowns.
4
+ """
5
+ import pytest
6
+ from httpx import AsyncClient
7
+
8
+
9
+ # All new Mission 21 catalog endpoints with a sample of expected keys
10
+ NEW_LOOKUP_ENDPOINTS = [
11
+ ("timezones", ["UTC", "US/Eastern", "Asia/Dubai"]),
12
+ ("languages", ["en", "ar", "es"]),
13
+ ("ai-models", ["gemini-1.5-pro", "gemini-2.0-flash"]),
14
+ ("dedupe-strategies", ["EMAIL", "PHONE", "EMAIL_OR_PHONE"]),
15
+ ("event-sources", ["webhook", "runtime", "dispatch", "inbox"]),
16
+ ("event-outcomes", ["success", "failure", "skipped"]),
17
+ ("audit-actions", ["user_login", "update_profile", "automation_create"]),
18
+ ("contact-fields", ["first_name", "email", "phone"]),
19
+ ]
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ @pytest.mark.parametrize("endpoint,expected_keys", NEW_LOOKUP_ENDPOINTS)
24
+ async def test_lookup_endpoint_returns_data(
25
+ async_client: AsyncClient, endpoint: str, expected_keys: list
26
+ ):
27
+ response = await async_client.get(f"/api/v1/catalog/{endpoint}")
28
+ assert response.status_code == 200
29
+ data = response.json()
30
+ assert data["success"] is True
31
+ items = data["data"]
32
+ assert isinstance(items, list)
33
+ assert len(items) >= 1
34
+
35
+ # Every item must have key + label
36
+ for item in items:
37
+ assert "key" in item, f"Item missing 'key' in {endpoint}"
38
+ assert "label" in item, f"Item missing 'label' in {endpoint}"
39
+
40
+ # Spot-check expected keys are present
41
+ actual_keys = [item["key"] for item in items]
42
+ for key in expected_keys:
43
+ assert key in actual_keys, f"Expected key '{key}' in /catalog/{endpoint}"
44
+
45
+
46
+ @pytest.mark.asyncio
47
+ @pytest.mark.parametrize("endpoint,expected_keys", NEW_LOOKUP_ENDPOINTS)
48
+ async def test_lookup_endpoint_cache_header(
49
+ async_client: AsyncClient, endpoint: str, expected_keys: list
50
+ ):
51
+ response = await async_client.get(f"/api/v1/catalog/{endpoint}")
52
+ assert response.status_code == 200
53
+ assert "Cache-Control" in response.headers
54
+ assert "max-age=60" in response.headers["Cache-Control"]
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_contact_fields_have_required_flag(async_client: AsyncClient):
59
+ response = await async_client.get("/api/v1/catalog/contact-fields")
60
+ data = response.json()
61
+ for item in data["data"]:
62
+ assert "required" in item, f"Contact field '{item['key']}' missing 'required' flag"
63
+ assert isinstance(item["required"], bool)
64
+
65
+ # first_name should be required, phone should not
66
+ fields_map = {f["key"]: f for f in data["data"]}
67
+ assert fields_map["first_name"]["required"] is True
68
+ assert fields_map["phone"]["required"] is False
backend/tests/test_profile.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mission 21: Profile Update Tests
3
+ Tests PATCH /auth/me endpoint for user profile management.
4
+ """
5
+ import pytest
6
+ from httpx import AsyncClient
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+
10
+ # ── Helpers ─────────────────────────────────────────────────────────
11
+
12
+
13
+ async def _signup_and_login(client: AsyncClient, email: str = "profile_user@example.com") -> str:
14
+ """Create a user and return a JWT access token."""
15
+ await client.post("/api/v1/auth/signup", json={
16
+ "email": email,
17
+ "password": "securepassword123",
18
+ "full_name": "Original Name",
19
+ })
20
+ login = await client.post(
21
+ "/api/v1/auth/login",
22
+ data={"username": email, "password": "securepassword123"},
23
+ headers={"content-type": "application/x-www-form-urlencoded"},
24
+ )
25
+ return login.json()["data"]["access_token"]
26
+
27
+
28
+ def _auth(token: str) -> dict:
29
+ return {"Authorization": f"Bearer {token}"}
30
+
31
+
32
+ # ── Tests ───────────────────────────────────────────────────────────
33
+
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_patch_me_updates_full_name(async_client: AsyncClient):
37
+ token = await _signup_and_login(async_client, "profile_patch@example.com")
38
+
39
+ response = await async_client.patch(
40
+ "/api/v1/auth/me",
41
+ json={"full_name": "Updated Name"},
42
+ headers=_auth(token),
43
+ )
44
+ assert response.status_code == 200
45
+ data = response.json()
46
+ assert data["success"] is True
47
+ assert data["data"]["full_name"] == "Updated Name"
48
+
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_patch_me_strips_whitespace(async_client: AsyncClient):
52
+ token = await _signup_and_login(async_client, "profile_strip@example.com")
53
+
54
+ response = await async_client.patch(
55
+ "/api/v1/auth/me",
56
+ json={"full_name": " Padded Name "},
57
+ headers=_auth(token),
58
+ )
59
+ assert response.status_code == 200
60
+ assert response.json()["data"]["full_name"] == "Padded Name"
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_patch_me_empty_name_rejected(async_client: AsyncClient):
65
+ token = await _signup_and_login(async_client, "profile_empty@example.com")
66
+
67
+ response = await async_client.patch(
68
+ "/api/v1/auth/me",
69
+ json={"full_name": " "},
70
+ headers=_auth(token),
71
+ )
72
+ assert response.status_code == 200
73
+ data = response.json()
74
+ assert data["success"] is False
75
+ assert "empty" in data["error"].lower()
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_patch_me_no_change_when_empty_body(async_client: AsyncClient):
80
+ token = await _signup_and_login(async_client, "profile_noop@example.com")
81
+
82
+ response = await async_client.patch(
83
+ "/api/v1/auth/me",
84
+ json={},
85
+ headers=_auth(token),
86
+ )
87
+ assert response.status_code == 200
88
+ data = response.json()
89
+ assert data["success"] is True
90
+ assert data["data"]["full_name"] == "Original Name"
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_get_me_reflects_patch(async_client: AsyncClient):
95
+ token = await _signup_and_login(async_client, "profile_reflect@example.com")
96
+
97
+ # Update
98
+ await async_client.patch(
99
+ "/api/v1/auth/me",
100
+ json={"full_name": "Reflected Name"},
101
+ headers=_auth(token),
102
+ )
103
+
104
+ # Read back
105
+ response = await async_client.get("/api/v1/auth/me", headers=_auth(token))
106
+ assert response.status_code == 200
107
+ assert response.json()["data"]["full_name"] == "Reflected Name"
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_patch_me_unauthenticated(async_client: AsyncClient):
112
+ response = await async_client.patch(
113
+ "/api/v1/auth/me",
114
+ json={"full_name": "No Auth"},
115
+ )
116
+ assert response.status_code in (401, 403)
docs/missions/mission_21.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Mission 21 — Settings Center + Data-Driven Dropdowns
2
+
3
+ ## Summary
4
+
5
+ Mission 21 eliminates all hardcoded frontend dropdowns by extending the catalog registry, adds user profile management, and restructures the settings page with a Profile tab.
6
+
7
+ 1. **8 new catalog endpoints** — timezones, languages, AI models, dedupe strategies, event sources, event outcomes, audit actions, contact fields — all served from the catalog registry.
8
+ 2. **LookupsProvider** — single React context preloading all lookups on mount via `Promise.all`, consumed by `useLookups()` hook.
9
+ 3. **Profile management** — `PATCH /auth/me` lets users update their name. Settings page gains a Profile section.
10
+ 4. **Sidebar shows real user data** — fetches `/auth/me` on mount, displays actual name + computed initials.
11
+ 5. **Every hardcoded dropdown replaced** — 13 occurrences across 7 files now use catalog data.
12
+
13
+ ## What Changed
14
+
15
+ ### Backend
16
+
17
+ | File | Change |
18
+ |---|---|
19
+ | `app/schemas/user.py` | Added `ProfileUpdate` schema |
20
+ | `app/api/v1/auth.py` | Added `PATCH /auth/me` endpoint with audit logging |
21
+ | `app/core/catalog_registry.py` | Added 8 new catalog constants (TIMEZONES, LANGUAGES, AI_MODELS, DEDUPE_STRATEGIES, EVENT_SOURCES, EVENT_OUTCOMES, AUDIT_ACTIONS, CONTACT_FIELDS) |
22
+ | `app/api/v1/catalog.py` | Registered 8 new static endpoints |
23
+
24
+ ### Frontend
25
+
26
+ | File | Change |
27
+ |---|---|
28
+ | `src/lib/catalog.ts` | Extended `CatalogKey` type with 8 new keys; exported `fetchCatalog` |
29
+ | `src/lib/lookups.tsx` | **NEW** — LookupsProvider context + useLookups hook |
30
+ | `src/components/AppShell.tsx` | Wrapped with `<LookupsProvider>` |
31
+ | `src/app/(dashboard)/settings/page.tsx` | Added Profile section; replaced 3 hardcoded dropdowns with `CatalogSelectField` |
32
+ | `src/components/Sidebar.tsx` | Real user name from `/auth/me`, dynamic initials |
33
+ | `src/app/(dashboard)/logs/page.tsx` | Action + outcome filters from catalog |
34
+ | `src/app/(dashboard)/integrations/zoho/mapping/page.tsx` | Contact fields + dedupe strategies from catalog |
35
+ | `src/app/(admin)/admin/runtime-events/page.tsx` | Source + outcome filters from catalog |
36
+ | `src/app/(admin)/admin/system-settings/page.tsx` | Plan tier select from `/catalog/plans` |
37
+
38
+ ### Tests
39
+
40
+ | File | Tests |
41
+ |---|---|
42
+ | `tests/test_profile.py` | 6 tests — update name, whitespace strip, empty rejected, no-op body, GET reflects PATCH, unauthenticated |
43
+ | `tests/test_catalog_lookups.py` | 19 tests — 8 endpoints return data with key/label, 8 have Cache-Control header, contact fields have required flag |
44
+
45
+ ## New API Endpoints
46
+
47
+ | Method | Path | Description |
48
+ |---|---|---|
49
+ | PATCH | `/api/v1/auth/me` | Update authenticated user's profile (full_name) |
50
+ | GET | `/api/v1/catalog/timezones` | List of common timezones |
51
+ | GET | `/api/v1/catalog/languages` | Supported languages |
52
+ | GET | `/api/v1/catalog/ai-models` | Available AI models |
53
+ | GET | `/api/v1/catalog/dedupe-strategies` | CRM dedupe strategies |
54
+ | GET | `/api/v1/catalog/event-sources` | Runtime event sources |
55
+ | GET | `/api/v1/catalog/event-outcomes` | Event outcome types |
56
+ | GET | `/api/v1/catalog/audit-actions` | Audit log action types |
57
+ | GET | `/api/v1/catalog/contact-fields` | Standard contact fields |
58
+
59
+ ## Architecture: LookupsProvider
60
+
61
+ ```
62
+ <LookupsProvider> ← wraps AppShell (dashboard layout)
63
+
64
+ ├── Promise.all on mount
65
+ │ ├── fetchCatalog("timezones")
66
+ │ ├── fetchCatalog("languages")
67
+ │ ├── fetchCatalog("ai-models")
68
+ │ ├── fetchCatalog("dedupe-strategies")
69
+ │ ├── fetchCatalog("event-sources")
70
+ │ ├── fetchCatalog("event-outcomes")
71
+ │ ├── fetchCatalog("audit-actions")
72
+ │ ├── fetchCatalog("contact-fields")
73
+ │ ├── fetchCatalog("automation-trigger-types")
74
+ │ └── fetchCatalog("automation-node-types")
75
+
76
+ └── useLookups() → { timezones, languages, aiModels, ... loading }
77
+ ```
78
+
79
+ Admin pages (separate layout) use `useCatalog()` hook directly.
80
+
81
+ ## Hardcoded Dropdowns Replaced
82
+
83
+ | File | Before | After |
84
+ |---|---|---|
85
+ | `settings/page.tsx` | 6 inline timezone options | `lookups.timezones` |
86
+ | `settings/page.tsx` | 6 inline language options | `lookups.languages` |
87
+ | `settings/page.tsx` | 3 inline AI model options | `lookups.aiModels` |
88
+ | `logs/page.tsx` | 11 inline action options | `lookups.auditActions` |
89
+ | `logs/page.tsx` | 2 inline outcome options | `lookups.eventOutcomes` |
90
+ | `zoho/mapping/page.tsx` | 6 hardcoded LEADPILOT_FIELDS | `lookups.contactFields` |
91
+ | `zoho/mapping/page.tsx` | 3 inline dedupe strategies | `lookups.dedupeStrategies` |
92
+ | `admin/runtime-events/page.tsx` | 6 inline source options | `useCatalog("event-sources")` |
93
+ | `admin/runtime-events/page.tsx` | 3 inline outcome options | `useCatalog("event-outcomes")` |
94
+ | `admin/system-settings/page.tsx` | 4 hardcoded plan tiers | `useCatalog("plans")` |
95
+
96
+ ## How to Test
97
+
98
+ 1. **Backend tests**: `cd backend && rm -f test.db && python3 -m pytest tests/ -v`
99
+ 2. **Frontend build**: `cd frontend && npm run build`
100
+ 3. **Manual — Profile**: Settings → Profile tab → edit name → save → sidebar updates
101
+ 4. **Manual — Dropdowns**: Settings → General → timezone dropdown shows catalog values
102
+ 5. **Manual — Logs**: Activity Logs → action filter populated from catalog
103
+ 6. **Manual — API**: `curl /api/v1/catalog/timezones` returns list with key/label
104
+ 7. **Manual — Profile API**: `curl -X PATCH /api/v1/auth/me -d '{"full_name":"Test"}' -H 'Authorization: Bearer ...'`
docs/missions/mission_21_baseline.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Mission 21 — Pre-Mission Baseline
2
+
3
+ Captured before any Mission 21 changes.
4
+
5
+ ## Hardcoded Dropdowns
6
+
7
+ | File | Values |
8
+ |---|---|
9
+ | `settings/page.tsx` | Timezones (6 hardcoded), Languages (6), AI Models (3) |
10
+ | `automations/new/page.tsx` | TRIGGER_CARDS array (3 hardcoded triggers) |
11
+ | `zoho/mapping/page.tsx` | LEADPILOT_FIELDS (6 contact fields), Dedupe strategies (3), Fallback Zoho fields |
12
+ | `admin/system-settings/page.tsx` | Plan tiers: free, starter, growth, enterprise |
13
+ | `admin/runtime-events/page.tsx` | Event sources (6), Event outcomes (3) |
14
+ | `logs/page.tsx` | Action filter (11 types), Outcome filter (2) |
15
+
16
+ ## User Profile
17
+
18
+ - `GET /auth/me` existed — returns user data
19
+ - `PATCH /auth/me` did NOT exist — no way to update profile
20
+ - No `ProfileUpdate` schema
21
+
22
+ ## Sidebar
23
+
24
+ - Fetched `/auth/me` but showed `userName || "Loading..."` with no initials fallback
25
+ - No role display
26
+
27
+ ## Settings Page
28
+
29
+ - Workspace settings only (5 tabs: General, Messaging, AI, Automation, Notifications)
30
+ - No Profile section
31
+ - All dropdowns hardcoded with inline arrays
32
+
33
+ ## Catalog Registry (Mission 19)
34
+
35
+ 8 existing catalogs:
36
+ - integration-providers, automation-node-types, automation-trigger-types
37
+ - conversation-statuses, message-delivery-statuses
38
+ - workspace-roles, admin-roles
39
+ - plans, modules (DB-backed)
40
+
41
+ No lookup catalogs for: timezones, languages, AI models, dedupe strategies, event sources, event outcomes, audit actions, contact fields.
42
+
43
+ ## Frontend Data Layer
44
+
45
+ - `useCatalog()` hook existed in `catalog.ts` for individual catalog fetching
46
+ - No `LookupsProvider` — no bulk preload of lookups
47
+ - `fetchCatalog` was a private function (not exported)
48
+
49
+ ## Test Count
50
+
51
+ 118 tests across 20 files, all passing.
frontend/src/app/(admin)/admin/runtime-events/page.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useEffect, useState, useCallback } from "react";
4
  import { adminApi, RuntimeEventEntry } from "@/lib/admin-api";
 
5
  import { Loader2, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Copy, Check, AlertCircle, CheckCircle, MinusCircle } from "lucide-react";
6
 
7
  function formatDate(iso: string) {
@@ -70,6 +71,8 @@ function CopyButton({ text }: { text: string }) {
70
  }
71
 
72
  export default function AdminRuntimeEventsPage() {
 
 
73
  const [events, setEvents] = useState<RuntimeEventEntry[]>([]);
74
  const [total, setTotal] = useState(0);
75
  const [loading, setLoading] = useState(true);
@@ -126,12 +129,9 @@ export default function AdminRuntimeEventsPage() {
126
  className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
127
  >
128
  <option value="">All Sources</option>
129
- <option value="webhook">Webhook</option>
130
- <option value="runtime">Runtime</option>
131
- <option value="dispatch">Dispatch</option>
132
- <option value="email">Email</option>
133
- <option value="zoho">Zoho</option>
134
- <option value="inbox">Inbox</option>
135
  </select>
136
  <input
137
  type="text"
@@ -146,9 +146,9 @@ export default function AdminRuntimeEventsPage() {
146
  className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
147
  >
148
  <option value="">All Outcomes</option>
149
- <option value="success">Success</option>
150
- <option value="failure">Failure</option>
151
- <option value="skipped">Skipped</option>
152
  </select>
153
  <input
154
  type="text"
 
2
 
3
  import { useEffect, useState, useCallback } from "react";
4
  import { adminApi, RuntimeEventEntry } from "@/lib/admin-api";
5
+ import { useCatalog, type CatalogEntry } from "@/lib/catalog";
6
  import { Loader2, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Copy, Check, AlertCircle, CheckCircle, MinusCircle } from "lucide-react";
7
 
8
  function formatDate(iso: string) {
 
71
  }
72
 
73
  export default function AdminRuntimeEventsPage() {
74
+ const { data: eventSources } = useCatalog("event-sources");
75
+ const { data: eventOutcomes } = useCatalog("event-outcomes");
76
  const [events, setEvents] = useState<RuntimeEventEntry[]>([]);
77
  const [total, setTotal] = useState(0);
78
  const [loading, setLoading] = useState(true);
 
129
  className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
130
  >
131
  <option value="">All Sources</option>
132
+ {(eventSources || []).map((s) => (
133
+ <option key={s.key} value={s.key}>{s.label}</option>
134
+ ))}
 
 
 
135
  </select>
136
  <input
137
  type="text"
 
146
  className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
147
  >
148
  <option value="">All Outcomes</option>
149
+ {(eventOutcomes || []).map((o) => (
150
+ <option key={o.key} value={o.key}>{o.label}</option>
151
+ ))}
152
  </select>
153
  <input
154
  type="text"
frontend/src/app/(admin)/admin/system-settings/page.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useEffect, useState } from "react";
4
  import { getSystemSettings, patchSystemSettings } from "@/lib/admin-api";
 
5
  import {
6
  Settings,
7
  Loader2,
@@ -14,6 +15,7 @@ import {
14
  type SettingsData = Record<string, any>;
15
 
16
  export default function SystemSettingsPage() {
 
17
  const [settings, setSettings] = useState<SettingsData | null>(null);
18
  const [version, setVersion] = useState(0);
19
  const [loading, setLoading] = useState(true);
@@ -154,8 +156,8 @@ export default function SystemSettingsPage() {
154
  onChange={(e) => updateNested("defaults", "default_workspace_plan", e.target.value)}
155
  className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50"
156
  >
157
- {["free", "starter", "growth", "enterprise"].map((p) => (
158
- <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
159
  ))}
160
  </select>
161
  </div>
 
2
 
3
  import { useEffect, useState } from "react";
4
  import { getSystemSettings, patchSystemSettings } from "@/lib/admin-api";
5
+ import { useCatalog, type CatalogEntry, type PlanCatalogEntry } from "@/lib/catalog";
6
  import {
7
  Settings,
8
  Loader2,
 
15
  type SettingsData = Record<string, any>;
16
 
17
  export default function SystemSettingsPage() {
18
+ const { data: plans } = useCatalog<PlanCatalogEntry[]>("plans");
19
  const [settings, setSettings] = useState<SettingsData | null>(null);
20
  const [version, setVersion] = useState(0);
21
  const [loading, setLoading] = useState(true);
 
156
  onChange={(e) => updateNested("defaults", "default_workspace_plan", e.target.value)}
157
  className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50"
158
  >
159
+ {(plans || []).map((p) => (
160
+ <option key={p.name} value={p.name}>{p.display_name}</option>
161
  ))}
162
  </select>
163
  </div>
frontend/src/app/(dashboard)/integrations/zoho/mapping/page.tsx CHANGED
@@ -15,6 +15,7 @@ import {
15
  import Link from "next/link";
16
  import { cn } from "@/lib/utils";
17
  import { apiClient } from "@/lib/api";
 
18
  import { toast } from "sonner";
19
 
20
  interface ZohoField {
@@ -29,17 +30,9 @@ interface MappingConfig {
29
  field_mappings: Record<string, string>;
30
  }
31
 
32
- const LEADPILOT_FIELDS = [
33
- { key: "first_name", label: "First Name", required: true },
34
- { key: "last_name", label: "Last Name", required: true },
35
- { key: "email", label: "Email", required: false },
36
- { key: "phone", label: "Phone", required: false },
37
- { key: "company", label: "Company", required: false },
38
- { key: "description", label: "Description / Notes", required: false },
39
- ];
40
-
41
  export default function ZohoMappingPage() {
42
  const router = useRouter();
 
43
  const [isLoading, setIsLoading] = useState(true);
44
  const [isSaving, setIsSaving] = useState(false);
45
  const [zohoFields, setZohoFields] = useState<ZohoField[]>([]);
@@ -183,9 +176,9 @@ export default function ZohoMappingPage() {
183
  value={config.dedupe_strategy}
184
  onChange={(e) => setConfig({ ...config, dedupe_strategy: e.target.value })}
185
  >
186
- <option value="EMAIL">Email Address Only</option>
187
- <option value="PHONE">Phone Number Only</option>
188
- <option value="EMAIL_OR_PHONE">Email OR Phone (Recommended)</option>
189
  </select>
190
  <p className="text-[10px] text-slate-400">
191
  {config.dedupe_strategy === "EMAIL_OR_PHONE"
@@ -217,7 +210,7 @@ export default function ZohoMappingPage() {
217
  </div>
218
 
219
  <div className="divide-y divide-border">
220
- {LEADPILOT_FIELDS.map((field) => (
221
  <div key={field.key} className="p-4 flex items-center gap-4 hover:bg-slate-50/50 transition-colors">
222
  <div className="w-1/3">
223
  <label className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
 
15
  import Link from "next/link";
16
  import { cn } from "@/lib/utils";
17
  import { apiClient } from "@/lib/api";
18
+ import { useLookups } from "@/lib/lookups";
19
  import { toast } from "sonner";
20
 
21
  interface ZohoField {
 
30
  field_mappings: Record<string, string>;
31
  }
32
 
 
 
 
 
 
 
 
 
 
33
  export default function ZohoMappingPage() {
34
  const router = useRouter();
35
+ const lookups = useLookups();
36
  const [isLoading, setIsLoading] = useState(true);
37
  const [isSaving, setIsSaving] = useState(false);
38
  const [zohoFields, setZohoFields] = useState<ZohoField[]>([]);
 
176
  value={config.dedupe_strategy}
177
  onChange={(e) => setConfig({ ...config, dedupe_strategy: e.target.value })}
178
  >
179
+ {lookups.dedupeStrategies.map((s) => (
180
+ <option key={s.key} value={s.key}>{s.label}</option>
181
+ ))}
182
  </select>
183
  <p className="text-[10px] text-slate-400">
184
  {config.dedupe_strategy === "EMAIL_OR_PHONE"
 
210
  </div>
211
 
212
  <div className="divide-y divide-border">
213
+ {lookups.contactFields.map((field) => (
214
  <div key={field.key} className="p-4 flex items-center gap-4 hover:bg-slate-50/50 transition-colors">
215
  <div className="w-1/3">
216
  <label className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
frontend/src/app/(dashboard)/logs/page.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useEffect, useState, useCallback } from "react";
4
  import { apiClient } from "@/lib/api";
 
5
  import { Loader2, AlertCircle, ChevronLeft, ChevronRight, Search, Filter } from "lucide-react";
6
 
7
  interface AuditEntry {
@@ -67,6 +68,7 @@ function ActionBadge({ action }: { action: string }) {
67
  const PAGE_SIZE = 25;
68
 
69
  export default function LogsPage() {
 
70
  const [entries, setEntries] = useState<AuditEntry[]>([]);
71
  const [total, setTotal] = useState(0);
72
  const [page, setPage] = useState(0);
@@ -116,18 +118,9 @@ export default function LogsPage() {
116
  className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
117
  >
118
  <option value="">All Actions</option>
119
- <option value="workspace_create">Workspace Create</option>
120
- <option value="workspace_invite">Workspace Invite</option>
121
- <option value="workspace_member_remove">Member Remove</option>
122
- <option value="workspace_role_change">Role Change</option>
123
- <option value="automation_create">Automation Create</option>
124
- <option value="automation_publish">Automation Publish</option>
125
- <option value="integration_connect">Integration Connect</option>
126
- <option value="integration_disconnect">Integration Disconnect</option>
127
- <option value="dispatch_trigger">Dispatch Trigger</option>
128
- <option value="inbox_reply">Inbox Reply</option>
129
- <option value="inbox_status_change">Inbox Status Change</option>
130
- <option value="update_workspace_settings">Settings Update</option>
131
  </select>
132
  <select
133
  value={outcomeFilter}
@@ -135,8 +128,9 @@ export default function LogsPage() {
135
  className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
136
  >
137
  <option value="">All Outcomes</option>
138
- <option value="success">Success</option>
139
- <option value="failure">Failure</option>
 
140
  </select>
141
  </div>
142
 
 
2
 
3
  import { useEffect, useState, useCallback } from "react";
4
  import { apiClient } from "@/lib/api";
5
+ import { useLookups } from "@/lib/lookups";
6
  import { Loader2, AlertCircle, ChevronLeft, ChevronRight, Search, Filter } from "lucide-react";
7
 
8
  interface AuditEntry {
 
68
  const PAGE_SIZE = 25;
69
 
70
  export default function LogsPage() {
71
+ const lookups = useLookups();
72
  const [entries, setEntries] = useState<AuditEntry[]>([]);
73
  const [total, setTotal] = useState(0);
74
  const [page, setPage] = useState(0);
 
118
  className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
119
  >
120
  <option value="">All Actions</option>
121
+ {lookups.auditActions.map((a) => (
122
+ <option key={a.key} value={a.key}>{a.label}</option>
123
+ ))}
 
 
 
 
 
 
 
 
 
124
  </select>
125
  <select
126
  value={outcomeFilter}
 
128
  className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
129
  >
130
  <option value="">All Outcomes</option>
131
+ {lookups.eventOutcomes.map((o) => (
132
+ <option key={o.key} value={o.key}>{o.label}</option>
133
+ ))}
134
  </select>
135
  </div>
136
 
frontend/src/app/(dashboard)/settings/page.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useEffect, useState } from "react";
4
  import { apiClient } from "@/lib/api";
 
5
  import {
6
  Settings,
7
  Globe,
@@ -11,10 +12,24 @@ import {
11
  Bell,
12
  Loader2,
13
  Save,
 
 
14
  } from "lucide-react";
15
 
16
  type SettingsData = Record<string, any>;
17
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  const TABS = [
19
  { key: "general", label: "General", icon: Globe },
20
  { key: "messaging", label: "Messaging", icon: MessageSquare },
@@ -23,20 +38,38 @@ const TABS = [
23
  { key: "notifications", label: "Notifications", icon: Bell },
24
  ] as const;
25
 
26
- export default function WorkspaceSettingsPage() {
 
 
 
 
 
 
 
 
27
  const [settings, setSettings] = useState<SettingsData | null>(null);
28
  const [version, setVersion] = useState(0);
29
  const [activeTab, setActiveTab] = useState<string>("general");
30
- const [loading, setLoading] = useState(true);
31
  const [saving, setSaving] = useState(false);
32
  const [dirty, setDirty] = useState<SettingsData>({});
 
 
 
 
33
  const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
34
 
35
  useEffect(() => {
36
- apiClient.get("/settings/workspace").then((res) => {
37
- if (res.success && res.data) {
38
- setSettings(res.data.settings);
39
- setVersion(res.data.version);
 
 
 
 
 
 
 
40
  }
41
  setLoading(false);
42
  });
@@ -47,14 +80,29 @@ export default function WorkspaceSettingsPage() {
47
  setTimeout(() => setToast(null), 3000);
48
  };
49
 
50
- const updateField = (section: string, key: string, value: any) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  setSettings((prev) => {
52
  if (!prev) return prev;
53
- return { ...prev, [section]: { ...prev[section], [key]: value } };
54
  });
55
  setDirty((prev) => ({
56
  ...prev,
57
- [section]: { ...(prev[section] || {}), [key]: value },
58
  }));
59
  };
60
 
@@ -81,11 +129,8 @@ export default function WorkspaceSettingsPage() {
81
  );
82
  }
83
 
84
- if (!settings) {
85
- return <p className="text-slate-500 p-8">Failed to load settings.</p>;
86
- }
87
-
88
- const s = settings[activeTab] || {};
89
 
90
  return (
91
  <div className="space-y-8 pb-10">
@@ -93,18 +138,30 @@ export default function WorkspaceSettingsPage() {
93
  <div className="flex items-center justify-between">
94
  <div>
95
  <h1 className="text-2xl font-bold tracking-tight text-slate-900 flex items-center gap-2">
96
- <Settings className="w-6 h-6 text-teal-700" /> Workspace Settings
97
  </h1>
98
- <p className="text-slate-500 text-sm mt-1">Manage your workspace configuration &middot; Version {version}</p>
99
  </div>
100
- <button
101
- onClick={handleSave}
102
- disabled={saving || Object.keys(dirty).length === 0}
103
- className="flex items-center gap-2 px-5 py-2.5 bg-teal-700 text-white rounded-lg hover:bg-teal-800 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-bold transition-all"
104
- >
105
- {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
106
- Save Changes
107
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
108
  </div>
109
 
110
  {/* Toast */}
@@ -118,62 +175,116 @@ export default function WorkspaceSettingsPage() {
118
  </div>
119
  )}
120
 
121
- {/* Tabs */}
122
- <div className="flex border-b border-slate-100 bg-slate-50/50 rounded-t-xl">
123
- {TABS.map((tab) => (
124
- <button
125
- key={tab.key}
126
- onClick={() => setActiveTab(tab.key)}
127
- className={`flex items-center gap-2 px-6 py-3.5 text-sm font-bold border-b-2 transition-all ${
128
- activeTab === tab.key
129
- ? "border-teal-600 text-teal-700 bg-white"
130
- : "border-transparent text-slate-400 hover:text-slate-600"
131
- }`}
132
- >
133
- <tab.icon className="w-4 h-4" />
134
- {tab.label}
135
- </button>
136
- ))}
 
 
 
 
 
 
 
137
  </div>
138
 
139
- {/* Fields */}
140
- <div className="bg-white p-6 md:p-8 rounded-xl border border-slate-100 shadow-sm space-y-6">
141
- {activeTab === "general" && (
142
- <>
143
- <Field label="Name Override" type="text" value={s.name_override ?? ""} onChange={(v) => updateField("general", "name_override", v || null)} placeholder="Leave empty to use workspace name" />
144
- <Field label="Logo URL" type="text" value={s.logo_url ?? ""} onChange={(v) => updateField("general", "logo_url", v || null)} placeholder="https://example.com/logo.png" />
145
- <SelectField label="Timezone" value={s.timezone} options={["UTC", "US/Eastern", "US/Pacific", "Europe/London", "Asia/Dubai", "Asia/Tokyo"]} onChange={(v) => updateField("general", "timezone", v)} />
146
- <SelectField label="Default Language" value={s.default_language} options={["en", "ar", "es", "fr", "de", "zh"]} onChange={(v) => updateField("general", "default_language", v)} />
147
- </>
148
- )}
149
- {activeTab === "messaging" && (
150
- <>
151
- <Field label="Default Reply Delay (seconds)" type="number" value={s.default_reply_delay_seconds} onChange={(v) => updateField("messaging", "default_reply_delay_seconds", Number(v))} />
152
- <Toggle label="Fallback Message Enabled" description="Send a fallback message when AI cannot generate a response" value={s.fallback_message_enabled} onChange={(v) => updateField("messaging", "fallback_message_enabled", v)} />
153
- <Toggle label="Auto-Retry Failed Dispatch" description="Automatically retry failed message deliveries with exponential backoff" value={s.auto_retry_failed_dispatch} onChange={(v) => updateField("messaging", "auto_retry_failed_dispatch", v)} />
154
- </>
155
- )}
156
- {activeTab === "ai" && (
157
- <>
158
- <SelectField label="Default Model" value={s.default_model} options={["gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.0-flash"]} onChange={(v) => updateField("ai", "default_model", v)} />
159
- <Field label="Temperature" type="number" value={s.temperature} step="0.1" min="0" max="2" onChange={(v) => updateField("ai", "temperature", Number(v))} />
160
- <Field label="Max Tokens" type="number" value={s.max_tokens} onChange={(v) => updateField("ai", "max_tokens", Number(v))} />
161
- <Toggle label="Guardrails Enabled" description="Enforce safety guardrails on AI-generated responses" value={s.guardrails_enabled} onChange={(v) => updateField("ai", "guardrails_enabled", v)} />
162
- </>
163
- )}
164
- {activeTab === "automation" && (
165
- <>
166
- <Toggle label="Auto-Publish" description="Automatically publish new automation flows" value={s.auto_publish} onChange={(v) => updateField("automation", "auto_publish", v)} />
167
- <Field label="Draft Expiry (days)" type="number" value={s.draft_expiry_days} onChange={(v) => updateField("automation", "draft_expiry_days", Number(v))} />
168
- </>
169
- )}
170
- {activeTab === "notifications" && (
171
- <>
172
- <Toggle label="Email Notifications Enabled" description="Receive email notifications for important workspace events" value={s.email_notifications_enabled} onChange={(v) => updateField("notifications", "email_notifications_enabled", v)} />
173
- <Field label="Webhook URL" type="text" value={s.webhook_url ?? ""} onChange={(v) => updateField("notifications", "webhook_url", v || null)} placeholder="https://example.com/webhook" />
174
- </>
175
- )}
176
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  </div>
178
  );
179
  }
@@ -195,7 +306,7 @@ function Field({ label, type, value, onChange, ...props }: { label: string; type
195
  );
196
  }
197
 
198
- function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
199
  return (
200
  <div>
201
  <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{label}</label>
@@ -204,8 +315,8 @@ function SelectField({ label, value, options, onChange }: { label: string; value
204
  onChange={(e) => onChange(e.target.value)}
205
  className="w-full p-2.5 bg-slate-50 border border-slate-200 rounded-lg text-sm text-slate-900 focus:ring-2 focus:ring-teal-500/20 focus:border-teal-500 outline-none transition-all"
206
  >
207
- {options.map((o) => (
208
- <option key={o} value={o}>{o}</option>
209
  ))}
210
  </select>
211
  </div>
 
2
 
3
  import { useEffect, useState } from "react";
4
  import { apiClient } from "@/lib/api";
5
+ import { useLookups } from "@/lib/lookups";
6
  import {
7
  Settings,
8
  Globe,
 
12
  Bell,
13
  Loader2,
14
  Save,
15
+ User,
16
+ ShieldCheck,
17
  } from "lucide-react";
18
 
19
  type SettingsData = Record<string, any>;
20
 
21
+ interface UserProfile {
22
+ id: string;
23
+ email: string;
24
+ full_name: string | null;
25
+ is_active: boolean;
26
+ is_superuser: boolean;
27
+ email_verified_at: string | null;
28
+ requires_email_verification: boolean;
29
+ }
30
+
31
+ type Section = "profile" | "workspace";
32
+
33
  const TABS = [
34
  { key: "general", label: "General", icon: Globe },
35
  { key: "messaging", label: "Messaging", icon: MessageSquare },
 
38
  { key: "notifications", label: "Notifications", icon: Bell },
39
  ] as const;
40
 
41
+ export default function SettingsPage() {
42
+ const lookups = useLookups();
43
+
44
+ // Profile state
45
+ const [profile, setProfile] = useState<UserProfile | null>(null);
46
+ const [profileName, setProfileName] = useState("");
47
+ const [savingProfile, setSavingProfile] = useState(false);
48
+
49
+ // Workspace state
50
  const [settings, setSettings] = useState<SettingsData | null>(null);
51
  const [version, setVersion] = useState(0);
52
  const [activeTab, setActiveTab] = useState<string>("general");
 
53
  const [saving, setSaving] = useState(false);
54
  const [dirty, setDirty] = useState<SettingsData>({});
55
+
56
+ // Shared
57
+ const [section, setSection] = useState<Section>("profile");
58
+ const [loading, setLoading] = useState(true);
59
  const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
60
 
61
  useEffect(() => {
62
+ Promise.all([
63
+ apiClient.get("/auth/me"),
64
+ apiClient.get("/settings/workspace"),
65
+ ]).then(([meRes, wsRes]) => {
66
+ if (meRes.success && meRes.data) {
67
+ setProfile(meRes.data);
68
+ setProfileName(meRes.data.full_name || "");
69
+ }
70
+ if (wsRes.success && wsRes.data) {
71
+ setSettings(wsRes.data.settings);
72
+ setVersion(wsRes.data.version);
73
  }
74
  setLoading(false);
75
  });
 
80
  setTimeout(() => setToast(null), 3000);
81
  };
82
 
83
+ // Profile save
84
+ const handleSaveProfile = async () => {
85
+ setSavingProfile(true);
86
+ const res = await apiClient.patch("/auth/me", { full_name: profileName.trim() || null });
87
+ setSavingProfile(false);
88
+ if (res.success && res.data) {
89
+ setProfile(res.data);
90
+ setProfileName(res.data.full_name || "");
91
+ showToast("success", "Profile updated successfully");
92
+ } else {
93
+ showToast("error", res.error || "Failed to update profile");
94
+ }
95
+ };
96
+
97
+ // Workspace settings
98
+ const updateField = (sec: string, key: string, value: any) => {
99
  setSettings((prev) => {
100
  if (!prev) return prev;
101
+ return { ...prev, [sec]: { ...prev[sec], [key]: value } };
102
  });
103
  setDirty((prev) => ({
104
  ...prev,
105
+ [sec]: { ...(prev[sec] || {}), [key]: value },
106
  }));
107
  };
108
 
 
129
  );
130
  }
131
 
132
+ const s = settings ? (settings[activeTab] || {}) : {};
133
+ const profileDirty = profileName.trim() !== (profile?.full_name || "");
 
 
 
134
 
135
  return (
136
  <div className="space-y-8 pb-10">
 
138
  <div className="flex items-center justify-between">
139
  <div>
140
  <h1 className="text-2xl font-bold tracking-tight text-slate-900 flex items-center gap-2">
141
+ <Settings className="w-6 h-6 text-teal-700" /> Settings
142
  </h1>
143
+ <p className="text-slate-500 text-sm mt-1">Manage your profile and workspace configuration</p>
144
  </div>
145
+ {section === "workspace" && (
146
+ <button
147
+ onClick={handleSave}
148
+ disabled={saving || Object.keys(dirty).length === 0}
149
+ className="flex items-center gap-2 px-5 py-2.5 bg-teal-700 text-white rounded-lg hover:bg-teal-800 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-bold transition-all"
150
+ >
151
+ {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
152
+ Save Changes
153
+ </button>
154
+ )}
155
+ {section === "profile" && (
156
+ <button
157
+ onClick={handleSaveProfile}
158
+ disabled={savingProfile || !profileDirty}
159
+ className="flex items-center gap-2 px-5 py-2.5 bg-teal-700 text-white rounded-lg hover:bg-teal-800 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-bold transition-all"
160
+ >
161
+ {savingProfile ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
162
+ Save Profile
163
+ </button>
164
+ )}
165
  </div>
166
 
167
  {/* Toast */}
 
175
  </div>
176
  )}
177
 
178
+ {/* Section Toggle */}
179
+ <div className="flex gap-2">
180
+ <button
181
+ onClick={() => setSection("profile")}
182
+ className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-bold transition-all ${
183
+ section === "profile"
184
+ ? "bg-teal-700 text-white shadow-sm"
185
+ : "bg-slate-100 text-slate-600 hover:bg-slate-200"
186
+ }`}
187
+ >
188
+ <User className="w-4 h-4" /> Profile
189
+ </button>
190
+ <button
191
+ onClick={() => setSection("workspace")}
192
+ className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-bold transition-all ${
193
+ section === "workspace"
194
+ ? "bg-teal-700 text-white shadow-sm"
195
+ : "bg-slate-100 text-slate-600 hover:bg-slate-200"
196
+ }`}
197
+ >
198
+ <ShieldCheck className="w-4 h-4" /> Workspace
199
+ {version > 0 && <span className="text-xs opacity-70">v{version}</span>}
200
+ </button>
201
  </div>
202
 
203
+ {/* Profile Section */}
204
+ {section === "profile" && profile && (
205
+ <div className="bg-white p-6 md:p-8 rounded-xl border border-slate-100 shadow-sm space-y-6">
206
+ <div>
207
+ <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">Email</label>
208
+ <input
209
+ type="text"
210
+ value={profile.email}
211
+ disabled
212
+ className="w-full p-2.5 bg-slate-100 border border-slate-200 rounded-lg text-sm text-slate-500 cursor-not-allowed"
213
+ />
214
+ </div>
215
+ <Field label="Full Name" type="text" value={profileName} onChange={setProfileName} placeholder="Enter your full name" />
216
+ <div className="flex items-center gap-3">
217
+ <span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Email Verification</span>
218
+ {profile.email_verified_at ? (
219
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">Verified</span>
220
+ ) : (
221
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-200">Unverified</span>
222
+ )}
223
+ </div>
224
+ </div>
225
+ )}
226
+
227
+ {/* Workspace Section */}
228
+ {section === "workspace" && settings && (
229
+ <>
230
+ {/* Tabs */}
231
+ <div className="flex border-b border-slate-100 bg-slate-50/50 rounded-t-xl">
232
+ {TABS.map((tab) => (
233
+ <button
234
+ key={tab.key}
235
+ onClick={() => setActiveTab(tab.key)}
236
+ className={`flex items-center gap-2 px-6 py-3.5 text-sm font-bold border-b-2 transition-all ${
237
+ activeTab === tab.key
238
+ ? "border-teal-600 text-teal-700 bg-white"
239
+ : "border-transparent text-slate-400 hover:text-slate-600"
240
+ }`}
241
+ >
242
+ <tab.icon className="w-4 h-4" />
243
+ {tab.label}
244
+ </button>
245
+ ))}
246
+ </div>
247
+
248
+ {/* Fields */}
249
+ <div className="bg-white p-6 md:p-8 rounded-xl border border-slate-100 shadow-sm space-y-6">
250
+ {activeTab === "general" && (
251
+ <>
252
+ <Field label="Name Override" type="text" value={s.name_override ?? ""} onChange={(v) => updateField("general", "name_override", v || null)} placeholder="Leave empty to use workspace name" />
253
+ <Field label="Logo URL" type="text" value={s.logo_url ?? ""} onChange={(v) => updateField("general", "logo_url", v || null)} placeholder="https://example.com/logo.png" />
254
+ <CatalogSelectField label="Timezone" value={s.timezone} items={lookups.timezones} onChange={(v) => updateField("general", "timezone", v)} />
255
+ <CatalogSelectField label="Default Language" value={s.default_language} items={lookups.languages} onChange={(v) => updateField("general", "default_language", v)} />
256
+ </>
257
+ )}
258
+ {activeTab === "messaging" && (
259
+ <>
260
+ <Field label="Default Reply Delay (seconds)" type="number" value={s.default_reply_delay_seconds} onChange={(v) => updateField("messaging", "default_reply_delay_seconds", Number(v))} />
261
+ <Toggle label="Fallback Message Enabled" description="Send a fallback message when AI cannot generate a response" value={s.fallback_message_enabled} onChange={(v) => updateField("messaging", "fallback_message_enabled", v)} />
262
+ <Toggle label="Auto-Retry Failed Dispatch" description="Automatically retry failed message deliveries with exponential backoff" value={s.auto_retry_failed_dispatch} onChange={(v) => updateField("messaging", "auto_retry_failed_dispatch", v)} />
263
+ </>
264
+ )}
265
+ {activeTab === "ai" && (
266
+ <>
267
+ <CatalogSelectField label="Default Model" value={s.default_model} items={lookups.aiModels} onChange={(v) => updateField("ai", "default_model", v)} />
268
+ <Field label="Temperature" type="number" value={s.temperature} step="0.1" min="0" max="2" onChange={(v) => updateField("ai", "temperature", Number(v))} />
269
+ <Field label="Max Tokens" type="number" value={s.max_tokens} onChange={(v) => updateField("ai", "max_tokens", Number(v))} />
270
+ <Toggle label="Guardrails Enabled" description="Enforce safety guardrails on AI-generated responses" value={s.guardrails_enabled} onChange={(v) => updateField("ai", "guardrails_enabled", v)} />
271
+ </>
272
+ )}
273
+ {activeTab === "automation" && (
274
+ <>
275
+ <Toggle label="Auto-Publish" description="Automatically publish new automation flows" value={s.auto_publish} onChange={(v) => updateField("automation", "auto_publish", v)} />
276
+ <Field label="Draft Expiry (days)" type="number" value={s.draft_expiry_days} onChange={(v) => updateField("automation", "draft_expiry_days", Number(v))} />
277
+ </>
278
+ )}
279
+ {activeTab === "notifications" && (
280
+ <>
281
+ <Toggle label="Email Notifications Enabled" description="Receive email notifications for important workspace events" value={s.email_notifications_enabled} onChange={(v) => updateField("notifications", "email_notifications_enabled", v)} />
282
+ <Field label="Webhook URL" type="text" value={s.webhook_url ?? ""} onChange={(v) => updateField("notifications", "webhook_url", v || null)} placeholder="https://example.com/webhook" />
283
+ </>
284
+ )}
285
+ </div>
286
+ </>
287
+ )}
288
  </div>
289
  );
290
  }
 
306
  );
307
  }
308
 
309
+ function CatalogSelectField({ label, value, items, onChange }: { label: string; value: string; items: { key: string; label: string }[]; onChange: (v: string) => void }) {
310
  return (
311
  <div>
312
  <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{label}</label>
 
315
  onChange={(e) => onChange(e.target.value)}
316
  className="w-full p-2.5 bg-slate-50 border border-slate-200 rounded-lg text-sm text-slate-900 focus:ring-2 focus:ring-teal-500/20 focus:border-teal-500 outline-none transition-all"
317
  >
318
+ {items.map((item) => (
319
+ <option key={item.key} value={item.key}>{item.label}</option>
320
  ))}
321
  </select>
322
  </div>
frontend/src/components/AppShell.tsx CHANGED
@@ -3,9 +3,11 @@
3
  import { Sidebar } from "./Sidebar";
4
  import { Header } from "./Header";
5
  import { ImpersonationBanner } from "./ImpersonationBanner";
 
6
 
7
  export function AppShell({ children }: { children: React.ReactNode }) {
8
  return (
 
9
  <div className="min-h-screen bg-background">
10
  <ImpersonationBanner />
11
  <Sidebar />
@@ -18,5 +20,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
18
  </main>
19
  </div>
20
  </div>
 
21
  );
22
  }
 
3
  import { Sidebar } from "./Sidebar";
4
  import { Header } from "./Header";
5
  import { ImpersonationBanner } from "./ImpersonationBanner";
6
+ import { LookupsProvider } from "@/lib/lookups";
7
 
8
  export function AppShell({ children }: { children: React.ReactNode }) {
9
  return (
10
+ <LookupsProvider>
11
  <div className="min-h-screen bg-background">
12
  <ImpersonationBanner />
13
  <Sidebar />
 
20
  </main>
21
  </div>
22
  </div>
23
+ </LookupsProvider>
24
  );
25
  }
frontend/src/components/Sidebar.tsx CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import Link from "next/link";
4
  import { usePathname } from "next/navigation";
5
- import { useRouter } from "next/navigation";
6
  import {
7
  LayoutDashboard,
8
  Users,
@@ -19,6 +19,7 @@ import {
19
  Landmark,
20
  } from "lucide-react";
21
  import { cn } from "@/lib/utils";
 
22
 
23
  const navItems = [
24
  { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
@@ -35,9 +36,24 @@ const navItems = [
35
  { name: "Agency", href: "/agency", icon: Landmark },
36
  ];
37
 
 
 
 
 
 
 
 
38
  export function Sidebar() {
39
  const pathname = usePathname();
40
- const router = useRouter();
 
 
 
 
 
 
 
 
41
 
42
  const handleLogout = () => {
43
  const { auth } = require("@/lib/auth");
@@ -74,11 +90,10 @@ export function Sidebar() {
74
  <div className="p-4 border-t border-border">
75
  <div className="flex items-center gap-3 px-3 py-2">
76
  <div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 text-xs font-bold">
77
- JD
78
  </div>
79
  <div className="flex-1 min-w-0">
80
- <p className="text-xs font-semibold truncate">John Doe</p>
81
- <p className="text-[10px] text-slate-500 truncate">Workspace Owner</p>
82
  </div>
83
  <button
84
  onClick={handleLogout}
 
2
 
3
  import Link from "next/link";
4
  import { usePathname } from "next/navigation";
5
+ import { useEffect, useState } from "react";
6
  import {
7
  LayoutDashboard,
8
  Users,
 
19
  Landmark,
20
  } from "lucide-react";
21
  import { cn } from "@/lib/utils";
22
+ import { apiClient } from "@/lib/api";
23
 
24
  const navItems = [
25
  { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
 
36
  { name: "Agency", href: "/agency", icon: Landmark },
37
  ];
38
 
39
+ function getInitials(name: string | null): string {
40
+ if (!name) return "?";
41
+ const parts = name.trim().split(/\s+/);
42
+ if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
43
+ return parts[0].substring(0, 2).toUpperCase();
44
+ }
45
+
46
  export function Sidebar() {
47
  const pathname = usePathname();
48
+ const [userName, setUserName] = useState<string | null>(null);
49
+
50
+ useEffect(() => {
51
+ apiClient.get("/auth/me").then((res) => {
52
+ if (res.success && res.data) {
53
+ setUserName(res.data.full_name || res.data.email);
54
+ }
55
+ });
56
+ }, []);
57
 
58
  const handleLogout = () => {
59
  const { auth } = require("@/lib/auth");
 
90
  <div className="p-4 border-t border-border">
91
  <div className="flex items-center gap-3 px-3 py-2">
92
  <div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 text-xs font-bold">
93
+ {getInitials(userName)}
94
  </div>
95
  <div className="flex-1 min-w-0">
96
+ <p className="text-xs font-semibold truncate">{userName || "Loading..."}</p>
 
97
  </div>
98
  <button
99
  onClick={handleLogout}
frontend/src/lib/catalog.ts CHANGED
@@ -34,7 +34,15 @@ export type CatalogKey =
34
  | "automation-node-types"
35
  | "automation-trigger-types"
36
  | "conversation-statuses"
37
- | "message-delivery-statuses";
 
 
 
 
 
 
 
 
38
 
39
  // ── Cache ────────────────────────────────────────────────────────────
40
 
@@ -62,7 +70,7 @@ function setCache<T>(key: string, data: T): void {
62
 
63
  // ── Fetch ────────────────────────────────────────────────────────────
64
 
65
- async function fetchCatalog<T = CatalogEntry[]>(key: CatalogKey): Promise<T> {
66
  const cached = getCached<T>(key);
67
  if (cached !== null) return cached;
68
 
 
34
  | "automation-node-types"
35
  | "automation-trigger-types"
36
  | "conversation-statuses"
37
+ | "message-delivery-statuses"
38
+ | "timezones"
39
+ | "languages"
40
+ | "ai-models"
41
+ | "dedupe-strategies"
42
+ | "event-sources"
43
+ | "event-outcomes"
44
+ | "audit-actions"
45
+ | "contact-fields";
46
 
47
  // ── Cache ────────────────────────────────────────────────────────────
48
 
 
70
 
71
  // ── Fetch ────────────────────────────────────────────────────────────
72
 
73
+ export async function fetchCatalog<T = CatalogEntry[]>(key: CatalogKey): Promise<T> {
74
  const cached = getCached<T>(key);
75
  if (cached !== null) return cached;
76
 
frontend/src/lib/lookups.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /**
4
+ * LookupsProvider — Mission 21
5
+ * Fetches all catalog lookups on mount and provides them via React context.
6
+ * Product dashboard pages consume via useLookups() hook.
7
+ */
8
+ import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
9
+ import { fetchCatalog, type CatalogEntry } from "./catalog";
10
+
11
+ export interface Lookups {
12
+ timezones: CatalogEntry[];
13
+ languages: CatalogEntry[];
14
+ aiModels: CatalogEntry[];
15
+ dedupeStrategies: CatalogEntry[];
16
+ eventSources: CatalogEntry[];
17
+ eventOutcomes: CatalogEntry[];
18
+ auditActions: CatalogEntry[];
19
+ contactFields: CatalogEntry[];
20
+ triggerTypes: CatalogEntry[];
21
+ nodeTypes: CatalogEntry[];
22
+ loading: boolean;
23
+ }
24
+
25
+ const defaultLookups: Lookups = {
26
+ timezones: [],
27
+ languages: [],
28
+ aiModels: [],
29
+ dedupeStrategies: [],
30
+ eventSources: [],
31
+ eventOutcomes: [],
32
+ auditActions: [],
33
+ contactFields: [],
34
+ triggerTypes: [],
35
+ nodeTypes: [],
36
+ loading: true,
37
+ };
38
+
39
+ const LookupsContext = createContext<Lookups>(defaultLookups);
40
+
41
+ export function LookupsProvider({ children }: { children: ReactNode }) {
42
+ const [lookups, setLookups] = useState<Lookups>(defaultLookups);
43
+
44
+ useEffect(() => {
45
+ Promise.all([
46
+ fetchCatalog("timezones").catch(() => []),
47
+ fetchCatalog("languages").catch(() => []),
48
+ fetchCatalog("ai-models").catch(() => []),
49
+ fetchCatalog("dedupe-strategies").catch(() => []),
50
+ fetchCatalog("event-sources").catch(() => []),
51
+ fetchCatalog("event-outcomes").catch(() => []),
52
+ fetchCatalog("audit-actions").catch(() => []),
53
+ fetchCatalog("contact-fields").catch(() => []),
54
+ fetchCatalog("automation-trigger-types").catch(() => []),
55
+ fetchCatalog("automation-node-types").catch(() => []),
56
+ ]).then(([
57
+ timezones, languages, aiModels, dedupeStrategies,
58
+ eventSources, eventOutcomes, auditActions, contactFields,
59
+ triggerTypes, nodeTypes,
60
+ ]) => {
61
+ setLookups({
62
+ timezones,
63
+ languages,
64
+ aiModels,
65
+ dedupeStrategies,
66
+ eventSources,
67
+ eventOutcomes,
68
+ auditActions,
69
+ contactFields,
70
+ triggerTypes,
71
+ nodeTypes,
72
+ loading: false,
73
+ });
74
+ });
75
+ }, []);
76
+
77
+ return (
78
+ <LookupsContext.Provider value={lookups}>
79
+ {children}
80
+ </LookupsContext.Provider>
81
+ );
82
+ }
83
+
84
+ export function useLookups(): Lookups {
85
+ return useContext(LookupsContext);
86
+ }