diff --git a/.ai/feature-flags.md b/.ai/feature-flags.md deleted file mode 100644 index 0cc7908..0000000 --- a/.ai/feature-flags.md +++ /dev/null @@ -1,38 +0,0 @@ -# Feature Flags Module Plan - -## Overview - -Moduł flag funkcjonalności umożliwia oddzielenie deploymentów od release'ów poprzez wprowadzenie systemu flag, które pozwalają na kontrolowanie dostępności poszczególnych funkcjonalności w zależności od środowiska. System ten może być stosowany: - -- na poziomie endpointów API (np. dla kolekcji, auth) -- na poziomie stron Astro (np. @login.astro, @signup.astro, @reset-password.astro) -- na poziomie widoczności kolekcji i komponentów (np. TwoPane.tsx, MobileNavigation.tsx) - -## Wymagania - -- **Środowiska:** modół obsługuje środowiska `local`, `integration` oraz `prod`. -- **Flagi:** Na początek moduł obsługuje flagi dla `auth` i `collections` jako proste wartości boolowskie (`true`/`false`). -- **Użycie:** W aplikacji można importować moduł i wykonywać `isFeatureEnabled('key')` w celu sprawdzenia, czy dana funkcjonalność jest aktywna. -- **Logowanie:** Każde zapytanie o flagę loguje informacje diagnostyczne, takie jak bieżące środowisko oraz wynik flagi. -- **Build Time:** Flagi są ustalane podczas kompilacji, wykorzystując zmienną środowiskową `import.meta.env.PUBLIC_ENV_NAME`, analogicznie do sposobu użycia w wytycznych @supabase.mdc. - -## Implementacja - -Moduł znajduje się w `src/features/featureFlags.ts` i składa się z następujących głównych elementów: - -1. **Wykrywanie środowiska:** - Moduł korzysta z `import.meta.env.PUBLIC_ENV_NAME`, aby określić bieżące środowisko. Jeśli zmienna nie jest ustawiona, zwraca `null`. - -2. **Konfiguracja flag:** - Obiekt konfiguracji mapuje nazwy funkcji na obiekty określające, czy funkcja jest włączona dla danego środowiska. - -3. **Funkcja sprawdzająca flagę:** - Funkcja `isFeatureEnabled(feature: string): boolean` sprawdza, czy dana flaga jest zdefiniowana, loguje wynik i zwraca ustawioną wartość flagi dla bieżącego środowiska. - -4. **Przykładowy kod:** - -`src/features/featureFlags.ts` - -## Podsumowanie - -Ten projekt modułu flag funkcjonalności zapewnia elastyczny system zarządzania funkcjami oparty na środowisku, który można wykorzystywać zarówno na backendzie, jak i frontendzie. Podejście to umożliwia łatwą rozbudowę systemu o kolejne flagi oraz umożliwia diagnostykę dzięki logowaniu stanu flag w trakcie wywołań. diff --git a/.ai/prompt-library/org-invite-link-plan.md b/.ai/prompt-library/org-invite-link-plan.md new file mode 100644 index 0000000..9074024 --- /dev/null +++ b/.ai/prompt-library/org-invite-link-plan.md @@ -0,0 +1,824 @@ +# Organization Invite Link Implementation Plan + +## Executive Summary + +This document outlines the implementation plan for an organization invite link system that allows users to create accounts and automatically join an organization (starting with 10xDevs). If a user already has an account, the invite link will add them to the organization. + +## 1. Problem Statement & Context + +### Current State +- Organizations exist (`organizations` table with 10xDevs seeded) +- Membership tracking exists (`organization_members` table) +- Users must be manually added to organizations +- No self-service mechanism for organization onboarding +- Current access request page directs users to email administrators + +### Target State +- Administrators can generate secure, time-limited invite links +- New users can sign up via invite link and auto-join organization +- Existing users can join organization via invite link +- Invite links are trackable and can be revoked +- Single invite token can be used by multiple users (team invite scenario) + +### Use Cases +1. **10xDevs cohort onboarding**: Admin generates invite link for course participants +2. **Team expansion**: Existing org admins invite new team members +3. **Self-service access**: Users with invite link don't need manual approval +4. **Existing user activation**: Users who signed up earlier can join org retroactively + +## 2. System Architecture + +### 2.1 Database Schema + +Create new table `organization_invites`: + +```sql +CREATE TABLE organization_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT TIMEZONE('utc', NOW()), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + max_uses INTEGER DEFAULT NULL, -- NULL means unlimited + current_uses INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin')), + metadata JSONB DEFAULT '{}'::jsonb -- For future extensibility +); + +CREATE INDEX organization_invites_token_idx ON organization_invites(token); +CREATE INDEX organization_invites_organization_id_idx ON organization_invites(organization_id); +CREATE INDEX organization_invites_expires_at_idx ON organization_invites(expires_at); + +-- Track invite usage +CREATE TABLE organization_invite_redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invite_id UUID NOT NULL REFERENCES organization_invites(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + redeemed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT TIMEZONE('utc', NOW()), + was_new_user BOOLEAN NOT NULL DEFAULT false, + UNIQUE(invite_id, user_id) +); + +CREATE INDEX organization_invite_redemptions_invite_id_idx ON organization_invite_redemptions(invite_id); +CREATE INDEX organization_invite_redemptions_user_id_idx ON organization_invite_redemptions(user_id); +``` + +### 2.2 TypeScript Types + +Update `src/db/database.types.ts` after migration (via Supabase type generation). + +Create `src/types/invites.ts`: + +```typescript +export interface OrganizationInvite { + id: string; + organizationId: string; + token: string; + createdBy: string; + createdAt: string; + expiresAt: string; + maxUses: number | null; + currentUses: number; + isActive: boolean; + role: 'member' | 'admin'; +} + +export interface InviteValidationResult { + valid: boolean; + invite?: OrganizationInvite; + organization?: { + id: string; + slug: string; + name: string; + }; + error?: string; +} + +export interface InviteRedemptionResult { + success: boolean; + alreadyMember?: boolean; + organizationId?: string; + organizationName?: string; + error?: string; +} +``` + +## 3. Implementation Workstreams + +### 3.1 Database Migration + +**File**: `supabase/migrations/YYYYMMDDHHMMSS_organization_invites.sql` + +**Tasks**: +1. Create `organization_invites` table +2. Create `organization_invite_redemptions` table +3. Add indexes for performance +4. Add comments for documentation +5. Create helper function for token generation (optional, can be done in app code) + +**Security Considerations**: +- Use `gen_random_uuid()` + hashing for tokens +- Store only hashed tokens in database (or use cryptographically random strings) +- Enforce cascading deletes to maintain referential integrity + +### 3.2 Service Layer + +**File**: `src/services/prompt-manager/invites.ts` + +**Functions**: + +```typescript +// Generate cryptographically secure invite token +export function generateInviteToken(): string + +// Create new invite +export async function createOrganizationInvite( + supabase: SupabaseClient, + params: { + organizationId: string; + createdBy: string; + expiresInDays: number; + maxUses?: number; + role: 'member' | 'admin'; + } +): Promise + +// Validate invite token +export async function validateInviteToken( + supabase: SupabaseClient, + token: string +): Promise + +// Redeem invite (add user to organization) +export async function redeemInvite( + supabase: SupabaseClient, + params: { + token: string; + userId: string; + wasNewUser: boolean; + } +): Promise + +// List organization invites (for admin UI) +export async function listOrganizationInvites( + supabase: SupabaseClient, + organizationId: string +): Promise + +// Revoke invite +export async function revokeInvite( + supabase: SupabaseClient, + inviteId: string +): Promise + +// Get invite usage stats +export async function getInviteStats( + supabase: SupabaseClient, + inviteId: string +): Promise<{ + totalRedemptions: number; + newUsers: number; + existingUsers: number; +}> +``` + +**Implementation Details**: +- Token generation: Use `crypto.randomBytes(32).toString('base64url')` for URL-safe tokens +- Validation checks: expiry, active status, max uses, organization existence +- Atomic redemption: Use Supabase transactions to prevent race conditions +- Idempotency: Check if user is already a member before inserting + +### 3.3 API Endpoints + +#### 3.3.1 Admin Endpoints (Create/Manage Invites) + +**File**: `src/pages/api/prompts/admin/invites.ts` + +**POST** `/api/prompts/admin/invites` - Create invite +- Auth: Required (admin role) +- Body: `{ organizationId, expiresInDays, maxUses?, role }` +- Returns: `{ inviteUrl, token, expiresAt }` + +**GET** `/api/prompts/admin/invites?organizationId={id}` - List invites +- Auth: Required (admin role) +- Returns: Array of invites with stats + +**File**: `src/pages/api/prompts/admin/invites/[id].ts` + +**DELETE** `/api/prompts/admin/invites/{id}` - Revoke invite +- Auth: Required (admin role) +- Returns: `{ success: true }` + +**GET** `/api/prompts/admin/invites/{id}/stats` - Get invite stats +- Auth: Required (admin role) +- Returns: Usage statistics + +#### 3.3.2 Public Endpoints (Validate/Redeem) + +**File**: `src/pages/api/invites/validate.ts` + +**POST** `/api/invites/validate` - Validate invite token +- Auth: Not required +- Body: `{ token }` +- Returns: `{ valid, organization?, error? }` + +**File**: `src/pages/api/invites/redeem.ts` + +**POST** `/api/invites/redeem` - Redeem invite (authenticated user) +- Auth: Required (any authenticated user) +- Body: `{ token }` +- Returns: `{ success, alreadyMember?, organizationName?, error? }` + +#### 3.3.3 Modified Signup Endpoint + +**File**: `src/pages/api/auth/signup.ts` + +**Modifications**: +1. Accept optional `inviteToken` parameter +2. Validate invite token if provided +3. Create user account +4. Automatically add user to organization from invite +5. Record redemption in `organization_invite_redemptions` +6. Return success with organization context + +**Enhanced Flow**: +```typescript +1. Validate inviteToken (if provided) +2. Create Supabase auth user +3. Store user consent +4. IF inviteToken: + a. Add user to organization (redeemInvite) + b. Return user + organization info +5. ELSE: + Return user only (existing behavior) +``` + +### 3.4 UI Components + +#### 3.4.1 Admin Invite Management UI + +**File**: `src/components/prompt-manager/admin/InviteManager.tsx` + +**Features**: +- Button to create new invite +- List of active invites with stats +- Copy invite link to clipboard +- Revoke invite button +- Invite creation modal with options: + - Expiration (7 days, 30 days, 90 days, custom) + - Max uses (unlimited, 10, 50, 100, custom) + - Role (member, admin) + +**File**: `src/components/prompt-manager/admin/InviteCreateModal.tsx` + +Form for creating invites with validation. + +**File**: `src/components/prompt-manager/admin/InviteList.tsx` + +Table showing: +- Invite link (with copy button) +- Created by +- Created date +- Expires date +- Uses (current/max) +- Status (active/expired/revoked) +- Actions (copy link, view stats, revoke) + +#### 3.4.2 Invite Landing Page + +**File**: `src/pages/invites/[token].astro` + +**Flow**: +1. Extract token from URL parameter +2. Validate token (server-side) +3. Check if user is authenticated +4. **If authenticated**: + - Show organization info + - "Join {Organization}" button → calls redeem endpoint + - If already member: redirect to prompts +5. **If not authenticated**: + - Show organization info + - Modified signup form with pre-filled invite context + - "Sign up and join {Organization}" button + +**File**: `src/components/invites/InviteLanding.tsx` + +React component handling the invite acceptance flow. + +#### 3.4.3 Modified Signup Component + +**File**: `src/components/auth/SignupForm.tsx` + +**Modifications**: +1. Accept optional `inviteToken` prop +2. Store invite token in component state +3. Pass invite token to signup API +4. Show organization context if invite token valid +5. Redirect to organization prompts after successful signup + +### 3.5 Routing & Navigation + +**New Routes**: +- `/invites/{token}` - Invite landing page +- `/prompts/admin/invites` - Admin invite management (new tab/section) + +**Modified Routes**: +- `/auth/signup` - Accept `?invite={token}` query param +- `/prompts/request-access` - Show message about requesting invite from admin + +### 3.6 Middleware Updates + +**File**: `src/middleware/index.ts` + +**Changes**: +- Allow `/invites/*` to be accessible without authentication +- Validate invite token in middleware for `/invites/{token}` route +- Attach invite context to `Astro.locals` if valid + +## 4. Security Considerations + +### 4.1 Token Security +- **Entropy**: Use 256-bit random tokens (32 bytes → base64url encoded) +- **Storage**: Store tokens securely (consider hashing if highly sensitive) +- **Transport**: Always use HTTPS +- **Expiration**: Default 7-day expiration, max 90 days +- **Single-use option**: Support max_uses=1 for sensitive invites + +### 4.2 Rate Limiting +- Limit invite creation: 10 invites per admin per hour +- Limit redemption attempts: 5 attempts per IP per hour +- Validation endpoint: 20 requests per IP per minute + +### 4.3 Authorization +- Only org admins can create invites for their organization +- Only org admins can view/revoke invites for their organization +- Users cannot redeem invite if already member (idempotent check) + +### 4.4 Audit Trail +- Log all invite creations in `organization_invites.metadata` +- Track all redemptions in `organization_invite_redemptions` +- Include IP address and user agent in redemption logs (optional) + +## 5. Testing Strategy + +### 5.1 Unit Tests + +**File**: `tests/unit/services/invites.test.ts` + +Test cases: +- `generateInviteToken()` produces unique tokens +- `validateInviteToken()` handles expired tokens +- `validateInviteToken()` handles max uses exceeded +- `redeemInvite()` adds user to organization +- `redeemInvite()` handles already-member case (idempotent) +- `redeemInvite()` increments usage counter atomically + +### 5.2 Integration Tests + +**File**: `tests/integration/org-invite-flow.test.ts` + +Test cases: +- Admin creates invite → receives valid URL +- Unauthenticated user validates invite → sees org info +- New user signs up with invite → auto-joins org +- Existing user redeems invite → joins org +- Expired invite → shows error +- Max uses exceeded → shows error +- Revoked invite → shows error + +### 5.3 E2E Tests + +**File**: `e2e/org-invites.spec.ts` + +Scenarios: +1. **Admin creates invite**: + - Login as admin + - Navigate to invite management + - Create invite with 30-day expiration + - Copy invite link + - Verify invite appears in list + +2. **New user accepts invite**: + - Logout + - Visit invite link + - See organization name + - Fill signup form + - Submit → auto-join org + - Verify redirect to prompts page + - Verify access to org prompts + +3. **Existing user accepts invite**: + - Create user without org membership + - Login + - Visit invite link + - Click "Join Organization" + - Verify membership added + - Verify access to org prompts + +4. **Expired invite handling**: + - Create invite with past expiration (via DB) + - Visit invite link + - See "expired" error message + +## 6. Migration & Deployment Plan + +### 6.1 Phase 1: Database & Backend (Day 1-2) +1. Create migration file +2. Test migration locally +3. Implement service layer (`invites.ts`) +4. Implement API endpoints +5. Unit test service layer +6. Manual API testing with Postman/curl + +### 6.2 Phase 2: Admin UI (Day 3-4) +1. Create admin invite management components +2. Integrate with admin panel +3. Style with existing design system +4. Add copy-to-clipboard functionality +5. Test invite creation flow + +### 6.3 Phase 3: Public Invite Flow (Day 4-5) +1. Create invite landing page +2. Modify signup flow to handle invites +3. Create invite acceptance components +4. Handle authenticated vs. unauthenticated states +5. Add error handling and user feedback + +### 6.4 Phase 4: Testing & Polish (Day 5-6) +1. Write integration tests +2. Write E2E tests +3. Security review +4. UX review +5. Documentation updates + +### 6.5 Phase 5: Deployment (Day 7) +1. Deploy to integration environment +2. Manual QA testing +3. Run E2E test suite +4. Deploy to production +5. Monitor error logs and metrics + +## 7. Feature Flags & Rollout + +### 7.1 Feature Flag +- Add `ORG_INVITES_ENABLED` feature flag +- Default to `false` in all environments initially +- Enable in `local` and `integration` for testing +- Enable in `prod` after successful QA + +### 7.2 Rollout Strategy +1. **Week 1**: Internal testing (development team) +2. **Week 2**: 10xDevs admin testing (create invites, monitor) +3. **Week 3**: Soft launch (share invite with 10-20 users) +4. **Week 4**: Full rollout (public announcement) + +### 7.3 Success Metrics +- At least 50 users join via invite links in first month +- <1% invite validation errors (excluding expired/revoked) +- Zero security incidents related to invites +- <2s average invite redemption API response time + +## 8. Documentation Updates + +### 8.1 User Documentation +- Create `docs/org-invites.md` guide for administrators +- Update `README.md` with invite feature description +- Add invite management to admin panel help text + +### 8.2 Developer Documentation +- Update `.ai/prompt-manager/prd.md` with new user stories +- Document invite API endpoints in API reference +- Add invite architecture to `.ai/diagrams/` + +### 8.3 Operational Documentation +- Runbook for invite system monitoring +- Troubleshooting guide for common invite issues +- Database query examples for support team + +## 9. Edge Cases & Error Handling + +### 9.1 Edge Cases +1. **User already member**: Show "already a member" message, redirect to prompts +2. **Invite used concurrently**: Handle race condition with DB constraints +3. **Organization deleted**: Invalidate all invites (CASCADE) +4. **Invite creator deleted**: Keep invite valid (track creator for audit only) +5. **User deletes account then redeems**: Treat as new user +6. **Multiple invites for same org**: Allow, track separately +7. **Expired invite visited**: Show clear expiration message with contact info + +### 9.2 Error Messages +- `INVITE_NOT_FOUND`: "This invite link is invalid. Please check the link and try again." +- `INVITE_EXPIRED`: "This invite link has expired. Please request a new invite from your administrator." +- `INVITE_REVOKED`: "This invite link has been revoked. Please contact your administrator." +- `INVITE_MAX_USES`: "This invite link has reached its maximum number of uses. Please request a new invite." +- `ORG_NOT_FOUND`: "The organization for this invite no longer exists." +- `ALREADY_MEMBER`: "You're already a member of this organization." + +## 10. Future Enhancements (Out of Scope) + +### Phase 2 Enhancements +- **Email invites**: Send invite via email instead of link sharing +- **Bulk invites**: Upload CSV of emails to invite +- **Custom invite messages**: Personalize invite with message +- **Invite templates**: Pre-configured invite settings for common scenarios +- **Invite analytics dashboard**: Detailed usage metrics and charts + +### Phase 3 Enhancements +- **Role-based invites**: Different default permissions per invite +- **Time-limited memberships**: Auto-expire membership after N days +- **Conditional invites**: Require email domain match +- **SSO integration**: Bypass signup for SSO-enabled orgs +- **Invite approval flow**: Admin must approve redemptions + +## 11. Open Questions & Decisions + +### Q1: Should invite tokens be single-use or multi-use by default? +**Decision**: Multi-use by default (maxUses=null), with option for single-use. This supports common use case of sharing one link with course cohort. + +### Q2: Should we send confirmation emails after joining via invite? +**Decision**: Phase 1 - No emails, just success message. Phase 2 - Add welcome email with org info. + +### Q3: How long should default invite expiration be? +**Decision**: 7 days default, with options for 30/90 days. Max allowed: 1 year. + +### Q4: Should we allow invites to grant admin role? +**Decision**: Yes, but show clear warning in UI. Useful for onboarding co-admins. + +### Q5: Should existing members be able to use invite links? +**Decision**: No-op with friendly message "You're already a member". Track as redemption but don't modify membership. + +### Q6: How to handle users who sign up without email confirmation? +**Decision**: Follow existing Supabase auth flow. If email confirmation required, user must confirm before joining org. + +## 12. Success Criteria + +### Must Have (MVP) +✓ Admin can create time-limited invite links +✓ New users can sign up via invite and auto-join org +✓ Existing users can join org via invite link +✓ Invites can be revoked by admin +✓ Admin can view list of active invites +✓ Invite links are secure (cryptographically random) +✓ Expired invites show clear error message +✓ System prevents duplicate memberships + +### Nice to Have (Post-MVP) +- Copy invite link to clipboard with one click +- Invite usage statistics (redemptions over time) +- Max uses configuration per invite +- Email notifications for invite creators + +### Out of Scope (Future) +- Email-based invites (send directly to recipient) +- Bulk invite generation +- Custom onboarding flows per invite +- A/B testing different invite flows + +## 13. Risk Assessment & Mitigation + +### High Risk: Token Prediction +**Risk**: Weak token generation allows brute force attacks +**Mitigation**: Use crypto.randomBytes(32) for 256-bit entropy, monitor failed validation attempts + +### Medium Risk: Expired Invite Usage +**Risk**: Users share expired invites, poor UX +**Mitigation**: Clear expiration warnings, ability to extend/recreate invites + +### Medium Risk: Race Conditions +**Risk**: Concurrent redemptions exceed max uses +**Mitigation**: Use atomic DB operations, unique constraints, transactions + +### Low Risk: Invite Abuse +**Risk**: Users create too many invites, spam +**Mitigation**: Rate limiting, audit logs, admin monitoring dashboard + +## 14. Acceptance Criteria + +This feature is considered complete when: + +1. ✅ Database migration runs successfully in all environments +2. ✅ Admin can create invite from UI with custom expiration +3. ✅ Invite URL is copyable with single click +4. ✅ New user can visit invite link and sign up +5. ✅ After signup via invite, user has org membership +6. ✅ Existing user can visit invite link and join org +7. ✅ Expired invites show appropriate error +8. ✅ Revoked invites cannot be used +9. ✅ Admin can view list of invites with stats +10. ✅ All unit tests pass (95%+ coverage for invite code) +11. ✅ All integration tests pass +12. ✅ All E2E tests pass +13. ✅ Security review approved +14. ✅ Documentation complete +15. ✅ Feature deployed to production + +## 15. Timeline Summary + +**Total Estimated Time**: 7-10 working days + +- **Day 1**: Database schema, migration, type generation +- **Day 2**: Service layer implementation, API endpoints +- **Day 3**: Admin UI components, invite management +- **Day 4**: Invite landing page, signup flow modifications +- **Day 5**: Testing (unit + integration) +- **Day 6**: E2E tests, security review, polish +- **Day 7**: Documentation, deployment +- **Days 8-10**: Buffer for issues, additional testing + +## 16. Implementation Checklist + +### Database +- [x] Create `organization_invites` table migration +- [x] Create `organization_invite_redemptions` table migration +- [x] Add indexes for performance +- [ ] Test migration rollback +- [ ] Regenerate TypeScript types (BLOCKED: migration sync issues with Supabase) + +### Backend +- [x] Implement `invites.ts` service layer +- [x] Implement admin API endpoints +- [x] Implement public validation/redemption endpoints +- [x] Modify signup endpoint to handle invites +- [x] Add rate limiting to invite endpoints +- [ ] Write unit tests for service layer + +### Frontend - Admin +- [x] Create `InvitesAdminPanel` component +- [x] Create `InviteCreateDialog` component +- [x] Create `InvitesList` component +- [x] Add invite management page at `/prompts/admin/invites` +- [x] Implement copy-to-clipboard functionality +- [ ] Add invite stats display (basic stats in list, detail view pending) + +### Frontend - Public +- [x] Create invite landing page `/invites/[token].astro` +- [x] Create `InviteLanding` component +- [x] Modify `SignupForm` to accept invite token +- [x] Add invite context display to signup page +- [x] Handle authenticated vs. unauthenticated states +- [x] Implement error handling and user feedback + +### Testing +- [ ] Write unit tests for `invites.ts` +- [ ] Write integration tests for invite flow +- [ ] Write E2E tests for admin creation +- [ ] Write E2E tests for new user signup +- [ ] Write E2E tests for existing user join +- [ ] Test expired/revoked invite handling +- [ ] Security penetration testing + +### Documentation +- [ ] Update PRD with invite user stories +- [ ] Create admin guide for invite management +- [ ] Document API endpoints +- [ ] Update README with invite feature +- [ ] Create operational runbook +- [ ] Document troubleshooting steps + +### Deployment +- [ ] Deploy migration to integration +- [ ] Enable feature flag in integration +- [ ] Run QA testing in integration +- [ ] Deploy to production +- [ ] Enable feature flag in production +- [ ] Monitor logs and metrics +- [ ] Announce feature to users + +--- + +## Implementation Progress (2025-10-03) + +### ✅ Completed + +**Database Layer:** +- Created migration file: `supabase/migrations/20251003000000_organization_invites.sql` +- Tables: `organization_invites`, `organization_invite_redemptions` +- Indexes for token lookup, organization filtering, expiration checks +- Comments and documentation in SQL + +**Type Definitions:** +- `src/types/invites.ts` - Complete TypeScript interfaces for invites +- Error codes and messages defined +- Service parameter types + +**Service Layer:** +- `src/services/prompt-manager/invites.ts` - Full implementation with: + - `generateInviteToken()` - Cryptographically secure token generation + - `createOrganizationInvite()` - Create new invites + - `validateInviteToken()` - Validate invite with full checks + - `redeemInvite()` - Atomic redemption with idempotency + - `listOrganizationInvites()` - Fetch invites for admin panel + - `revokeInvite()` - Mark invites as inactive + - `getInviteStats()` - Usage statistics + +**API Endpoints:** +- `src/pages/api/prompts/admin/invites.ts` - POST (create), GET (list) +- `src/pages/api/prompts/admin/invites/[id].ts` - DELETE (revoke) +- `src/pages/api/prompts/admin/invites/[id]/stats.ts` - GET (stats) +- `src/pages/api/invites/validate.ts` - POST (public validation) +- `src/pages/api/invites/redeem.ts` - POST (authenticated redemption) +- Modified `src/pages/api/auth/signup.ts` - Added inviteToken parameter + +**Admin UI Components:** +- `src/components/prompt-manager/admin/InvitesAdminPanel.tsx` - Main panel +- `src/components/prompt-manager/admin/InvitesList.tsx` - Table with status badges +- `src/components/prompt-manager/admin/InviteCreateDialog.tsx` - Creation modal +- `src/pages/prompts/admin/invites.astro` - Admin page route + +**Public UI Components:** +- `src/components/invites/InviteLanding.tsx` - Invite acceptance page +- `src/pages/invites/[token].astro` - Public invite route +- Modified `src/components/auth/SignupForm.tsx` - Accept invite tokens +- Modified `src/pages/auth/signup.astro` - Extract invite from query params +- Modified `src/services/auth.ts` - Pass invite to signup API +- Modified `src/hooks/useAuth.ts` - Support invite parameter + +**Middleware Updates:** +- Added `/invites/*` to public paths +- Added `/api/invites/validate` to public API paths +- Added rate limiting for invite endpoints (10 seconds) + +### ⚠️ Blocked + +**Database Migration Sync:** +- Migration file created locally but cannot sync to remote due to Supabase CLI migration history mismatch +- Error: Remote migrations `20251001181600` and `20251001182224` not in local directory +- Attempted repair commands succeeded partially but connection timeouts occurred +- **Action Required:** Manual migration sync/repair needed before testing + +### 📋 Remaining Tasks + +**Immediate (Post-Migration):** +1. Resolve migration sync issues +2. Push migration to Supabase +3. Regenerate TypeScript types with `npx supabase gen types typescript` +4. Manual testing of full invite flow +5. Add login page handling for invite redirects (currently only signup handles it) + +**Short-term:** +1. Unit tests for invite service layer +2. Integration tests for API endpoints +3. Add invite stats detail view +4. Add navigation link to invites page in admin UI +5. Feature flag for invite system + +**Long-term:** +1. Documentation and runbooks +2. Production deployment +3. Monitoring and analytics +4. E2E tests for invite flows + +### 🗂️ Files Created/Modified + +**Created:** +- `supabase/migrations/20251003000000_organization_invites.sql` +- `src/types/invites.ts` +- `src/services/prompt-manager/invites.ts` +- `src/pages/api/prompts/admin/invites.ts` +- `src/pages/api/prompts/admin/invites/[id].ts` +- `src/pages/api/prompts/admin/invites/[id]/stats.ts` +- `src/pages/api/invites/validate.ts` +- `src/pages/api/invites/redeem.ts` +- `src/components/prompt-manager/admin/InvitesAdminPanel.tsx` +- `src/components/prompt-manager/admin/InvitesList.tsx` +- `src/components/prompt-manager/admin/InviteCreateDialog.tsx` +- `src/pages/prompts/admin/invites.astro` +- `src/components/invites/InviteLanding.tsx` +- `src/pages/invites/[token].astro` + +**Modified:** +- `src/middleware/index.ts` - Public paths and rate limiting +- `src/pages/api/auth/signup.ts` - Invite token support +- `src/components/auth/SignupForm.tsx` - Invite parameter +- `src/pages/auth/signup.astro` - Extract invite from URL +- `src/services/auth.ts` - Pass invite to API +- `src/hooks/useAuth.ts` - Support invite parameter + +### 🔍 Known Issues + +1. **Migration sync error** - Requires manual intervention +2. **Login page** - Does not yet handle invite token query parameter (needs redirect after login) +3. **No atomic counter** - Using basic increment, should add DB function for true atomicity +4. **No feature flag** - System is always enabled once migrated +5. **No stats detail page** - Only basic stats shown in list view + +--- + +## Notes & Assumptions + +1. **Supabase Auth**: Assumes current Supabase auth setup remains unchanged +2. **Email Confirmation**: Follows existing email confirmation flow (if enabled) +3. **10xDevs Focus**: Initial implementation focuses on 10xDevs org, but supports multi-org +4. **No RLS**: Following existing pattern of middleware-based auth (no RLS in phase 1) +5. **Existing Design System**: Reuses existing UI components and styling +6. **No Email Sending**: Phase 1 only generates shareable links, no email integration + +## References + +- Existing auth flow: `src/pages/api/auth/signup.ts` +- Password reset token pattern: `src/hooks/useTokenHashVerification.ts` +- Organization service: `src/services/prompt-manager/organizations.ts` +- Phase 2 implementation: `.ai/prompt-manager/phase-2-impl-plan.md` +- Database schema: `supabase/migrations/20250413093000_prompt_manager_orgs.sql` diff --git a/.ai/prompt-library/phase-1-impl-plan.md b/.ai/prompt-library/phase-1-impl-plan.md new file mode 100644 index 0000000..50859c9 --- /dev/null +++ b/.ai/prompt-library/phase-1-impl-plan.md @@ -0,0 +1,68 @@ +# Phase 1 – Feature Flag & Access Foundations Implementation Plan + +## Goal & Context +- Enable controlled rollout of the Prompt Manager behind the `PROMPT_MANAGER_ENABLED` flag while enforcing organization-aware access, satisfying @.ai/prompt-manager/prd.md stories US-001 (flag control) and US-002 (membership validation). +- Aligns with POC guardrails: flag-first rollout, reuse existing Astro + Supabase patterns, and default organization focus on `10xDevs` cohort. + +## Scope (Included / Deferred) +- **Included:** feature flag wiring, Supabase metadata helpers, middleware gating, fallback UX state, unit tests, flag documentation updates. +- **Deferred:** Supabase schema migrations (Phase 2), UI changes beyond access state messaging, Playwright coverage (lands in later phases), telemetry hooks. + +## Workstream Breakdown + +### 1. Feature Flag Wiring +- Audit existing flag system in `src/features/featureFlags.ts` (or equivalent) to confirm pattern for new boolean flags. +- Add `PROMPT_MANAGER_ENABLED` constant with default `false`; expose through existing configuration surfaces without altering env schema. +- Update any server/client flag consumers (e.g., feature-flag hook, layout conditional) to recognize the new flag key. +- Document toggle expectations for local/integration/prod in `.ai/status` or ops checklist so environments can flip safely. + +### 2. Access Helper Utilities +- Define TypeScript helpers: + - `isPromptManagerEnabled(envFlags)` – returns boolean gating entry points. + - `getUserOrganizations(session)` – parses Supabase users data into `{ id, role }` tuples, defaulting to `10xDevs` when membership present. + - `hasPromptManagerAccess(session)` – convenience predicate requiring at least one organization membership; reuse across middleware/tests. +- Ensure helpers gracefully handle missing metadata, malformed roles, or multiple organizations (for future phases) with clear return values and typed errors. +- Add lightweight logging or debug hooks (behind existing logger) for unexpected membership shapes to aid QA without noisy logs. + +### 3. Middleware & Route Guarding +- Update Astro middleware (likely `src/middleware/index.ts`) to: + - Short-circuit `/prompts` and `/prompts/admin` routes when flag disabled (render "request access" state or redirect per PRD acceptance criteria). + - Require authenticated Supabase session; route unauthenticated users to existing login flow. + - Validate organization membership + role: allow any member for `/prompts`, restrict `/prompts/admin` to `admin` role (prepping for Phase 5). +- Introduce shared response helpers for access-denied vs. flag-disabled states to keep messaging consistent and reusable by future UI slices. +- Confirm middleware integration does not pre-fetch prompt data when guard fails to avoid leaking information. + +### 4. Testing & QA Hooks +- Write Vitest suites covering: + - Flag utility truth-table (env overrides, disabled default). + - `getUserOrganizations()` metadata parsing (single org, multiple, malformed, none). + - Middleware behaviour for key paths: unauthenticated redirect, no membership fallback message, admin gating. +- Add fixtures or mocks for Supabase session objects representing member/admin/unauthorized cases; reuse across tests to seed later phases. +- Update `.ai/prompt-manager/test-plan.md` with Phase 1 unit scenarios and manual verification checklist for toggling flag per environment (defer e2e until Phase 4/5). + +### 5. Operational & Documentation Tasks +- Capture runbook notes for enabling the flag (env var name, default) in .ai/prompt-manager/phase-1-notes.md; highlight that Phase 1 ships with flag off by default. + +## Delivery Sequencing +1. Feature flag wiring (Workstream 1) – unblockable prerequisite for all guarded logic. +2. Implement helpers (Workstream 2) – build on flag scaffolding; merge with tests where possible. +3. Integrate middleware (Workstream 3) – leverage helpers; include initial fallback UI state. +4. Finalize tests + docs (Workstream 4 & 5) – ensures regression coverage and operational clarity before flag flip. + +## Dependencies & Coordination +DR1: Requires existing Supabase auth session retrieval pipeline; confirm helper access to session data in middleware context. +D1: Auth session retrieval pipeline is already in place. +DR2: Need clarity on where organization metadata lives (Supabase user app_metadata vs. separate table) to shape helper parsing; coordinate with data team if schema differs from assumption. +D2: Organization metadata lives in separate tables as described in @schema-proposal.md +DR3: Ensure security review acknowledges temporary lack of RLS (per POC decision) but confirms middleware sufficiency for Phase 1. +D3: Security review acknowledges temporary lack of RLS (per POC decision) but confirms middleware sufficiency for Phase 1. + +## Exit Criteria (Definition of Done) +- `PROMPT_MANAGER_ENABLED` flag toggle hides/shows `/prompts` and `/prompts/admin` without code redeploy. +- Middleware prevents access for unauthenticated or non-member users, returning 404 per @.ai/prompt-manager/prd.md. +- Unit tests green for flag utilities, organization parsing, and middleware guard paths. +- Documentation updated with flag usage + manual QA checklist; stakeholders briefed on rollout procedure. + +## Follow-On Actions (Post-Phase 1) +- Implement Supabase `organizations` + `organization_members` migrations and extend helpers to query live tables (Phase 2). +- Hook middleware into new data sources and expand Playwright coverage once UI slices land (Phases 3–5). diff --git a/.ai/prompt-library/phase-1-notes.md b/.ai/prompt-library/phase-1-notes.md new file mode 100644 index 0000000..81a4964 --- /dev/null +++ b/.ai/prompt-library/phase-1-notes.md @@ -0,0 +1,30 @@ +# Prompt Manager Phase 1 – Rollout Notes + +## Flag Overview +- **Flag key:** `PROMPT_MANAGER_ENABLED` (internal constant). +- **Environment toggle:** `PUBLIC_PROMPT_MANAGER_ENABLED` (`true`|`false`|`1`|`0` case-insensitive). +- **Local/testing override:** `PROMPT_MANAGER_ENABLED` (same truthy/falsey parsing) can be used when a non-public variable is required (e.g., Vitest, local CLI scripts). +- **Default:** `false` across `local`, `integration`, and `prod` environments; requires explicit opt-in. +- **Fallback:** If override is missing or invalid the feature remains hidden. + +## How to Toggle +1. Local development + - Start via `PUBLIC_PROMPT_MANAGER_ENABLED=true npm run dev` to surface `/prompts` routes. + - Remove the override (or set to `false`) to return to the disabled state without code changes. +2. Integration / staging + - Add `PUBLIC_PROMPT_MANAGER_ENABLED=true` in the environment configuration (Cloudflare/CI secrets) and redeploy workflow picks up the toggle at runtime. + - Document the change in the release checklist; default should remain `false` until membership data is seeded. +3. Production + - Keep `false` until security sign-off post Phase 2 migrations. + - When enabling, double check seeded `organization_members` rows for launch cohort only. + +## Access Expectations +- Middleware enforces flag check **and** organization membership before `/prompts` or `/prompts/admin` render. +- Non-members or users hitting the routes while the flag is off receive `404` responses (no data prefetch). +- `/prompts/admin` additionally requires `role === 'admin'`; members are redirected to `/prompts`. +- Middleware stores parsed membership details on `locals.promptManager` for downstream use. + +## Operational Notes +- Logging: invalid metadata structures surface via `console.debug` only in `local` environment to aid QA without noisy production logs. +- Tests: `npm run test` now verifies flag overrides, metadata parsing, and middleware gating; run before toggling in shared environments. +- Known limitation: organization data still sourced from Supabase metadata placeholders until Phase 2 migrations land. diff --git a/.ai/prompt-library/phase-1-test-plan.md b/.ai/prompt-library/phase-1-test-plan.md new file mode 100644 index 0000000..d88fd59 --- /dev/null +++ b/.ai/prompt-library/phase-1-test-plan.md @@ -0,0 +1,27 @@ +# Prompt Manager – Phase 2 Test Plan + +## Scope +- Validate Supabase-backed organization membership flow delivered in Phase 2, replacing metadata mocks. +- Confirm middleware, helpers, and query-param organization selection operate against live tables. +- Document verification of migrations, seeding steps, and manual QA for roster changes while UI remains unchanged. + +## Automated Coverage +- `npm run test` + - `tests/unit/services/promptManagerAccess.test.ts` + - Exercises Supabase membership fetch helpers, slug selection logic, and feature-flag enforcement utilities. + - `tests/unit/middleware/promptManagerMiddleware.test.ts` + - Mocks Phase 2 context loading to verify flag gating, 404 fallbacks without memberships, admin redirects including active organization slug, and locals hydration. + - Existing feature flag and supporting suites continue to guard flag toggling and global middleware regressions. + +## Manual Verification Checklist +1. Apply new migration via Supabase CLI (`supabase db push`) or deployment pipeline; confirm `organizations` and `organization_members` exist with expected columns and indices. +2. Seed `10xDevs` organization (auto via migration) and backfill organization members using the documented SQL snippet (replace placeholder emails). +3. Run the app with `PUBLIC_PROMPT_MANAGER_ENABLED=true` and sign in as a seeded member: `/prompts` loads, `locals.promptManager.activeOrganization` matches the roster entry. +4. Append `?organization=` to `/prompts`; ensure switching to a valid slug updates content, and invalid/omitted slugs fall back to the first membership without errors. +5. Attempt `/prompts/admin` as member → expect redirect to `/prompts?organization=`; retry as admin → route allowed. +6. Remove membership row and verify the same user now receives 404 on `/prompts`, confirming live roster enforcement. + +## Known Gaps / Deferred +- No Playwright or Supabase emulator coverage yet; earmarked for future phases once UI surfaces emerge. +- RLS policies remain disabled pending dedicated security review (tracked for Phase 4). +- Prompt catalog schema work begins in Phase 3; current tests cover membership foundation only. diff --git a/.ai/prompt-library/phase-2-impl-plan.md b/.ai/prompt-library/phase-2-impl-plan.md new file mode 100644 index 0000000..b352d7c --- /dev/null +++ b/.ai/prompt-library/phase-2-impl-plan.md @@ -0,0 +1,91 @@ +# Phase 2 – Organization Membership Foundation Implementation Plan + +## Goal & Context +- Persist organization roster data in Supabase so memberships are no longer mocked from metadata, fulfilling PRD stories US-002 (enforced membership) and pre-requisites for US-003/US-005. +- Deliver the foundation described in `poc-impl-plan.md` Phase 2 and `poc-arch-plan.md` Track A by introducing the real `organizations` + `organization_members` tables, seeds, and server helpers that hydrate active organization context. +- Keep the feature behind `PROMPT_MANAGER_ENABLED`; Phase 2 focuses on data layer + middleware adjustments without changing member/admin UI yet. + +## Scope (Included / Deferred) +- **Included:** Supabase migrations + seeds, TypeScript database typings update, helper/service refactors to query live tables, middleware integration, organization switch plumbing (server context + query param), unit/integration tests, operational docs for seeding cohort. +- **Deferred:** Prompt collections/prompts schema (Phase 3), UI for membership management, automation of roster sync, Playwright coverage ( Phase 4+ ), RLS policies, audit logging. + +## Deliverables +1. Supabase migration pair creating `organizations` & `organization_members` with indices, constraints, and seeds for `10xDevs` + curated launch cohort placeholders. +2. Updated `src/db/database.types.ts` via `supabase gen types typescript` (or scripted equivalent) to reflect new tables. +3. Server-side membership services that load organizations for the logged-in user, respect query param overrides, and expose admin/member role helpers. +4. Middleware + locals updates reading from the live membership tables (dropping metadata fallback except for single-phase backward compatibility if needed). +5. Revised unit tests covering Supabase-backed helpers and middleware plus new integration-level tests using mocked RPC responses. +6. Documentation + runbook updates (.ai/prompt-manager/test-plan.md, phase notes, README snippet) describing migrations, seeding steps, and manual QA scenarios. + +## Workstreams + +### 1. Database Migrations & Seeds +- Create SQL migration `supabase/migrations/__prompt_manager_orgs.sql` implementing the schema from `schema-proposal.md`: + - `organizations` table (uuid PK, slug unique, timestamps). + - `organization_members` table (composite PK `(organization_id,user_id)`, `role` check in `('member','admin')`). + - Indexes on `organization_members(user_id)` for membership lookups. +- Seed data inside the migration (idempotent using `on conflict do nothing`): + - `10xDevs` organization with slug `10xdevs`. + - Placeholder membership rows referencing launch cohort UUIDs (pull from secure config / TODO comment if IDs not yet final). +- Provide companion script or SQL snippet to backfill additional members post-migration (documented for ops). +- Ensure migrations run locally (`npm run db:migrate` or documented Supabase CLI command) and verify rollback strategy (DROP TABLE cascades) in dev. + +### 2. Types & Service Layer Updates +- Regenerate `src/db/database.types.ts` after migration apply; commit alongside migration ensuring type safety. +- Introduce `src/services/prompt-manager/organizations.ts` (or extend existing access module) to expose: + - `fetchUserOrganizations(supabase, userId)` → queries `organization_members` joined with `organizations`. + - `fetchOrganizationBySlug(supabase, slug)` → used for slug→id resolution when query param set. + - Typed return models aligning with PRD roles. +- Ensure services use server Supabase client (from middleware locals) and gracefully handle empty results. + +### 3. Middleware & Helper Refactor +- Update `getUserOrganizations` helper to prefer DB fetch: + - Call membership service when `PROMPT_MANAGER_ENABLED` true and Supabase client available. + - Maintain metadata fallback behind feature flag (`PROMPT_MANAGER_FALLBACK_METADATA=true`) for one release if required; default to DB-only once seeds land. +- Extend middleware to support organization selection via `?organization=` query param: + - Validate slug belongs to user; store active organization on `locals.promptManager.activeOrganization`. + - Default to first membership (still 10xDevs) when slug missing/invalid. +- Update redirect logic to include active org in redirect targets (e.g., `/prompts?organization=...`). +- Guarantee no additional Supabase calls execute when user lacks memberships (short-circuit). + +### 4. Testing & QA +- Unit tests (Vitest): + - Mock Supabase query responses for membership service (happy path, no membership, multiple orgs, admin vs member). + - Validate helper selection logic (query param, fallback order, metadata fallback disabled by default). + - Middleware tests ensuring new locals (`activeOrganization`) propagate and 404 responses respect DB-backed membership. +- Integration smoke (optional if time): + - Add Supabase test harness or contract tests hitting local Supabase emulator (flagged optional but document plan). +- Update `.ai/prompt-manager/test-plan.md` with Phase 2 scenarios: migration verification, manual org switch, membership removal edge cases. + +### 5. Operational Documentation & Tooling +- Document migration apply steps for all environments (local, integration, prod) in `.ai/prompt-manager/phase-2-notes.md`: + - Required secrets (service role) for seeding script. + - Safety checklist before enabling flag in staging (verify membership counts, ensure non-cohort users absent). +- Update README or dedicated Supabase doc with commands to regenerate types and run migrations. +- Prepare manual QA checklist: login as member/admin, change organization via query param, remove membership and verify access revocation. + +## Timeline & Sequencing +1. **Day 1–2:** Draft & apply migrations locally, regenerate types, commit seeds (Workstream 1 & part of 2). +2. **Day 3:** Implement service layer + helper refactor, ensure middleware uses new flow (Workstreams 2–3). +3. **Day 4:** Expand unit tests, run full suite, adjust fixtures (Workstream 4). +4. **Day 5:** Author docs/runbooks, dry-run migration rollout in staging sandbox (Workstream 5), handoff for review. + +## Dependencies & Open Questions +DR1: Need launch cohort Supabase user UUIDs (coordinate with data/ops before finalizing seeds). +D1: Launch cohort Supabase user UUIDs will be provided later. +DR2: Confirm Supabase CLI availability in CI for migration/type gen automation. +D2: Supabase CLI is available in CI for migration/type gen automation. +DR3: Decide whether metadata fallback must remain during transition (default plan removes fallback unless explicitly required). +D3: Get rid of metadata fallback. +DR4: Ensure security review sign-off acknowledging continued absence of RLS in Phase 2 (middleware enforced). +D4: Security review sign-off acknowledging continued absence of RLS in Phase 2 (middleware enforced). + +## Exit Criteria (Definition of Done) +- `organizations` + `organization_members` migrations applied in local + integration envs with seed data verified. +- Helper functions and middleware rely on Supabase tables to determine access; metadata fallback removed. +- Authenticated users with seeded membership can access `/prompts` and switch organizations via slug parameter; non-members receive 404. +- Unit test suite green; docs updated with migration/runbook + revised QA checklist; stakeholders briefed on cohort seeding steps. + +## Risks & Mitigations +- **Risk:** Additional DB fetch in middleware could impact latency. *Mitigation:* cache membership result on `locals` (single fetch per request), add index on `organization_members(user_id)`. +- **Risk:** Query param misuse exposing other org IDs. *Mitigation:* enforce join on user-specific memberships; 404 slug not belonging to user. diff --git a/.ai/prompt-library/phase-2-notes.md b/.ai/prompt-library/phase-2-notes.md new file mode 100644 index 0000000..989f0f8 --- /dev/null +++ b/.ai/prompt-library/phase-2-notes.md @@ -0,0 +1,58 @@ +# Prompt Manager Phase 2 – Rollout Notes + +## Migration Checklist +- Merge and deploy the `20250413093000_prompt_manager_orgs.sql` migration to all environments via Supabase CLI (`supabase db push`) or the existing CI migration step. +- Confirm the following tables exist with expected schema: + - `organizations` (uuid PK, unique slug, timestamps). + - `organization_members` (composite PK `(organization_id, user_id)`, role check constraint, index on `user_id`). +- Capture a snapshot of the schema after deployment for the security review package (no RLS yet by design). + +## Seeding Organization Memberships +- The migration seeds the `10xDevs` organization automatically. +- Replace the placeholder emails in the `launch_members` CTE inside the migration with the vetted launch roster before running in shared environments, or execute an updated insert: + ```sql + WITH launch_members(email, role) AS ( + VALUES + ('member@example.com', 'member'), + ('admin@example.com', 'admin') + ), matched_users AS ( + SELECT users.id, launch_members.role + FROM auth.users AS users + JOIN launch_members ON users.email = launch_members.email + ), target_org AS ( + SELECT id FROM organizations WHERE slug = '10xdevs' + ) + INSERT INTO organization_members (organization_id, user_id, role) + SELECT target_org.id, matched_users.id, matched_users.role + FROM target_org + JOIN matched_users ON TRUE + ON CONFLICT DO NOTHING; + ``` +- After seeding, verify membership counts: + ```sql + SELECT o.slug, m.role, COUNT(*) + FROM organization_members m + JOIN organizations o ON o.id = m.organization_id + GROUP BY 1, 2 + ORDER BY 1, 2; + ``` + +## Type Regeneration +- After applying migrations locally run the Supabase CLI type generation (from repo root): + ```bash + supabase gen types typescript --project-ref "$SUPABASE_PROJECT_REF" \ + --schema public > src/db/database.types.ts + ``` +- Re-run `npm run test` to ensure the regenerated types compile and pass linting. + +## Application Verification +- Start the app with `PUBLIC_PROMPT_MANAGER_ENABLED=true npm run dev`. +- Sign in as seeded member and confirm `/prompts` loads; inspect DevTools network response to ensure no metadata parsing errors. +- Append `?organization=` to force active organization selection; invalid slugs should fall back to the first membership without errors. +- Sign in as admin and open `/prompts/admin`; downgrade role to `member` and verify middleware redirect to `/prompts?organization=`. +- Remove membership row and refresh the page; expect 404 response confirming Supabase-backed enforcement. + +## Operational Notes +- Flag remains disabled by default in `integration` and `prod`; enable only after verifying seeded roster and manual QA. +- Keep a checklist item in the release plan to remove placeholder emails from the migration before promotion. +- Security review remains pending until RLS policies ship in later phases; communicate that access continues to rely on middleware checks. diff --git a/.ai/prompt-library/phase-2-test-plan.md b/.ai/prompt-library/phase-2-test-plan.md new file mode 100644 index 0000000..d88fd59 --- /dev/null +++ b/.ai/prompt-library/phase-2-test-plan.md @@ -0,0 +1,27 @@ +# Prompt Manager – Phase 2 Test Plan + +## Scope +- Validate Supabase-backed organization membership flow delivered in Phase 2, replacing metadata mocks. +- Confirm middleware, helpers, and query-param organization selection operate against live tables. +- Document verification of migrations, seeding steps, and manual QA for roster changes while UI remains unchanged. + +## Automated Coverage +- `npm run test` + - `tests/unit/services/promptManagerAccess.test.ts` + - Exercises Supabase membership fetch helpers, slug selection logic, and feature-flag enforcement utilities. + - `tests/unit/middleware/promptManagerMiddleware.test.ts` + - Mocks Phase 2 context loading to verify flag gating, 404 fallbacks without memberships, admin redirects including active organization slug, and locals hydration. + - Existing feature flag and supporting suites continue to guard flag toggling and global middleware regressions. + +## Manual Verification Checklist +1. Apply new migration via Supabase CLI (`supabase db push`) or deployment pipeline; confirm `organizations` and `organization_members` exist with expected columns and indices. +2. Seed `10xDevs` organization (auto via migration) and backfill organization members using the documented SQL snippet (replace placeholder emails). +3. Run the app with `PUBLIC_PROMPT_MANAGER_ENABLED=true` and sign in as a seeded member: `/prompts` loads, `locals.promptManager.activeOrganization` matches the roster entry. +4. Append `?organization=` to `/prompts`; ensure switching to a valid slug updates content, and invalid/omitted slugs fall back to the first membership without errors. +5. Attempt `/prompts/admin` as member → expect redirect to `/prompts?organization=`; retry as admin → route allowed. +6. Remove membership row and verify the same user now receives 404 on `/prompts`, confirming live roster enforcement. + +## Known Gaps / Deferred +- No Playwright or Supabase emulator coverage yet; earmarked for future phases once UI surfaces emerge. +- RLS policies remain disabled pending dedicated security review (tracked for Phase 4). +- Prompt catalog schema work begins in Phase 3; current tests cover membership foundation only. diff --git a/.ai/prompt-library/phase-3-impl-plan.md b/.ai/prompt-library/phase-3-impl-plan.md new file mode 100644 index 0000000..9285fbe --- /dev/null +++ b/.ai/prompt-library/phase-3-impl-plan.md @@ -0,0 +1,585 @@ +# Phase 3 Implementation Plan: Prompt Collection Schema & Admin APIs + +## Overview +This phase implements the database schema for organization-scoped prompt collections and builds the admin-only API layer that enables curators to create, edit, and publish prompts. All functionality remains behind the `PROMPT_MANAGER_ENABLED` feature flag with middleware enforcing admin role requirements. + +## 1. Database Migrations + +### Migration 1: Organizations & Members +**File:** `supabase/migrations/{timestamp}_prompt_manager_orgs.sql` + +```sql +-- Create organizations table +create table organizations ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + name text not null, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +-- Create organization members table with role-based access +create table organization_members ( + organization_id uuid not null references organizations(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'member' check (role in ('member','admin')), + created_at timestamptz default now(), + updated_at timestamptz default now(), + primary key (organization_id, user_id) +); + +-- Seed launch organization +insert into organizations (slug, name) +values ('10xdevs', '10xDevs') +on conflict (slug) do nothing; +``` + +**Post-migration tasks:** +- Manually seed launch cohort members via separate SQL script +- Document member insertion process in migration notes + +### Migration 2: Prompt Catalog +**File:** `supabase/migrations/{timestamp}_prompt_manager_catalog.sql` + +```sql +-- Create prompt collections table +create table prompt_collections ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null references organizations(id) on delete cascade, + slug text not null, + title text not null, + description text, + sort_order integer not null default 0, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique (organization_id, slug) +); +create index idx_prompt_collections_org_sort on prompt_collections(organization_id, sort_order); + +-- Create collection segments table +create table prompt_collection_segments ( + id uuid primary key default gen_random_uuid(), + collection_id uuid not null references prompt_collections(id) on delete cascade, + slug text not null, + title text not null, + sort_order integer not null default 0, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique (collection_id, slug) +); +create index idx_prompt_segments_collection_sort on prompt_collection_segments(collection_id, sort_order); + +-- Create prompts table (single active version per prompt) +create table prompts ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null references organizations(id) on delete cascade, + collection_id uuid not null references prompt_collections(id) on delete cascade, + segment_id uuid references prompt_collection_segments(id) on delete set null, + title text not null, + markdown_body text not null, + status text not null default 'draft' check (status in ('draft','published')), + created_by uuid references auth.users(id), + created_at timestamptz default now(), + updated_at timestamptz default now() +); +create index idx_prompts_org_scope on prompts(organization_id, status, collection_id, segment_id); + +-- Seed demo collections for 10xDevs +do $$ +declare + v_org_id uuid; + v_coll1_id uuid; + v_coll2_id uuid; + v_seg1_id uuid; + v_seg2_id uuid; +begin + -- Get 10xDevs organization ID + select id into v_org_id from organizations where slug = '10xdevs'; + + if v_org_id is null then + raise exception '10xDevs organization not found'; + end if; + + -- Insert collections + insert into prompt_collections (organization_id, slug, title, description, sort_order) + values + (v_org_id, 'fundamentals', 'Fundamentals', 'Core prompts for foundational concepts', 1), + (v_org_id, 'advanced', 'Advanced Topics', 'Advanced prompts for experienced developers', 2) + on conflict (organization_id, slug) do nothing + returning id into v_coll1_id; + + -- Get collection IDs if already existed + if v_coll1_id is null then + select id into v_coll1_id from prompt_collections where organization_id = v_org_id and slug = 'fundamentals'; + end if; + select id into v_coll2_id from prompt_collections where organization_id = v_org_id and slug = 'advanced'; + + -- Insert segments + insert into prompt_collection_segments (collection_id, slug, title, sort_order) + values + (v_coll1_id, 'getting-started', 'Getting Started', 1), + (v_coll1_id, 'best-practices', 'Best Practices', 2) + on conflict (collection_id, slug) do nothing + returning id into v_seg1_id; + + if v_seg1_id is null then + select id into v_seg1_id from prompt_collection_segments where collection_id = v_coll1_id and slug = 'getting-started'; + end if; + select id into v_seg2_id from prompt_collection_segments where collection_id = v_coll1_id and slug = 'best-practices'; + + -- Insert sample prompts + insert into prompts (organization_id, collection_id, segment_id, title, markdown_body, status) + values + ( + v_org_id, + v_coll1_id, + v_seg1_id, + 'Project Setup Guide', + '# Project Setup\n\nThis prompt helps you set up a new project with best practices.\n\n## Steps\n1. Initialize repository\n2. Configure tooling\n3. Set up CI/CD', + 'published' + ), + ( + v_org_id, + v_coll1_id, + v_seg2_id, + 'Code Review Checklist', + '# Code Review Checklist\n\nUse this checklist when reviewing pull requests.\n\n- [ ] Tests pass\n- [ ] Code follows style guide\n- [ ] Documentation updated', + 'draft' + ) + on conflict do nothing; +end $$; +``` + +## 2. Server-Side Services + +### Directory Structure +``` +src/services/prompt-manager/ +├── promptService.ts # Core CRUD operations +├── collectionService.ts # Collection/segment queries +└── types.ts # Shared TypeScript types +``` + +### `src/services/prompt-manager/types.ts` +```typescript +export interface Prompt { + id: string; + organization_id: string; + collection_id: string; + segment_id: string | null; + title: string; + markdown_body: string; + status: 'draft' | 'published'; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface PromptCollection { + id: string; + organization_id: string; + slug: string; + title: string; + description: string | null; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface PromptSegment { + id: string; + collection_id: string; + slug: string; + title: string; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface CreatePromptInput { + title: string; + collection_id: string; + segment_id?: string; + markdown_body: string; +} + +export interface UpdatePromptInput { + title?: string; + markdown_body?: string; + segment_id?: string; +} + +export interface PromptFilters { + status?: 'draft' | 'published'; + collection_id?: string; + segment_id?: string; +} +``` + +### `src/services/prompt-manager/promptService.ts` +**Core functionality:** +- `createPrompt(organizationId, data)` - Create new draft prompt +- `updatePrompt(promptId, organizationId, data)` - Update existing prompt +- `publishPrompt(promptId, organizationId)` - Change status to published +- `unpublishPrompt(promptId, organizationId)` - Revert to draft +- `deletePrompt(promptId, organizationId)` - Remove prompt (admin only) +- `getPrompt(promptId, organizationId)` - Fetch single prompt +- `listPrompts(organizationId, filters)` - List prompts with filtering + +**Implementation details:** +- Use Supabase service role client for all operations +- Enforce organization scoping on all queries +- Update `updated_at` timestamp on modifications +- Return typed results using types from `types.ts` + +### `src/services/prompt-manager/collectionService.ts` +**Core functionality:** +- `getCollections(organizationId)` - List collections for organization +- `getSegments(collectionId)` - List segments for collection +- `createCollection(organizationId, data)` - Create new collection (admin) +- `createSegment(collectionId, data)` - Create new segment (admin) + +**Implementation details:** +- Order by `sort_order` ASC +- Return full objects with metadata + +## 3. Supabase Client Configuration + +### `src/db/supabase-admin.ts` +```typescript +import { createClient } from '@supabase/supabase-js'; +import type { Database } from './database.types'; + +const supabaseUrl = import.meta.env.SUPABASE_URL; +const supabaseServiceKey = import.meta.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + throw new Error('Missing Supabase admin credentials'); +} + +export const supabaseAdmin = createClient( + supabaseUrl, + supabaseServiceKey, + { + auth: { + autoRefreshToken: false, + persistSession: false + } + } +); +``` + +### Update `.env.local` +Add service role key: +``` +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +``` + +## 4. API Endpoints + +### Directory Structure +``` +src/pages/api/prompts/admin/ +├── prompts.ts # POST /api/prompts/admin/prompts +├── prompts/ +│ ├── [id].ts # PUT /api/prompts/admin/prompts/[id] +│ ├── [id]/ +│ │ └── publish.ts # PATCH /api/prompts/admin/prompts/[id]/publish +├── prompt-collections.ts # GET /api/prompts/admin/prompt-collections +└── prompt-collections/ + └── [id]/ + └── segments.ts # GET /api/prompts/admin/prompt-collections/[id]/segments +``` + +### Endpoint Implementations + +#### `POST /api/prompts/admin/prompts` +- Verify admin access via middleware +- Validate request body (title, collection_id, markdown_body required) +- Extract organizationId from request context +- Call `promptService.createPrompt()` +- Return 201 with created prompt + +#### `PUT /api/prompts/admin/prompts/[id]` +- Verify admin access and ownership +- Validate request body +- Call `promptService.updatePrompt()` +- Return 200 with updated prompt + +#### `PATCH /api/prompts/admin/prompts/[id]/publish` +- Verify admin access +- Check current status +- Toggle between draft/published +- Return 200 with updated prompt + +#### `DELETE /api/prompts/admin/prompts/[id]` +- Verify admin access and ownership +- Call `promptService.deletePrompt()` +- Return 204 No Content + +#### `GET /api/prompts/admin/prompts` +- Verify admin access +- Parse query params (status, collection_id, segment_id) +- Call `promptService.listPrompts()` with filters +- Return 200 with prompt array + +#### `GET /api/prompts/admin/prompt-collections` +- Verify admin access +- Call `promptCollectionService.getCollections()` +- Return 200 with collections array + +#### `GET /api/prompts/admin/prompt-collections/[id]/segments` +- Verify admin access +- Call `promptCollectionService.getSegments()` +- Return 200 with segments array + +### Common Response Patterns +```typescript +// Success responses +{ data: T, error: null } + +// Error responses +{ data: null, error: { message: string, code: string } } + +// HTTP status codes +201 - Created +200 - Success +204 - No Content +400 - Bad Request +401 - Unauthorized +403 - Forbidden +404 - Not Found +500 - Internal Server Error +``` + +## 5. Middleware & Access Control + +### `src/middleware/promptAccess.ts` +**Requirements:** +1. Check `PROMPT_MANAGER_ENABLED` feature flag +2. Verify Supabase session exists +3. Query `organization_members` for user's organizations and roles +4. For admin routes, verify role = 'admin' +5. Attach `organizationId` and `role` to request context +6. Handle error cases with appropriate status codes + +**Implementation approach:** +- Use Astro middleware pattern +- Access Supabase anon client with user session +- Query organization_members table +- Store context in `locals` for endpoint access +- Short-circuit on failures with error responses + +### Apply Middleware in `astro.config.mjs` +```javascript +import { defineMiddleware } from 'astro:middleware'; + +export const onRequest = defineMiddleware(async (context, next) => { + // Apply prompt access middleware for /prompts routes + if (context.url.pathname.startsWith('/prompts') || + context.url.pathname.startsWith('/api/prompts')) { + return await promptAccessMiddleware(context, next); + } + return next(); +}); +``` + +## 6. Testing Strategy + +### Unit Tests + +#### `tests/services/prompt-manager/promptService.test.ts` +**Test cases:** +- ✅ createPrompt creates draft with correct organization scoping +- ✅ updatePrompt modifies existing prompt and updates timestamp +- ✅ publishPrompt changes status to published +- ✅ unpublishPrompt reverts to draft +- ✅ deletePrompt removes prompt +- ✅ getPrompt retrieves single prompt by ID +- ✅ listPrompts filters by status +- ✅ listPrompts filters by collection_id +- ✅ listPrompts respects organization scoping +- ✅ Operations fail for non-existent organization +- ✅ Operations fail for prompts in other organizations + +#### `tests/services/prompt-manager/collectionService.test.ts` +**Test cases:** +- ✅ getCollections returns collections ordered by sort_order +- ✅ getCollections filters by organization +- ✅ getSegments returns segments for collection +- ✅ getSegments orders by sort_order + +#### `tests/middleware/promptAccess.test.ts` +**Test cases:** +- ✅ Blocks access when feature flag disabled +- ✅ Blocks access without session +- ✅ Blocks access without organization membership +- ✅ Allows member access to /prompts +- ✅ Blocks member access to /prompts/admin +- ✅ Allows admin access to /prompts/admin +- ✅ Attaches correct context to request locals + +### API Endpoint Tests + +#### `tests/api/prompts/admin/prompts.test.ts` +**Test cases:** +- ✅ POST creates prompt as admin +- ✅ POST rejects non-admin users +- ✅ POST validates required fields +- ✅ PUT updates prompt in same organization +- ✅ PUT rejects cross-organization updates +- ✅ PATCH publish toggles status +- ✅ DELETE removes prompt +- ✅ GET lists prompts with filters + +### Integration Test + +#### `tests/integration/prompt-admin-flow.test.ts` +**End-to-end scenario:** +1. Admin logs in +2. Creates draft prompt +3. Updates prompt content +4. Publishes prompt +5. Verifies prompt visible to members +6. Unpublishes prompt +7. Verifies prompt hidden from members + +## 7. Type Generation + +### Update `src/db/database.types.ts` +After running migrations, regenerate Supabase types: +```bash +npx supabase gen types typescript --project-id your-project-id > src/db/database.types.ts +``` + +Ensure new tables are included: +- `organizations` +- `organization_members` +- `prompt_collections` +- `prompt_collection_segments` +- `prompts` + +## 8. Documentation Updates + +### Update `schema-proposal.md` +- Document any deviations from original schema +- Add notes about migration execution +- Record seed data structure + +### Create `docs/prompt-manager/admin-api.md` +Document all admin API endpoints with: +- Request/response schemas +- Authentication requirements +- Example curl commands +- Error response formats + +## Exit Criteria + +### Database +- ✅ Migration 001 runs successfully and creates organizations + members tables +- ✅ Migration 002 runs successfully and creates collections, segments, prompts tables +- ✅ 10xDevs organization seeded +- ✅ At least 2 demo collections created +- ✅ At least 2 demo segments created +- ✅ At least 2 sample prompts created (1 draft, 1 published) +- ✅ All indexes created successfully + +### Services +- ✅ promptService implements all CRUD functions +- ✅ collectionService implements query functions +- ✅ All service functions enforce organization scoping +- ✅ Service unit tests pass with >80% coverage + +### API Endpoints +- ✅ All 7 admin endpoints functional +- ✅ Endpoints validate input correctly +- ✅ Endpoints enforce admin role requirement +- ✅ Endpoints enforce organization scoping +- ✅ Error handling returns proper HTTP status codes +- ✅ API tests pass with >80% coverage + +### Middleware +- ✅ promptAccess middleware blocks unauthorized access +- ✅ Middleware differentiates member vs admin routes +- ✅ Context properly attached to request locals +- ✅ Middleware tests pass with 100% coverage + +### Integration +- ✅ End-to-end flow test passes (create → update → publish → verify) +- ✅ Cross-organization access properly blocked + +### Manual Verification +- ✅ Admin can create draft via API call (Postman/curl) +- ✅ Admin can edit draft via API call +- ✅ Admin can publish prompt via API call +- ✅ Admin can unpublish prompt via API call +- ✅ Admin can delete prompt via API call +- ✅ Non-admin receives 403 on admin endpoints +- ✅ User without organization membership blocked by middleware + +### Documentation +- ✅ schema-proposal.md updated with any schema changes +- ✅ admin-api.md created with full endpoint documentation +- ✅ Migration notes include member seeding instructions + +## Implementation Order + +1. **Database setup** (Day 1) + - Create migration files + - Run migrations locally + - Verify seed data + +2. **Type generation & service layer** (Day 2) + - Regenerate database types + - Implement promptService + - Implement collectionService + - Write service unit tests + +3. **Middleware & access control** (Day 3) + - Implement promptAccess middleware + - Configure middleware in Astro + - Write middleware tests + +4. **API endpoints** (Days 4-5) + - Implement all 7 endpoints + - Add validation and error handling + - Write API endpoint tests + +5. **Integration & manual testing** (Day 6) + - Write integration test + - Manual API testing via Postman/curl + - Fix any issues + +6. **Documentation** (Day 7) + - Update schema documentation + - Create admin API documentation + - Document migration process + +## Risk Mitigation + +**Risk:** Migration fails due to missing auth.users table +- **Mitigation:** Verify Supabase project has auth enabled before running migrations + +**Risk:** Service role key exposure +- **Mitigation:** Add to .env.local, never commit, document in setup guide + +**Risk:** Cross-organization data leaks +- **Mitigation:** Comprehensive testing of organization scoping in all operations + +**Risk:** Middleware performance issues +- **Mitigation:** Cache organization memberships in session, benchmark middleware execution time + +**Risk:** Type mismatches between service and database +- **Mitigation:** Regenerate types immediately after migrations, use strict TypeScript mode + +## Next Phase Dependencies + +Phase 4 (Member Experience) depends on: +- Published prompts data available via GET /api/prompts (non-admin endpoint) +- Collection and segment metadata accessible +- Organization membership validation working + +Phase 5 (Admin Experience) depends on: +- All admin API endpoints functional +- Proper error handling and validation +- Draft/publish workflow verified diff --git a/.ai/prompt-library/phase-4-impl-plan.md b/.ai/prompt-library/phase-4-impl-plan.md new file mode 100644 index 0000000..c7c66cc --- /dev/null +++ b/.ai/prompt-library/phase-4-impl-plan.md @@ -0,0 +1,304 @@ +# Phase 4: Member Experience Slice & Member APIs - Implementation Plan + +## Overview +Build member-facing prompt browser with organization switching, collection/segment filtering, search, markdown rendering, copy/download actions. Reuse existing patterns from Rules Builder while extracting reusable components. + +## Part A: Component Extraction & Reusability + +### A1. Extract Generic Components (from existing codebase) +Create shared UI primitives by extracting from existing components: + +**Files to create:** +- `src/components/ui/SearchBar.tsx` - Extract from `SearchInput.tsx`, make generic + - Props: `value`, `onChange`, `placeholder`, `matchCount?`, `totalCount?` + - Reusable for both Rules Builder and Prompts search + +- `src/components/ui/Dropdown.tsx` - Extract dropdown logic from `EnvironmentDropdown.tsx` + - Generic dropdown with portal support, keyboard navigation + - Props: `options`, `value`, `onChange`, `label`, `renderOption?` + +- `src/components/ui/CopyDownloadActions.tsx` - Extract from `RulesPreviewCopyDownloadActions.tsx` + - Generic copy/download buttons with tooltip + - Props: `content`, `filename`, `onCopy?`, `onDownload?`, `showCopied` + +- `src/components/ui/MarkdownRenderer.tsx` - Extract from `MarkdownContentRenderer.tsx` + - Generic markdown display component + - Props: `content`, `className?`, `actions?` + +### A2. Create Prompt-Specific Store +**File:** `src/store/promptsStore.ts` + +Pattern: Follow `ruleCollectionsStore.ts` structure + +**State:** +```typescript +{ + // Organization context + organizations: OrganizationMembership[], + activeOrganization: OrganizationMembership | null, + + // Collections & Segments + collections: PromptCollection[], + segments: PromptSegment[], + + // Prompts (published only for members) + prompts: Prompt[], + + // Filters + selectedCollectionId: string | null, + selectedSegmentId: string | null, + searchQuery: string, + + // UI State + selectedPromptId: string | null, + isLoading: boolean, + error: string | null, +} +``` + +**Actions:** +- `fetchOrganizations()` - Load user's orgs +- `setActiveOrganization(org)` - Switch org +- `fetchCollections(orgId)` - Load collections for org +- `fetchSegments(collectionId)` - Load segments for collection +- `fetchPrompts(filters)` - Load published prompts with filters +- `selectPrompt(promptId)` - Open detail view +- `setFilters(collection, segment, search)` - Update filters + +## Part B: Member API Endpoints + +### B1. Create Member API Routes +**Files to create:** + +1. `src/pages/api/prompts/index.ts` (member version) + - `GET /api/prompts` - List published prompts + - Query params: `?organization_id=X&collection_id=Y&segment_id=Z&search=query` + - Returns only `status='published'` prompts + - Uses `locals.user` for auth check (member or admin role) + +2. `src/pages/api/prompts/[id].ts` (member version) + - `GET /api/prompts/:id` - Get single published prompt + - Returns 404 if draft or wrong organization + +3. `src/pages/api/prompts/collections.ts` + - `GET /api/prompts/collections?organization_id=X` - List collections for org + - Member-accessible (no admin check) + +4. `src/pages/api/prompts/collections/[id]/segments.ts` + - `GET /api/prompts/collections/:id/segments` - List segments for collection + - Member-accessible + + +### B2. Extend Service Layer +**Files to modify:** + +1. `src/services/prompt-manager/promptService.ts` + - Add `listPublishedPrompts(orgId, filters)` - member-safe query + - Add `getPublishedPrompt(orgId, promptId)` - single prompt fetch + +2. `src/services/prompt-manager/promptCollectionService.ts` + - Add `listCollections(orgId)` - public collections + - Add `listSegments(collectionId)` - public segments + + +### B3. Middleware & Access Guards +**Pattern:** Reuse from `/api/rule-collections.ts` + +All member APIs check: +1. `isFeatureEnabled('promptManager')` - Feature flag +2. `locals.user` - Authentication required +3. `locals.promptManager.activeOrganization` - Organization membership + + +## Part C: Member UI Components + +### C1. Organization Selector Component +**File:** `src/components/prompt-manager/OrganizationSelector.tsx` + +**Pattern:** Similar to `EnvironmentDropdown.tsx` + +**Features:** +- Dropdown with user's organizations +- Shows current org name +- Persists selection in store (`setActiveOrganization`) +- Triggers data refresh on change + +### C2. Collection & Segment Filters +**File:** `src/components/prompt-manager/PromptFilters.tsx` + +**Pattern:** Similar to `LayerSelector.tsx` + `StackSelector.tsx` + +**Features:** +- Collection dropdown (fetched from API) +- Segment dropdown (filtered by collection) +- "All" option for both +- Updates `promptsStore` filters +- Shows count of prompts per collection/segment + +### C3. Prompts List View +**File:** `src/components/prompt-manager/PromptsList.tsx` + +**Pattern:** Similar to `RuleCollectionsList.tsx` + +**Features:** +- Grid/list of prompt cards +- Each card shows: title, collection/segment tags +- Click to open detail modal/view +- Empty state when no prompts match filters +- Loading skeleton + +**File:** `src/components/prompt-manager/PromptCard.tsx` +- Individual card component +- Hover effects +- Accessibility (keyboard navigation) + +### C4. Prompt Detail View +**File:** `src/components/prompt-manager/PromptDetail.tsx` + +**Pattern:** Modal similar to admin edit view (to be created in Phase 5) + +**Features:** +- Full markdown content (use `MarkdownRenderer` from Part A1) +- Title, collection/segment breadcrumb +- Copy & Download actions (use `CopyDownloadActions` from Part A1) +- Close button +- Modal overlay (use existing modal pattern) + +### C5. Main Member Page Container +**File:** `src/components/prompt-manager/PromptsBrowser.tsx` + +**Structure:** +``` +┌─────────────────────────────────────┐ +│ Organization Selector │ +├─────────────────────────────────────┤ +│ Filters: Collection | Segment │ +│ Search: [ ] [X] │ +├─────────────────────────────────────┤ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Card │ │Card │ │Card │ │Card │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ ┌─────┐ ┌─────┐ │ +│ │Card │ │Card │ ... │ +│ └─────┘ └─────┘ │ +└─────────────────────────────────────┘ +``` + +**Uses:** +- `OrganizationSelector` +- `PromptFilters` +- `SearchBar` (from Part A1) +- `PromptsList` +- `PromptDetail` (modal) + +## Part D: Routing & Pages + +### D1. Prompts Main Page (Member) +**File:** `src/pages/prompts/index.astro` + +**Features:** +- Feature flag check: `isPromptManagerEnabled()` +- Auth check: `Astro.locals.user` required +- Organization check: `Astro.locals.promptManager?.organizations.length > 0` +- If checks fail: redirect to `/auth/login` or show "request access" message +- If checks pass: render `` + +**Layout:** Reuse `Layout.astro` with Topbar/Footer like `index.astro` + +### D2. Access Guard Middleware +**File:** `src/middleware.ts` (extend existing) + +**Add logic:** +```typescript +if (url.pathname.startsWith('/prompts')) { + if (!isPromptManagerEnabled()) { + return new Response(null, { status: 404 }); + } + + if (!locals.user) { + return Astro.redirect('/auth/login?redirect=/prompts'); + } + + // Build promptManager context + locals.promptManager = await buildPromptManagerContext({ + supabase: locals.supabase, + userId: locals.user.id, + requestedSlug: url.searchParams.get('org'), + }); + + if (!hasPromptManagerAccess(locals.promptManager.organizations)) { + // Show "request access" page instead of 404 + return Astro.redirect('/prompts/request-access'); + } +} +``` + +### D3. Request Access Page +**File:** `src/pages/prompts/request-access.astro` + +**Features:** +- Shows message: "You need organization membership to access prompts" +- Link back to home + +## Part E: Testing & Validation + +### E1. Unit Tests +**Files to create:** +- `tests/unit/store/promptsStore.test.ts` - Store logic tests +- `tests/unit/components/prompt-manager/PromptCard.test.tsx` - Component tests +- `tests/unit/components/prompt-manager/PromptFilters.test.tsx` - Filter logic + +### E2. Integration Tests +**File:** `tests/integration/prompt-member-flow.test.ts` + +**Scenarios:** +1. Authenticated member with org → sees prompts list +2. Member switches organization → prompts refresh +3. Member filters by collection → list updates +4. Member searches → results filter +5. Member clicks prompt → detail modal opens +6. Member copies prompt → clipboard contains markdown +7. Member downloads prompt → file downloads + +### E3. E2E Tests (Playwright) +**File:** `e2e/prompt-manager-member.spec.ts` + +**Test cases:** +- Full member flow (login → browse → filter → view → copy) +- Organization switching +- Accessibility checks (keyboard navigation) + +## Part G: Documentation Updates + +### G1. Update Docs +**Files to update:** +- `README.md` - Add member routes `/prompts` +- `.ai/prompt-manager/poc-impl-plan.md` - Mark Phase 4 complete +- `.ai/test-plan.md` - Document member flow tests + +## Implementation Order + +1. **Part A** - Extract reusable components (1-2 hours) +3. **Part A2** - Create `promptsStore` (1 hour) +4. **Part B** - Member APIs & services (2-3 hours) +5. **Part C** - UI components (3-4 hours) +6. **Part D** - Routes & middleware (1-2 hours) +7. **Part E** - Tests (2-3 hours) +8. **Part G** - Documentation (30 min) + +**Total estimate:** 12-16 hours + +## Success Criteria (Exit Criteria from PRD) + +✅ Authenticated member with organization membership can: +- Switch organizations (default 10xDevs) +- Browse prompts filtered by collection/segment +- Search prompts +- View markdown content +- Copy to clipboard (Cursor-compatible formatting) +- Download prompts + +✅ Unauthenticated users redirected to login +✅ Users without organization see "request access" page +✅ Feature flag disabled → 404 on `/prompts` routes +✅ Only published prompts visible (drafts hidden) diff --git a/.ai/prompt-library/phase-5-impl-plan.md b/.ai/prompt-library/phase-5-impl-plan.md new file mode 100644 index 0000000..713b0b2 --- /dev/null +++ b/.ai/prompt-library/phase-5-impl-plan.md @@ -0,0 +1,275 @@ +# Phase 5: Admin Experience Slice - Implementation Plan + +## Overview +Build admin curation UI at `/prompts/admin` with organization selector, draft list view, editor form, and publish toggle - reusing existing patterns from member UI and rule collections. + +## Part 1: Reusable UI Components (Extract & Create) + +### 1.1 Extract `FormInput` component +**File:** `src/components/ui/FormInput.tsx` +- Extract pattern from `AuthInput.tsx` +- Generic input with label, error state, and validation +- Support text, textarea modes +- Used by both auth forms and prompt editor + +### 1.2 Extract `FormTextarea` component +**File:** `src/components/ui/FormTextarea.tsx` +- Similar to FormInput but for multiline text +- Auto-resize capability +- Support markdown preview toggle (future) + +### 1.3 Create `StatusBadge` component +**File:** `src/components/ui/StatusBadge.tsx` +- Display draft/published status +- Color-coded: gray for draft, green for published +- Compact design for cards and lists + +## Part 2: Admin-Specific Components + +### 2.1 `PromptEditorDialog` component +**File:** `src/components/prompt-manager/admin/PromptEditorDialog.tsx` +- Reuse `ConfirmDialog` pattern from SaveRuleCollectionDialog +- Form fields: title (input), collection (dropdown), segment (dropdown), markdown_body (textarea) +- Client-side validation (required fields) +- Loading state during save +- Error display +- Support create + edit modes (initialData prop) + +### 2.2 `AdminPromptCard` component +**File:** `src/components/prompt-manager/admin/AdminPromptCard.tsx` +- Based on RuleCollectionListEntry pattern +- Shows: title, preview, collection/segment badges, status badge, updated date +- Actions: Edit (pencil icon), Delete (trash icon), Publish toggle (toggle switch or button) +- Hover actions pattern from RuleCollectionListEntry +- Click to select/preview in detail view + +### 2.3 `AdminPromptsList` component +**File:** `src/components/prompt-manager/admin/AdminPromptsList.tsx` +- Grid layout similar to PromptsList +- Map prompts to AdminPromptCard components +- Show "Create New Prompt" button (similar to RuleCollectionsList) +- Handle loading, error, empty states +- Support filters (status: all/draft/published) + +### 2.4 `PromptsAdminPanel` component +**File:** `src/components/prompt-manager/admin/PromptsAdminPanel.tsx` +- Main admin container (similar to PromptsBrowser) +- Top section: OrganizationSelector + status filter dropdown + search +- Main section: AdminPromptsList +- Side panel/modal: PromptDetail (reuse existing) or PromptEditorDialog +- Manage dialog states (editor open/closed, delete confirmation) + +## Part 3: Store Enhancement + +### 3.1 Extend `promptsStore` with admin actions +**File:** `src/store/promptsStore.ts` + +Add to state: +```typescript +// Admin-specific state +adminPrompts: Prompt[]; // includes drafts +isAdminMode: boolean; +statusFilter: 'all' | 'draft' | 'published'; +``` + +Add actions: +```typescript +// Admin CRUD +createPrompt: (data: CreatePromptInput) => Promise +updatePrompt: (id: string, data: UpdatePromptInput) => Promise +deletePrompt: (id: string) => Promise +togglePublishStatus: (id: string) => Promise + +// Admin fetching (includes drafts) +fetchAdminPrompts: (filters) => Promise + +// Filters +setStatusFilter: (status) => void +setAdminMode: (enabled: boolean) => void +``` + +Implementation notes: +- Use admin API endpoints (`/api/prompts/admin/*`) +- Handle errors with user-friendly messages +- Optimistic updates for toggle publish +- Refetch after mutations + +## Part 4: Admin Page + +### 4.1 Create admin page +**File:** `src/pages/prompts/admin/index.astro` +```astro +--- +import Layout from '../../../layouts/Layout.astro'; +import Topbar from '../../../components/Topbar'; +import Footer from '../../../components/Footer'; +import PromptsAdminPanel from '../../../components/prompt-manager/admin/PromptsAdminPanel'; + +const user = Astro.locals.user; +// Middleware already ensures admin access +--- + +
+ +
+ +
+
+
+
+``` + +## Part 5: Integration & Polish + +### 5.1 Add navigation link +- Add "Admin Panel" link in Topbar for admin users +- Conditional render based on admin role +- Highlight active state when on admin routes + +### 5.2 Error handling & validation +- Form validation in PromptEditorDialog +- API error display with toast/inline messages +- Confirmation dialog for destructive actions (delete) + +### 5.3 Optimistic updates +- Toggle publish status immediately, rollback on error +- Show loading spinners during mutations + +## Implementation Order + +1. **Part 1 (Reusable UI):** FormInput, FormTextarea, StatusBadge +2. **Part 3 (Store):** Extend promptsStore with admin actions +3. **Part 2 (Admin Components):** PromptEditorDialog → AdminPromptCard → AdminPromptsList → PromptsAdminPanel +4. **Part 4 (Page):** Create admin/index.astro +5. **Part 5 (Integration):** Navigation, error handling, polish + +## Testing Strategy + +- Unit tests: Store admin actions, form validation +- Integration tests: Full admin workflow (create → edit → publish → delete) +- E2E test: Playwright scenario matching US-009 from PRD + +## Component Reuse Summary + +**Reusing:** +- ConfirmDialog, Dropdown, SearchBar, MarkdownRenderer, CopyDownloadActions +- OrganizationSelector, PromptDetail, PromptFilters +- Patterns from RuleCollectionListEntry (edit/delete actions) +- Patterns from SaveRuleCollectionDialog (form dialog) + +**Creating:** +- FormInput, FormTextarea, StatusBadge (generic) +- PromptEditorDialog, AdminPromptCard, AdminPromptsList, PromptsAdminPanel (admin-specific) + +**Enhancing:** +- promptsStore (admin actions) + +## Detailed Component Specifications + +### PromptEditorDialog Props +```typescript +interface PromptEditorDialogProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: CreatePromptInput | UpdatePromptInput) => Promise; + organizationId: string; + collections: PromptCollection[]; + segments: PromptSegment[]; + initialData?: Prompt; // For edit mode +} +``` + +### AdminPromptCard Props +```typescript +interface AdminPromptCardProps { + prompt: Prompt; + collections: PromptCollection[]; + segments: PromptSegment[]; + onEdit: (prompt: Prompt) => void; + onDelete: (promptId: string) => void; + onTogglePublish: (promptId: string) => void; + onSelect: (promptId: string) => void; + isSelected?: boolean; +} +``` + +### PromptsAdminPanel State Management +```typescript +// Local component state +const [isEditorOpen, setIsEditorOpen] = useState(false); +const [editingPrompt, setEditingPrompt] = useState(null); +const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); +const [deletingPromptId, setDeletingPromptId] = useState(null); + +// Store state +const { + adminPrompts, + isLoading, + error, + activeOrganization, + collections, + segments, + statusFilter, + searchQuery, + selectedPromptId, + // Actions + fetchAdminPrompts, + createPrompt, + updatePrompt, + deletePrompt, + togglePublishStatus, + setStatusFilter, + setSearchQuery, + selectPrompt, +} = usePromptsStore(); +``` + +## API Endpoints Already Implemented + +✅ POST `/api/prompts/admin/prompts` - Create prompt +✅ GET `/api/prompts/admin/prompts` - List prompts (with filters) +✅ GET `/api/prompts/admin/prompts/[id]` - Get single prompt +✅ PUT `/api/prompts/admin/prompts/[id]` - Update prompt +✅ DELETE `/api/prompts/admin/prompts/[id]` - Delete prompt +✅ PATCH `/api/prompts/admin/prompts/[id]/publish` - Toggle publish status + +All endpoints already enforce: +- Authentication (middleware) +- Admin role check (middleware) +- Organization scoping (service layer) + +## PRD Requirements Mapping + +### US-005: Admin Panel Access ✓ +- Middleware already handles role-based routing +- Admin users see admin panel, members redirected + +### US-006: Create and Edit Drafts ✓ +- PromptEditorDialog handles both modes +- Form validation before save +- Error handling with inline display + +### US-007: Publish Toggle ✓ +- AdminPromptCard includes publish toggle +- Optimistic update for instant feedback +- Status change reflects in member view immediately + +### US-009: E2E Testing ✓ +- Playwright scenario: create draft → edit → publish → member views + +## Success Criteria (from Phase 5 in POC Plan) + +✅ Admin walkthrough demo-ready: +1. Switch organization via OrganizationSelector +2. Create new draft via "New Prompt" button +3. Edit draft via pencil icon +4. Publish via toggle button +5. Verify appears in member view + +✅ Admin-only UI route at `/prompts/admin` +✅ Organization selector with active org context +✅ Draft list view with filters +✅ Editor form (Markdown) with validation +✅ Simple publish toggle (no diffing/bulk in POC) +✅ API integration with error handling +✅ Behind feature flag and access check diff --git a/.ai/prompt-library/phase-6-localization-plan.md b/.ai/prompt-library/phase-6-localization-plan.md new file mode 100644 index 0000000..70f7370 --- /dev/null +++ b/.ai/prompt-library/phase-6-localization-plan.md @@ -0,0 +1,564 @@ +# Phase 6: Localization - Detailed Implementation Plan + +## Executive Summary + +Phase 6 aims to complete the localization implementation for the Prompt Manager POC, enabling full bilingual support (English and Polish) across the admin and member experiences. The database schema and admin input forms are **already implemented**, but several critical components still reference deprecated non-localized field names, and the member experience lacks language selection capabilities beyond the detail modal. + +--- + +## Current State Analysis + +### ✅ Already Completed + +| Component | Status | Details | +|-----------|--------|---------| +| **Database Schema** | ✅ Complete | Migration `20251002100000_localize_prompts_table.sql` renamed `title` → `title_en`, `markdown_body` → `markdown_body_en`, added `title_pl` and `markdown_body_pl` | +| **TypeScript Types** | ✅ Complete | `database.types.ts` reflects localized schema with proper nullable types for Polish fields | +| **Store Types** | ✅ Complete | `CreatePromptInput` and `UpdatePromptInput` support all localized fields (`title_en`, `title_pl`, `markdown_body_en`, `markdown_body_pl`) | +| **Admin Input Form** | ✅ Complete | `PromptEditorDialog` has separate input fields for English (required) and Polish (optional) titles and content | +| **Member Detail Modal** | ✅ Complete | `PromptDetail` component has language switcher and fallback logic (per system modifications) | + +### ⚠️ Critical Bugs - References to Non-Existent Fields + +These components reference `prompt.title` and `prompt.markdown_body` which **no longer exist** after the migration: + +1. **`AdminPromptCard.tsx`** (lines 58, 85) + - Uses `prompt.title` for display and ARIA labels + - Uses `prompt.markdown_body` for preview generation + +2. **`PromptCard.tsx`** (member-facing, lines 28, 37, 39) + - Uses `prompt.title` for display and ARIA labels + - Uses `prompt.markdown_body` for preview generation + +**Impact**: These components will **fail at runtime** once the migration runs, breaking both admin and member UIs. + +### 🚧 Incomplete Features + +| Feature | Status | Requirement | +|---------|--------|-------------| +| **Global Language Preference** | ❌ Missing | Users cannot set a persistent language preference for browsing prompts | +| **Member Language Switcher** | ⚠️ Partial | Only exists in detail modal; no global switcher for list view | +| **Language Indicators (Admin)** | ❌ Missing | Admins cannot see at-a-glance which language versions exist for each prompt | +| **Language Filtering** | ❌ Missing | No way to filter prompts by available languages (e.g., "show only prompts with Polish versions") | +| **Fallback Strategy** | ⚠️ Partial | Detail modal has fallback; cards do not specify fallback behavior | +| **Collection/Segment Localization** | ❌ Deferred | Collections and segments remain single-language (acceptable for POC) | + +--- + +## Implementation Plan + +### Task Breakdown + +#### **Task 1: Fix Critical Bugs - Update Component References** + +**Priority**: 🔴 CRITICAL - Must complete before migration runs in production + +**Files to Modify**: +1. `src/components/prompt-manager/admin/AdminPromptCard.tsx` +2. `src/components/prompt-manager/PromptCard.tsx` + +**Requirements**: +- Replace all references to `prompt.title` with language-aware logic +- Replace all references to `prompt.markdown_body` with language-aware logic +- Implement fallback strategy: prefer `title_en`/`markdown_body_en` (always present) with optional `title_pl`/`markdown_body_pl` +- For POC, default to English in card views; defer language preference until Task 2 + +**Acceptance Criteria**: +- [ ] `AdminPromptCard` displays `title_en` for title and uses `markdown_body_en` for preview +- [ ] `PromptCard` (member) displays `title_en` for title and uses `markdown_body_en` for preview +- [ ] No runtime errors when accessing prompt properties +- [ ] ARIA labels use correct localized titles + +**Implementation Notes**: +```typescript +// Example pattern for AdminPromptCard.tsx +const title = prompt.title_en; // Always present per schema +const preview = prompt.markdown_body_en.slice(0, 150) + + (prompt.markdown_body_en.length > 150 ? '...' : ''); +``` + +--- + +#### **Task 2: Add Global Language Preference Management** + +**Priority**: 🟡 HIGH - Required for Phase 6 exit criteria + +**Files to Modify**: +1. `src/store/promptsStore.ts` - Add language preference state +2. `src/services/prompt-manager/language.ts` - Create language utilities (new file) + +**Requirements**: +- Add `preferredLanguage: 'en' | 'pl'` to `PromptsState` +- Add `setPreferredLanguage(lang: 'en' | 'pl')` action +- Persist preference to `localStorage` with key `prompt-manager:language` +- Initialize from localStorage on store creation, default to `'en'` +- Provide utility functions: + - `getLocalizedTitle(prompt: Prompt, lang: 'en' | 'pl'): string` - with fallback to English + - `getLocalizedBody(prompt: Prompt, lang: 'en' | 'pl'): string` - with fallback to English + - `hasPolishVersion(prompt: Prompt): boolean` + +**Acceptance Criteria**: +- [ ] Store includes `preferredLanguage` state +- [ ] Language preference persists across page reloads +- [ ] Utility functions handle missing Polish versions gracefully +- [ ] Default language is English for new users + +**Implementation Notes**: +```typescript +// src/services/prompt-manager/language.ts +export type Language = 'en' | 'pl'; + +export const getLocalizedTitle = (prompt: Prompt, lang: Language): string => { + return lang === 'pl' && prompt.title_pl ? prompt.title_pl : prompt.title_en; +}; + +export const getLocalizedBody = (prompt: Prompt, lang: Language): string => { + return lang === 'pl' && prompt.markdown_body_pl + ? prompt.markdown_body_pl + : prompt.markdown_body_en; +}; + +export const hasPolishVersion = (prompt: Prompt): boolean => { + return !!(prompt.title_pl && prompt.markdown_body_pl); +}; + +// LocalStorage key +const LANGUAGE_PREFERENCE_KEY = 'prompt-manager:language'; + +export const loadLanguagePreference = (): Language => { + try { + const stored = localStorage.getItem(LANGUAGE_PREFERENCE_KEY); + return stored === 'pl' ? 'pl' : 'en'; + } catch { + return 'en'; + } +}; + +export const saveLanguagePreference = (lang: Language): void => { + try { + localStorage.setItem(LANGUAGE_PREFERENCE_KEY, lang); + } catch { + // Fail silently if localStorage is unavailable + } +}; +``` + +--- + +#### **Task 3: Update Cards to Use Language Preference** + +**Priority**: 🟡 HIGH - Required for Phase 6 exit criteria + +**Files to Modify**: +1. `src/components/prompt-manager/PromptCard.tsx` (member-facing) +2. `src/components/prompt-manager/admin/AdminPromptCard.tsx` + +**Requirements**: +- Update both card components to use language utilities from Task 2 +- Member cards should respect `preferredLanguage` from store +- Admin cards should always show English (or add a separate preference for admin view) +- Show language indicator badges on cards with Polish versions + +**Acceptance Criteria**: +- [ ] Member `PromptCard` displays title/preview in user's preferred language +- [ ] Admin `AdminPromptCard` shows language availability indicator (e.g., "EN + PL" badge) +- [ ] Fallback to English works seamlessly when Polish version missing +- [ ] Cards update when language preference changes + +**Implementation Notes**: +```typescript +// src/components/prompt-manager/PromptCard.tsx +import { getLocalizedTitle, getLocalizedBody, hasPolishVersion } from '../../services/prompt-manager/language'; + +export const PromptCard: React.FC = ({ prompt }) => { + const { selectPrompt, collections, segments, preferredLanguage } = usePromptsStore(); + + const title = getLocalizedTitle(prompt, preferredLanguage); + const body = getLocalizedBody(prompt, preferredLanguage); + const preview = body.substring(0, 150) + (body.length > 150 ? '...' : ''); + + // ... rest of component +}; +``` + +```typescript +// src/components/prompt-manager/admin/AdminPromptCard.tsx +// Add language indicator badge +{hasPolishVersion(prompt) && ( + + EN + PL + +)} +``` + +--- + +#### **Task 4: Add Global Language Switcher to Member UI** + +**Priority**: 🟡 HIGH - Required for Phase 6 exit criteria + +**Files to Modify**: +1. `src/components/prompt-manager/PromptsBrowser.tsx` (or appropriate parent component) +2. Create `src/components/prompt-manager/LanguageSwitcher.tsx` (new file) + +**Requirements**: +- Create reusable `LanguageSwitcher` component with EN/PL toggle buttons +- Place switcher in member UI header/toolbar (near filters or search bar) +- Switcher updates `preferredLanguage` in store +- Visual indication of active language (e.g., highlighted button) +- Accessible keyboard navigation + +**Acceptance Criteria**: +- [ ] Language switcher visible and functional in member prompts list view +- [ ] Clicking EN/PL updates store preference and re-renders cards +- [ ] Active language visually highlighted +- [ ] Component follows existing UI design patterns (colors, spacing) +- [ ] ARIA labels and keyboard navigation work correctly + +**Implementation Notes**: +```typescript +// src/components/prompt-manager/LanguageSwitcher.tsx +import React from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import type { Language } from '../../services/prompt-manager/language'; + +export const LanguageSwitcher: React.FC = () => { + const { preferredLanguage, setPreferredLanguage } = usePromptsStore(); + + const handleLanguageChange = (lang: Language) => { + setPreferredLanguage(lang); + }; + + return ( +
+ Language: + + +
+ ); +}; +``` + +--- + +#### **Task 5: Sync Detail Modal Language with Global Preference** + +**Priority**: 🟢 MEDIUM - Nice-to-have for consistency + +**Files to Modify**: +1. `src/components/prompt-manager/PromptDetail.tsx` + +**Requirements**: +- Initialize detail modal language from `preferredLanguage` in store instead of hardcoded `'en'` +- Allow user to change language within modal (independent of global preference) +- Optionally: persist modal language choice back to global preference + +**Acceptance Criteria**: +- [ ] Detail modal opens showing content in user's preferred language (if available) +- [ ] In-modal language switch still works independently +- [ ] Closing and reopening modal respects global preference + +**Implementation Notes**: +```typescript +// Modify PromptDetail.tsx +const { selectedPromptId, prompts, preferredLanguage } = usePromptsStore(); +const [language, setLanguage] = useState<'en' | 'pl'>(preferredLanguage); + +useEffect(() => { + if (selectedPromptId) { + document.body.style.overflow = 'hidden'; + // Reset to global preference when opening + setLanguage(preferredLanguage); + } else { + document.body.style.overflow = ''; + } + // ... +}, [selectedPromptId, preferredLanguage]); +``` + +--- + +#### **Task 6: Add Language Filtering (Optional - Post-MVP)** + +**Priority**: 🔵 LOW - Defer to post-POC unless time permits + +**Files to Modify**: +1. `src/components/prompt-manager/PromptFilters.tsx` +2. `src/store/promptsStore.ts` + +**Requirements**: +- Add filter option: "Show only prompts with Polish versions" +- Filter state managed in store +- API calls respect filter (or client-side filtering if API unchanged) + +**Acceptance Criteria**: +- [ ] Filter checkbox/toggle added to filters UI +- [ ] When enabled, only prompts with `title_pl` AND `markdown_body_pl` are shown +- [ ] Filter state persists during session +- [ ] Works in combination with existing collection/segment filters + +**Defer Criteria**: +This can be deferred if: +- Time-boxed sprint is running short +- Exit criteria for Phase 6 are met without this feature +- Team decides to validate basic localization first + +--- + +#### **Task 7: Update Admin UI Language Indicators** + +**Priority**: 🟢 MEDIUM - Improves admin UX + +**Files to Modify**: +1. `src/components/prompt-manager/admin/AdminPromptCard.tsx` (already modified in Task 3) +2. `src/components/prompt-manager/admin/AdminPromptsList.tsx` (optional - add filter/sort by language availability) + +**Requirements**: +- Show clear visual indicator on each admin card: "EN", "EN + PL" +- Optionally: add filter/sort dropdown to show drafts missing Polish translations +- Tooltip or help text explaining language indicator badges + +**Acceptance Criteria**: +- [ ] Admin cards show "EN" badge if only English version exists +- [ ] Admin cards show "EN + PL" badge if both versions exist +- [ ] Badge styling matches existing design system +- [ ] (Optional) Filter to show only prompts missing Polish translations + +**Implementation Notes**: +Already covered in Task 3; can be enhanced with filtering logic here. + +--- + +#### **Task 8: Testing & Validation** + +**Priority**: 🔴 CRITICAL - Required for Phase 6 sign-off + +**Files to Create/Modify**: +1. `tests/unit/services/language.test.ts` - Unit tests for language utilities +2. `e2e/prompt-manager/localization.spec.ts` - E2E tests for localization flow +3. Update `.ai/test-plan.md` with localization test scenarios + +**Test Scenarios**: + +##### Unit Tests +- [ ] `getLocalizedTitle` returns `title_pl` when available and lang is 'pl' +- [ ] `getLocalizedTitle` falls back to `title_en` when `title_pl` is null +- [ ] `getLocalizedBody` follows same fallback logic +- [ ] `hasPolishVersion` returns true only when both `title_pl` and `markdown_body_pl` are present +- [ ] `loadLanguagePreference` defaults to 'en' on first load +- [ ] `saveLanguagePreference` persists to localStorage + +##### Integration Tests +- [ ] Store initializes with language preference from localStorage +- [ ] `setPreferredLanguage` updates store and localStorage +- [ ] Cards re-render with correct language when preference changes + +##### E2E Tests (Playwright) +- [ ] **Member language switching**: + 1. Open member prompts view + 2. Verify cards display English titles by default + 3. Click "Polski" language switcher + 4. Verify cards display Polish titles (for prompts with Polish versions) + 5. Reload page + 6. Verify Polish preference persisted + +- [ ] **Detail modal language**: + 1. Select prompt with both EN and PL versions + 2. Verify detail modal shows language switcher + 3. Switch language in modal + 4. Verify content updates + 5. Close and reopen modal + 6. Verify global preference respected + +- [ ] **Admin indicators**: + 1. Login as admin + 2. Navigate to admin prompts + 3. Verify cards show "EN + PL" badge for fully localized prompts + 4. Verify cards show "EN" badge for English-only prompts + 5. Edit a prompt and add Polish version + 6. Verify badge updates to "EN + PL" + +- [ ] **Fallback behavior**: + 1. Switch to Polish + 2. View prompt with only English version + 3. Verify English content displayed (no error) + 4. Verify no Polish language switcher in detail modal + +**Acceptance Criteria**: +- [ ] All unit tests pass with >80% code coverage for language utilities +- [ ] E2E tests pass in CI pipeline +- [ ] Manual QA walkthrough completed by product owner +- [ ] Test plan document updated with Phase 6 scenarios + +--- + +## Migration & Deployment Strategy + +### Pre-Deployment Checklist + +- [ ] **Task 1 (Critical Bug Fixes) must be completed** before migration runs +- [ ] All TypeScript compilation errors resolved +- [ ] Supabase migration `20251002100000_localize_prompts_table.sql` reviewed and approved +- [ ] Backup of production `prompts` table taken (in case rollback needed) +- [ ] Feature flag `PROMPT_MANAGER_ENABLED` confirmed active in staging + +### Migration Sequence + +1. **Staging Environment**: + - Run migration in staging Supabase instance + - Deploy code with Tasks 1-5 completed + - Execute E2E test suite + - Manual QA by team (test Polish and English content) + +2. **Production Environment** (assuming zero-downtime requirement): + - Run migration during low-traffic window (optional: use blue-green deployment) + - Deploy code immediately after migration completes + - Monitor error logs for 30 minutes post-deployment + - Verify with smoke tests (admin create/edit, member browse/view) + +### Rollback Plan + +If critical issues arise: +1. **Code rollback**: Revert to previous deployment (pre-localization code) +2. **Database rollback** (destructive - avoid if possible): + ```sql + -- Rollback migration (loses Polish content!) + ALTER TABLE prompts DROP COLUMN title_pl; + ALTER TABLE prompts DROP COLUMN markdown_body_pl; + ALTER TABLE prompts RENAME COLUMN title_en TO title; + ALTER TABLE prompts RENAME COLUMN markdown_body_en TO markdown_body; + ``` +3. **Preferred approach**: Fix-forward by deploying hotfix rather than rolling back database + +--- + +## Exit Criteria (Phase 6 Completion) + +Phase 6 is considered **complete** when: + +- [x] Database schema supports `title_en`, `title_pl`, `markdown_body_en`, `markdown_body_pl` (already done) +- [x] Admin UI has separate input fields for English and Polish content (already done) +- [x] **All components use localized field names** (no references to deprecated `title` or `markdown_body`) +- [x] Member UI has global language switcher (EN/PL toggle) +- [x] Language preference persists across sessions via localStorage +- [x] Detail modal respects global language preference and allows in-modal switching +- [x] Admin cards show language availability indicators (EN vs EN+PL badges) +- [x] All E2E tests pass, including localization-specific scenarios +- [ ] Manual QA sign-off from product owner +- [ ] Documentation updated (README, test plan, PRD) + +--- + +## Risks & Mitigations + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| **Migration runs before code updates** | 🔴 Critical - app breaks | Medium | Deploy code and migration together; ensure Task 1 merged first | +| **Incomplete Polish translations** | 🟡 Medium - poor UX for PL users | High | Implement robust fallback to English; document as expected behavior | +| **LocalStorage unavailable** | 🟢 Low - preference not saved | Low | Graceful degradation: fallback to default 'en' on each load | +| **Collections/segments not localized** | 🟡 Medium - inconsistent language mixing | High | Accept for POC; document as future enhancement; English metadata is acceptable | +| **Performance with language filtering** | 🟢 Low - slow queries | Low | Client-side filtering acceptable for POC; defer server-side optimization | +| **Timezone issues with `updated_at`** | 🟢 Low - confusing timestamps | Medium | Ensure `toLocaleDateString()` respects user locale | + +--- + +## Timeline Estimate + +Assuming 1 developer working full-time: + +| Task | Estimated Effort | Priority | +|------|------------------|----------| +| Task 1: Fix Critical Bugs | 2-3 hours | 🔴 Critical | +| Task 2: Language Preference Management | 3-4 hours | 🟡 High | +| Task 3: Update Cards with Language | 2-3 hours | 🟡 High | +| Task 4: Global Language Switcher | 2-3 hours | 🟡 High | +| Task 5: Sync Detail Modal | 1-2 hours | 🟢 Medium | +| Task 6: Language Filtering (optional) | 3-4 hours | 🔵 Low (defer) | +| Task 7: Admin Indicators | 1-2 hours | 🟢 Medium | +| Task 8: Testing & Validation | 4-6 hours | 🔴 Critical | +| **Total (without Task 6)** | **15-23 hours (~2-3 days)** | | +| **Total (with Task 6)** | **18-27 hours (~2.5-3.5 days)** | | + +**Recommendation**: Focus on Tasks 1-5 + 8 for initial Phase 6 release (~2-3 days). Add Tasks 6-7 in follow-up iteration if time permits. + +--- + +## Post-Phase 6 Future Enhancements + +Deferred to subsequent phases (outside POC scope): + +1. **Automated Translation Suggestions**: Integrate with translation API to pre-fill Polish versions +2. **Collection & Segment Localization**: Extend schema and UI to support multilingual metadata +3. **Language Analytics**: Track which languages are most used by members +4. **Additional Languages**: Support for third language (e.g., Ukrainian, German) with minimal code changes +5. **Language-Specific Search**: Improve search to index and query both language versions +6. **Admin Bulk Translation Tools**: UI to batch-translate multiple prompts or copy EN → PL templates +7. **Diff View for Translations**: Show side-by-side comparison of EN and PL versions in admin UI + +--- + +## Appendix: Key Files Reference + +| File Path | Purpose | Localization Involvement | +|-----------|---------|--------------------------| +| `supabase/migrations/20251002100000_localize_prompts_table.sql` | Database migration | Renames and adds localized columns | +| `src/db/database.types.ts` | Generated TypeScript types | Reflects localized schema | +| `src/store/promptsStore.ts` | State management | Will add `preferredLanguage` state | +| `src/services/prompt-manager/language.ts` | Language utilities | **NEW**: Localization logic | +| `src/components/prompt-manager/PromptCard.tsx` | Member prompt card | **NEEDS FIX**: Uses deprecated fields | +| `src/components/prompt-manager/PromptDetail.tsx` | Member detail modal | Already localized (per system mods) | +| `src/components/prompt-manager/admin/AdminPromptCard.tsx` | Admin prompt card | **NEEDS FIX**: Uses deprecated fields | +| `src/components/prompt-manager/admin/PromptEditorDialog.tsx` | Admin form | Already localized | +| `src/components/prompt-manager/LanguageSwitcher.tsx` | Language toggle | **NEW**: Global switcher component | + +--- + +## Questions for Product Owner / Stakeholders + +Before proceeding, confirm these decisions: + +1. **Default Language**: Should new users default to English or auto-detect browser locale? +- Auto-detect browser locale +2. **Admin Language Preference**: Should admins have separate language preference for admin UI, or always show English? +- Always show English +3. **Incomplete Translations**: Is it acceptable to show English content when Polish is selected but unavailable, or should we hide such prompts? +- When Polish is not available, don't allow switching to Polish +4. **Collection Metadata**: Are English-only collection/segment names acceptable for POC, or is this a blocker? +- English-only collection/segment names are acceptable for POC +5. **Language Indicator Verbosity**: Prefer "EN + PL" badges or language flag icons (🇬🇧 🇵🇱)? +- Prefer language flag icons +6. **Testing Coverage**: Is manual QA sufficient, or do we need automated visual regression tests for language switching? +- Manual QA is sufficient +7. **Rollout Strategy**: Should localization launch behind a separate feature flag, or tied to `PROMPT_MANAGER_ENABLED`? +- Tied to `PROMPT_MANAGER_ENABLED` + +--- + +## Conclusion + +Phase 6 is **substantially complete** in terms of backend infrastructure and admin input capabilities. The remaining work focuses on: +1. **Critical bug fixes** (non-existent field references) +2. **Member-facing language selection UX** +3. **Admin visibility improvements** + +With focused effort on Tasks 1-5 and 8, Phase 6 can be delivered in **2-3 business days**, meeting all exit criteria defined in `poc-impl-plan.md` and `prd.md`. diff --git a/.ai/prompt-library/poc-arch-plan.md b/.ai/prompt-library/poc-arch-plan.md new file mode 100644 index 0000000..582d77e --- /dev/null +++ b/.ai/prompt-library/poc-arch-plan.md @@ -0,0 +1,86 @@ +# Dodex Architecture Prep v2 (POC MVP Cut) + +## Core Objective +Deliver the prompt manager proof of concept in the shortest path that still honors the product intent captured in the planning docs. The POC focus is the admin curation flow and member browsing flow that validate data contracts without over-investing in long-term automation. +> "Ship a feature-flagged Prompt Manager proof of concept that exercises the core flow (admin curates prompts, member browses gated list) while deferring advanced workflows like version diffing, automated localization, and rich analytics." — `docs/prompts/poc-plan.md` (§ Objective) + + +## MVP Guardrails (Keep Lean, Stay Grounded) +- **Flag-first rollout:** Everything hides behind `PROMPT_MANAGER_ENABLED` with toggles defined in `featureFlags.ts`; +- **Reuse existing rails:** Stick to Astro routes + Supabase fetch patterns already proven in Rules Builder; no new client libraries until the POC ships. +- **Single active version:** Maintain only one editable/published prompt entry (POC "Simplified prompt catalog schema") to avoid versioning overhead now. +- **Organization-aware access:** Require an explicit `organization_members` membership before any prompt routes load; the launch cohort is mapped to `10xDevs`, and the remaining ~254 existing users stay locked out until they receive an organization assignment. +- **Naming clarity:** Internal feature remains `prompt-manager`; member/admin surfaces still live at `/prompts` and `/prompts/admin`. +- **Documentation last:** Update README + `.ai/test-plan.md` once the MVP slice stands up; avoid churn mid-build. + +## Must-Haves vs. Nice-to-Haves +| Area | Must-Have (keep) | Nice-to-Have (defer) | +| --- | --- | --- | +| Feature gating | Middleware check for flag + organization membership helper (default to 10xDevs) | Feature analytics for rollout, per-env telemetry metrics | +| Supabase schema | `organizations`, `organization_members`, `prompt_collections`, `prompt_segments`, `prompts` | `prompt_versions`, `prompt_usage_logs`, enums, audit tables | +| Admin UI | Table view scoped to active organization, edit/publish modal, status toggle | Markdown diffing, bulk publish, drafts filter presets | +| Member UI | Organization selector, collection/segment filters, prompt accordion, copy & download actions | Full-text search, skeleton loaders, localization banner | +| Testing | Vitest for guards + API, Playwright happy-path | Edge-case Vitest, telemetry + localization suites | +| Documentation | README + `.ai/test-plan.md` update post-delivery | Dedicated module docs, diagrams, migration walkthroughs | + +## Inline Schema & Data Contracts +@.ai/prompt-manager/schema-proposal.md + + +### Seeding Strategy +- Seed `organizations` with at least `10xdevs` slug plus any internal testing orgs. +- Seed `prompt_collections`/`prompt_collection_segments` for 10xDevs modules and sub-groups inside the migration (INSERT statements guarded by `if not exists`) so new environments come online instantly. +- Provide a one-off SQL insert block in the same migration for 2 exemplar prompts scoped to 10xDevs—matching the POC need for demo data—then delete/replace via admin UI. + +## Lean API & UI Flow Snapshot (Embedded Context) +- **Admin APIs (`/api/prompts/admin/*`):** + - `POST /prompts` → create draft scoped to `organization_id` (requires flag + organization role in [`admin`]). + - `PATCH /prompts/:id` → update fields or toggle status, ensuring organization ownership. Uses Supabase service key (POC "direct data access"). + - `GET /prompts-collections?organization_id=...` helper returning seeded collections/segments for UI selects. +- **Member APIs (`/api/prompts/*`):** + - `GET /prompts?organization_id=...&collection=...&segment=...` filters to `status='published'` using the index above. +- **UI Reuse Plan:** reuse Rules Builder table, drawer, and copy interaction to honor MVP guardrail "Reuse existing components". Organization selector reuses existing dropdown component styled for nav. +- **Routing:** Admin route at `/prompts/admin`, member route `/prompts`. Feature flag guard sits in middleware + page-level loader to avoid hydration of hidden routes while validating organization membership. + +## Implementation Tracks (3 Parallelizable Slices) +1. **Track A – Access & Seed Bootstrapping** + - Add feature flags in `featureFlags.ts` + middleware guard (POC phase A). + - Implement `ensurePromptAccess()` middleware helper reading organization memberships from Supabase. + - Apply migration above; document Supabase command in README after completion, including organization + collection seeds. + - Drop seed SQL file into `supabase/migrations/__prompt_manager_seed.sql` using the snippet here (no dependency on other docs). +2. **Track B – Admin & Member Surfaces** + - Admin page uses existing table component, inline modal for create/edit; publish button flips `status` via API scoped to selected organization. + - Member page reuses select components for organization + collection, accordion list, copy and download actions. + - Data fetching: Astro route loaders call Supabase via `fetch` using service role for admin, anon key for member (still behind middleware) while injecting `organization_id` filter. +3. **Track C – QA & Wrap-Up** + - Vitest coverage: middleware guard, admin CRUD handler, member list handler (mock Supabase) with multi-organization fixture. + - Playwright scenario: admin switches organization → creates prompt → toggles publish → member selects organization + collection → copy and download success. + + +## Questions to Close Before Build +Q1: Which organization role strings (`member`, `admin`) should unlock admin surfaces vs. member-only access for the POC? (Needed to finalize middleware guard.) +A1: `admin` for admin surfaces, `member` for member-only access. +Q2: Do we seed demo prompts in migrations or via Supabase dashboard before demo? (Impacts developer onboarding.) +A2: In migrations. +Q3: Which environment holds the service role key for admin APIs during the POC? (Clarifies deployment risk.) +A3: In all environments. +Q4: Are we comfortable skipping RLS for the short-lived POC while using service role? (Decision affects follow-up hardening.) +A4: Yes. + +## Decisions Required Up Front +DR1: Approve the lean schema (columns + indexes) above so migration can land without rework. +D1: Lean schema approved. +DR2: Confirm API route placement (`src/pages/api/prompts/...`) vs. co-located feature folder to keep imports consistent. +D2: API route placement confirmed. + + +## De-Scoped (Documented Deferrals) +- No roster validation, telemetry tables, version history, or localization enhancements; revisit with full architecture plan once POC validated. +- No Zustand store or React Query adoption—component state suffices until patterns stabilize. +- No advanced error boundaries or offline caching; fallback is a simple error message in both routes. + +## Definition of Done +- Feature flag + middleware hide all prompt routes when disabled and enforce organization membership + role rules when enabled. +- Admin can CRUD & publish prompts tied to seeded organization collections/segments; member sees only published prompts and can copy them with persistence. +- Supabase migration applied with seed data matching tables above; API endpoints backed by the lean schema respond successfully. +- Vitest + Playwright checks in Track C pass locally; README + `.ai/test-plan.md` updated to describe the MVP flow and known deferrals. diff --git a/.ai/prompt-library/poc-impl-plan.md b/.ai/prompt-library/poc-impl-plan.md new file mode 100644 index 0000000..1910391 --- /dev/null +++ b/.ai/prompt-library/poc-impl-plan.md @@ -0,0 +1,97 @@ +# Prompt Manager POC Plan (80/20) + +## Objective +Ship a feature-flagged Prompt Manager proof of concept that exercises the core flow (admin curates prompts, member browses gated list) while deferring advanced workflows like version diffing, automated localization, and rich analytics. Deliver this slice in ~3 sprints to validate UX and data contracts before expanding to the full implementation plan. + +## Guiding Constraints +- Keep everything behind `PROMPT_MANAGER_ENABLED` so partial work can deploy safely. +- Reuse existing Astro + Supabase patterns; avoid new infrastructure unless required. +- Store organization-scoped prompt collections exclusively in Supabase (no repository seeds). +- For the initial POC, restrict prompt access to Supabase users who are explicitly mapped to an organization membership record; seed only the launch cohort inside the `10xDevs` organization and block the remaining ~254 legacy accounts until they are assigned. +- Provide member copy-to-clipboard with formatting that behaves well in Cursor. +- Keep the feature name `prompt-manager`; only the public-facing routes and APIs use `/prompts` naming. + +## Scope Included in POC +- Feature flags, organization helpers, and route guards using Supabase user data (e.g., organization list + per-organization role for admin/member distinctions). +- Cohort gate placeholder that currently allows only authenticated users with a seeded organization membership (initially `10xDevs`) while keeping the hook for richer roster validation later. +- Simplified prompt catalog schema (single active version per prompt) stored entirely in Supabase with organization + collection scoping. +- Admin curation UI with list + edit/publish toggle scoped to active organization. +- Member list with organization selector, collection/segment filters, markdown rendering, copy-to-clipboard. +- Localization placeholder messaging while data remains Polish-only. + +## Phase Breakdown + +## Guiding Principles +- Deliver value in thin vertical slices that can be demoed and validated quickly. +- Keep feature flags around major surfaces so incomplete work can ship disabled. +- Reuse existing infrastructure (Supabase auth, feature flags, markdown rendering) before building new systems. +- Maintain localization requirements from the outset of relevant slices. +- Model organizations and multi-organization memberships early so prompt collections stay organization-agnostic. +- Keep the feature name `prompt-manager` in code/docs even though user-facing routes live under `/prompts`. + +## Phase 1 – Feature Flag & Access Foundations +**Goal:** Gate future work safely and prep organization-aware identity checks. +- Add `PROMPT_MANAGER_ENABLED` to `featureFlags.ts`, configuration surfaces, and documentation (no env schema changes). +- Implement helper utilities `isPromptManagerEnabled()` and `getUserOrganizations()` that read Supabase tables, defaulting to organization `10xDevs` when present. +- Add access guard middleware that requires authentication and at least one organization membership (defaulting to `10xDevs`) before reaching `/prompts` routes, returning a "request access" state for everyone else. +- Write minimal tests for feature flag utilities, organization parsing, and middleware edge cases. +- Exit criteria: Flags controllable per environment, guard helpers return expected results in unit tests, middleware blocks unauthenticated/zero-organization users. + +## Phase 2 – Organization Membership Foundation +**Goal:** Persist organizations and flexible user assignments. +- Create Supabase migrations for `organizations` and `organization_members`, limiting roles to `member` and `admin`; defer dedicated RLS until after the POC. +- Seed default organization `10xDevs` and associate only the curated launch cohort (subset of the 254 existing users) through manual SQL, leaving everyone else without membership until explicitly approved. +- Extend helper utilities to hydrate active organization context on session load (respect query param switch). +- Exit criteria: Authenticated user with organization membership, admin flags respected per organization. + +## Phase 3 – Prompt Collection Schema & Admin APIs +**Goal:** Persist organization-scoped prompt collections with a single active version per prompt. +- Implement migrations for `prompt_collections`, `prompt_collection_segments` (generic grouping instead of modules/lessons), `prompts`, and `prompt_favorites`, seeding baseline 10xDevs collections/segments/prompts. +- Skip `prompt_versions` for the POC; rely on the `status` field and simple draft→publish transitions. +- Build server-side utilities/APIs (Astro endpoints) for CRUD operations restricted to organization admins using middleware checks rather than RLS. +- Exit criteria: Admin-only API supports create draft → publish scoped to an organization/collection, migrations pass tests, and middleware-gated access behaves as expected in local runs. + +## Phase 4 – Member Experience Slice & Member APIs ✅ **COMPLETED** +**Goal:** Provide members with gated prompt discovery. +- ✅ Build member-facing routes: organization selector (default 10xDevs), collection and segment filters, search/filter bar, prompt detail modal/page. +- ✅ Enforce access guard via middleware using organization membership checks alongside the feature flag. +- ✅ Ensure graceful fallback states for unauthorized users, users without organization membership, and when flag disabled. +- ✅ Exit criteria: Authenticated member with organization membership can switch organizations, browse, search, view markdown, copy and download prompts end-to-end. +- ✅ Build server-side utilities/APIs (Astro endpoints) for CRUD operations restricted to organization members using middleware checks rather than RLS. +- ⚠️ Favorites toggle deferred to future phase. + +**Implemented Components:** +- Generic UI components: `SearchBar`, `Dropdown`, `CopyDownloadActions`, `MarkdownRenderer` +- Store: `promptsStore` with organization, collection, segment, and prompt management +- Member API routes: `/api/prompts`, `/api/prompts/[id]`, `/api/prompts/collections`, `/api/prompts/collections/[id]/segments` +- Service layer: `listPublishedPrompts()`, `getPublishedPrompt()` for member-safe queries +- UI components: `OrganizationSelector`, `PromptFilters`, `PromptsList`, `PromptCard`, `PromptDetail`, `PromptsBrowser` +- Pages: `/prompts/index.astro`, `/prompts/request-access.astro` +- Middleware: Already exists with full prompt manager support (no changes needed) + +## Phase 5 – Admin Experience Slice +**Goal:** Enable admins to curate and publish prompts iteratively. +- Create admin-only UI route guarded by feature flag and access check, surfaced under `/prompts/admin` with organization selector. +- Implement draft list view, editor form (Markdown), and a simple publish toggle scoped to the active organization (no diffing or bulk queue in the POC). +- Connect UI to APIs with error handling; add optimistic updates or revalidation. +- Exit criteria: Admin walkthrough (switch organization, draft create, publish) demo-ready behind flag. + +## Phase 6 – Localization +**Goal:** Support storing and viewing prompt content in both Polish and English. +- Extend database schema to store separate content for Polish and English versions of each prompt. +- Update the Admin UI to include separate text areas for admins to manually enter or edit content for both languages (toggle language via switch or dropdown) +- Add a language switcher in the Member UI for users to toggle between Polish and English prompt versions (if both versions are available) +- Exit criteria: Admins can create and edit prompts in both Polish and English. Members can view prompts and switch between available languages. + + +## Deferred Until Post-POC +- Build-time external roster sync (Google Sheets or CRM) run on merge to `master`. +- Automated nightly sync & failure auditing. +- Version history, diffing, bulk publish. +- Anonymous telemetry hardening (opt-out, metadata hashing, dashboards). +- Language switcher improvements, offline access. + +## Risks & Mitigations +- Without richer roster validation, early testers might see prompts unexpectedly—limit feature flag access to trusted organizations and monitor membership records. +- Lack of versioning means edits overwrite drafts: communicate constraint and limit pilot to controlled admins. +- Copy-to-clipboard reliability: test across Cursor/Windsurf early to catch formatting issues. diff --git a/.ai/prompt-library/prd.md b/.ai/prompt-library/prd.md new file mode 100644 index 0000000..827571a --- /dev/null +++ b/.ai/prompt-library/prd.md @@ -0,0 +1,93 @@ +# Dokument wymagań produktu (PRD) - Prompt Manager POC +## 1. Przegląd produktu +Prompt Manager POC to pierwsza iteracja menedżera promptów uruchamiana pod ścisłą kontrolą flagi funkcji, która ma zweryfikować przepływ kuracji i konsumpcji treści przez kohortę 10xDevs. Projekt zakłada wykorzystanie istniejących wzorców Astro + Supabase oraz dostarczenie kompletnego przepływu admin → member w ciągu około trzech sprintów, zanim zespół rozszerzy produkt o wersjonowanie i analitykę. +- Funkcjonalność jest ukryta za flagą `PROMPT_MANAGER_ENABLED`, a dostęp do tras `/prompts` i `/prompts/admin` wymaga pozytywnej walidacji członkostwa w organizacji Supabase. +- Supabase przechowuje schemat organizacji, kolekcji, segmentów i promptów, utrzymując pojedynczą aktywną wersję na wpis przy równoczesnym seedowaniu danych startowych dla 10xDevs. +- Interfejs administracyjny reużywa tabele, widoki i komponenty Rules Buildera, koncentrując się na edycji, publikacji i filtrowaniu promptów w ramach jednej organizacji. +- Interfejs członkowski udostępnia wybór organizacji, filtr kolekcji/segmentów oraz akcje kopiuj/pobierz dla opublikowanych promptów z zachowaniem spójności formatowania. + +## 2. Problem użytkownika +- Członkowie organizacji nie mają bezpiecznego katalogu promptów dopasowanego do ich ról; obecnie znalezienie aktualnych wersji wymaga ręcznego wyszukiwania lub proszenia administratorów. +- Administratorzy merytoryczni nie dysponują scentralizowanym narzędziem do kuracji promptów dla różnych organizacji, przez co trudno utrzymać spójność treści i kontrolować publikację. + +## 3. Wymagania funkcjonalne +- Feature flag i middleware: `PROMPT_MANAGER_ENABLED` musi warunkować ładowanie tras, a middleware ma weryfikować sesję Supabase, członkostwo organizacyjne i rolę (`member` lub `admin`). +- Zarządzanie organizacjami: schemat Supabase obejmuje tabele `organizations`, `organization_members`, `prompt_collections`, `prompt_collection_segments` oraz `prompts`, wraz z seedami dla 10xDevs i ograniczeniem ról w członkostwach. +- Interfejs admina: tabela z listą promptów, modal tworzenia/edycji, przypisanie do kolekcji/segmentu, przełącznik statusu draft/published oraz dostęp tylko dla ról `admin` w bieżącej organizacji. +- Interfejs członka: selector organizacji (domyślnie 10xDevs), filtry kolekcji/segmentu, lista promptów z markdown, akcje kopiuj i pobierz, widoczność wyłącznie opublikowanych wpisów. +- API i logika serwerowa: końcówki Astro `/api/prompts/admin/*` dla operacji CRUD z weryfikacją roli i `/api/prompts/*` dla listowania publikacji, wykorzystujące klucze Supabase (service role dla admin, anon dla member) z wymuszonym filtrowaniem po `organization_id`. +- Migracje i seedy: dwa pliki migracji z indeksami, domyślnymi wartościami, wstawieniem organizacji 10xDevs, kolekcji, segmentów i przykładowych promptów oraz instrukcją aktualizacji dokumentacji po wdrożeniu. +- Testy i dokumentacja: pokrycie Vitest dla flag, helperów dostępu i API, scenariusz Playwright admin→member, aktualizacja README i `.ai/test-plan.md` po ukończeniu MVP. + +## 4. Granice produktu +- Brak wersjonowania promptów, historii zmian oraz porównywania diffów; POC utrzymuje pojedynczą aktywną wersję wpisu. +- Brak telemetrycznych tabel `prompt_usage_logs`, brak opt-out oraz brak anonimowych statystyk do czasu kolejnych faz. +- Brak automatycznej lokalizacji, przełącznika języków i pipeline'u tłumaczeń; zawartość POC pozostaje w jednym języku z placeholderem komunikatu. +- Brak zaawansowanych workflow (bulk publish, szkielety ładowania, zaawansowane filtry) oraz brak dedykowanych magazynów stanów (Zustand/React Query) poza lokalnym stanem komponentów. +- Brak reguł RLS podczas POC; bezpieczeństwo egzekwują middleware i klucze serwisowe, co wymaga świadomego operacyjnego nadzoru. +- Brak integracji z zewnętrznymi rosterami i automatycznej synchronizacji członkostw; przypisania odbywają się ręcznie lub skryptem seedującym. + +## 5. Historyjki użytkowników +### US-001 +- ID: US-001 +- Tytuł: Sterowanie flagą Prompt Manager +- Opis: Jako operator platformy chcę móc włączać i wyłączać flagę `PROMPT_MANAGER_ENABLED`, aby bezpiecznie kontrolować rollout funkcji w środowiskach. +- Kryteria akceptacji: +1) `PROMPT_MANAGER_ENABLED` domyślnie ukrywa trasy `/prompts` i `/prompts/admin`; +2) Zmiana flagi w konfiguracji środowiska natychmiast blokuje lub udostępnia interfejs bez dodatkowego wdrożenia kodu; +3) Przy wyłączonej fladze użytkownicy widzą stan 404, a komponenty promptów nie są renderowane. + +### US-002 +- ID: US-002 +- Tytuł: Egzekwowanie uwierzytelnienia i członkostwa +- Opis: Jako użytkownik chcę, aby dostęp do promptów wymagał aktywnej sesji i członkostwa w organizacji, żeby treść była chroniona przed nieuprawnionymi osobami. +- Kryteria akceptacji: 1) Brak sesji Supabase skutkuje przekierowaniem do logowania przed wejściem na `/prompts`; 2) Użytkownik z sesją, lecz bez rekordu w `organization_members`, otrzymuje komunikat o konieczności uzyskania dostępu i nie widzi treści; 3) Middleware dopuszcza użytkowników jedynie, gdy istnieje powiązanie z aktywną organizacją przypisaną do flagi. + +### US-003 +- ID: US-003 +- Tytuł: Wybór organizacji i filtrów przez członka +- Opis: Jako członek organizacji chcę wybierać organizację, kolekcję i segment, aby szybko znaleźć odpowiednie prompty. +- Kryteria akceptacji: 1) Domyślna organizacja to pierwsza dostępna (10xDevs) i można ją zmienić; 2) Lista kolekcji i segmentów pobierana jest dla bieżącej organizacji; 3) Zastosowanie filtra odświeża listę promptów bez przeładowania strony; 4) Reset filtrów przy zmianie organizacji zapobiega pustym wynikom. + +### US-004 +- ID: US-004 +- Tytuł: Konsumpcja promptów przez członka +- Opis: Jako członek organizacji chcę oglądać treść promptów, kopiować ją do schowka i pobierać, aby używać ich w pracy. +- Kryteria akceptacji: 1) Publikowane prompty są renderowane w markdown zgodnie z projektem; 2) Akcja kopiuj zachowuje formatowanie kompatybilne z edytorami typu Cursor; 3) Akcja pobierz udostępnia plik tekstowy lub markdown z aktualną treścią; 4) Prompty w statusie draft nie są widoczne dla członków. + +### US-005 +- ID: US-005 +- Tytuł: Dostęp administratora do panelu kuracji +- Opis: Jako administrator organizacji chcę mieć dostęp do `/prompts/admin`, aby zarządzać promptami i kontrolować status publikacji. +- Kryteria akceptacji: 1) Użytkownik z rolą `admin` w `organization_members` może wejść na `/prompts/admin`; 2) Użytkownik `member` próbuje wejść na `/prompts/admin` i otrzymuje komunikat o braku uprawnień oraz przekierowanie do widoku członka; 3) Wejście na panel admina po wyłączeniu flagi skutkuje blokadą dostępu jak w historii US-001. + +### US-006 +- ID: US-006 +- Tytuł: Tworzenie i edycja draftów promptów +- Opis: Jako administrator chcę tworzyć i edytować prompty w wersji roboczej, aby przygotować treści przed publikacją. +- Kryteria akceptacji: 1) Formularz wymaga tytułu, kolekcji i treści markdown przed zapisem; 2) Zapis tworzy rekord w `prompts` powiązany z aktualną organizacją i kolekcją; 3) Edycja aktualizuje treść oraz `updated_at`, zachowując historię w Supabase; 4) Błędy walidacji wyświetlają się inline bez utraty wprowadzonych treści. + +### US-007 +- ID: US-007 +- Tytuł: Publikacja i cofnięcie publikacji +- Opis: Jako administrator chcę przełączać status promptu między draft a published, aby kontrolować jego dostępność dla członków. +- Kryteria akceptacji: 1) Przełącznik statusu zmienia pole `status` i natychmiast odświeża listę członków; 2) Prompty w statusie published pojawiają się w widoku członka po aktualizacji bez konieczności ponownego logowania; 3) Cofnięcie publikacji usuwa prompt z listy członka, pozostawiając go w panelu admina jako draft. + +### US-008 +- ID: US-008 +- Tytuł: Seedowanie organizacji i katalogu bazowego +- Opis: Jako inżynier wdrożeniowy chcę posiadać migracje i seedy tworzące organizację 10xDevs, kolekcje, segmenty i przykładowe prompty, aby środowiska startowały z kompletnymi danymi do demo. +- Kryteria akceptacji: 1) Migracje tworzą tabele z indeksami i ograniczeniami zgodnie ze schematem POC; 2) Seedy dodają organizację 10xDevs oraz minimum dwie przykładowe kolekcje i prompty; 3) Uruchomienie migracji wielokrotnie nie duplikuje danych dzięki klauzulom `on conflict do nothing`. + +### US-009 +- ID: US-009 +- Tytuł: Testy end-to-end ścieżki admin → member +- Opis: Jako zespół QA chcę wykonać scenariusz end-to-end tworzenia, publikacji i konsumpcji promptu, aby potwierdzić gotowość POC. +- Kryteria akceptacji: 1) Vitest pokrywa helpery flag, middleware oraz API admin/member z fixture wieloorganizacyjnym; 2) Playwright przeprowadza scenariusz stworzenia draftu, publikacji i odczytu przez członka; 3) Raport testów jest częścią artefaktów wdrożeniowych i wykorzystywany w przeglądzie POC. + +## 6. Metryki sukcesu +- Osiągnięcie 100% zgodności kontroli dostępu w testach QA (brak przypadków nieautoryzowanego wejścia na `/prompts`). +- Co najmniej dwóch administratorów 10xDevs jest w stanie stworzyć i opublikować prompt end-to-end w środowisku staging w czasie krótszym niż 10 minut. +- Minimum pięciu członków 10xDevs korzysta z widoku `/prompts`, a w logach telemetrycznych POC (manualnych) odnotowano co najmniej 10 akcji kopiuj/pobierz w tygodniu pilota. +- Migracje i seedy są uruchomione bez błędów w trzech środowiskach (local, integration, prod) i raportowane w README. +- Playwright smoke test admin → member przechodzi w pipeline CI przy każdym wdrożeniu z włączoną flagą. diff --git a/.ai/prompt-library/prompt-link-impl-plan.md b/.ai/prompt-library/prompt-link-impl-plan.md new file mode 100644 index 0000000..a2816c5 --- /dev/null +++ b/.ai/prompt-library/prompt-link-impl-plan.md @@ -0,0 +1,289 @@ +# Direct Linking to PromptDetail via URL Query Parameters - Implementation Plan + +## 🎯 Overview +Enable deep linking to specific prompts using organization, collection, and segment slugs or IDs via URL query parameters. This allows sharing links like: +- `/prompts?org=acme&collection=onboarding&segment=welcome&prompt=intro-message` +- `/prompts?org=uuid&collection=uuid&segment=uuid&prompt=uuid` + +## 🧠 Ultrathink Analysis + +### URL Design Considerations + +**Option A: Query Parameters (Recommended)** +``` +/prompts?org=acme&collection=onboarding&segment=welcome&prompt=abc123 +``` +✅ Pros: Clean separation from Astro routing, easy to make optional, backward compatible, works with existing page +✅ Best for: Links with optional hierarchy (can link to just org, or org+collection, etc.) +❌ Cons: Less SEO-friendly, doesn't look as "pretty" + +**Option B: Path Parameters** +``` +/prompts/acme/onboarding/welcome/abc123 +``` +✅ Pros: SEO-friendly, clean URLs, clear hierarchy +❌ Cons: Requires Astro dynamic routes ([org]/[collection]/[segment]/[prompt].astro), more complex routing, requires all params + +**Decision: Option A (Query Parameters)** +- Maintains existing `/prompts` page structure +- Supports partial deep links (e.g., just org+collection) +- Easier to implement incrementally +- Better UX for internal sharing + +### Slug vs ID Support Strategy + +**Database Schema Analysis:** +- `organizations`: has `slug` (unique, indexed) +- `prompt_collections`: has `slug` (unique per organization) +- `prompt_collection_segments`: has `slug` (unique per collection) +- `prompts`: has `id` only (UUID) + +**Lookup Strategy:** +1. Accept both slug and ID for org/collection/segment (fallback to ID if slug fails) +2. Use ID only for prompts (no slug field exists) +3. Normalize slugs (lowercase, trim) before lookup + +### State Hydration Flow + +```mermaid +1. Page loads → Parse URL params +2. If params exist → Pause normal store initialization +3. Fetch organization by slug/ID +4. Set as activeOrganization +5. Fetch collections → Find collection by slug/ID +6. Set selectedCollectionId filter +7. Fetch segments → Find segment by slug/ID +8. Set selectedSegmentId filter +9. Fetch prompts with filters +10. Find prompt by ID → selectPrompt(id) +11. PromptDetail modal opens automatically +``` + +### Error Handling Philosophy + +**Graceful Degradation:** +- Invalid org slug → Show organization selector (don't auto-select) +- Invalid collection slug → Show all collections +- Invalid segment slug → Show all segments +- Invalid prompt ID → Don't open modal, maybe show toast +- User feedback via toast notifications for each failure point + +### URL Sync Strategy + +**One-way vs Two-way:** +- **One-way (Recommended)**: URL → State only on initial load + - Simpler implementation + - No history pollution + - User can navigate freely without URL updates + +- **Two-way**: State ↔ URL bidirectional sync + - Better for browser back/forward + - More complex (need debouncing, history management) + - Could implement later as enhancement + +**Decision**: One-way for now + +## 📋 Implementation Plan + +### Part 1: URL Utilities + +**File: `src/utils/urlParams.ts`** +```typescript +export interface PromptLinkParams { + org?: string; // slug or ID + collection?: string; // slug or ID + segment?: string; // slug or ID + prompt?: string; // ID only +} + +export function parsePromptParams(url: URL): PromptLinkParams +export function buildPromptUrl(params: PromptLinkParams): string +export function isUUID(value: string): boolean +``` + +### Part 2: Lookup Services + +**File: `src/services/prompt-manager/lookupService.ts`** + +Add functions: +- `findCollectionBySlugOrId(orgId: string, slugOrId: string): Promise` +- `findSegmentBySlugOrId(collectionId: string, slugOrId: string): Promise` + +Reuse existing: +- `fetchOrganizationBySlug()` from organizations.ts + +### Part 3: Store Enhancement + +**File: `src/store/promptsStore.ts`** + +Add new action: +```typescript +hydrateFromUrl: (params: PromptLinkParams) => Promise<{ + success: boolean; + errors: string[]; +}>; +``` + +Implementation: +1. Try to resolve organization (by slug or ID) +2. If found, fetch collections +3. Try to resolve collection (by slug or ID) +4. If found, fetch segments +5. Try to resolve segment (by slug or ID) +6. Fetch prompts with filters +7. Try to find prompt by ID +8. If found, call selectPrompt(id) +9. Return success status + array of errors + +### Part 4: PromptsBrowser Enhancement + +**File: `src/components/prompt-manager/PromptsBrowser.tsx`** + +Modify `useEffect` initialization: +```typescript +useEffect(() => { + const params = parsePromptParams(new URL(window.location.href)); + + if (hasValidParams(params)) { + // Deep link mode + hydrateFromUrl(params).then(result => { + if (result.errors.length > 0) { + // Show toast for each error + result.errors.forEach(showToast); + } + }); + } else { + // Normal mode + setPreferredLanguage(loadLanguagePreference()); + fetchOrganizations(); + } +}, []); +``` + +### Part 5: API Enhancements (if needed) + +**File: `src/pages/api/prompts/collections.ts`** +- Already supports org filtering ✓ + +**File: `src/pages/api/prompts/collections/[id]/segments.ts`** +- Already supports collection filtering ✓ + +**New File: `src/pages/api/prompts/collections/by-slug.ts`** +```typescript +GET /api/prompts/collections/by-slug?org_id=xxx&slug=yyy +// Returns collection or 404 +``` + +**New File: `src/pages/api/prompts/segments/by-slug.ts`** +```typescript +GET /api/prompts/segments/by-slug?collection_id=xxx&slug=yyy +// Returns segment or 404 +``` + +### Part 6: PromptDetail Enhancement + +**File: `src/components/prompt-manager/PromptDetail.tsx`** + +Add "Share" button that copies current URL with params: +```typescript +const handleShare = () => { + const url = buildPromptUrl({ + org: activeOrganization.slug, + collection: collection?.slug, + segment: segment?.slug, + prompt: selectedPrompt.id + }); + navigator.clipboard.writeText(url); + toast.success('Link copied!'); +}; +``` + +## 🧪 Testing Strategy + +### Unit Tests +- `parsePromptParams()` with various URL formats +- `buildPromptUrl()` URL generation +- `isUUID()` validation +- Lookup services with mock data + +### Integration Tests (Playwright) +1. Direct link with all valid params → modal opens automatically +2. Direct link with invalid org slug → shows org selector +3. Direct link with invalid collection slug → shows collections list +4. Direct link with invalid prompt ID → shows toast error +5. Direct link with partial params (org only) → filters applied +6. Share button copies correct URL + +### Edge Cases +- URL params with special characters (URL encoding) +- Multiple tabs with different links (isolation) +- Back/forward browser navigation +- Expired/deleted prompts +- User without org access (redirects to access request) + +## 📦 Deliverables + +1. ✅ `src/utils/urlParams.ts` - URL parsing and building utilities +2. ✅ `src/services/prompt-manager/lookupService.ts` - Slug/ID lookup functions +3. ✅ `src/store/promptsStore.ts` - Add `hydrateFromUrl()` action +4. ✅ `src/components/prompt-manager/PromptsBrowser.tsx` - URL initialization logic +5. ✅ `src/pages/api/prompts/collections/by-slug.ts` - Collection lookup endpoint +6. ✅ `src/pages/api/prompts/segments/by-slug.ts` - Segment lookup endpoint +7. ✅ `src/components/prompt-manager/PromptDetail.tsx` - Share button +9. ✅ Update `.ai/prompt-manager/prompt-link-impl-plan.md` (this file) + +## 🚀 Implementation Phases + +**Phase 1: Core Infrastructure (2-3 hours)** +- URL utilities (parsing, building, validation) +- Lookup service functions +- Unit tests + +**Phase 2: Store Integration (1-2 hours)** +- `hydrateFromUrl()` action +- Error handling +- Loading states + +**Phase 3: UI Integration (2-3 hours)** +- PromptsBrowser URL initialization +- Toast notifications for errors +- Loading indicators + +**Phase 4: API Endpoints (1-2 hours)** +- Slug lookup endpoints +- Error responses +- Integration tests + +**Phase 5: Enhancements & Testing (2-3 hours)** +- Share button in PromptDetail +- E2E test suite +- Documentation + +**Total Estimate: 8-13 hours** + +## 🔒 Security Considerations + +1. **Authorization**: Verify user has access to organization before resolving slugs +2. **Input validation**: Sanitize all URL params before DB queries +3. **Rate limiting**: Consider rate limits on lookup endpoints (DoS prevention) +4. **XSS prevention**: URL params displayed in UI must be escaped +5. **SSRF prevention**: Validate UUIDs match expected format + +## 📚 Documentation Updates + +- Update user documentation with deep linking examples +- Add API documentation for new lookup endpoints +- Include URL structure in sharing feature guide + +## 🎁 Future Enhancements + +1. **Two-way URL sync**: Update URL as user navigates (useEffect + history.replaceState) +2. **SEO optimization**: Add Open Graph meta tags based on URL params +3. **Analytics**: Track deep link usage (which orgs/collections most shared) +4. **QR codes**: Generate QR codes for prompt links +5. **Short URLs**: Service to create shortened prompt links (e.g., `/p/abc123`) +6. **Path-based routing**: Migrate to `/prompts/:org/:collection/:segment/:id` for better SEO + +--- + +**Ready to implement? This plan provides a solid foundation for deep linking while maintaining backward compatibility and user experience.** diff --git a/.ai/prompt-library/prompt-link-reload-plan.md b/.ai/prompt-library/prompt-link-reload-plan.md new file mode 100644 index 0000000..90fed64 --- /dev/null +++ b/.ai/prompt-library/prompt-link-reload-plan.md @@ -0,0 +1,318 @@ +# Direct Link Screen Flashing - Root Cause Analysis + +## 🧠 Ultrathink Deep Analysis + +### Observed Problem +When opening a direct link like: +``` +http://localhost:3000/prompts?org=10xdevs&collection=advanced&segment=performance&prompt=xxx +``` + +User sees the **full list blinking 2-3 times** before the prompt detail modal opens. + +--- + +## 🔍 Root Cause Analysis + +### ROOT CAUSE #1: `fetchOrganizations()` loads WRONG organization's data + +**Location:** `src/store/promptsStore.ts:138-171` + +**The Problem:** +When `hydrateFromUrl()` is called (line 363): +1. Line 381-383: Checks if organizations are loaded, if not calls `fetchOrganizations()` +2. `fetchOrganizations()` auto-selects `organizations[0]` as active (line 151) +3. It then fetches **collections/segments/prompts for organizations[0]** (line 160) +4. Sets `isLoading: false` (line 164) → **USER SEES WRONG ORG'S DATA** +5. Returns control to `hydrateFromUrl()` +6. `hydrateFromUrl()` finds the CORRECT org from URL params (line 386-393) +7. Clears state and fetches data for the CORRECT org (line 402-413) +8. User sees the correct org's data + +**Result:** User sees **Flash #1** (wrong org's prompts) → **Flash #2** (correct org's prompts) + +**Code Flow:** +```typescript +// hydrateFromUrl line 382 +if (!organizations.length) { + await get().fetchOrganizations(); // ← Loads wrong org! +} + +// fetchOrganizations line 151 +const activeOrg = get().activeOrganization || organizations[0] || null; +// ↑ Defaults to organizations[0], which may not match URL param! + +// fetchOrganizations line 160 +if (activeOrg) { + await get().fetchCollections(activeOrg.id, true); // ← Fetches data for wrong org +} + +// fetchOrganizations line 164 +set({ isLoading: false }); // ← USER SEES THE WRONG DATA +``` + +**Why This Happens:** +- `fetchOrganizations()` was designed for normal navigation (no URL params) +- It assumes "first organization" is a sensible default +- But in deep-link mode, we KNOW which org we want from URL params +- The wrong org's data loads and displays before the right org is resolved + +--- + +### ROOT CAUSE #2: Multiple `set()` calls cause cascading re-renders + +**Location:** Throughout `hydrateFromUrl()` and nested fetch functions + +**The Problem:** +Even after fixing Root Cause #1, there are still **8+ separate `set()` calls** during hydration: + +| # | Line | set() Call | Effect | +|---|------|-----------|---------| +| 1 | 368 | `set({ isLoading: true })` | Loading starts | +| 2 | 155 | `set({ organizations, activeOrganization })` | Orgs loaded (from fetchOrganizations) | +| 3 | 203 | `set({ collections })` | Collections loaded | +| 4 | 242 | `set({ segments })` | Segments loaded (called N times for N collections) | +| 5 | 305 | `set({ prompts })` | Prompts loaded (unfiltered) | +| 6 | 443 | `set({ selectedCollectionId, selectedSegmentId })` | Filters applied | +| 7 | 320 | `set({ selectedPromptId })` | Prompt selected (via selectPrompt) | +| 8 | 465 | `set({ isLoading: false })` | Loading ends | + +**Each `set()` call triggers a React re-render**, even if `isLoading` is true. + +**Result:** Even with a persistent loading indicator, the DOM is being updated 8+ times, causing visual flashing/repainting. + +**Why This Happens:** +- Zustand triggers re-renders on every `set()` call +- We're updating state incrementally as data arrives +- React must reconcile the component tree on each state change +- This causes browser repaints even if content looks the same (loading indicator) + +--- + +### ROOT CAUSE #3: PromptsList renders with unfiltered data before filters are applied + +**Location:** `src/store/promptsStore.ts:436-446` + +**The Problem:** +The data loading sequence creates a timing window where prompts are loaded but filters aren't applied yet: + +```typescript +// Line 436-440: Fetch prompts with filters applied SERVER-SIDE +await get().fetchPrompts({ + organizationId: targetOrg.id, + collectionId: targetCollection?.id, + segmentId: targetSegment?.id, +}, true); +// At this point: prompts array has FILTERED data +// But: selectedCollectionId and selectedSegmentId are still NULL + +// Line 443-446: Apply filters CLIENT-SIDE +set({ + selectedCollectionId: targetCollection?.id || null, + selectedSegmentId: targetSegment?.id || null, +}); +// Now filters are applied +``` + +**Wait, this shouldn't cause the issue because:** +- The prompts are already filtered server-side (line 436-440) +- Setting selectedCollectionId/selectedSegmentId is just for UI display (dropdowns) +- PromptsList doesn't do client-side filtering based on selectedCollectionId + +**Re-analyzing:** Actually, this is NOT a root cause. The prompts array is correct from the start. + +**Revised ROOT CAUSE #3: `fetchCollections()` fetches unfiltered prompts first** + +**Location:** `src/store/promptsStore.ts:193-221` + +Looking at `fetchCollections()` when called from `fetchOrganizations()` or `hydrateFromUrl()`: + +```typescript +// Line 207: Fetch segments for ALL collections +await Promise.all(collections.map((collection) => get().fetchSegments(collection.id, true))); + +// Line 210: Fetch prompts for the ENTIRE organization (no collection/segment filter) +await get().fetchPrompts({ organizationId: orgId }, true); +``` + +**Aha!** When `fetchCollections()` is called (line 413 in hydrateFromUrl), it: +1. Fetches collections → **RENDER** +2. Fetches segments for all collections → **MULTIPLE RENDERS** +3. Fetches **ALL prompts for the org** (line 210) → **RENDER with UNFILTERED list** +4. Then hydrateFromUrl continues and fetches prompts again with filters (line 436-440) → **RENDER with FILTERED list** + +**Result:** User sees **all organization prompts** → then **filtered prompts** + +**Why This Happens:** +- `fetchCollections()` was designed for normal navigation +- It assumes user wants to see ALL prompts for the org by default +- But in deep-link mode, we want to show FILTERED prompts immediately +- The unfiltered fetch (line 210) is redundant and causes flash + +--- + +## 📊 Flash Sequence Breakdown + +**Current user experience when loading direct link:** + +| Time | State | What User Sees | Caused By | +|------|-------|----------------|-----------| +| T0 | Page loads | Empty/Loading | Initial mount | +| T1 | fetchOrganizations() completes | **FLASH #1: Wrong org's prompts** | Root Cause #1 | +| T2 | hydrateFromUrl() sets correct org | Loading indicator | isLoading stays true | +| T3 | fetchCollections() completes | **FLASH #2: All prompts (unfiltered)** | Root Cause #3 | +| T4 | Filtered prompts fetched | **FLASH #3: Filtered prompts** | Extra fetch | +| T5 | selectedPromptId set | Prompt detail modal opens | Correct behavior | + +**Optimized experience should be:** + +| Time | State | What User Sees | Changes Needed | +|------|-------|----------------|----------------| +| T0 | Page loads | Loading indicator | ✅ Already works | +| T1 | All data loaded | Filtered prompts + modal | Fix all 3 root causes | + +--- + +## 🎯 Solutions + +### Solution 1: Add `skipAutoLoad` parameter to `fetchOrganizations()` + +**Change:** Don't auto-load collections/prompts when called from `hydrateFromUrl()` + +```typescript +fetchOrganizations: async (skipAutoLoad = false) => { + // ... fetch organizations ... + + // Only auto-load if NOT in deep-link mode + if (activeOrg && !skipAutoLoad) { + await get().fetchCollections(activeOrg.id, true); + } + + set({ isLoading: false }); +} + +// In hydrateFromUrl: +if (!organizations.length) { + await get().fetchOrganizations(true); // Skip auto-load +} +``` + +**Pros:** Simple, minimal changes +**Cons:** Adds another boolean flag (code smell) + +--- + +### Solution 2: Separate `fetchOrganizationsList()` from `fetchOrganizations()` + +**Change:** Split into two functions: +- `fetchOrganizationsList()`: Just loads org list, no auto-selection +- `fetchOrganizations()`: Loads orgs + auto-selects + loads data (current behavior) + +```typescript +fetchOrganizationsList: async () => { + const response = await fetch('/api/prompt-manager/organizations'); + const data = await response.json(); + set({ organizations: data.organizations || [] }); +}, + +// hydrateFromUrl calls the lightweight version: +if (!organizations.length) { + await get().fetchOrganizationsList(); +} +``` + +**Pros:** Cleaner separation of concerns, more explicit +**Cons:** More code, need to update call sites + +--- + +### Solution 3: Don't call `fetchPrompts()` from `fetchCollections()` in deep-link mode + +**Change:** Add a parameter to skip the automatic prompt fetch + +```typescript +fetchCollections: async (orgId: string, skipLoadingToggle = false, skipPromptFetch = false) => { + // ... fetch collections and segments ... + + // Only fetch prompts if not in deep-link mode + if (!skipPromptFetch) { + await get().fetchPrompts({ organizationId: orgId }, true); + } +} + +// In hydrateFromUrl: +await get().fetchCollections(targetOrg.id, true, true); // Skip both loading toggle and prompt fetch +``` + +**Pros:** Fixes Root Cause #3 directly +**Cons:** Another boolean parameter (code smell), parameter list getting long + +--- + +### Solution 4: Batch ALL state updates into a single `set()` at the end + +**Change:** Fetch all data first, then update state once + +```typescript +hydrateFromUrl: async (params: PromptLinkParams) => { + set({ isLoading: true }); + + // Fetch all data WITHOUT updating state + const orgsData = await fetchOrganizationsAPI(); + const targetOrg = findOrgBySlug(orgsData, params.org); + const collectionsData = await fetchCollectionsAPI(targetOrg.id); + const targetCollection = findCollectionBySlug(collectionsData, params.collection); + const segmentsData = await fetchSegmentsAPI(targetCollection.id); + const targetSegment = findSegmentBySlug(segmentsData, params.segment); + const promptsData = await fetchPromptsAPI(filters); + + // Single atomic state update + set({ + organizations: orgsData, + activeOrganization: targetOrg, + collections: collectionsData, + segments: segmentsData, + prompts: promptsData, + selectedCollectionId: targetCollection.id, + selectedSegmentId: targetSegment.id, + selectedPromptId: params.prompt, + isLoading: false, + }); +} +``` + +**Pros:** **ELIMINATES ALL FLASHING** - only 2 renders (loading → loaded) +**Cons:** Requires refactoring to separate API calls from state updates, more complex + +--- + +## 🏆 Recommended Solution + +**Hybrid approach combining Solutions 2, 3, and 4 (partially):** + +1. **Create `fetchOrganizationsList()`** to load orgs without side effects +2. **Add `skipPromptFetch` parameter to `fetchCollections()`** +3. **Batch filter state updates** (already done in line 443-446) +4. **Keep isLoading true throughout the entire hydration** (already done) + +This gives us: +- ✅ No wrong-org data flash (Solution 2) +- ✅ No unfiltered prompts flash (Solution 3) +- ✅ Minimal changes to existing code +- ✅ Clear intent (deep-link mode vs normal mode) + +--- + +## 📝 Implementation Checklist + +- [ ] Create `fetchOrganizationsList()` that only fetches org list +- [ ] Add `skipPromptFetch` parameter to `fetchCollections()` +- [ ] Update `hydrateFromUrl()` to use `fetchOrganizationsList()` +- [ ] Update `hydrateFromUrl()` to pass `skipPromptFetch: true` to `fetchCollections()` +- [ ] Remove redundant `fetchPrompts()` call from hydrateFromUrl (line 436-440 becomes the only fetch) +- [ ] Test deep-link scenarios with different orgs/collections/segments +- [ ] Verify normal navigation still works (non-deep-link mode) + +--- + +**Expected Result:** User sees loading indicator → then directly sees filtered prompts + modal (no flashing) diff --git a/.ai/prompt-library/rename-to-library-plan.md b/.ai/prompt-library/rename-to-library-plan.md new file mode 100644 index 0000000..b9f6a00 --- /dev/null +++ b/.ai/prompt-library/rename-to-library-plan.md @@ -0,0 +1,315 @@ +# Refactor Plan: "prompt-manager" → "prompt-library" + +## Overview +This plan covers a comprehensive rename from "prompt-manager" to "prompt-library" across the codebase, including directories, files, code references, API routes, and documentation. The database table names will remain unchanged as they are already in production. + +--- + +## 1. Directory Renames + +### Source Directories +- `src/services/prompt-manager/` → `src/services/prompt-library/` +- `src/components/prompt-manager/` → `src/components/prompt-library/` +- `src/pages/api/prompt-manager/` → `src/pages/api/prompt-library/` +- `src/features/prompt-manager/` → (empty, can be deleted) + +### Test Directories +- `tests/unit/services/prompt-manager/` → `tests/unit/services/prompt-library/` + +### Documentation Directories +- `docs/prompt-manager/` → `docs/prompt-library/` +- `.ai/prompt-manager/` → `.ai/prompt-library/` + +### Build Artifacts +- `dist/_worker.js/pages/api/prompt-manager` (will regenerate automatically on build) + +--- + +## 2. File Renames + +### Test Files +- `tests/fixtures/promptManagerFixtures.ts` → `tests/fixtures/promptLibraryFixtures.ts` +- `tests/unit/middleware/promptManagerMiddleware.test.ts` → `tests/unit/middleware/promptLibraryMiddleware.test.ts` +- `tests/unit/services/promptManagerAccess.test.ts` → `tests/unit/services/promptLibraryAccess.test.ts` + +--- + +## 3. Code Reference Updates + +### 3.1 Import Path Updates +All imports from `@/services/prompt-manager/*` → `@/services/prompt-library/*` +All imports from `@/components/prompt-manager/*` → `@/components/prompt-library/*` + +**Affected files (63 files total):** +- All API route files in `src/pages/api/prompts/**/*.ts` +- `src/middleware/index.ts` +- `src/env.d.ts` +- `src/store/promptsStore.ts` +- All component files in `src/components/prompt-manager/**/*.tsx` +- All test files + +### 3.2 Variable & Function Name Updates + +**Pattern: `promptManager` → `promptLibrary`** +- `locals.promptManager` → `locals.promptLibrary` (in env.d.ts and all API routes) +- `buildPromptManagerContext` → `buildPromptLibraryContext` +- `hasPromptManagerAccess` → `hasPromptLibraryAccess` +- `hasPromptManagerAdminAccess` → `hasPromptLibraryAdminAccess` +- `ensurePromptManagerEnabled` → `ensurePromptLibraryEnabled` +- `shouldAllowPromptManagerAccess` → `shouldAllowPromptLibraryAccess` +- `shouldAllowPromptManagerAdminAccess` → `shouldAllowPromptLibraryAdminAccess` + +**Pattern: `PromptManager` → `PromptLibrary`** (Type names) +- `PromptManagerContext` → `PromptLibraryContext` +- `PromptManagerContextOptions` → `PromptLibraryContextOptions` + +### 3.3 Constant Name Updates + +**Pattern: `PROMPT_MANAGER_*` → `PROMPT_LIBRARY_*`** +- `PROMPT_MANAGER_ENABLED` → `PROMPT_LIBRARY_ENABLED` +- `PROMPT_MANAGER_BASE_PATH` → `PROMPT_LIBRARY_BASE_PATH` +- `PROMPT_MANAGER_ADMIN_PATH` → `PROMPT_LIBRARY_ADMIN_PATH` +- `PROMPT_MANAGER_REQUEST_ACCESS_PATH` → `PROMPT_LIBRARY_REQUEST_ACCESS_PATH` +- `PROMPT_MANAGER_API_PATH` → `PROMPT_LIBRARY_API_PATH` +- `PROMPT_MANAGER_OVERRIDE_KEYS` → `PROMPT_LIBRARY_OVERRIDE_KEYS` +- `TEXT_PROMPT_MANAGER_DISABLED` → `TEXT_PROMPT_LIBRARY_DISABLED` + +### 3.4 Function Name Updates (middleware) +- `isPromptManagerRoute` → `isPromptLibraryRoute` +- `isPromptManagerAdminRoute` → `isPromptLibraryAdminRoute` +- `promptManagerFlagDisabledResponse` → `promptLibraryFlagDisabledResponse` +- `isPromptManagerEnabled` → `isPromptLibraryEnabled` +- `readPromptManagerOverride` → `readPromptLibraryOverride` + +--- + +## 4. Feature Flag Updates + +### 4.1 Feature Flag Name +In `src/features/featureFlags.ts`: +- Type: `'promptManager'` → `'promptLibrary'` +- Constant export: `PROMPT_MANAGER_ENABLED` → `PROMPT_LIBRARY_ENABLED` +- Array: `PROMPT_MANAGER_ENABLED` → `PROMPT_LIBRARY_ENABLED` in FEATURE_KEYS + +### 4.2 Environment Variables +- `PUBLIC_PROMPT_MANAGER_ENABLED` → `PUBLIC_PROMPT_LIBRARY_ENABLED` +- `PROMPT_MANAGER_ENABLED` → `PROMPT_LIBRARY_ENABLED` + +Update in: +- `.env.local` (if exists) +- `.env.example` (if exists) +- CI/CD configuration files +- Deployment configuration + +--- + +## 5. API Route Updates + +### 5.1 Route Path Changes +- `/api/prompt-manager/organizations` → `/api/prompt-library/organizations` + +**Affected file:** +- `src/pages/api/prompt-manager/organizations.ts` (move to `src/pages/api/prompt-library/organizations.ts`) + +### 5.2 Client-side API Calls +Update fetch URLs in: +- `src/store/promptsStore.ts`: Line 149 `/api/prompt-manager/organizations` → `/api/prompt-library/organizations` +- `src/store/promptsStore.ts`: Line 184 `/api/prompt-manager/organizations` → `/api/prompt-library/organizations` + +### 5.3 Important: URL Paths That DON'T Change +These paths use `/prompts` (not `/prompt-manager`) and should **NOT** be changed: +- `/prompts/*` (all user-facing routes) +- `/prompts/admin/*` (admin routes) +- `/api/prompts/*` (all prompt API routes) + +--- + +## 6. Type Definition Updates + +### 6.1 In env.d.ts +```typescript +// Line 22-26 +promptLibrary?: { + organizations: OrganizationMembership[]; + activeOrganization: OrganizationMembership | null; + flagEnabled: boolean; +}; +``` + +Import update: +```typescript +import type { OrganizationMembership } from './services/prompt-library/access'; +``` + +--- + +## 7. Comment & Documentation Updates + +### 7.1 Code Comments +Update all comments containing "Prompt Manager" → "Prompt Library": +- In migration files (comments only, NOT table names) +- In service files +- In middleware +- In test files + +### 7.2 Documentation Files +Update content in: +- `docs/prompt-manager/admin-api.md` (move to `docs/prompt-library/`) +- All files in `.ai/prompt-manager/` (move to `.ai/prompt-library/`) +- Update PRD references to "Prompt Library POC" + +### 7.3 Error Messages & User-Facing Text +- `TEXT_PROMPT_MANAGER_DISABLED = 'Prompt Manager is not available.'` → `'Prompt Library is not available.'` +- Any UI text referencing "Prompt Manager" + +--- + +## 8. Database Considerations + +### ⚠️ CRITICAL: DO NOT CHANGE +- Table names: `organizations`, `prompt_collections`, `prompt_collection_segments`, `prompts` +- These are already in production and should remain unchanged + +### CAN UPDATE +- Comments in migration files referencing "Prompt Manager" +- New migration files can use "Prompt Library" naming convention + +--- + +## 9. Test Updates + +### 9.1 Test File Content +Update all test descriptions, assertions, and comments: +- Change "Prompt Manager" → "Prompt Library" in test names +- Update feature flag checks +- Update middleware test cases + +### 9.2 Integration Tests +- `tests/integration/prompt-admin-flow.test.ts` +- `tests/integration/prompt-language.test.ts` +- `tests/integration/invite-flow.test.ts` + +--- + +## 10. Build & Configuration + +### Files to Check/Update +- `package.json` - Check for any scripts mentioning prompt-manager +- `astro.config.mjs` - Check for any references +- `tsconfig.json` - Check path aliases +- CI/CD workflows - Update environment variable names + +--- + +## 11. Execution Order + +### Phase 1: Preparation +1. Create backup branch +2. Run full test suite to establish baseline +3. Document current environment variable values + +### Phase 2: Directory & File Renames +1. Rename directories (services, components, pages/api, tests, docs, .ai) +2. Rename individual files (fixtures, test files) + +### Phase 3: Code Updates +1. Update all import paths +2. Update variable, function, and type names +3. Update constants and feature flags +4. Update API client calls + +### Phase 4: Documentation & Comments +1. Update code comments +2. Update documentation files +3. Update error messages + +### Phase 5: Configuration +1. Update environment variables +2. Update build configurations + +### Phase 6: Testing & Verification +1. Run linter: `npm run lint` +2. Run tests: `npm test` +3. Build project: `npm run build` +4. Manual testing of all prompt-library features +5. Verify API routes work correctly + +--- + +## 12. Files Requiring Updates (Summary) + +**Total estimated files: ~80-100 files** + +Key file groups: +- 10 service files in `src/services/prompt-manager/` +- 10 component files in `src/components/prompt-manager/` +- ~30 API route files using imports +- ~15 test files +- ~25 documentation/planning files +- 3-5 configuration files +- 1 middleware file +- 1 store file +- 1 env.d.ts file +- 1 feature flags file + +--- + +## 13. Risk Assessment + +### Low Risk +- Directory and file renames (automated tools available) +- Import path updates (IDE can handle) +- Variable name updates within functions + +### Medium Risk +- Feature flag changes (requires environment updates) +- API route path changes (requires client update sync) + +### High Risk +- Database references (already mitigated - NOT changing table names) +- Breaking changes in production (mitigated by feature flag) + +--- + +## 14. Rollback Plan + +If issues arise: +1. Revert to backup branch +2. Keep feature flag disabled during fix +3. Database tables unchanged, so no data risk +4. Environment variables can be quickly reverted + +--- + +## 15. Search & Replace Patterns + +### Exact Matches (Case Sensitive) +``` +prompt-manager → prompt-library +promptManager → promptLibrary +PromptManager → PromptLibrary +PROMPT_MANAGER → PROMPT_LIBRARY +Prompt Manager → Prompt Library +``` + +### Files to Exclude from Search/Replace +- `node_modules/` +- `dist/` +- `.git/` +- `package-lock.json` +- Database migration SQL files (for table names) + +--- + +## 16. Post-Refactor Checklist + +- [ ] All tests passing +- [ ] Linter clean +- [ ] Build successful +- [ ] No broken imports +- [ ] API routes responding correctly +- [ ] Feature flag working +- [ ] Environment variables updated +- [ ] Documentation updated +- [ ] Git history clean (meaningful commit messages) +- [ ] PR created with detailed description diff --git a/.ai/prompt-library/rls-plan.md b/.ai/prompt-library/rls-plan.md new file mode 100644 index 0000000..abd948f --- /dev/null +++ b/.ai/prompt-library/rls-plan.md @@ -0,0 +1,474 @@ +# Comprehensive RLS Alignment Plan + +## Current State Analysis + +### Critical Gaps Identified + +**Missing RLS Policies:** +1. `prompts` table - NO RLS policies (high risk) +2. `prompt_collections` table - NO RLS policies +3. `prompt_collection_segments` table - NO RLS policies + +**Architectural Inconsistency:** +- Prompt services use `supabaseAdmin` (service_role) → bypasses RLS +- Invite services use authenticated client → respects RLS +- All authorization currently happens at application layer only + +### Security Risks + +Without RLS on prompt tables: +- Direct database access could bypass app authorization +- Compromised service key could expose all data +- No defense-in-depth protection +- Inconsistent with invite system's security model + +## Confirmed Decisions + +### ✅ Decision 1: RLS Strategy +**Selected:** Option A - Add RLS + migrate to authenticated client for defense-in-depth + +### ✅ Decision 2: Prompt Access Rules +- **Members:** See only published prompts +- **Admins:** See all prompts (draft + published) +- **Isolation:** Full org isolation - prompts only visible within org context + +### ✅ Decision 3: Collection/Segment Visibility +**All org members** can browse collections/segments to navigate the structure + +## Implementation Plan + +### Phase 1: Add RLS Policies (Migration) + +**Create new migration:** `add_prompt_manager_rls.sql` + +1. **Enable RLS on tables:** + ```sql + ALTER TABLE prompts ENABLE ROW LEVEL SECURITY; + ALTER TABLE prompt_collections ENABLE ROW LEVEL SECURITY; + ALTER TABLE prompt_collection_segments ENABLE ROW LEVEL SECURITY; + ALTER TABLE user_consents ENABLE ROW LEVEL SECURITY; + ``` + +2. **Prompts table policies:** + - SELECT: Members see published prompts in their orgs, admins see all + - INSERT/UPDATE/DELETE: Org admins only + - Filter by organization_id using org membership + +3. **Prompt Collections policies:** + - SELECT: All org members + - INSERT/UPDATE/DELETE: Org admins only + +4. **Prompt Collection Segments policies:** + - SELECT: All org members (via collection membership) + - INSERT/UPDATE/DELETE: Org admins only + +5. **User Consents policies:** + - SELECT/INSERT/UPDATE: Own consents only + - Use auth.uid() = user_id pattern + +### Phase 2: Migrate Services to Authenticated Client + +1. **Update prompt services** (`src/services/prompt-manager/promptService.ts`): + - Replace `supabaseAdmin` import with `SupabaseClient` type + - Add `supabase` parameter as first argument to all functions + - Pass `supabase` from API endpoint `locals.supabase` + - Keep organization_id filters for explicit intent (defense-in-depth) + - Keep app-layer admin role checks + +2. **Update collection services** (`src/services/prompt-manager/promptCollectionService.ts`): + - Same pattern as prompt services + - Add `supabase` parameter to all functions + +3. **Update all API endpoints** that call these services: + - `src/pages/api/prompts/admin/prompts.ts` + - `src/pages/api/prompts/admin/prompts/[id].ts` + - `src/pages/api/prompts/admin/prompts/[id]/publish.ts` + - `src/pages/api/prompts/admin/prompt-collections.ts` + - `src/pages/api/prompts/admin/prompt-collections/[id]/segments.ts` + - `src/pages/api/prompts/index.ts` + - `src/pages/api/prompts/[id].ts` + - `src/pages/api/prompts/collections.ts` + - `src/pages/api/prompts/collections/by-slug.ts` + - `src/pages/api/prompts/collections/[id]/segments.ts` + - `src/pages/api/prompts/segments/by-slug.ts` + - Pass `locals.supabase` to service functions + +### Phase 3: Testing & Validation + +1. **Test member access:** + - Can view published prompts only + - Cannot create/update/delete + - Cannot see other orgs' data + +2. **Test admin access:** + - Can view all prompts (draft + published) + - Can create/update/delete in their org + - Cannot access other orgs' data + +3. **Test invite flow:** + - Validation still works (anon access) + - Redemption still works + +### Phase 4: Grant Alignment + +Verify grants in comprehensive RLS setup migration: +- ✅ Organizations: authenticated, service_role +- ✅ Organization Members: authenticated, service_role +- ✅ Organization Invites: authenticated, service_role, anon +- ❌ Prompts: Need authenticated, service_role grants +- ❌ Prompt Collections: Need authenticated, service_role grants +- ❌ Prompt Collection Segments: Need authenticated, service_role grants + +## Recommended RLS Policies (Detailed) + +### Prompts Table + +```sql +-- Members can view published prompts in their orgs +CREATE POLICY "Members can view published prompts" +ON prompts FOR SELECT +TO authenticated +USING ( + status = 'published' AND + EXISTS ( + SELECT 1 FROM organization_members + WHERE organization_id = prompts.organization_id + AND user_id = auth.uid() + ) +); + +-- Admins can view all prompts in their orgs +CREATE POLICY "Admins can view all prompts" +ON prompts FOR SELECT +TO authenticated +USING ( + is_org_admin(organization_id, auth.uid()) +); + +-- Admins can insert prompts +CREATE POLICY "Admins can create prompts" +ON prompts FOR INSERT +TO authenticated +WITH CHECK ( + is_org_admin(organization_id, auth.uid()) +); + +-- Admins can update prompts in their org +CREATE POLICY "Admins can update prompts" +ON prompts FOR UPDATE +TO authenticated +USING (is_org_admin(organization_id, auth.uid())) +WITH CHECK (is_org_admin(organization_id, auth.uid())); + +-- Admins can delete prompts in their org +CREATE POLICY "Admins can delete prompts" +ON prompts FOR DELETE +TO authenticated +USING (is_org_admin(organization_id, auth.uid())); +``` + +### Prompt Collections Table + +```sql +-- All org members can view collections +CREATE POLICY "Members can view collections" +ON prompt_collections FOR SELECT +TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM organization_members + WHERE organization_id = prompt_collections.organization_id + AND user_id = auth.uid() + ) +); + +-- Admins can create collections +CREATE POLICY "Admins can create collections" +ON prompt_collections FOR INSERT +TO authenticated +WITH CHECK ( + is_org_admin(organization_id, auth.uid()) +); + +-- Admins can update collections +CREATE POLICY "Admins can update collections" +ON prompt_collections FOR UPDATE +TO authenticated +USING (is_org_admin(organization_id, auth.uid())) +WITH CHECK (is_org_admin(organization_id, auth.uid())); + +-- Admins can delete collections +CREATE POLICY "Admins can delete collections" +ON prompt_collections FOR DELETE +TO authenticated +USING (is_org_admin(organization_id, auth.uid())); +``` + +### Prompt Collection Segments Table + +```sql +-- Members can view segments in their org collections +CREATE POLICY "Members can view segments" +ON prompt_collection_segments FOR SELECT +TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM prompt_collections pc + JOIN organization_members om ON om.organization_id = pc.organization_id + WHERE pc.id = prompt_collection_segments.collection_id + AND om.user_id = auth.uid() + ) +); + +-- Admins can create segments +CREATE POLICY "Admins can create segments" +ON prompt_collection_segments FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS ( + SELECT 1 FROM prompt_collections pc + WHERE pc.id = collection_id + AND is_org_admin(pc.organization_id, auth.uid()) + ) +); + +-- Admins can update segments +CREATE POLICY "Admins can update segments" +ON prompt_collection_segments FOR UPDATE +TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM prompt_collections pc + WHERE pc.id = collection_id + AND is_org_admin(pc.organization_id, auth.uid()) + ) +) +WITH CHECK ( + EXISTS ( + SELECT 1 FROM prompt_collections pc + WHERE pc.id = collection_id + AND is_org_admin(pc.organization_id, auth.uid()) + ) +); + +-- Admins can delete segments +CREATE POLICY "Admins can delete segments" +ON prompt_collection_segments FOR DELETE +TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM prompt_collections pc + WHERE pc.id = collection_id + AND is_org_admin(pc.organization_id, auth.uid()) + ) +); +``` + +### User Consents Table + +```sql +-- Users can view their own consents +CREATE POLICY "Users can view own consents" +ON user_consents FOR SELECT +TO authenticated +USING (auth.uid() = user_id); + +-- Users can insert their own consents +CREATE POLICY "Users can insert own consents" +ON user_consents FOR INSERT +TO authenticated +WITH CHECK (auth.uid() = user_id); + +-- Users can update their own consents +CREATE POLICY "Users can update own consents" +ON user_consents FOR UPDATE +TO authenticated +USING (auth.uid() = user_id) +WITH CHECK (auth.uid() = user_id); + +-- Users can delete their own consents +CREATE POLICY "Users can delete own consents" +ON user_consents FOR DELETE +TO authenticated +USING (auth.uid() = user_id); +``` + +## Implementation Sequence + +### Step 1: Create Migration File +Create `supabase/migrations/20251004000000_add_prompt_manager_rls.sql` with: +- GRANT statements for prompts, prompt_collections, prompt_collection_segments +- ENABLE RLS on all four tables +- CREATE POLICY statements as detailed below + +### Step 2: Migrate Service Functions +Update service layer to accept authenticated supabase client: +- `promptService.ts` - 8 functions to update +- `promptCollectionService.ts` - 6 functions to update + +### Step 3: Update API Endpoints +Update 13 endpoint files to pass `locals.supabase` to service functions + +### Step 4: Local Testing +- Test with member user (published prompts only) +- Test with admin user (all prompts) +- Test cross-org isolation +- Test invite flow still works + +### Step 5: Deploy +- Apply migration to local DB +- Run manual tests +- Deploy to integration +- Run integration tests +- Deploy to production + +## Files to Modify + +### New Files: +- `supabase/migrations/20251004000000_add_prompt_manager_rls.sql` + +### Modified Files: +- `src/services/prompt-manager/promptService.ts` - Add supabase param to all functions +- `src/services/prompt-manager/promptCollectionService.ts` - Add supabase param to all functions +- `src/pages/api/prompts/admin/prompts.ts` - Pass locals.supabase +- `src/pages/api/prompts/admin/prompts/[id].ts` - Pass locals.supabase +- `src/pages/api/prompts/admin/prompts/[id]/publish.ts` - Pass locals.supabase +- `src/pages/api/prompts/admin/prompt-collections.ts` - Pass locals.supabase +- `src/pages/api/prompts/admin/prompt-collections/[id]/segments.ts` - Pass locals.supabase +- `src/pages/api/prompts/index.ts` - Pass locals.supabase +- `src/pages/api/prompts/[id].ts` - Pass locals.supabase +- `src/pages/api/prompts/collections.ts` - Pass locals.supabase +- `src/pages/api/prompts/collections/by-slug.ts` - Pass locals.supabase +- `src/pages/api/prompts/collections/[id]/segments.ts` - Pass locals.supabase +- `src/pages/api/prompts/segments/by-slug.ts` - Pass locals.supabase + +## Risk Mitigation + +- Run migration in transaction +- Test rollback procedure +- Verify existing functionality before/after +- Monitor for RLS-related errors in logs +- Keep service_role grants for backward compatibility during transition + +## Key Architecture Insights + +### Current Service Pattern Analysis + +**Invite Services (invites.ts):** +- ✅ Uses authenticated client from `locals.supabase` +- ✅ Relies on RLS for security +- ✅ Defense-in-depth approach + +**Prompt Services (promptService.ts, promptCollectionService.ts):** +- ❌ Uses `supabaseAdmin` with service_role key +- ❌ Bypasses all RLS policies +- ❌ Security only at application layer +- ⚠️ Organization filtering happens in application code only + +### Authorization Logic Comparison + +**Middleware (`src/middleware/index.ts`):** +- Builds `promptManager` context with user's organizations +- Checks admin role for admin routes +- But services don't use this context fully + +**API Endpoints:** +- Admin endpoints check `locals.promptManager.activeOrganization.role === 'admin'` +- Member endpoints check organization membership +- Then call services with organization_id + +**Services Layer:** +- Services filter by organization_id in queries +- BUT use service_role client → no RLS enforcement +- If service is called incorrectly, no DB-level protection + +### Why This Matters + +**Current Risk:** +```typescript +// If this is called with wrong org_id, nothing stops it at DB level +await getPrompt(promptId, "attacker-org-id"); +``` + +**With RLS:** +```typescript +// DB enforces user can only see their org's data +// Even if app logic has bug, DB protects +await getPrompt(supabase, promptId, "attacker-org-id"); // Would return null/error +``` + +## Service Migration Examples + +### Pattern Overview + +All service functions will be updated to: +1. Accept `supabase: SupabaseClient` as first parameter +2. Use the authenticated client instead of `supabaseAdmin` +3. Keep existing organization_id filters (belt + suspenders approach) + +### Service Migration Example + +**Before:** +```typescript +// src/services/prompt-manager/promptService.ts +import { supabaseAdmin } from '@/db/supabase-admin'; + +export async function getPrompt( + promptId: string, + organizationId: string, +): Promise> { + const { data, error } = await supabaseAdmin + .from('prompts') + .select('*') + .eq('id', promptId) + .eq('organization_id', organizationId) // App-layer filter + .single(); + // ... +} +``` + +**After:** +```typescript +// src/services/prompt-manager/promptService.ts +import type { SupabaseClient } from '@supabase/supabase-js'; + +export async function getPrompt( + supabase: SupabaseClient, + promptId: string, + organizationId: string, +): Promise> { + const { data, error } = await supabase + .from('prompts') + .select('*') + .eq('id', promptId) + .eq('organization_id', organizationId) // Still check, but RLS also enforces + .single(); + // ... +} +``` + +**Endpoint Update:** +```typescript +// src/pages/api/prompts/admin/prompts/[id].ts +const result = await getPrompt( + locals.supabase, // Pass authenticated client + promptId, + organizationId +); +``` + +### Testing Strategy + +**Manual Testing Checklist:** +- [ ] Member can view published prompts in their org +- [ ] Member cannot view draft prompts +- [ ] Member cannot view prompts from other orgs +- [ ] Member cannot create/update/delete prompts +- [ ] Admin can view all prompts (draft + published) in their org +- [ ] Admin cannot view prompts from other orgs +- [ ] Admin can create/update/delete prompts in their org +- [ ] Admin cannot create/update/delete prompts in other orgs +- [ ] All members can view collections/segments in their org +- [ ] Invite validation still works (anon access) +- [ ] Invite redemption still works (authenticated access) +- [ ] User consents are isolated per user diff --git a/.ai/prompt-library/schema-proposal.md b/.ai/prompt-library/schema-proposal.md new file mode 100644 index 0000000..16ccf42 --- /dev/null +++ b/.ai/prompt-library/schema-proposal.md @@ -0,0 +1,106 @@ +# Prompt Manager Schema Snapshot (POC) + +## Overview +This schema mirrors the lean setup described in `poc-arch-plan.md` and `poc-impl-plan.md`. It keeps the Prompt Manager POC limited to a single published version per prompt, organization-scoped access. All tables live in Supabase with feature-flagged access from the Astro app. + +During rollout seed a single `10xDevs` organization. Map only the curated launch cohort into membership rows so the remaining ~254 legacy accounts stay locked out until explicitly approved. + +## Minimal Table Set +| Table | Purpose | Key Columns | +| --- | --- | --- | +| `organizations` | Registry of organizations that can access prompts. | `id uuid pk`, `slug text unique`, `name text`, timestamps | +| `organization_members` | Links Supabase users to organizations with a lightweight role. | `organization_id uuid fk`, `user_id uuid fk`, `role text check in ('member',,'admin')`, timestamps, primary key `(organization_id,user_id)` | +| `prompt_collections` | Top-level grouping of prompts per organization. | `id uuid pk`, `organization_id uuid fk`, `slug text`, `title text`, `description text`, `sort_order int default 0`, timestamps, unique `(organization_id, slug)` | +| `prompt_collection_segments` | Optional sub-group inside a collection. | `id uuid pk`, `collection_id uuid fk`, `slug text`, `title text`, `sort_order int default 0`, timestamps, unique `(collection_id, slug)` | +| `prompts` | Single active prompt entry (draft/published). | `id uuid pk`, `organization_id uuid fk`, `collection_id uuid fk`, `segment_id uuid fk nullable`, `title text`, `markdown_body text`, `status text check in ('draft','published')`, `created_by uuid fk`, `updated_at timestamptz`, index `(organization_id,status,collection_id,segment_id)` | + +Notes: +- Stick to nullable `segment_id` so collections without segments remain valid. +- Default `organization_members.role` to `member`; elevate to `admin` for users who can publish. +- No enums or triggers—keep migrations portable. + +## Suggested Migration Outline +```sql +-- 001_prompt_manager_orgs.sql +create table organizations ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + name text not null, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table organization_members ( + organization_id uuid not null references organizations(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'member' check (role in ('member','admin')), + created_at timestamptz default now(), + updated_at timestamptz default now(), + primary key (organization_id, user_id) +); + +-- seed launch org + vetted members +insert into organizations (slug, name) +values ('10xdevs', '10xDevs') +on conflict (slug) do nothing; +``` +```sql +-- 002_prompt_manager_catalog.sql +create table prompt_collections ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null references organizations(id) on delete cascade, + slug text not null, + title text not null, + description text, + sort_order integer not null default 0, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique (organization_id, slug) +); +create index idx_prompt_collections_org_sort on prompt_collections(organization_id, sort_order); + +create table prompt_collection_segments ( + id uuid primary key default gen_random_uuid(), + collection_id uuid not null references prompt_collections(id) on delete cascade, + slug text not null, + title text not null, + sort_order integer not null default 0, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique (collection_id, slug) +); +create index idx_prompt_segments_collection_sort on prompt_collection_segments(collection_id, sort_order); + +create table prompts ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null references organizations(id) on delete cascade, + collection_id uuid not null references prompt_collections(id) on delete cascade, + segment_id uuid references prompt_collection_segments(id) on delete set null, + title text not null, + markdown_body text not null, + status text not null default 'draft' check (status in ('draft','published')), + created_by uuid references auth.users(id), + updated_at timestamptz default now() +); +create index idx_prompts_org_scope on prompts(organization_id, status, collection_id, segment_id); +``` + +Seed collections/segments and a couple of demo prompts in the same migration using `insert ... on conflict do nothing` so fresh environments boot quickly. + +## Access & RLS Notes +- RLS can stay off for the POC while middleware and Supabase service-role keys enforce access; enable policies later when the API surface grows. +- Middleware should reject any request lacking both the feature flag and an `organization_members` row. +- Admin-only routes verify `role in ('admin')`; plain members keep read-only access to published prompts. + +## Deferred for Later Iterations +These tables intentionally stay out of the POC to keep migrations lean: +- `prompt_versions` (version history) +- `prompt_usage_logs` (telemetry) +- External roster sync tables (`organization_roster_members`, etc.) + +Revisit them once the POC validates the end-to-end flow. + +## Next Steps +1. Translate this outline into Supabase migrations (two files per the outline above). +2. Backfill the vetted 10xDevs members via SQL or the admin UI before enabling the flag in staging. +3. Confirm middleware/helpers (`useOrganizationAccess`, `ensurePromptAccess`) match the column names above. diff --git a/.ai/prompt-library/test-analysis.md b/.ai/prompt-library/test-analysis.md new file mode 100644 index 0000000..6a84dbd --- /dev/null +++ b/.ai/prompt-library/test-analysis.md @@ -0,0 +1,607 @@ +# Test Analysis: Value vs Maintenance Cost + +**Analysis Date:** 2025-10-04 +**Scope:** Prompt Manager test suite (unit + integration tests) + +## Executive Summary + +The current test suite demonstrates **high intentionality** with well-structured tests that focus on critical business logic. The tests provide excellent confidence in core workflows while maintaining reasonable maintenance costs. Key strengths include comprehensive integration tests for user workflows and robust mocking infrastructure. Primary opportunities lie in reducing mock boilerplate and improving test data reuse. + +**Overall Rating: 8/10** - Strong test suite with clear value proposition + +--- + +## Test Infrastructure Analysis + +### Mock Supabase Client (`tests/helpers/mockSupabaseClient.ts`) + +**Value: ★★★★★ (9/10)** +- Provides comprehensive, reusable mock for all Supabase query operations +- Supports method chaining, matching real Supabase client behavior +- Type-safe with proper TypeScript integration +- Includes role-based factory functions (`createMemberMockClient`, `createAdminMockClient`) + +**Maintenance Cost: ★★★☆☆ (3/10)** +- Well-abstracted, single point of change for mock behavior +- Self-documenting with clear JSDoc comments +- Minimal dependencies, unlikely to break with upgrades + +**Key Insight:** This is a **force multiplier** - the investment in building this mock pays dividends across all service tests. It eliminates 80% of boilerplate that would otherwise be scattered across test files. + +**Recommendation:** ✅ Keep as-is. Consider documenting common usage patterns in a test README. + +--- + +### Test Fixtures (`tests/fixtures/promptManagerFixtures.ts`) + +**Value: ★★★★☆ (8/10)** +- Centralized test data ensures consistency across test suites +- Domain-organized (organizations, users, collections, segments, prompts) +- Helper functions for filtered queries simulate real-world data access patterns +- Simulates RLS (Row-Level Security) behavior for different user roles + +**Maintenance Cost: ★★☆☆☆ (2/10)** +- Pure data structure, minimal logic to maintain +- Changes to domain models will require updates, but TypeScript catches these +- Well-organized with clear naming conventions + +**Potential Issues:** +- ⚠️ Fixture data grows stale if not actively used - some fixtures may be unused +- ⚠️ Implicit dependencies between fixtures (e.g., org IDs referenced in prompts) + +**Recommendation:** ✅ Keep and expand. Periodically audit for unused fixtures. Consider adding validation to ensure fixture relationships are correct. + +--- + +## Unit Test Analysis + +### 1. Invites Service (`tests/unit/services/prompt-manager/invites.test.ts`) + +**Value: ★★★★★ (10/10)** +``` +Coverage: +- Token generation (uniqueness, format) +- Invite creation (with/without max uses, different roles) +- Token validation (all error cases: expired, revoked, max uses, invalid) +- Invite listing and revocation +- Statistics retrieval with user details +``` + +**Why High Value:** +- Tests **critical security logic** - invite tokens control org access +- Validates all business rules: expiration, max uses, revocation +- Tests error handling comprehensively +- Each test is atomic and focused on single behavior + +**Maintenance Cost: ★★★☆☆ (3/10)** +- Moderate mock setup required (10-30 lines per test) +- Each test manually constructs query builder chains +- Changes to service signatures require updates across multiple tests + +**Example of Good Test Design:** +```typescript +it('rejects an expired invite', async () => { + const mockInvite = { + // ... clear test data + expires_at: new Date(Date.now() - 86400000).toISOString(), // 1 day ago + is_active: true, + // ... + }; + + // Simple, focused assertion + expect(validationResult.valid).toBe(false); + expect(validationResult.error).toBe(INVITE_ERROR_MESSAGES.INVITE_EXPIRED); +}); +``` + +**Key Strengths:** +- ✅ Tests business rules, not implementation details +- ✅ Clear test names describe exact scenario +- ✅ Good coverage of edge cases (empty token, null values, etc.) + +**Improvement Opportunities:** +- 🔧 Mock setup is repetitive - could extract builder pattern for common scenarios +- 🔧 Some tests could use table-driven approach for similar cases (e.g., different validation errors) + +**Recommendation:** ✅ Keep with minor refactoring to reduce mock boilerplate. + +--- + +### 2. Prompt Service (`tests/unit/services/prompt-manager/promptService.test.ts`) + +**Value: ★★★★☆ (9/10)** +``` +Coverage: +- CRUD operations (create, update, publish, unpublish, delete, get, list) +- Organization scoping enforcement +- Status transitions (draft → published → draft) +- Partial updates (only specified fields) +- Error handling (not found, DB errors, unexpected exceptions) +- Filtering (by status, collection, segment) +``` + +**Why High Value:** +- Tests **core domain logic** - prompts are the primary entity +- Validates critical authorization rules (organization scoping) +- Tests both happy paths and error cases +- Ensures data integrity during state transitions + +**Maintenance Cost: ★★★★☆ (4/10)** +- **Heavy mock setup** - some tests have 15-20 lines of mock chaining +- Mock builder pattern is verbose: `eq → select → single → mockResolvedValue` +- Each CRUD operation requires similar but slightly different mock setup + +**Example of Mock Complexity:** +```typescript +// This pattern repeats ~50 times across the file +const single = vi.fn().mockResolvedValue({ data: mockPrompt, error: null }); +const select = vi.fn().mockReturnValue({ single }); +const eq2 = vi.fn().mockReturnValue({ select }); +const eq1 = vi.fn().mockReturnValue({ eq: eq2 }); +const update = vi.fn().mockReturnValue({ eq: eq1 }); +mockSupabase.from.mockReturnValue({ update }); +``` + +**Key Strengths:** +- ✅ Comprehensive coverage of all service methods +- ✅ Tests organization-level data isolation (critical for multi-tenant system) +- ✅ Validates error messages and codes (useful for debugging) +- ✅ Tests filtering logic with chainable query builders + +**Pain Points:** +- ❌ Mock setup dominates test code (60-70% of lines are mocking) +- ❌ Hard to see what's actually being tested among the setup +- ❌ Brittle to changes in query builder chaining order + +**Improvement Opportunities:** +- 🔧 **Extract mock builders**: Create helpers like `mockSelectSingle()`, `mockUpdate()`, etc. +- 🔧 **Use test fixtures more**: Replace inline mock data with fixture references +- 🔧 Consider higher-level abstractions: `mockSupabase.mockQuery('prompts').returns(mockPrompt)` + +**Recommendation:** ⚠️ Keep but refactor urgently. Extract common mock patterns into helpers. + +--- + +### 3. Collection Service (`tests/unit/services/prompt-manager/promptCollectionService.test.ts`) + +**Value: ★★★★☆ (8/10)** +``` +Coverage: +- Fetching collections and segments (with ordering) +- Creating collections and segments (with auto-incrementing sort_order) +- Organization filtering +- Error handling (DB errors, exceptions) +- Edge cases (empty results, null data) +``` + +**Why High Value:** +- Tests **structural integrity** - collections organize prompts hierarchically +- Validates sort order logic (critical for UI presentation) +- Tests default value handling (sort_order auto-increment) + +**Maintenance Cost: ★★★★☆ (4/10)** +- Same mock complexity as promptService tests +- More complex mocking when testing sort_order auto-increment (2 DB calls) + +**Example of Complex Test:** +```typescript +it('creates collection with default sort_order when not provided', async () => { + // Mock 1: Get max sort_order + const maxSingle = vi.fn().mockResolvedValue({ data: null, error: null }); + const maxLimit = vi.fn().mockReturnValue({ single: maxSingle }); + // ... 4 more lines + + // Mock 2: Insert with calculated sort_order + const insertSingle = vi.fn().mockResolvedValue({ data: mockCollection, error: null }); + // ... 3 more lines + + // Setup: Return different mocks for each call + mockSupabase.from + .mockReturnValueOnce({ select: maxSelect }) + .mockReturnValueOnce({ insert }); +}); +``` + +**Key Strengths:** +- ✅ Tests both explicit and implicit sort ordering +- ✅ Validates relationship integrity (collections → segments) +- ✅ Good exception handling tests + +**Pain Points:** +- ❌ Multi-step operations require complex mock orchestration +- ❌ `.mockReturnValueOnce()` chains are hard to follow + +**Recommendation:** ⚠️ Refactor to use builder pattern for multi-step operations. + +--- + +### 4. Middleware (`tests/unit/middleware/promptManagerMiddleware.test.ts`) + +**Value: ★★★★★ (10/10)** +``` +Coverage: +- Authentication (redirects unauthenticated users) +- Feature flags (returns 404 when disabled) +- Organization membership checks +- Role-based authorization (admin vs member access) +- Context building and injection +``` + +**Why High Value:** +- Tests **critical security boundary** - prevents unauthorized access +- Validates feature flag behavior (important for deployment safety) +- Tests authorization at multiple levels (authentication, membership, role) +- Ensures context is correctly passed to downstream handlers + +**Maintenance Cost: ★★☆☆☆ (2/10)** +- Clean, focused tests with minimal setup +- Good use of helper functions (`createContext`, `createUser`) +- Environment variable mocking is well-isolated + +**Key Strengths:** +- ✅ **Excellent separation of concerns** - each test validates one auth rule +- ✅ Tests actual middleware behavior, not implementation +- ✅ Verifies correct redirect behavior and status codes +- ✅ Uses dynamic module loading to test env-dependent behavior + +**Recommendation:** ✅ Exemplary test suite. Use as template for other middleware tests. + +--- + +## Integration Test Analysis + +### 1. Invite Flow (`tests/integration/invite-flow.test.ts`) + +**Value: ★★★★★ (10/10)** +``` +Complete workflow coverage: +1. Admin creates invite link +2. Unauthenticated user validates invite +3. User signs up and redeems invite (auto-joins org) +4. Existing user also redeems same invite +5. Admin views invite statistics +6. Admin lists all invites +7. Admin revokes invite +8. Verify revoked invite cannot be validated +``` + +**Why Extremely High Value:** +- Tests **critical user journey** from end to end +- Validates invite system actually works as a cohesive whole +- Catches integration issues that unit tests miss +- Documents expected system behavior better than any documentation +- Tests idempotency (existing member redemption) + +**Maintenance Cost: ★★★★☆ (4/10)** +- Long test (280 lines) but well-organized with clear steps +- Mock setup is complex but necessarily so (simulates full workflow) +- Changes to workflow require updating single test (good isolation) + +**Key Strengths:** +- ✅ **Self-documenting** - test reads like a user story +- ✅ Tests edge cases in context (expiry, max uses, revocation) +- ✅ Validates complete data flow through system +- ✅ Tests both new and existing user paths + +**Valuable Pattern:** +```typescript +// Step comments make workflow crystal clear +// Step 1: Admin creates an invite link +const createResult = await createOrganizationInvite(...) +expect(createResult.error).toBeNull(); + +// Step 2: Unauthenticated user validates the invite +const validationResult = await validateInviteToken(...) +expect(validationResult.valid).toBe(true); +``` + +**Recommendation:** ✅ Keep as-is. This test provides immense value for confidence in the invite system. + +--- + +### 2. Prompt Admin Flow (`tests/integration/prompt-admin-flow.test.ts`) + +**Value: ★★★★★ (10/10)** +``` +Complete admin workflow: +1. Fetch available collections +2. Fetch segments for collection +3. Create draft prompt +4. Update prompt content +5. Publish prompt +6. Verify in published list +7. Unpublish prompt +8. Verify back to draft +9. Delete prompt +10. Verify deletion +``` + +**Why Extremely High Value:** +- Tests **primary admin use case** from start to finish +- Validates state machine (draft → published → draft → deleted) +- Ensures collections/segments are properly integrated with prompts +- Tests that prompts appear/disappear from lists correctly based on status + +**Maintenance Cost: ★★★★☆ (4/10)** +- Similar complexity to invite flow +- Well-structured with clear step comments + +**Additional Test Cases:** +- ✅ Organization scoping enforcement (trying to access wrong org's data) +- ✅ Error handling (DB errors return proper error codes) + +**Key Strengths:** +- ✅ Tests complete CRUD lifecycle in realistic sequence +- ✅ Validates list filtering by status +- ✅ Tests organization-level isolation (security-critical) + +**Recommendation:** ✅ Keep as-is. Core workflow test that prevents regressions. + +--- + +## Cross-Cutting Observations + +### What's Working Well + +1. **Clear Test Naming** - Every test name describes the exact behavior being tested + - ✅ "rejects an expired invite" + - ✅ "allows admin route for admin members" + - ✅ "creates collection with default sort_order when not provided" + +2. **Behavior-Focused, Not Implementation-Focused** + - Tests validate **what** happens, not **how** it happens + - Minimal coupling to internal implementation details + - Tests would survive refactoring of internal logic + +3. **Good Error Case Coverage** + - Every service method tests both success and failure paths + - Error codes and messages are validated (useful for API consumers) + - Edge cases (null, empty, expired, revoked) are comprehensively tested + +4. **Integration Tests Document User Journeys** + - Integration tests serve as executable specifications + - Step-by-step comments make workflows crystal clear + - Tests validate that multiple services work together correctly + +### Pain Points + +1. **Mock Boilerplate Dominance** (★★★★★ High Impact) + - 60-70% of test code is mock setup + - Same patterns repeated 50+ times across files + - Makes tests harder to read and maintain + + **Solution:** Extract mock builders + ```typescript + // Instead of: + const single = vi.fn().mockResolvedValue({ data: mockPrompt, error: null }); + const select = vi.fn().mockReturnValue({ single }); + const eq2 = vi.fn().mockReturnValue({ select }); + const eq1 = vi.fn().mockReturnValue({ eq: eq2 }); + const update = vi.fn().mockReturnValue({ eq: eq1 }); + mockSupabase.from.mockReturnValue({ update }); + + // Use: + mockSupabase.whenUpdate('prompts').returns(mockPrompt); + ``` + +2. **Test Data Inline vs Fixtures** (★★★☆☆ Medium Impact) + - Some tests use fixtures, others create data inline + - Inconsistent approach makes tests harder to understand + - Inline data often duplicates fixture data + + **Solution:** Establish convention - use fixtures for common entities, inline for test-specific variations + +3. **Multi-Step Mock Orchestration** (★★★☆☆ Medium Impact) + - Tests with multiple DB calls require `.mockReturnValueOnce()` chains + - Hard to track which mock applies to which call + - Easy to get order wrong, causing confusing failures + + **Solution:** Add builder with explicit sequencing + ```typescript + mockSupabase + .expectQuery('collections').toReturn(maxSortOrder) + .expectInsert('collections').toReturn(newCollection); + ``` + +--- + +## Maintenance Cost Drivers + +### High Maintenance Risk Areas + +1. **Query Builder Mock Chains** 🔴 High Risk + - **Why:** Tightly coupled to Supabase query builder API + - **Impact:** Any change to Supabase client requires updating 100+ mock chains + - **Mitigation:** Abstract behind builder pattern + +2. **Test Data Updates** 🟡 Medium Risk + - **Why:** Domain model changes require updating fixtures and inline data + - **Impact:** TypeScript catches most issues, but test assertions may need updates + - **Mitigation:** Use type-safe fixture builders + +3. **Mock Sequencing** 🟡 Medium Risk + - **Why:** `.mockReturnValueOnce()` chains fragile to reordering + - **Impact:** Adding new query in service breaks unrelated tests + - **Mitigation:** Use explicit mock expectations with better error messages + +### Low Maintenance Risk Areas + +1. **Integration Tests** 🟢 Low Risk + - Tests are high-level, don't care about implementation + - Changes to internal logic don't affect tests + - Only break when actual behavior changes (which is desired) + +2. **Middleware Tests** 🟢 Low Risk + - Small, focused tests with minimal dependencies + - Test contract, not implementation + - Helper functions isolate changes + +--- + +## Value vs Cost Matrix + +``` + Value + Low High + ┌─────────────┬─────────────┐ + │ │ │ + High │ ❌ REMOVE │ ⚠️ REFACTOR │ Maintenance + │ (none) │ • Unit │ Cost + │ │ tests │ + ├─────────────┼─────────────┤ + │ │ │ + Low │ DELETE │ ✅ KEEP │ + │ (none) │ • Integ. │ + │ │ • Middle. │ + └─────────────┴─────────────┘ +``` + +### Quadrant Analysis + +**✅ High Value, Low Maintenance (KEEP)** +- Integration tests (invite-flow, prompt-admin-flow) +- Middleware tests +- Test infrastructure (mock client, fixtures) + +**⚠️ High Value, High Maintenance (REFACTOR)** +- Unit tests (invites, promptService, collectionService) +- Keep the tests, reduce mock boilerplate + +**❌ Low Value, High Maintenance (REMOVE)** +- None identified - test suite is well-curated + +**DELETE Low Value, Low Maintenance** +- None identified + +--- + +## Recommendations + +### Immediate Actions (Week 1) + +1. **Extract Mock Builders** 🔧 Priority: HIGH + ```typescript + // Create tests/helpers/mockQueryBuilders.ts + export function mockSelect(client: MockSupabaseClient, data: T) { + const single = vi.fn().mockResolvedValue({ data, error: null }); + const select = vi.fn().mockReturnValue({ single }); + const eq = vi.fn().mockReturnValue({ single }); + client.from.mockReturnValue({ select, eq }); + } + ``` + **Impact:** Reduce test code by 40-50%, improve readability + +2. **Document Mock Patterns** 📚 Priority: MEDIUM + - Create `tests/README.md` with common mock patterns + - Show examples of each builder function + - Explain when to use fixtures vs inline data + +3. **Audit Fixture Usage** 🔍 Priority: LOW + - Identify unused fixtures (search for references) + - Remove or update stale test data + - Document fixture relationships + +### Short-term Improvements (Month 1) + +4. **Add RLS Verification Tests** 🔒 Priority: HIGH + - Use Supabase local testing to verify RLS policies + - Test that organization_id filtering actually prevents cross-org access + - Validate that member vs admin roles enforce correctly + +5. **Add Concurrency Tests** ⚡ Priority: MEDIUM + - Test invite max_uses under concurrent redemptions + - Test sort_order conflicts when creating collections simultaneously + - Use Promise.all() to simulate parallel operations + +6. **Improve Error Testing** ❌ Priority: LOW + - Test transaction rollback scenarios + - Verify partial failure handling (e.g., RPC fails but insert succeeds) + - Add tests for database constraint violations + +### Long-term Strategy + +7. **Consider Contract Testing** 📋 + - Add Pact or similar for API contract verification + - Ensure frontend and backend stay in sync + - Generate OpenAPI spec from tests + +8. **Add Performance Benchmarks** 📊 + - Test query performance with realistic data volumes + - Verify pagination doesn't degrade with large collections + - Monitor test execution time (flag slow tests) + +9. **Expand E2E Coverage** 🌐 + - Current E2E tests focus on core app, not prompt manager + - Add Playwright tests for invite redemption flow + - Test prompt creation through UI + +--- + +## Metrics Summary + +| Category | Count | Value Score (avg) | Maint. Cost (avg) | Net Score | +|----------|-------|-------------------|-------------------|-----------| +| **Integration Tests** | 2 | 10/10 | 4/10 | +6 ✅ | +| **Unit Tests (Services)** | 3 | 9/10 | 4/10 | +5 ⚠️ | +| **Unit Tests (Middleware)** | 1 | 10/10 | 2/10 | +8 ✅ | +| **Test Infrastructure** | 2 | 9/10 | 3/10 | +6 ✅ | +| **Overall** | 8 suites | **9.3/10** | **3.4/10** | **+5.9** ✅ | + +**Interpretation:** +- **Net Score +5.9** indicates tests provide significantly more value than they cost to maintain +- **Value score 9.3** shows tests cover critical business logic comprehensively +- **Maintenance cost 3.4** is moderate, mainly due to mock boilerplate (addressable) + +--- + +## Conclusion + +### The Good ✅ + +This test suite demonstrates **excellent engineering judgment**: +- Tests focus on business-critical functionality (auth, invites, CRUD) +- Integration tests validate complete user workflows +- Clear, descriptive test names serve as documentation +- Good separation between unit and integration testing +- Robust test infrastructure (mock client, fixtures) + +### The Improvement Opportunity ⚠️ + +The primary pain point is **mock boilerplate overhead**: +- 60-70% of test code is repetitive mock setup +- Same patterns repeated across 50+ tests +- Makes tests harder to write and maintain + +**This is easily fixable** with helper functions and builder patterns. + +### Final Assessment + +**Keep: 100% of tests** ✅ +**Refactor: 60% of tests** ⚠️ (reduce mock boilerplate) +**Delete: 0% of tests** ❌ + +**ROI: Excellent** - These tests provide high confidence in critical workflows with reasonable maintenance burden. The recommended refactoring will cut maintenance costs by ~40% while maintaining all current value. + +--- + +## Action Plan Priority + +``` +Priority 1 (Do First): +└── Extract mock builders → Reduces 40% of maintenance burden + +Priority 2 (Next Sprint): +├── Add RLS verification tests → Closes security gap +└── Document test patterns → Improves developer experience + +Priority 3 (Next Month): +├── Add concurrency tests → Prevents production race conditions +└── Expand E2E coverage → Increases confidence in UI flows + +Priority 4 (Ongoing): +└── Monitor test execution time → Keeps feedback loop fast +``` + +**Estimated effort to implement Priority 1-2:** 1-2 developer days +**Expected maintenance cost reduction:** 40-50% +**Expected value increase:** 10-15% (from closing security gaps) diff --git a/.ai/prompt-library/testable-rls-approach.md b/.ai/prompt-library/testable-rls-approach.md new file mode 100644 index 0000000..ab0695f --- /dev/null +++ b/.ai/prompt-library/testable-rls-approach.md @@ -0,0 +1,1516 @@ +# Testable RLS Approach - Analysis & Implementation Plan + +## Executive Summary + +The RLS implementation introduced a fundamental architectural change: service functions now accept an authenticated `SupabaseClient` instead of using the global `supabaseAdmin`. This breaks **27 existing tests** across unit and integration test suites. This document provides a comprehensive analysis and actionable plan to restore test coverage while enhancing testability for RLS-enforced authorization. + +--- + +## Table of Contents + +1. [Current Test Failures Analysis](#current-test-failures-analysis) +2. [Root Cause Analysis](#root-cause-analysis) +3. [Testing Challenges with RLS](#testing-challenges-with-rls) +4. [Proposed Testing Strategy](#proposed-testing-strategy) +5. [Implementation Plan](#implementation-plan) +6. [Test Utilities & Helpers](#test-utilities--helpers) +7. [RLS-Specific Test Scenarios](#rls-specific-test-scenarios) +8. [Migration Checklist](#migration-checklist) + +--- + +## Current Test Failures Analysis + +### Test Failure Summary (27 total failures) + +``` +❌ tests/unit/services/prompt-manager/promptService.test.ts (24/24 failed) +❌ tests/unit/services/prompt-manager/promptCollectionService.test.ts (likely similar failures) +❌ tests/integration/prompt-admin-flow.test.ts (3/3 failed) +❌ tests/integration/invite-flow.test.ts (2/4 failed) +✅ tests/unit/services/prompt-manager/invites.test.ts (all passing - uses correct pattern) +``` + +### Failure Categories + +#### Category 1: Mock Import Mismatch (24 failures in promptService.test.ts) +**Symptom:** +``` +expected "spy" to be called with arguments: [ 'prompts' ] +Received: Number of calls: 0 +``` + +**Root Cause:** +- Tests mock `@/db/supabase-admin` module +- Service functions no longer import or use `supabaseAdmin` +- Service functions now expect `supabase` as first parameter +- Mocked module is never called + +**Affected Tests:** +- All tests in `promptService.test.ts` +- All tests in `promptCollectionService.test.ts` + +#### Category 2: Function Signature Mismatch (3 failures in prompt-admin-flow.test.ts) +**Symptom:** +```typescript +// Old call (pre-RLS) +const result = await getCollections('org-1'); + +// New signature (post-RLS) +const result = await getCollections(supabase, 'org-1'); +``` + +**Root Cause:** +- Service function signatures changed to accept `supabase` as first parameter +- Tests call functions with old signatures +- All 9 functions in promptService changed +- All 6 functions in promptCollectionService changed + +**Affected Functions:** +```typescript +// promptService.ts +createPrompt(supabase, organizationId, data) // was: createPrompt(organizationId, data) +updatePrompt(supabase, promptId, orgId, data) // was: updatePrompt(promptId, orgId, data) +publishPrompt(supabase, promptId, orgId) // was: publishPrompt(promptId, orgId) +unpublishPrompt(supabase, promptId, orgId) // was: unpublishPrompt(promptId, orgId) +deletePrompt(supabase, promptId, orgId) // was: deletePrompt(promptId, orgId) +getPrompt(supabase, promptId, orgId) // was: getPrompt(promptId, orgId) +listPrompts(supabase, orgId, filters?) // was: listPrompts(orgId, filters?) +listPublishedPrompts(supabase, orgId, filters?) // was: listPublishedPrompts(orgId, filters?) +getPublishedPrompt(supabase, promptId, orgId) // was: getPublishedPrompt(promptId, orgId) + +// promptCollectionService.ts +getCollections(supabase, orgId) // was: getCollections(orgId) +getCollectionBySlug(supabase, orgId, slug) // was: getCollectionBySlug(orgId, slug) +getSegments(supabase, collectionId) // was: getSegments(collectionId) +getSegmentBySlug(supabase, collectionId, slug) // was: getSegmentBySlug(collectionId, slug) +createCollection(supabase, orgId, data) // was: createCollection(orgId, data) +createSegment(supabase, collectionId, data) // was: createSegment(collectionId, data) +``` + +#### Category 3: Mock Implementation Incompleteness (2 failures in invite-flow.test.ts) +**Symptom:** +``` +TypeError: supabase.from(...).select(...).eq(...).eq(...).maybeSingle is not a function +``` + +**Root Cause:** +- Mock Supabase clients in integration tests don't fully implement the query builder chain +- Missing `.maybeSingle()` method in mock +- Mock chaining incomplete for complex queries + +--- + +## Root Cause Analysis + +### Architectural Change Impact + +**Before RLS Implementation:** +```typescript +// Service Layer +import { supabaseAdmin } from '@/db/supabase-admin'; + +export async function getPrompt(promptId: string, orgId: string) { + const { data, error } = await supabaseAdmin // Global admin client + .from('prompts') + .select('*') + .eq('id', promptId) + .eq('organization_id', orgId) + .single(); + // ... +} + +// Test Layer +vi.mock('@/db/supabase-admin', () => ({ + supabaseAdmin: { + from: vi.fn(), // Mock the global + }, +})); +``` + +**After RLS Implementation:** +```typescript +// Service Layer +import type { SupabaseClient } from '@supabase/supabase-js'; + +export async function getPrompt( + supabase: SupabaseClient, // Injected authenticated client + promptId: string, + orgId: string +) { + const { data, error } = await supabase // Uses injected client + .from('prompts') + .select('*') + .eq('id', promptId) + .eq('organization_id', orgId) + .single(); + // ... +} + +// Test Layer - OLD (BROKEN) +vi.mock('@/db/supabase-admin', () => ({ + supabaseAdmin: { + from: vi.fn(), // ❌ Never called! + }, +})); + +// Test Layer - NEW (REQUIRED) +const mockSupabase = createMockSupabaseClient(); +const result = await getPrompt(mockSupabase, 'prompt-1', 'org-1'); +``` + +### Why This Change Matters for Testing + +**Dependency Injection Benefits:** +1. ✅ **Easier Mocking**: Pass mock clients directly, no module mocking needed +2. ✅ **Better Isolation**: Each test controls its own Supabase behavior +3. ✅ **RLS Testing**: Can simulate different user contexts (admin vs member) +4. ✅ **Type Safety**: TypeScript ensures client interface compatibility + +**Challenges:** +1. ❌ **Test Boilerplate**: Every test needs to create and pass mock client +2. ❌ **Mock Complexity**: Query builder chains are complex to mock +3. ❌ **Test Data Setup**: Need to simulate RLS filtering in mocks +4. ❌ **Context Simulation**: Must mock different user roles/permissions + +--- + +## Testing Challenges with RLS + +### Challenge 1: Query Builder Complexity + +The Supabase client uses extensive method chaining: + +```typescript +const { data, error } = await supabase + .from('prompts') // QueryBuilder + .select('*') // PostgrestFilterBuilder + .eq('id', 'x') // PostgrestFilterBuilder + .eq('organization_id', 'y')// PostgrestFilterBuilder + .single(); // PostgrestBuilder (returns promise) +``` + +**Testing Problem:** +Each method must return a mock that has the next method in the chain. + +**Current invites.test.ts Pattern (Working):** +```typescript +const mockSupabase = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: mockData, error: null }) + }) + }) + }) + }) +}; +``` + +**Problem:** This is verbose and error-prone. Need helper utilities. + +### Challenge 2: RLS Policy Simulation in Tests + +With RLS enabled, the database enforces access control. Tests need to simulate this. + +**Member User Scenario:** +```typescript +// RLS Policy: Members can only see published prompts +const memberClient = createMockSupabaseClient({ + role: 'member', + orgId: 'org-1', + userId: 'user-1' +}); + +// Should return only published prompts +const result = await listPrompts(memberClient, 'org-1'); +expect(result.data).toEqual([/* only published */]); +``` + +**Admin User Scenario:** +```typescript +// RLS Policy: Admins can see all prompts +const adminClient = createMockSupabaseClient({ + role: 'admin', + orgId: 'org-1', + userId: 'admin-1' +}); + +// Should return all prompts (draft + published) +const result = await listPrompts(adminClient, 'org-1'); +expect(result.data).toEqual([/* draft + published */]); +``` + +**Challenge:** Need to encode RLS rules into test mock behavior. + +### Challenge 3: Cross-Org Isolation Testing + +RLS ensures prompts are isolated by organization. Tests should verify this. + +```typescript +it('cannot access prompts from other organizations', async () => { + const mockClient = createMockSupabaseClient({ + role: 'member', + orgId: 'org-1', + userId: 'user-1' + }); + + // Try to access org-2 prompt while authenticated to org-1 + const result = await getPrompt(mockClient, 'prompt-org2', 'org-2'); + + // RLS should prevent access + expect(result.data).toBeNull(); + expect(result.error?.code).toBe('NOT_FOUND'); +}); +``` + +### Challenge 4: Integration vs Unit Test Boundaries + +**Unit Tests:** +- Mock Supabase client completely +- Test service function logic in isolation +- Don't need real RLS enforcement + +**Integration Tests:** +- Should test RLS policies work correctly +- Need real Supabase connection (local Docker or Supabase Cloud test instance) +- Verify member/admin access patterns +- Test org isolation + +**Question:** Where to draw the line? +- ❓ Should unit tests simulate RLS filtering in mocks? +- ❓ Should integration tests use real Supabase with RLS? +- ❓ Do we need E2E tests with real database? + +--- + +## Proposed Testing Strategy + +### Three-Tier Testing Approach + +#### Tier 1: Unit Tests (Mock Everything) +**Scope:** Service functions in isolation +**Tools:** Vitest + Mock Supabase client +**RLS Simulation:** No (mocks return what test specifies) +**Files:** +- `tests/unit/services/prompt-manager/promptService.test.ts` +- `tests/unit/services/prompt-manager/promptCollectionService.test.ts` + +**Example:** +```typescript +it('creates a new prompt', async () => { + const mockSupabase = createMockSupabaseClient(); + mockSupabase.from('prompts').insert.mockResolvedValue({ + data: mockPrompt, + error: null + }); + + const result = await createPrompt(mockSupabase, 'org-1', promptData); + + expect(result.data).toEqual(mockPrompt); +}); +``` + +**Focus:** +- Service function logic correctness +- Error handling +- Data transformation +- Edge cases + +#### Tier 2: Integration Tests (Mock API + Supabase) +**Scope:** API routes + services working together +**Tools:** Vitest + MSW (mock HTTP) + Mock Supabase +**RLS Simulation:** Partial (via mock behavior) +**Files:** +- `tests/integration/prompt-admin-flow.test.ts` +- `tests/integration/prompt-language.test.ts` +- `tests/integration/invite-flow.test.ts` + +**Example:** +```typescript +it('member can only list published prompts', async () => { + const mockSupabase = createMockSupabaseClient({ role: 'member' }); + + // Mock filters by status='published' + mockSupabase.from('prompts').select.mockImplementation((query) => { + if (query.includes('published')) { + return { data: [publishedPrompt], error: null }; + } + return { data: [], error: null }; + }); + + const result = await listPrompts(mockSupabase, 'org-1'); + expect(result.data).toHaveLength(1); + expect(result.data[0].status).toBe('published'); +}); +``` + +**Focus:** +- Full request/response flow +- Authorization checks +- Multi-service interactions +- Realistic data flows + +#### Tier 3: E2E Tests (Real Supabase with RLS) +**Scope:** End-to-end with real database +**Tools:** Playwright + Local Supabase (Docker) +**RLS Simulation:** Real RLS policies enforced +**Files:** +- `e2e/prompt-manager-rls.spec.ts` (NEW) + +**Example:** +```typescript +test('member cannot see draft prompts', async ({ page }) => { + // Login as member user (uses real Supabase auth) + await loginAsMember(page, 'member@example.com'); + + // Navigate to prompts page + await page.goto('/prompts/admin'); + + // Only published prompts should be visible + const promptCards = await page.locator('[data-testid="prompt-card"]').all(); + for (const card of promptCards) { + const status = await card.getAttribute('data-status'); + expect(status).toBe('published'); + } +}); +``` + +**Focus:** +- Real RLS policies work correctly +- Browser-level user experience +- Authentication flows +- Cross-browser compatibility + +--- + +## Implementation Plan + +### Phase 1: Create Test Utilities (Foundation) + +**Goal:** Build reusable helpers to simplify test writing. + +#### 1.1 Create Mock Supabase Client Factory + +**File:** `tests/helpers/mockSupabaseClient.ts` + +**Purpose:** +- Generate fully-typed mock Supabase clients +- Support query builder chaining +- Simulate RLS filtering behavior +- Reduce test boilerplate + +**Key Functions:** +```typescript +// Create basic mock client +createMockSupabaseClient(options?: MockClientOptions): MockSupabaseClient + +// Create mock with pre-configured responses +createMockSupabaseClientWithData(fixtures: DataFixtures): MockSupabaseClient + +// Create role-based mock (member vs admin) +createMemberMockClient(orgId: string, userId: string): MockSupabaseClient +createAdminMockClient(orgId: string, userId: string): MockSupabaseClient +``` + +**Features Needed:** +- ✅ Query builder method chaining (`.from().select().eq()...`) +- ✅ Fluent API support (`.insert().select().single()`) +- ✅ Error injection for testing error paths +- ✅ Call history tracking for assertions +- ✅ RLS-aware filtering (optional) + +#### 1.2 Create Test Data Fixtures + +**File:** `tests/fixtures/promptManagerFixtures.ts` + +**Purpose:** +- Standard test data for prompts, collections, segments +- Consistent IDs and relationships +- Easy to compose for different scenarios + +**Example Structure:** +```typescript +export const testOrganizations = { + org1: { id: 'org-test-1', name: 'Test Org 1' }, + org2: { id: 'org-test-2', name: 'Test Org 2' }, +}; + +export const testUsers = { + adminUser: { id: 'user-admin-1', email: 'admin@test.com', role: 'admin' }, + memberUser: { id: 'user-member-1', email: 'member@test.com', role: 'member' }, +}; + +export const testCollections = { + collection1: { + id: 'coll-test-1', + organization_id: 'org-test-1', + slug: 'fundamentals', + title: 'Fundamentals', + // ... + }, +}; + +export const testPrompts = { + draftPrompt: { status: 'draft', /* ... */ }, + publishedPrompt: { status: 'published', /* ... */ }, +}; +``` + +#### 1.3 Create RLS Behavior Simulator + +**File:** `tests/helpers/rlsSimulator.ts` + +**Purpose:** +- Simulate RLS policy filtering in mock responses +- Apply access rules based on user context +- Filter data based on organization membership + +**Example Usage:** +```typescript +const mockClient = createMockSupabaseClient(); +const rlsFilter = createRLSFilter({ + userId: 'user-1', + orgId: 'org-1', + role: 'member' +}); + +mockClient.from('prompts').select = vi.fn().mockImplementation(() => { + const allPrompts = [draftPrompt, publishedPrompt]; + const filtered = rlsFilter.applyPromptsPolicy(allPrompts); + return { data: filtered, error: null }; +}); +``` + +--- + +### Phase 2: Migrate Unit Tests + +**Goal:** Update existing unit tests to use new service signatures and mock clients. + +#### 2.1 Update promptService.test.ts + +**Changes Required:** + +**Before:** +```typescript +vi.mock('@/db/supabase-admin', () => ({ + supabaseAdmin: { + from: vi.fn(), + }, +})); + +import { supabaseAdmin } from '@/db/supabase-admin'; + +it('creates a new prompt', async () => { + const single = vi.fn().mockResolvedValue({ data: mockPrompt, error: null }); + const select = vi.fn().mockReturnValue({ single }); + const insert = vi.fn().mockReturnValue({ select }); + (supabaseAdmin.from as ReturnType).mockReturnValue({ insert }); + + const result = await createPrompt('org-1', input); + + expect(supabaseAdmin.from).toHaveBeenCalledWith('prompts'); + expect(result.data).toEqual(mockPrompt); +}); +``` + +**After:** +```typescript +import { createMockSupabaseClient } from '@/tests/helpers/mockSupabaseClient'; + +it('creates a new prompt', async () => { + const mockSupabase = createMockSupabaseClient(); + + mockSupabase.mockInsert('prompts', { data: mockPrompt, error: null }); + + const result = await createPrompt(mockSupabase, 'org-1', input); + + expect(mockSupabase.from).toHaveBeenCalledWith('prompts'); + expect(result.data).toEqual(mockPrompt); +}); +``` + +**Migration Steps:** +1. Remove `vi.mock('@/db/supabase-admin')` block +2. Import `createMockSupabaseClient` helper +3. Create mock client in each test (or beforeEach) +4. Update service function calls to pass mock client as first argument +5. Simplify mock setup using helper methods + +**Estimated Changes:** +- 24 test cases in `promptService.test.ts` +- All tests in `promptCollectionService.test.ts` + +#### 2.2 Update promptCollectionService.test.ts + +**Similar migration as above.** + +**Additional consideration:** +- Tests for `createCollection` and `createSegment` need mock for sort_order calculation query +- Need to mock two sequential `from()` calls (max query + insert query) + +--- + +### Phase 3: Migrate Integration Tests + +**Goal:** Update integration tests to pass mock clients and handle new signatures. + +#### 3.1 Update prompt-admin-flow.test.ts + +**Current Issues:** +1. Service functions called with old signatures +2. Mock client doesn't support full query chain +3. No RLS simulation + +**Migration Strategy:** + +**Before:** +```typescript +const collectionsResult = await getCollections(ORG_ID); +``` + +**After:** +```typescript +const mockSupabase = createMockSupabaseClient(); +mockSupabase.mockSelect('prompt_collections', { + data: mockCollections, + error: null +}); + +const collectionsResult = await getCollections(mockSupabase, ORG_ID); +``` + +**Key Updates:** +- Pass mock client to all service function calls +- Use helper to set up query chain mocks +- Add tests for RLS scenarios (member vs admin) + +#### 3.2 Update invite-flow.test.ts + +**Current Issue:** +``` +TypeError: supabase.from(...).select(...).eq(...).eq(...).maybeSingle is not a function +``` + +**Root Cause:** +Mock client missing `.maybeSingle()` method in chain. + +**Fix:** +Ensure mock client factory includes all Supabase query methods: +- `.single()` +- `.maybeSingle()` +- `.limit()` +- `.order()` +- `.range()` +- `.or()` +- `.not()` +- etc. + +--- + +### Phase 4: Add RLS-Specific Tests + +**Goal:** Verify RLS policies work correctly with comprehensive test coverage. + +#### 4.1 Create RLS Policy Unit Tests + +**File:** `tests/unit/services/prompt-manager/rlsPolicies.test.ts` (NEW) + +**Purpose:** +Test that service functions behave correctly under different user roles. + +**Example Tests:** +```typescript +describe('RLS Policy Enforcement', () => { + describe('Member Access', () => { + it('member can only see published prompts', async () => { + const mockClient = createMemberMockClient('org-1', 'user-1'); + + // Mock returns only published prompts + mockClient.mockSelect('prompts', { + data: [publishedPrompt], + error: null + }); + + const result = await listPrompts(mockClient, 'org-1'); + + expect(result.data).toHaveLength(1); + expect(result.data[0].status).toBe('published'); + }); + + it('member cannot create prompts', async () => { + const mockClient = createMemberMockClient('org-1', 'user-1'); + + // Mock RLS rejection + mockClient.mockInsert('prompts', { + data: null, + error: { code: 'PGRST301', message: 'permission denied' } + }); + + const result = await createPrompt(mockClient, 'org-1', promptInput); + + expect(result.error).toBeTruthy(); + expect(result.error?.code).toContain('PGRST'); + }); + }); + + describe('Admin Access', () => { + it('admin can see all prompts (draft + published)', async () => { + const mockClient = createAdminMockClient('org-1', 'admin-1'); + + mockClient.mockSelect('prompts', { + data: [draftPrompt, publishedPrompt], + error: null + }); + + const result = await listPrompts(mockClient, 'org-1'); + + expect(result.data).toHaveLength(2); + }); + + it('admin can create prompts', async () => { + const mockClient = createAdminMockClient('org-1', 'admin-1'); + + mockClient.mockInsert('prompts', { + data: newPrompt, + error: null + }); + + const result = await createPrompt(mockClient, 'org-1', promptInput); + + expect(result.data).toBeTruthy(); + }); + }); + + describe('Organization Isolation', () => { + it('user cannot access prompts from other orgs', async () => { + const mockClient = createMemberMockClient('org-1', 'user-1'); + + // Mock RLS filtering out org-2 prompts + mockClient.mockSelect('prompts', { + data: [], + error: null + }); + + const result = await getPrompt(mockClient, 'prompt-org2', 'org-2'); + + expect(result.data).toBeNull(); + }); + }); +}); +``` + + + +--- + +## Test Utilities & Helpers + +### Utility 1: Mock Supabase Client Factory + +**Location:** `tests/helpers/mockSupabaseClient.ts` + +**Interface:** +```typescript +interface MockClientOptions { + role?: 'member' | 'admin'; + userId?: string; + orgId?: string; + rlsEnabled?: boolean; +} + +interface MockSupabaseClient { + from: (table: string) => MockQueryBuilder; + mockSelect: (table: string, result: { data: any, error: any }) => void; + mockInsert: (table: string, result: { data: any, error: any }) => void; + mockUpdate: (table: string, result: { data: any, error: any }) => void; + mockDelete: (table: string, result: { data: any, error: any }) => void; + reset: () => void; +} + +function createMockSupabaseClient(options?: MockClientOptions): MockSupabaseClient; +function createMemberMockClient(orgId: string, userId: string): MockSupabaseClient; +function createAdminMockClient(orgId: string, userId: string): MockSupabaseClient; +``` + +**Key Features:** +- Fluent API that mimics real Supabase client +- Support for all query builder methods +- Simplified setup methods (`.mockSelect()`, etc.) +- Optional RLS filtering +- Call history tracking + +**Implementation Sketch:** +```typescript +export function createMockSupabaseClient(options: MockClientOptions = {}): MockSupabaseClient { + const queryResults = new Map(); + + const createQueryBuilder = (table: string) => { + let currentResult = queryResults.get(table) || { data: [], error: null }; + + const builder = { + select: vi.fn().mockReturnValue(builder), + insert: vi.fn().mockReturnValue(builder), + update: vi.fn().mockReturnValue(builder), + delete: vi.fn().mockReturnValue(builder), + eq: vi.fn().mockReturnValue(builder), + neq: vi.fn().mockReturnValue(builder), + or: vi.fn().mockReturnValue(builder), + order: vi.fn().mockReturnValue(builder), + limit: vi.fn().mockReturnValue(builder), + single: vi.fn().mockResolvedValue(currentResult), + maybeSingle: vi.fn().mockResolvedValue(currentResult), + then: vi.fn((resolve) => resolve(currentResult)), + }; + + return builder; + }; + + const client = { + from: vi.fn().mockImplementation(createQueryBuilder), + + mockSelect: (table: string, result: any) => { + queryResults.set(table, result); + }, + + mockInsert: (table: string, result: any) => { + queryResults.set(table, result); + }, + + // ... other helper methods + + reset: () => { + queryResults.clear(); + vi.clearAllMocks(); + }, + }; + + return client as unknown as MockSupabaseClient; +} +``` + +--- + +### Utility 2: Test Data Fixtures + +**Location:** `tests/fixtures/promptManagerFixtures.ts` + +**Purpose:** +Provide consistent, reusable test data for all test suites. + +**Structure:** +```typescript +export const fixtures = { + organizations: { + org1: { + id: 'org-test-1', + name: 'Test Organization 1', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + org2: { + id: 'org-test-2', + name: 'Test Organization 2', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + }, + + users: { + adminUser: { + id: 'user-admin-1', + email: 'admin@test.com', + created_at: '2025-01-01T00:00:00Z', + }, + memberUser: { + id: 'user-member-1', + email: 'member@test.com', + created_at: '2025-01-01T00:00:00Z', + }, + }, + + organizationMembers: { + org1Admin: { + user_id: 'user-admin-1', + organization_id: 'org-test-1', + role: 'admin', + created_at: '2025-01-01T00:00:00Z', + }, + org1Member: { + user_id: 'user-member-1', + organization_id: 'org-test-1', + role: 'member', + created_at: '2025-01-01T00:00:00Z', + }, + }, + + collections: { + fundamentals: { + id: 'coll-test-1', + organization_id: 'org-test-1', + slug: 'fundamentals', + title: 'Fundamentals', + description: 'Core concepts', + sort_order: 1, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + }, + + segments: { + gettingStarted: { + id: 'seg-test-1', + collection_id: 'coll-test-1', + slug: 'getting-started', + title: 'Getting Started', + sort_order: 1, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + }, + + prompts: { + draftPrompt: { + id: 'prompt-draft-1', + organization_id: 'org-test-1', + collection_id: 'coll-test-1', + segment_id: 'seg-test-1', + title_en: 'Draft Prompt', + title_pl: null, + markdown_body_en: '# Draft Content', + markdown_body_pl: null, + status: 'draft' as const, + created_by: 'user-admin-1', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + publishedPrompt: { + id: 'prompt-published-1', + organization_id: 'org-test-1', + collection_id: 'coll-test-1', + segment_id: 'seg-test-1', + title_en: 'Published Prompt', + title_pl: null, + markdown_body_en: '# Published Content', + markdown_body_pl: null, + status: 'published' as const, + created_by: 'user-admin-1', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + }, +}; + +// Helper to get related data +export function getCollectionPrompts(collectionId: string) { + return Object.values(fixtures.prompts).filter( + p => p.collection_id === collectionId + ); +} + +export function getPublishedPrompts() { + return Object.values(fixtures.prompts).filter( + p => p.status === 'published' + ); +} + +export function getDraftPrompts() { + return Object.values(fixtures.prompts).filter( + p => p.status === 'draft' + ); +} +``` + +--- + +### Utility 3: RLS Simulator + +**Location:** `tests/helpers/rlsSimulator.ts` + +**Purpose:** +Apply RLS-like filtering to mock data based on user context. + +**Interface:** +```typescript +interface UserContext { + userId: string; + orgId: string; + role: 'member' | 'admin'; +} + +class RLSSimulator { + constructor(context: UserContext); + + filterPrompts(prompts: Prompt[]): Prompt[]; + filterCollections(collections: PromptCollection[]): PromptCollection[]; + filterSegments(segments: PromptSegment[]): PromptSegment[]; + + canCreate(table: string): boolean; + canUpdate(table: string, record: any): boolean; + canDelete(table: string, record: any): boolean; +} + +export function createRLSSimulator(context: UserContext): RLSSimulator; +``` + +**Implementation:** +```typescript +export function createRLSSimulator(context: UserContext): RLSSimulator { + return { + filterPrompts(prompts: Prompt[]): Prompt[] { + // Apply RLS policy: Members see published, Admins see all + return prompts.filter(prompt => { + // Org isolation + if (prompt.organization_id !== context.orgId) { + return false; + } + + // Member vs Admin access + if (context.role === 'member') { + return prompt.status === 'published'; + } + + return true; // Admins see all + }); + }, + + filterCollections(collections: PromptCollection[]): PromptCollection[] { + // All org members can see collections + return collections.filter( + c => c.organization_id === context.orgId + ); + }, + + filterSegments(segments: PromptSegment[]): PromptSegment[] { + // Filter segments based on collection access + // (Requires collection lookup - simplified here) + return segments; + }, + + canCreate(table: string): boolean { + // Only admins can create prompts/collections/segments + if (['prompts', 'prompt_collections', 'prompt_collection_segments'].includes(table)) { + return context.role === 'admin'; + } + return true; + }, + + canUpdate(table: string, record: any): boolean { + if (table === 'prompts' && record.organization_id === context.orgId) { + return context.role === 'admin'; + } + return false; + }, + + canDelete(table: string, record: any): boolean { + return this.canUpdate(table, record); + }, + }; +} +``` + +--- + +## RLS-Specific Test Scenarios + +### Scenario 1: Member Access Restrictions + +**Test Coverage:** +- ✅ Members can view published prompts in their org +- ✅ Members cannot view draft prompts +- ✅ Members cannot create prompts +- ✅ Members cannot update prompts +- ✅ Members cannot delete prompts +- ✅ Members cannot publish/unpublish prompts +- ✅ Members can view collections +- ✅ Members can view segments + +**Example Test:** +```typescript +describe('Member Access Control', () => { + let memberClient: MockSupabaseClient; + + beforeEach(() => { + memberClient = createMemberMockClient('org-1', 'user-member-1'); + }); + + it('member can view published prompts', async () => { + memberClient.mockSelect('prompts', { + data: [fixtures.prompts.publishedPrompt], + error: null + }); + + const result = await listPublishedPrompts(memberClient, 'org-1'); + + expect(result.data).toHaveLength(1); + expect(result.data[0].status).toBe('published'); + }); + + it('member cannot view draft prompts', async () => { + memberClient.mockSelect('prompts', { + data: [], // RLS filters out drafts + error: null + }); + + const result = await getPrompt(memberClient, 'prompt-draft-1', 'org-1'); + + expect(result.data).toBeNull(); + }); + + it('member cannot create prompts', async () => { + memberClient.mockInsert('prompts', { + data: null, + error: { code: 'PGRST301', message: 'permission denied for table prompts' } + }); + + const result = await createPrompt(memberClient, 'org-1', promptInput); + + expect(result.error).toBeTruthy(); + expect(result.error?.code).toContain('PGRST'); + }); +}); +``` + +--- + +### Scenario 2: Admin Full Access + +**Test Coverage:** +- ✅ Admins can view all prompts (draft + published) +- ✅ Admins can create prompts +- ✅ Admins can update prompts +- ✅ Admins can delete prompts +- ✅ Admins can publish prompts +- ✅ Admins can unpublish prompts +- ✅ Admins can create collections +- ✅ Admins can create segments + +**Example Test:** +```typescript +describe('Admin Full Access', () => { + let adminClient: MockSupabaseClient; + + beforeEach(() => { + adminClient = createAdminMockClient('org-1', 'user-admin-1'); + }); + + it('admin can view all prompts', async () => { + adminClient.mockSelect('prompts', { + data: [fixtures.prompts.draftPrompt, fixtures.prompts.publishedPrompt], + error: null + }); + + const result = await listPrompts(adminClient, 'org-1'); + + expect(result.data).toHaveLength(2); + }); + + it('admin can create prompts', async () => { + const newPrompt = { ...fixtures.prompts.draftPrompt, id: 'new-prompt-1' }; + adminClient.mockInsert('prompts', { + data: newPrompt, + error: null + }); + + const result = await createPrompt(adminClient, 'org-1', promptInput); + + expect(result.data).toBeTruthy(); + expect(result.error).toBeNull(); + }); +}); +``` + +--- + +### Scenario 3: Organization Isolation + +**Test Coverage:** +- ✅ Users cannot see prompts from other orgs +- ✅ Users cannot create prompts in other orgs +- ✅ Users cannot update prompts in other orgs +- ✅ Users cannot delete prompts from other orgs +- ✅ Admin of org-1 cannot access org-2 data + +**Example Test:** +```typescript +describe('Organization Isolation', () => { + it('admin cannot access prompts from other orgs', async () => { + const org1Admin = createAdminMockClient('org-1', 'admin-1'); + + // Try to access org-2 prompt + org1Admin.mockSelect('prompts', { + data: [], // RLS filters by organization_id + error: null + }); + + const result = await getPrompt(org1Admin, 'prompt-org2', 'org-2'); + + expect(result.data).toBeNull(); + }); + + it('member cannot list prompts from other orgs', async () => { + const org1Member = createMemberMockClient('org-1', 'member-1'); + + org1Member.mockSelect('prompts', { + data: [], // No cross-org access + error: null + }); + + const result = await listPrompts(org1Member, 'org-2'); + + expect(result.data).toEqual([]); + }); +}); +``` + +--- + +### Scenario 4: User Consent Isolation + +**Test Coverage:** +- ✅ Users can view their own consents +- ✅ Users cannot view other users' consents +- ✅ Users can create their own consents +- ✅ Users can update their own consents +- ✅ Users can delete their own consents + +--- + +## Migration Checklist + +### Pre-Migration Preparation + +- [ ] **Read and understand this document fully** +- [ ] **Review current test failures** (`npm run test`) +- [ ] **Backup current test files** (git branch: `pre-rls-test-migration`) +- [ ] **Set up local Supabase** (for E2E tests, optional) +- [ ] **Review invites.test.ts** (reference implementation) + +### Phase 1: Test Utilities (Week 1) + +- [ ] **Create `tests/helpers/mockSupabaseClient.ts`** + - [ ] Implement `createMockSupabaseClient()` + - [ ] Implement query builder chain methods + - [ ] Add helper methods (`.mockSelect()`, etc.) + - [ ] Add `.maybeSingle()` support + - [ ] Test the helper itself + +- [ ] **Create `tests/fixtures/promptManagerFixtures.ts`** + - [ ] Define organizations + - [ ] Define users + - [ ] Define organization_members + - [ ] Define collections + - [ ] Define segments + - [ ] Define prompts (draft + published) + - [ ] Add helper functions + +- [ ] **Create `tests/helpers/rlsSimulator.ts`** (optional) + - [ ] Implement filtering logic + - [ ] Add permission checks + - [ ] Test RLS simulation + +- [ ] **Create helper exports** (`tests/helpers/index.ts`) + +### Phase 2: Unit Tests Migration (Week 2) + +- [ ] **Migrate `tests/unit/services/prompt-manager/promptService.test.ts`** + - [ ] Remove `vi.mock('@/db/supabase-admin')` + - [ ] Import `createMockSupabaseClient` + - [ ] Update `createPrompt` tests (2 tests) + - [ ] Update `updatePrompt` tests (3 tests) + - [ ] Update `publishPrompt` tests (2 tests) + - [ ] Update `unpublishPrompt` tests (2 tests) + - [ ] Update `deletePrompt` tests (2 tests) + - [ ] Update `getPrompt` tests (3 tests) + - [ ] Update `listPrompts` tests (10 tests) + - [ ] Verify all tests pass + +- [ ] **Migrate `tests/unit/services/prompt-manager/promptCollectionService.test.ts`** + - [ ] Remove `vi.mock('@/db/supabase-admin')` + - [ ] Import `createMockSupabaseClient` + - [ ] Update `getCollections` tests + - [ ] Update `getSegments` tests + - [ ] Update `createCollection` tests + - [ ] Update `createSegment` tests + - [ ] Update `getCollectionBySlug` tests + - [ ] Update `getSegmentBySlug` tests + - [ ] Verify all tests pass + +- [ ] **Run unit tests** (`npm run test tests/unit`) + - [ ] All promptService tests pass + - [ ] All promptCollectionService tests pass + - [ ] All invites tests pass (already passing) + +### Phase 3: Integration Tests Migration (Week 3) + +- [ ] **Migrate `tests/integration/prompt-admin-flow.test.ts`** + - [ ] Update service function calls (add mock client) + - [ ] Fix `getCollections()` call + - [ ] Fix `getSegments()` call + - [ ] Fix `createPrompt()` call + - [ ] Fix `updatePrompt()` call + - [ ] Fix `publishPrompt()` call + - [ ] Fix `unpublishPrompt()` call + - [ ] Fix `deletePrompt()` call + - [ ] Fix `getPrompt()` call + - [ ] Fix `listPrompts()` call + - [ ] Verify all tests pass + +- [ ] **Migrate `tests/integration/invite-flow.test.ts`** + - [ ] Add `.maybeSingle()` to mock client + - [ ] Fix incomplete query builder chains + - [ ] Verify all tests pass + +- [ ] **Run integration tests** (`npm run test tests/integration`) + - [ ] All tests pass + + + +### Phase 4: Verification & Cleanup + +- [ ] **Run all tests** (`npm run test`) + - [ ] All unit tests pass + - [ ] All integration tests pass + - [ ] All E2E tests pass (if created) + +- [ ] **Code review** + - [ ] Review test coverage report + - [ ] Ensure no test skips or `.only()` + - [ ] Check for test duplication + - [ ] Verify mock setup consistency + +- [ ] **Documentation** + - [ ] Update test README if exists + - [ ] Document new test utilities + - [ ] Add inline comments for complex mocks + +- [ ] **Clean up** + - [ ] Remove old test helpers (if any) + - [ ] Remove dead code + - [ ] Format code (`npm run format`) + +--- + +## Success Criteria + +### Quantitative Metrics + +- ✅ **All 27 currently failing tests pass** +- ✅ **Test coverage ≥ 80%** for prompt manager services +- ✅ **0 test skips** (no `.skip()` or `x` prefixes) +- ✅ **Build succeeds** (`npm run build`) +- ✅ **Linting passes** (`npm run lint`) + +### Qualitative Goals + +- ✅ **Tests are readable and maintainable** +- ✅ **Mock setup is DRY** (Don't Repeat Yourself) +- ✅ **Tests clearly show RLS intent** +- ✅ **Easy to add new tests** (good infrastructure) +- ✅ **Tests document expected behavior** + +--- + +## Key Insights & Best Practices + +### Insight 1: Dependency Injection Improves Testability + +**Before (Global Dependency):** +```typescript +// Service tightly coupled to supabaseAdmin +import { supabaseAdmin } from '@/db/supabase-admin'; + +export async function getPrompt(id: string) { + const { data } = await supabaseAdmin.from('prompts')... +} + +// Test must mock the module +vi.mock('@/db/supabase-admin'); +``` + +**After (Injected Dependency):** +```typescript +// Service accepts any SupabaseClient +export async function getPrompt(supabase: SupabaseClient, id: string) { + const { data } = await supabase.from('prompts')... +} + +// Test passes mock directly +const mock = createMockSupabaseClient(); +await getPrompt(mock, 'id-1'); +``` + +**Benefits:** +- No module mocking needed +- Each test controls its own client +- Easier to test different scenarios +- Type-safe mock injection + +--- + +### Insight 2: Test Helpers Reduce Boilerplate + +**Without Helper (Verbose):** +```typescript +const single = vi.fn().mockResolvedValue({ data: mockData, error: null }); +const eq2 = vi.fn().mockReturnValue({ single }); +const eq1 = vi.fn().mockReturnValue({ eq: eq2 }); +const select = vi.fn().mockReturnValue({ eq: eq1 }); +mockSupabase.from.mockReturnValue({ select }); +``` + +**With Helper (Concise):** +```typescript +mockSupabase.mockSelect('prompts', { data: mockData, error: null }); +``` + +**Lesson:** Invest time in good test utilities upfront. + +--- + +### Insight 3: Fixtures Improve Test Consistency + +**Without Fixtures:** +```typescript +// Test 1 +const prompt = { id: 'p1', title: 'Test', organization_id: 'org1', ... }; + +// Test 2 +const prompt = { id: 'p1', title: 'Test Prompt', organization_id: 'org-1', ... }; +// ^^^^ inconsistent +``` + +**With Fixtures:** +```typescript +// Both tests use same fixture +const prompt = fixtures.prompts.publishedPrompt; +``` + +**Lesson:** Centralize test data to avoid inconsistencies. + +--- + +### Insight 4: RLS Testing Needs Both Unit and Integration Tests + +**Unit Tests (Mock RLS):** +- Fast execution +- Test service logic in isolation +- Simulate RLS with mocks +- No database needed + +**Integration Tests (Real RLS):** +- Slower but realistic +- Test actual RLS policies +- Catch policy bugs +- Requires database + +**Lesson:** Use both approaches for comprehensive coverage. + +--- + +## Common Pitfalls to Avoid + +### Pitfall 1: Incomplete Mock Chains + +**Problem:** +```typescript +mockSupabase.from.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: [], error: null }) + }) +}); + +// Breaks if query uses .single() +await supabase.from('prompts').select().eq().single(); +// ^^^^^^ undefined +``` + +**Solution:** +Use helper that implements all methods. + +--- + +### Pitfall 2: Hardcoded Test Data + +**Problem:** +```typescript +const result = await getPrompt(mock, 'prompt-123', 'org-abc'); +expect(result.data.id).toBe('prompt-123'); +expect(result.data.organization_id).toBe('org-abc'); +// ^^^^^^^^ typo risk +``` + +**Solution:** +Use fixtures with constants. + +--- + +### Pitfall 3: Testing Implementation Instead of Behavior + +**Problem:** +```typescript +it('calls supabase.from with correct table', async () => { + await getPrompt(mock, 'id', 'org'); + expect(mock.from).toHaveBeenCalledWith('prompts'); + // ^^^^^^^^ implementation detail +}); +``` + +**Solution:** +Test outcomes, not implementation: +```typescript +it('returns prompt when found', async () => { + mock.mockSelect('prompts', { data: mockPrompt, error: null }); + const result = await getPrompt(mock, 'id', 'org'); + expect(result.data).toEqual(mockPrompt); + // ^^^^^^ behavior +}); +``` + +--- + +### Pitfall 4: Not Testing RLS Scenarios + +**Problem:** +Only testing happy paths, not access control. + +**Solution:** +Add tests for: +- Member cannot access drafts +- Admin can access all +- Cross-org isolation +- Permission denied errors + +--- + +## Conclusion + +The RLS implementation improves security through defense-in-depth, but requires comprehensive test migration. By following this plan: + +1. **Create reusable test utilities** to reduce boilerplate +2. **Migrate existing tests** to use new service signatures +3. **Add RLS-specific tests** to verify access control +4. **Use fixtures** for consistent test data +5. **Consider E2E tests** for real RLS validation + +**Estimated Effort:** +- **Phase 1 (Utilities):** 2-3 days +- **Phase 2 (Unit Tests):** 3-4 days +- **Phase 3 (Integration Tests):** 2-3 days +- **Phase 4 (RLS Tests):** 2-3 days +- **Total:** ~2 weeks + +**Next Steps:** +1. Review and approve this plan +2. Create test utilities (Phase 1) +3. Migrate one test file as proof-of-concept +4. Review POC with team +5. Complete remaining migration + +**Success means:** +- ✅ All 27 tests passing +- ✅ Comprehensive RLS coverage +- ✅ Maintainable test infrastructure +- ✅ Confidence in defense-in-depth security diff --git a/.ai/prompt-library/unit-tests-plan.md b/.ai/prompt-library/unit-tests-plan.md new file mode 100644 index 0000000..e2d638b --- /dev/null +++ b/.ai/prompt-library/unit-tests-plan.md @@ -0,0 +1,683 @@ +# Unit Tests Failure Analysis & Fix Plan + +## Executive Summary + +9 out of 178 tests are currently failing across 3 test files. All failures stem from **API contract mismatches** between the service implementation and test expectations. The implementation evolved (made `generateInviteToken` async, changed return types), but tests weren't updated to reflect these changes. + +**Recommended Approach**: Fix tests to match current implementation (Option A), as the current implementation is more consistent and aligned with real-world usage. + +--- + +## Detailed Failure Analysis + +### 1. Unit Tests: `invites.test.ts` (6/17 failures) + +#### Failure 1: `generates a token of expected length` +**Location**: `tests/unit/services/prompt-manager/invites.test.ts:37-41` + +**Error**: +``` +actual value must be number or bigint, received "undefined" +``` + +**Root Cause**: +- `generateInviteToken()` became `async` (src/services/prompt-manager/invites.ts:25) +- Test calls it synchronously at line 38 +- Returns a Promise instead of a string +- `token.length` evaluates to `undefined` because `token` is a Promise + +**Current Code**: +```typescript +it('generates a token of expected length', () => { + const token = generateInviteToken(); // Missing await! + expect(token.length).toBeGreaterThan(40); +}); +``` + +**Fix**: +```typescript +it('generates a token of expected length', async () => { + const token = await generateInviteToken(); + expect(token.length).toBeGreaterThan(40); +}); +``` + +--- + +#### Failure 2: `lists invites for an organization` +**Location**: `tests/unit/services/prompt-manager/invites.test.ts:267-307` + +**Error**: +``` +Target cannot be null or undefined. +``` + +**Root Cause**: +- `listOrganizationInvites()` now returns `OrganizationInvite[]` directly (invites.ts:300-330) +- Test expects `{ data: OrganizationInvite[], error: string | null }` structure +- Test tries to access `result.data` on an array at line 305 + +**Current Implementation** (invites.ts:297-330): +```typescript +export async function listOrganizationInvites( + supabase: Supabase, + organizationId: string, +): Promise { // Returns array directly + try { + // ... + return (data ?? []).map(/* transform */); + } catch (err) { + return []; // Returns empty array on error + } +} +``` + +**Test Expectation** (line 302-306): +```typescript +const result = await listOrganizationInvites(mockSupabase as any, 'org-1'); + +expect(mockSupabase.from).toHaveBeenCalledWith('organization_invites'); +expect(result.data).toHaveLength(2); // ❌ result is array, not { data } +expect(result.error).toBeNull(); // ❌ result has no error property +``` + +**Fix**: +```typescript +const result = await listOrganizationInvites(mockSupabase as any, 'org-1'); + +expect(mockSupabase.from).toHaveBeenCalledWith('organization_invites'); +expect(result).toHaveLength(2); +expect(Array.isArray(result)).toBe(true); +``` + +--- + +#### Failure 3: `handles errors when listing invites` +**Location**: `tests/unit/services/prompt-manager/invites.test.ts:309-319` + +**Error**: +``` +expected undefined to be null +``` + +**Root Cause**: +- Same as Failure 2 - return type mismatch +- Test expects `result.error` to be null, but `result` is an array + +**Current Test** (line 315-318): +```typescript +const result = await listOrganizationInvites(mockSupabase as any, 'org-1'); + +expect(result.data).toBeNull(); // ❌ result is array +expect(result.error).toBeTruthy(); // ❌ no error property +``` + +**Fix**: +```typescript +const result = await listOrganizationInvites(mockSupabase as any, 'org-1'); + +expect(result).toEqual([]); // Empty array on error +expect(Array.isArray(result)).toBe(true); +``` + +--- + +#### Failure 4: `revokes an invite successfully` +**Location**: `tests/unit/services/prompt-manager/invites.test.ts:323-334` + +**Error**: +``` +expected undefined to be null +``` + +**Root Cause**: +- `revokeInvite()` returns `{ success: boolean; error?: string }` (invites.ts:338) +- Test expects `{ error: string | null }` structure +- On success, `error` is `undefined`, not `null` + +**Current Implementation** (invites.ts:335-355): +```typescript +export async function revokeInvite( + supabase: Supabase, + inviteId: string, +): Promise<{ success: boolean; error?: string }> { + try { + const { error } = await supabase + .from('organization_invites') + .update({ is_active: false }) + .eq('id', inviteId); + + if (error) { + return { success: false, error: error.message }; + } + + return { success: true }; // No error property + } catch (err) { + return { success: false, error: 'Failed to revoke invite' }; + } +} +``` + +**Current Test** (line 329-333): +```typescript +const result = await revokeInvite(mockSupabase as any, 'invite-1'); + +expect(mockSupabase.from).toHaveBeenCalledWith('organization_invites'); +expect(update).toHaveBeenCalledWith({ is_active: false }); +expect(result.error).toBeNull(); // ❌ error is undefined on success +``` + +**Fix**: +```typescript +const result = await revokeInvite(mockSupabase as any, 'invite-1'); + +expect(mockSupabase.from).toHaveBeenCalledWith('organization_invites'); +expect(update).toHaveBeenCalledWith({ is_active: false }); +expect(result.success).toBe(true); +expect(result.error).toBeUndefined(); +``` + +--- + +#### Failure 5: `handles errors when revoking` +**Location**: `tests/unit/services/prompt-manager/invites.test.ts:336-345` + +**Error**: +``` +expected undefined to be truthy +``` + +**Root Cause**: +- Same as Failure 4 - return type mismatch +- Test expects error to exist, but checks wrong field structure + +**Current Test** (line 342-344): +```typescript +const result = await revokeInvite(mockSupabase as any, 'invite-1'); + +expect(result.error).toBeTruthy(); // Works but should also check success +``` + +**Fix**: +```typescript +const result = await revokeInvite(mockSupabase as any, 'invite-1'); + +expect(result.success).toBe(false); +expect(result.error).toBeTruthy(); +``` + +--- + +#### Failure 6: `retrieves invite statistics` +**Location**: `tests/unit/services/prompt-manager/invites.test.ts:349-367` + +**Error**: +``` +expected null to deeply equal { totalRedemptions: 3, newUsers: 2, existingUsers: 1 } +``` + +**Root Cause**: +- Implementation calls `.single()` on line 370 to get invite details first +- Mock doesn't properly set up the chain for `.single()` +- The implementation also makes an RPC call that the mock doesn't handle + +**Current Implementation** (invites.ts:360-429): +```typescript +export async function getInviteStats( + supabase: Supabase, + inviteId: string, +): Promise { + try { + // Step 1: Get invite details with .single() + const { data: invite, error: inviteError } = await supabase + .from('organization_invites') + .select('max_uses, current_uses') + .eq('id', inviteId) + .single(); // ⚠️ Mock needs this + + if (inviteError || !invite) { + return null; + } + + // Step 2: Get redemptions + const { data: redemptions, error: redemptionsError } = await supabase + .from('organization_invite_redemptions') + .select('user_id, was_new_user, redeemed_at') + .eq('invite_id', inviteId) + .order('redeemed_at', { ascending: false }); + + // Step 3: Get user emails via RPC + const { data: userEmails } = await supabase.rpc('get_user_emails', { + user_ids: userIds, + }); + + // ... build stats + } catch (err) { + return null; + } +} +``` + +**Current Mock** (line 356-358): +```typescript +const eq = vi.fn().mockResolvedValue({ data: mockRedemptions, error: null }); +const select = vi.fn().mockReturnValue({ eq }); +mockSupabase.from.mockReturnValueOnce({ select }); +``` + +**Problem**: +1. Mock only sets up `select -> eq`, but implementation needs `select -> eq -> single` +2. Implementation fetches from TWO tables: `organization_invites` and `organization_invite_redemptions` +3. Implementation calls RPC `get_user_emails` +4. Mock only handles one `from()` call + +**Fix**: Completely rewrite the mock setup: +```typescript +it('retrieves invite statistics', async () => { + // Mock 1: Get invite details (first from call) + const inviteSingle = vi.fn().mockResolvedValue({ + data: { max_uses: null, current_uses: 3 }, + error: null + }); + const inviteEq = vi.fn().mockReturnValue({ single: inviteSingle }); + const inviteSelect = vi.fn().mockReturnValue({ eq: inviteEq }); + + // Mock 2: Get redemptions (second from call) + const mockRedemptions = [ + { id: '1', invite_id: 'invite-1', user_id: 'user-1', was_new_user: true, redeemed_at: '2025-01-01' }, + { id: '2', invite_id: 'invite-1', user_id: 'user-2', was_new_user: false, redeemed_at: '2025-01-02' }, + { id: '3', invite_id: 'invite-1', user_id: 'user-3', was_new_user: true, redeemed_at: '2025-01-03' }, + ]; + const redemptionsOrder = vi.fn().mockResolvedValue({ data: mockRedemptions, error: null }); + const redemptionsEq = vi.fn().mockReturnValue({ order: redemptionsOrder }); + const redemptionsSelect = vi.fn().mockReturnValue({ eq: redemptionsEq }); + + // Setup from() to return different mocks based on table name + mockSupabase.from + .mockReturnValueOnce({ select: inviteSelect }) // First call: organization_invites + .mockReturnValueOnce({ select: redemptionsSelect }); // Second call: organization_invite_redemptions + + // Mock 3: RPC call for user emails + mockSupabase.rpc.mockResolvedValueOnce({ + data: [ + { id: 'user-1', email: 'user1@example.com' }, + { id: 'user-2', email: 'user2@example.com' }, + { id: 'user-3', email: 'user3@example.com' }, + ], + error: null, + }); + + const result = await getInviteStats(mockSupabase as any, 'invite-1'); + + expect(result).toEqual({ + totalRedemptions: 3, + newUsers: 2, + existingUsers: 1, + remainingUses: null, + users: expect.arrayContaining([ + expect.objectContaining({ id: 'user-1', wasNewUser: true }), + expect.objectContaining({ id: 'user-2', wasNewUser: false }), + expect.objectContaining({ id: 'user-3', wasNewUser: true }), + ]), + }); +}); +``` + +--- + +### 2. Integration Tests: `invite-flow.test.ts` (2/4 failures) + +#### Failure 7: `completes the full invite workflow successfully` +**Location**: `tests/integration/invite-flow.test.ts:38-296` + +**Error**: +``` +expected false to be true // Object.is equality +``` + +**Root Cause**: +- At line 156: `expect(redeemResult.success).toBe(true)` fails +- The `redeemInvite` call at line 150 returns `success: false` +- Mock chain for member check doesn't properly handle `.maybeSingle()` + +**Implementation Detail** (invites.ts:216-223): +```typescript +// Check if user is already a member +const { data: existingMember } = await supabase + .from('organization_members') + .select('organization_id') + .eq('organization_id', validation.organization.id) + .eq('user_id', params.userId) + .maybeSingle(); // ⚠️ Uses maybeSingle, not single +``` + +**Current Mock** (line 107-114): +```typescript +const memberCheckQueryBuilder = { + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: null, error: { code: 'PGRST116' } }), + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), +}; +mockSupabase.from.mockReturnValueOnce({ + select: vi.fn().mockReturnValue(memberCheckQueryBuilder), +} as any); +``` + +**Problem**: +The mock is configured correctly for `.maybeSingle()`, BUT the issue is in the **insert operations** that follow. Looking at line 129-136: + +```typescript +// Insert new organization member +mockSupabase.from.mockReturnValueOnce({ + insert: vi.fn().mockResolvedValue({ data: null, error: null }), // ⚠️ Direct resolve +}); + +// Insert redemption record +mockSupabase.from.mockReturnValueOnce({ + insert: vi.fn().mockResolvedValue({ data: null, error: null }), // ⚠️ Direct resolve +}); +``` + +The implementation (invites.ts:236) expects: +```typescript +const { error: memberError } = await supabase.from('organization_members').insert({...}); +``` + +But `.insert()` returns `this`, not a promise. The chain should be: +```typescript +mockSupabase.from.mockReturnValueOnce({ + insert: vi.fn().mockReturnValue({ + // No await here - insert returns builder, not promise + // The await happens on the builder itself + }), +}); +``` + +Actually, looking more carefully at the Supabase API, `insert()` without `.select()` **does** return a promise directly. So the mock is potentially correct. + +Let me trace through more carefully. The implementation uses: +1. Line 236: `insert()` - returns PromiseLike +2. Line 251: `insert()` - returns PromiseLike +3. Line 265: `rpc()` - returns Promise + +The issue might be with the RPC call. Looking at line 265-276: + +```typescript +// Increment usage counter atomically +const { error: incrementError } = await supabase.rpc( + 'increment_invite_usage' as never, + { invite_id: validation.invite.id } as never, +); + +if (incrementError) { + // If RPC doesn't exist, fall back to manual increment + await supabase + .from('organization_invites') + .update({ current_uses: validation.invite.currentUses + 1 }) + .eq('id', validation.invite.id); +} +``` + +The test doesn't mock the RPC call! When RPC fails (because it's not mocked), it falls back to the update path, which ALSO isn't properly mocked in sequence. + +**Fix**: Add proper RPC mock and update mock: +```typescript +// After the redemption insert mock (around line 136) + +// Mock RPC increment call (or let it fail and mock the fallback) +mockSupabase.rpc.mockResolvedValueOnce({ data: null, error: null }); + +// OR: Mock the fallback update if RPC fails +// mockSupabase.rpc.mockResolvedValueOnce({ data: null, error: { message: 'RPC not found' } }); +// mockSupabase.from.mockReturnValueOnce({ +// update: vi.fn().mockReturnValue({ +// eq: vi.fn().mockResolvedValue({ data: null, error: null }), +// }), +// }); +``` + +--- + +#### Failure 8: `handles user already being a member (idempotent redemption)` +**Location**: `tests/integration/invite-flow.test.ts:368-442` + +**Error**: +``` +expected undefined to be 'Test Organization' +``` + +**Root Cause**: +- Line 441 expects: `redeemResult.organization?.name` +- Implementation returns: `redeemResult.organizationName` (flat structure) + +**Implementation** (invites.ts:224-232): +```typescript +if (existingMember) { + // User is already a member - idempotent success + return { + success: true, + alreadyMember: true, + organizationId: validation.organization.id, + organizationSlug: validation.organization.slug, + organizationName: validation.organization.name, // Flat field + }; +} +``` + +**Type Definition** (types/invites.ts:40-48): +```typescript +export interface InviteRedemptionResult { + success: boolean; + alreadyMember?: boolean; + organizationId?: string; + organizationSlug?: string; + organizationName?: string; // Not nested + error?: string; + errorCode?: InviteErrorCode; +} +``` + +**Current Test** (line 439-441): +```typescript +expect(redeemResult.success).toBe(true); +expect(redeemResult.alreadyMember).toBe(true); +expect(redeemResult.organization?.name).toBe('Test Organization'); // ❌ Wrong field +``` + +**Fix**: +```typescript +expect(redeemResult.success).toBe(true); +expect(redeemResult.alreadyMember).toBe(true); +expect(redeemResult.organizationName).toBe('Test Organization'); +``` + +Also check line 157 in the same file for consistency. + +--- + +### 3. Middleware Test: `promptManagerMiddleware.test.ts` (1/6 failures) + +#### Failure 9: `blocks prompt routes without organization membership` +**Location**: `tests/unit/middleware/promptManagerMiddleware.test.ts:156-168` + +**Error**: +``` +expected 302 to be 404 +``` + +**Root Cause**: +- Test expects 404 when user has no organization membership +- Middleware redirects (302) to request access page instead + +**Middleware Implementation** (src/middleware/index.ts:188-189): +```typescript +if (!hasPromptManagerAccess(context.organizations)) { + return redirect(PROMPT_MANAGER_REQUEST_ACCESS_PATH); // 302, not 404 +} +``` + +**Current Test** (line 156-168): +```typescript +it('blocks prompt routes without organization membership', async () => { + currentUser = createUser(); + delete mutableEnv.PUBLIC_PROMPT_MANAGER_ENABLED; + mutableEnv.PROMPT_MANAGER_ENABLED = 'true'; + buildPromptManagerContextMock.mockResolvedValue({ + organizations: [], + activeOrganization: null, + }); + const { onRequest } = await loadMiddleware(); + const context = createContext('/prompts'); + const response = await onRequest(context, () => Promise.resolve(new Response('ok'))); + expect(response.status).toBe(404); // ❌ Should be 302 +}); +``` + +**Fix**: +```typescript +it('blocks prompt routes without organization membership', async () => { + currentUser = createUser(); + delete mutableEnv.PUBLIC_PROMPT_MANAGER_ENABLED; + mutableEnv.PROMPT_MANAGER_ENABLED = 'true'; + buildPromptManagerContextMock.mockResolvedValue({ + organizations: [], + activeOrganization: null, + }); + const { onRequest } = await loadMiddleware(); + const context = createContext('/prompts'); + const response = await onRequest(context, () => Promise.resolve(new Response('ok'))); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/prompts/request-access'); +}); +``` + +--- + +## Summary of Changes Required + +| File | Test | Change Type | Lines | +|------|------|-------------|-------| +| `invites.test.ts` | Token length | Add `async/await` | 37-41 | +| `invites.test.ts` | List invites success | Update return type expectations | 267-307 | +| `invites.test.ts` | List invites error | Update return type expectations | 309-319 | +| `invites.test.ts` | Revoke success | Change to `success`-based assertion | 323-334 | +| `invites.test.ts` | Revoke error | Change to `success`-based assertion | 336-345 | +| `invites.test.ts` | Get stats | Complete mock rewrite | 349-377 | +| `invite-flow.test.ts` | Full workflow | Add RPC mock | ~150 | +| `invite-flow.test.ts` | Idempotent | Fix field name | 441, 157 | +| `promptManagerMiddleware.test.ts` | No membership | Update status expectation | 156-168 | + +--- + +## Implementation Plan + +### Phase 1: Quick Wins (Trivial fixes) +**Estimated Time**: 15 minutes + +1. ✅ Fix async token generation test (Failure 1) +2. ✅ Fix middleware test expectation (Failure 9) +3. ✅ Fix idempotent redemption field name (Failure 8) + +### Phase 2: Return Type Updates (Medium complexity) +**Estimated Time**: 30 minutes + +4. ✅ Fix list invites success test (Failure 2) +5. ✅ Fix list invites error test (Failure 3) +6. ✅ Fix revoke invite success test (Failure 4) +7. ✅ Fix revoke invite error test (Failure 5) + +### Phase 3: Complex Mock Refactoring (High complexity) +**Estimated Time**: 45 minutes + +8. ✅ Fix get invite stats test (Failure 6) - requires multi-table mock +9. ✅ Fix full workflow test (Failure 7) - requires RPC mock + +### Phase 4: Validation +**Estimated Time**: 15 minutes + +10. ✅ Run all tests and verify 0 failures +11. ✅ Run tests in watch mode and verify stability +12. ✅ Review test coverage hasn't decreased + +--- + +## Alternative Approach: Fix Implementation (Not Recommended) + +If we were to update the implementation instead of tests, we would need to: + +1. Make `generateInviteToken` synchronous again (breaking change for browser crypto) +2. Change `listOrganizationInvites` to return `{ data, error }` structure +3. Change `revokeInvite` to return `{ error }` instead of `{ success, error? }` +4. Change `InviteRedemptionResult` to have nested `organization` object +5. Change middleware to return 404 instead of redirecting + +**Why this is not recommended**: +- Breaking changes to existing API consumers +- Current implementation is more idiomatic and type-safe +- Middleware redirect is better UX than 404 +- Would require updates to API route handlers and frontend code + +--- + +## Risk Assessment + +### Low Risk Changes +- Async/await fixes +- Field name corrections +- Status code expectations + +### Medium Risk Changes +- Return type expectations (need to verify no other consumers) +- Mock refactoring (isolated to tests) + +### High Risk Changes +- None (we're not changing implementation) + +--- + +## Testing Strategy + +After implementing fixes: + +1. **Unit Tests**: `npm run test tests/unit/services/prompt-manager/invites.test.ts` +2. **Integration Tests**: `npm run test tests/integration/invite-flow.test.ts` +3. **Middleware Tests**: `npm run test tests/unit/middleware/promptManagerMiddleware.test.ts` +4. **Full Suite**: `npm run test` +5. **Type Check**: `npm run lint:check` +6. **Coverage**: `npm run test:coverage` (ensure no regression) + +--- + +## Appendix: Type Definitions Reference + +### Current Return Types + +```typescript +// ✅ Consistent pattern +createOrganizationInvite(): Promise<{ data: OrganizationInvite | null; error: string | null }> +validateInviteToken(): Promise // Has valid/error/errorCode + +// ❌ Inconsistent patterns +listOrganizationInvites(): Promise // No error handling in return type +revokeInvite(): Promise<{ success: boolean; error?: string }> // Different from create pattern +getInviteStats(): Promise // No error in return type + +// ✅ Good pattern +redeemInvite(): Promise // Has success/error/errorCode +``` + +### Recommended Future Refactoring (Post-Fix) + +Consider standardizing all service functions to one of two patterns: + +**Pattern A - Result Type**: +```typescript +type Result = { success: true; data: T } | { success: false; error: string; errorCode?: string } +``` + +**Pattern B - Data/Error Tuple**: +```typescript +type DataResult = { data: T | null; error: string | null } +``` + +This would make the API more predictable and easier to test. diff --git a/.claude/commands/generate-types-from-supabase.md b/.claude/commands/generate-types-from-supabase.md new file mode 100644 index 0000000..857de75 --- /dev/null +++ b/.claude/commands/generate-types-from-supabase.md @@ -0,0 +1,33 @@ +You are a skilled TypeScript developer tasked with creating a library of DTO (Data Transfer Object) types and a Command Model for the application. Your task is to analyse the database model definition, and then create appropriate DTO types that accurately represent the data structures required by the API, while maintaining the connection to the underlying database models. + +First, carefully review the following input data: + + +@database.types.ts + + +Your task is to create TypeScript type definitions for the DTO and Command Models, ensuring that they are derived from the database models. Follow these steps: + +1. Analyse the database models. +2. Create DTO and Command Model types, using the database entity definitions. +3. Ensure consistency between DTO and Command Models. +4. Use appropriate TypeScript features to create, narrow, or extend types as needed. +5. Perform a final check to ensure that all DTOs are included and correctly linked to entity definitions. + +Before creating the final output, work inside the tags in your thinking block to show your thought process and ensure that all requirements are met. In your analysis: +- List all DTOs and Command Models, numbering each one. +- For each DTO and Command Model: +- Identify the relevant database entities and any necessary type transformations. + - Describe the TypeScript functions or tools you plan to use. + - Create a brief outline of the DTO and Command Model structure. +- Explain how you will ensure that each DTO and Command Model is directly or indirectly linked to the entity type definitions. + +After completing your analysis, provide the final definitions for the DTO and Command Model types that will appear in the src/types.ts file. Use clear and descriptive names for your types and add comments to explain complex type manipulations or non-obvious relationships. + +Remember: +- Make sure that all DTOs and Command Models are included. +- Each DTO and Command Model should directly refer to one or more database entities. +- Use TypeScript features such as Pick, Omit, Partial, etc. as needed. +- Add comments to explain complex or non-obvious type manipulations. + +The final result should consist solely of the DTO and Command Model type definitions that you will save in the src/types - create new files and/or update existing files, without duplicating or re-doing any work done in the thinking block. \ No newline at end of file diff --git a/.claude/commands/project-prd.md b/.claude/commands/project-prd.md new file mode 100644 index 0000000..03b8b92 --- /dev/null +++ b/.claude/commands/project-prd.md @@ -0,0 +1,66 @@ +Jesteś doświadczonym menedżerem produktu, którego zadaniem jest stworzenie kompleksowego dokumentu wymagań produktu (PRD) w oparciu o poniższe opisy: + + +@.ai/prompt-manager/poc-arch-plan.md + + + +@.ai/prompt-manager/poc-impl-plan.md @schema-proposal.md + + +Wykonaj następujące kroki, aby stworzyć kompleksowy i dobrze zorganizowany dokument: + +1. Podziel PRD na następujące sekcje: + a. Przegląd projektu + b. Problem użytkownika + c. Wymagania funkcjonalne + d. Granice projektu + e. Historie użytkownika + f. Metryki sukcesu + +2. W każdej sekcji należy podać szczegółowe i istotne informacje w oparciu o opis projektu i odpowiedzi na pytania wyjaśniające. Upewnij się, że: + - Używasz jasnego i zwięzłego języka + - W razie potrzeby podajesz konkretne szczegóły i dane + - Zachowujesz spójność w całym dokumencie + - Odnosisz się do wszystkich punktów wymienionych w każdej sekcji + +3. Podczas tworzenia historyjek użytkownika i kryteriów akceptacji + - Wymień WSZYSTKIE niezbędne historyjki użytkownika, w tym scenariusze podstawowe, alternatywne i skrajne. + - Przypisz unikalny identyfikator wymagań (np. US-001) do każdej historyjki użytkownika w celu bezpośredniej identyfikowalności. + - Uwzględnij co najmniej jedną historię użytkownika specjalnie dla bezpiecznego dostępu lub uwierzytelniania, jeśli aplikacja wymaga identyfikacji użytkownika lub ograniczeń dostępu. + - Upewnij się, że żadna potencjalna interakcja użytkownika nie została pominięta. + - Upewnij się, że każda historia użytkownika jest testowalna. + +Użyj następującej struktury dla każdej historii użytkownika: +- ID +- Tytuł +- Opis +- Kryteria akceptacji + +4. Po ukończeniu PRD przejrzyj go pod kątem tej listy kontrolnej: + - Czy każdą historię użytkownika można przetestować? + - Czy kryteria akceptacji są jasne i konkretne? + - Czy mamy wystarczająco dużo historyjek użytkownika, aby zbudować w pełni funkcjonalną aplikację? + - Czy uwzględniliśmy wymagania dotyczące uwierzytelniania i autoryzacji (jeśli dotyczy)? + +5. Formatowanie PRD: + - Zachowaj spójne formatowanie i numerację. + - Nie używaj pogrubionego formatowania w markdown ( ** ). + - Wymień WSZYSTKIE historyjki użytkownika. + - Sformatuj PRD w poprawnym markdown. + +Przygotuj PRD z następującą strukturą: + +```markdown +# Dokument wymagań produktu (PRD) - {{app-name}} +## 1. Przegląd produktu +## 2. Problem użytkownika +## 3. Wymagania funkcjonalne +## 4. Granice produktu +## 5. Historyjki użytkowników +## 6. Metryki sukcesu +``` + +Pamiętaj, aby wypełnić każdą sekcję szczegółowymi, istotnymi informacjami w oparciu o opis projektu i nasze pytania wyjaśniające. Upewnij się, że PRD jest wyczerpujący, jasny i zawiera wszystkie istotne informacje potrzebne do dalszej pracy nad produktem. + +Ostateczny wynik powinien składać się wyłącznie z PRD zgodnego ze wskazanym formatem w markdown, który zapiszesz w pliku .ai/prompt-manager/prd.md \ No newline at end of file diff --git a/.claude/commands/shadcn-init.md b/.claude/commands/shadcn-init.md new file mode 100644 index 0000000..883b8f8 --- /dev/null +++ b/.claude/commands/shadcn-init.md @@ -0,0 +1,11 @@ +# shadcn-init + +Automatyzuje pełną inicjalizację komponentów shadcn/ui w projekcie. + +``` +Uruchom `npx shadcn@latest init` w projekcie 10xRules.ai. +Nie używaj flagi `--defaults`. +Po inicjalizacji dodaj komponenty: + npx shadcn@latest add button card input tabs table dialog sheet form textarea select switch toast +Potwierdź, że komponenty trafiły do `src/components/ui` i motyw to "new-york". +``` diff --git a/.claude/commands/shadcn-plan-session.md b/.claude/commands/shadcn-plan-session.md new file mode 100644 index 0000000..7f82588 --- /dev/null +++ b/.claude/commands/shadcn-plan-session.md @@ -0,0 +1,61 @@ +# shadcn-plan-session + +Prompt do przeprowadzenia sesji planistycznej UI z modelem reasoningowym. + +``` +Jesteś asystentem AI, którego zadaniem jest pomoc w zaplanowaniu architektury interfejsu użytkownika dla MVP (Minimum Viable Product) na podstawie dostarczonych informacji. Twoim celem jest wygenerowanie listy pytań i zaleceń, które zostaną wykorzystane w kolejnym promptowaniu do utworzenia szczegółowej architektury UI, map podróży użytkownika i struktury nawigacji. + +Prosimy o uważne zapoznanie się z poniższymi informacjami: + + +@prd.md + + + +@tech-stack.md + + + +@api-plan.md + + +Przeanalizuj dostarczone informacje, koncentrując się na aspektach istotnych dla projektowania interfejsu użytkownika. Rozważ następujące kwestie: + +1. Zidentyfikuj kluczowe widoki i ekrany na podstawie wymagań produktu i dostępnych endpointów API. +2. Określ potencjalne przepływy użytkownika i nawigację między widokami, uwzględniając możliwości API. +3. Rozważ komponenty UI i wzorce interakcji, które mogą być konieczne do efektywnej komunikacji z API. +4. Pomyśl o responsywności i dostępności interfejsu. +5. Oceń wymagania bezpieczeństwa i uwierzytelniania w kontekście integracji z API. +6. Rozważ wszelkie konkretne biblioteki UI lub frameworki, które mogą być korzystne dla projektu. +7. Przeanalizuj, jak struktura API wpływa na projekt UI i przepływy danych w aplikacji. + +Na podstawie analizy wygeneruj listę pytań i zaleceń. Powinny one dotyczyć wszelkich niejasności, potencjalnych problemów lub obszarów, w których potrzeba więcej informacji, aby stworzyć efektywną architekturę UI. Rozważ pytania dotyczące: + +1. Hierarchia i organizacja widoków w odniesieniu do struktury API +2. Przepływy użytkownika i nawigacja wspierane przez dostępne endpointy +3. Responsywność i adaptacja do różnych urządzeń +4. Dostępność i inkluzywność +5. Bezpieczeństwo i autoryzacja na poziomie UI w powiązaniu z mechanizmami API +6. Spójność designu i doświadczenia użytkownika +7. Strategia zarządzania stanem aplikacji i synchronizacji z API +8. Obsługa stanów błędów i wyjątków zwracanych przez API +9. Strategie buforowania i optymalizacji wydajności w komunikacji z API + +Dane wyjściowe powinny mieć następującą strukturę: + + + +[Wymień tutaj swoje pytania, ponumerowane] + + + +[Wymień tutaj swoje zalecenia, ponumerowane] + + + +Pamiętaj, że Twoim celem jest dostarczenie kompleksowej listy pytań i zaleceń, które pomogą w stworzeniu solidnej architektury UI dla MVP, w pełni zintegrowanej z dostępnymi endpointami API. Skoncentruj się na jasności, trafności i dokładności swoich wyników. Nie dołączaj żadnych dodatkowych komentarzy ani wyjaśnień poza określonym formatem wyjściowym. + +Kontynuuj ten proces, generując nowe pytania i rekomendacje w oparciu o przekazany kontekst i odpowiedzi użytkownika, dopóki użytkownik wyraźnie nie poprosi o podsumowanie. + +Pamiętaj, aby skupić się na jasności, trafności i dokładności wyników. Nie dołączaj żadnych dodatkowych komentarzy ani wyjaśnień poza określonym formatem wyjściowym. +``` diff --git a/.claude/commands/shadcn-plan-summary.md b/.claude/commands/shadcn-plan-summary.md new file mode 100644 index 0000000..52434d2 --- /dev/null +++ b/.claude/commands/shadcn-plan-summary.md @@ -0,0 +1,44 @@ +# shadcn-plan-summary + +Prompt do podsumowania sesji planistycznej UI. + +``` +{{latest-round-answers}} <- lista odpowiedzi na drugą runde pytań + +--- + +Jesteś asystentem AI, którego zadaniem jest podsumowanie rozmowy na temat planowania architektury UI dla MVP i przygotowanie zwięzłego podsumowania dla następnego etapu rozwoju. W historii konwersacji znajdziesz następujące informacje: +1. Dokument wymagań produktu (PRD) +2. Informacje o stacku technologicznym +3. Plan API +4. Historia rozmów zawierająca pytania i odpowiedzi +5. Zalecenia dotyczące architektury UI + +Twoim zadaniem jest: +1. Podsumować historię konwersacji, koncentrując się na wszystkich decyzjach związanych z planowaniem architektury UI. +2. Dopasować zalecenia modelu do odpowiedzi udzielonych w historii konwersacji. Zidentyfikować, które zalecenia są istotne w oparciu o dyskusję. +3. Przygotować szczegółowe podsumowanie rozmowy, które obejmuje: + a. Główne wymagania dotyczące architektury UI + b. Kluczowe widoki, ekrany i przepływy użytkownika + c. Strategię integracji z API i zarządzania stanem + d. Kwestie dotyczące responsywności, dostępności i bezpieczeństwa + e. Wszelkie nierozwiązane kwestie lub obszary wymagające dalszego wyjaśnienia +4. Sformatować wyniki w następujący sposób: + + + +[Wymień decyzje podjęte przez użytkownika, ponumerowane]. + + +[Lista najistotniejszych zaleceń dopasowanych do rozmowy, ponumerowanych] + + +[Podaj szczegółowe podsumowanie rozmowy, w tym elementy wymienione w kroku 3]. + + +[Wymień wszelkie nierozwiązane kwestie lub obszary wymagające dalszych wyjaśnień, jeśli takie istnieją] + + + +Końcowy wynik powinien zawierać tylko treść w formacie markdown. Upewnij się, że Twoje podsumowanie jest jasne, zwięzłe i zapewnia cenne informacje dla następnego etapu planowania architektury UI i integracji z API. +``` diff --git a/.claude/commands/shadcn-resume-implementation.md b/.claude/commands/shadcn-resume-implementation.md new file mode 100644 index 0000000..9a7d514 --- /dev/null +++ b/.claude/commands/shadcn-resume-implementation.md @@ -0,0 +1,41 @@ +# shadcn-resume-implementation + +Prompt do wznowienia implementacji widoku na podstawie zapisanego statusu. + +``` +Twoim zadaniem jest zaimplementowanie widoku frontendu w oparciu o podany plan implementacji i zasady implementacji. Twoim celem jest stworzenie szczegółowej i dokładnej implementacji, która jest zgodna z dostarczonym planem, poprawnie reprezentuje strukturę komponentów, integruje się z API i obsługuje wszystkie określone interakcje użytkownika. + +Najpierw przejrzyj plan implementacji: + + +{{implementation-plan}} <- zamień na referencję do planu implementacji widoku (np. @generations-view-implementation-plan.md) + + +Teraz przejrzyj zasady implementacji: + + +{{frontend-rules}} <- zamień na referencję do reguł frontendowych (np. @shared.mdc, @frontend.mdc, @astro.mdc, @react.mdc, @ui-shadcn-helper.mdc) + + +Przejrzyj zdefiniowane typy: + + +{{types}} <- zamień na referencję do definicji DTOsów (np. @types.ts) + + +Wdrażaj plan zgodnie z następującym podejściem: + + +Realizuj maksymalnie 3 kroki planu implementacji, podsumuj krótko co zrobiłeś i opisz plan na 3 kolejne działania - zatrzymaj w tym momencie pracę i czekaj na mój feedback. + + +Nie zaczynaj pracy od poczatku kroków implementacji, weź pod uwagę obecny status: + + +{{implementation-status}} <- zamień na referencję do utworzonego statusu implementacji 👈 + + +Dokładnie przeanalizuj plan wdrożenia, zasady i jego obecny status (zacznij od "Następne kroki"). Zwróć szczególną uwagę na strukturę komponentów, wymagania dotyczące integracji API i interakcje użytkownika opisane w planie. + +// reszta prompta taka sama jak w oryginalnym poleceniu implementacji +``` diff --git a/.claude/commands/shadcn-status-checkpoint.md b/.claude/commands/shadcn-status-checkpoint.md new file mode 100644 index 0000000..d6659d7 --- /dev/null +++ b/.claude/commands/shadcn-status-checkpoint.md @@ -0,0 +1,17 @@ +# shadcn-status-checkpoint + +Prompt do zapisania statusu implementacji widoku. + +``` +Podsumuj swoją pracę w pliku .ai/{nazwa-zadania}-implementation-status.md w formacie markdown: + +# Status implementacji widoku {nazwa widoku} + +## Zrealizowane kroki +[Szczegółowa lista zrealizowanych kroków] + +## Kolejne kroki +[Lista dalszych kroków, zgodna z planem implementacji] + +Po utworzeniu pliku ze statusem, napisz "Gotowe". Na tym zakończ pracę w tym wątku. +``` diff --git a/.claude/commands/shadcn-ui-plan.md b/.claude/commands/shadcn-ui-plan.md new file mode 100644 index 0000000..4673c18 --- /dev/null +++ b/.claude/commands/shadcn-ui-plan.md @@ -0,0 +1,80 @@ +# shadcn-ui-plan + +Prompt do wygenerowania wysokopoziomowej architektury UI w `.ai/ui-plan.md`. + +``` +Jesteś wykwalifikowanym architektem frontend, którego zadaniem jest stworzenie kompleksowej architektury interfejsu użytkownika w oparciu o dokument wymagań produktu (PRD), plan API i notatki z sesji planowania. Twoim celem jest zaprojektowanie struktury interfejsu użytkownika, która skutecznie spełnia wymagania produktu, jest zgodna z możliwościami API i zawiera spostrzeżenia z sesji planowania. + +Najpierw dokładnie przejrzyj następujące dokumenty: + +Dokument wymagań produktu (PRD): + +{{prd}} <- zamień na referencję do @prd.md + + +Plan API: + +{{api-plan}} <- zamień na referencję do @api-plan.md + + +Session Notes: + +{{session-notes}} <- wklej notatki z podsumowaniem sesji planistycznej + + +Twoim zadaniem jest stworzenie szczegółowej architektury interfejsu użytkownika, która obejmuje niezbędne widoki, mapowanie podróży użytkownika, strukturę nawigacji i kluczowe elementy dla każdego widoku. Projekt powinien uwzględniać doświadczenie użytkownika, dostępność i bezpieczeństwo. + +Wykonaj następujące kroki, aby ukończyć zadanie: + +1. Dokładnie przeanalizuj PRD, plan API i notatki z sesji. +2. Wyodrębnij i wypisz kluczowe wymagania z PRD. +3. Zidentyfikuj i wymień główne punkty końcowe API i ich cele. +4. Utworzenie listy wszystkich niezbędnych widoków na podstawie PRD, planu API i notatek z sesji. +5. Określenie głównego celu i kluczowych informacji dla każdego widoku. +6. Zaplanuj podróż użytkownika między widokami, w tym podział krok po kroku dla głównego przypadku użycia. +7. Zaprojektuj strukturę nawigacji. +8. Zaproponuj kluczowe elementy interfejsu użytkownika dla każdego widoku, biorąc pod uwagę UX, dostępność i bezpieczeństwo. +9. Rozważ potencjalne przypadki brzegowe lub stany błędów. +10. Upewnij się, że architektura interfejsu użytkownika jest zgodna z planem API. +11. Przejrzenie i zmapowanie wszystkich historyjek użytkownika z PRD do architektury interfejsu użytkownika. +12. Wyraźne mapowanie wymagań na elementy interfejsu użytkownika. +13. Rozważ potencjalne punkty bólu użytkownika i sposób, w jaki interfejs użytkownika je rozwiązuje. + +Dla każdego głównego kroku pracuj wewnątrz tagów w bloku myślenia, aby rozbić proces myślowy przed przejściem do następnego kroku. Ta sekcja może być dość długa. To w porządku, że ta sekcja może być dość długa. + +Przedstaw ostateczną architekturę interfejsu użytkownika w następującym formacie Markdown: + +```markdown +# Architektura UI dla [Nazwa produktu] + +## 1. Przegląd struktury UI + +[Przedstaw ogólny przegląd struktury UI] + +## 2. Lista widoków + +[Dla każdego widoku podaj: +- Nazwa widoku +- Ścieżka widoku +- Główny cel +- Kluczowe informacje do wyświetlenia +- Kluczowe komponenty widoku +- UX, dostępność i względy bezpieczeństwa] + +## 3. Mapa podróży użytkownika + +[Opisz przepływ między widokami i kluczowymi interakcjami użytkownika] + +## 4. Układ i struktura nawigacji + +[Wyjaśnij, w jaki sposób użytkownicy będą poruszać się między widokami] + +## 5. Kluczowe komponenty + +[Wymień i krótko opisz kluczowe komponenty, które będą używane w wielu widokach]. +``` + +Skup się wyłącznie na architekturze interfejsu użytkownika, podróży użytkownika, nawigacji i kluczowych elementach dla każdego widoku. Nie uwzględniaj szczegółów implementacji, konkretnego projektu wizualnego ani przykładów kodu, chyba że są one kluczowe dla zrozumienia architektury. + +Końcowy rezultat powinien składać się wyłącznie z architektury UI w formacie Markdown w języku polskim, którą zapiszesz w pliku .ai/ui-plan.md. Nie powielaj ani nie powtarzaj żadnej pracy wykonanej w bloku myślenia. +``` diff --git a/.claude/commands/shadcn-view-implement.md b/.claude/commands/shadcn-view-implement.md new file mode 100644 index 0000000..dc60696 --- /dev/null +++ b/.claude/commands/shadcn-view-implement.md @@ -0,0 +1,75 @@ +# shadcn-view-implement + +Prompt do iteracyjnej implementacji widoku z shadcn/ui. + +``` +Twoim zadaniem jest zaimplementowanie widoku frontendu w oparciu o podany plan implementacji i zasady implementacji. Twoim celem jest stworzenie szczegółowej i dokładnej implementacji, która jest zgodna z dostarczonym planem, poprawnie reprezentuje strukturę komponentów, integruje się z API i obsługuje wszystkie określone interakcje użytkownika. + +Najpierw przejrzyj plan implementacji: + + +{{implementation-plan}} <- zamień na referencję do planu implementacji widoku (np. @generations-view-implementation-plan.md) + + +Teraz przejrzyj zasady implementacji: + + +{{frontend-rules}} <- zamień na referencję do reguł frontendowych (np. @shared.mdc, @frontend.mdc, @astro.mdc, @react.mdc, @ui-shadcn-helper.mdc) + + +Przejrzyj zdefiniowane typy: + + +{{types}} <- zamień na referencję do definicji DTOsów (np. @types.ts) + + +Wdrażaj plan zgodnie z następującym podejściem: + + +Realizuj maksymalnie 3 kroki planu implementacji, podsumuj krótko co zrobiłeś i opisz plan na 3 kolejne działania - zatrzymaj w tym momencie pracę i czekaj na mój feedback. + + +Dokładnie przeanalizuj plan wdrożenia i zasady. Zwróć szczególną uwagę na strukturę komponentów, wymagania dotyczące integracji API i interakcje użytkownika opisane w planie. + +Wykonaj następujące kroki, aby zaimplementować widok frontendu: + +1. Struktura komponentów: + - Zidentyfikuj wszystkie komponenty wymienione w planie wdrożenia. + - Utwórz hierarchiczną strukturę tych komponentów. + - Upewnij się, że obowiązki i relacje każdego komponentu są jasno zdefiniowane. + +2. Integracja API: + - Zidentyfikuj wszystkie endpointy API wymienione w planie. + - Wdróż niezbędne wywołania API dla każdego endpointa. + - Obsłuż odpowiedzi z API i odpowiednio aktualizacji stan komponentów. + +3. Interakcje użytkownika: + - Wylistuj wszystkie interakcje użytkownika określone w planie wdrożenia. + - Wdróż obsługi zdarzeń dla każdej interakcji. + - Upewnij się, że każda interakcja wyzwala odpowiednią akcję lub zmianę stanu. + +4. Zarządzanie stanem: + - Zidentyfikuj wymagany stan dla każdego komponentu. + - Zaimplementuj zarządzanie stanem przy użyciu odpowiedniej metody (stan lokalny, custom hook, stan współdzielony). + - Upewnij się, że zmiany stanu wyzwalają niezbędne ponowne renderowanie. + +5. Stylowanie i layout: + - Zastosuj określone stylowanie i layout, jak wspomniano w planie wdrożenia. + - Zapewnienie responsywności, jeśli wymaga tego plan. + +6. Obsługa błędów i przypadki brzegowe: + - Wdrożenie obsługi błędów dla wywołań API i interakcji użytkownika. + - Rozważ i obsłuż potencjalne edge case'y wymienione w planie. + +7. Optymalizacja wydajności: + - Wdrożenie wszelkich optymalizacji wydajności określonych w planie lub zasadach. + - Zapewnienie wydajnego renderowania i minimalnej liczby niepotrzebnych ponownych renderowań. + +8. Testowanie: + - Jeśli zostało to określone w planie, zaimplementuj testy jednostkowe dla komponentów i funkcji. + - Dokładnie przetestuj wszystkie interakcje użytkownika i integracje API. + +W trakcie całego procesu implementacji należy ściśle przestrzegać dostarczonych zasad implementacji. Zasady te mają pierwszeństwo przed wszelkimi ogólnymi najlepszymi praktykami, które mogą być z nimi sprzeczne. + +Upewnij się, że twoja implementacja dokładnie odzwierciedla dostarczony plan implementacji i przestrzega wszystkich określonych zasad. Zwróć szczególną uwagę na strukturę komponentów, integrację API i obsługę interakcji użytkownika. +``` diff --git a/.claude/commands/shadcn-view-plan.md b/.claude/commands/shadcn-view-plan.md new file mode 100644 index 0000000..149402d --- /dev/null +++ b/.claude/commands/shadcn-view-plan.md @@ -0,0 +1,87 @@ +# shadcn-view-plan + +Prompt do przygotowania szczegółowego planu implementacji widoku (`.ai/{view}-view-implementation-plan.md`). + +``` +Jako starszy programista frontendu Twoim zadaniem jest stworzenie szczegółowego planu wdrożenia nowego widoku w aplikacji internetowej. Plan ten powinien być kompleksowy i wystarczająco jasny dla innego programisty frontendowego, aby mógł poprawnie i wydajnie wdrożyć widok. + +Najpierw przejrzyj następujące informacje: + +1. Product Requirements Document (PRD): + +{{prd}} <- zamień na referencję do pliku @prd.md + + +2. Opis widoku: + +{{view-description}} <- wklej opis implementowanego widoku z ui-plan.md + + +3. User Stories: + +{{user-stories}} <- wklej historyjki użytkownika z @prd.md, które będą adresowane przez widok + + +4. Endpoint Description: + +{{endpoint-description}} <- wklej opisy endpointów z api-plan.md, z których będzie korzystał widok + + +5. Endpoint Implementation: + +{{endpoint-implementation}} <- zamień na referencję do implementacji endpointów, z których będzie korzystał widok (np. @generations.ts, @flashcards.ts) + + +6. Type Definitions: + +{{types}} <- zamień na referencję do pliku z definicjami DTOsów (np. @types.ts) + + +7. Tech Stack: + +{{tech-stack}} <- zamień na referencję do pliku @tech-stack.md + + +Przed utworzeniem ostatecznego planu wdrożenia przeprowadź analizę i planowanie wewnątrz tagów w swoim bloku myślowym. Ta sekcja może być dość długa, ponieważ ważne jest, aby być dokładnym. + +W swoim podziale implementacji wykonaj następujące kroki: +1. Dla każdej sekcji wejściowej (PRD, User Stories, Endpoint Description, Endpoint Implementation, Type Definitions, Tech Stack): + - Podsumuj kluczowe punkty + - Wymień wszelkie wymagania lub ograniczenia + - Zwróć uwagę na wszelkie potencjalne wyzwania lub ważne kwestie +2. Wyodrębnienie i wypisanie kluczowych wymagań z PRD +3. Wypisanie wszystkich potrzebnych głównych komponentów, wraz z krótkim opisem ich opisu, potrzebnych typów, obsługiwanych zdarzeń i warunków walidacji +4. Stworzenie wysokopoziomowego diagramu drzewa komponentów +5. Zidentyfikuj wymagane DTO i niestandardowe typy ViewModel dla każdego komponentu widoku. Szczegółowo wyjaśnij te nowe typy, dzieląc ich pola i powiązane typy. +6. Zidentyfikuj potencjalne zmienne stanu i niestandardowe hooki, wyjaśniając ich cel i sposób ich użycia +7. Wymień wymagane wywołania API i odpowiadające im akcje frontendowe +8. Zmapuj każdej historii użytkownika do konkretnych szczegółów implementacji, komponentów lub funkcji +9. Wymień interakcje użytkownika i ich oczekiwane wyniki +10. Wymień warunki wymagane przez API i jak je weryfikować na poziomie komponentów +11. Zidentyfikuj potencjalne scenariusze błędów i zasugeruj, jak sobie z nimi poradzić +12. Wymień potencjalne wyzwania związane z wdrożeniem tego widoku i zasugeruj możliwe rozwiązania + +Po przeprowadzeniu analizy dostarcz plan wdrożenia w formacie Markdown z następującymi sekcjami: + +1. Przegląd: Krótkie podsumowanie widoku i jego celu. +2. Routing widoku: Określenie ścieżki, na której widok powinien być dostępny. +3. Struktura komponentów: Zarys głównych komponentów i ich hierarchii. +4. Szczegóły komponentu: Dla każdego komponentu należy opisać: + - Opis komponentu, jego przeznaczenie i z czego się składa + - Główne elementy HTML i komponenty dzieci, które budują komponent + - Obsługiwane zdarzenia + - Warunki walidacji (szczegółowe warunki, zgodnie z API) + - Typy (DTO i ViewModel) wymagane przez komponent + - Propsy, które komponent przyjmuje od rodzica (interfejs komponentu) +5. Typy: Szczegółowy opis typów wymaganych do implementacji widoku, w tym dokładny podział wszelkich nowych typów lub modeli widoku według pól i typów. +6. Zarządzanie stanem: Szczegółowy opis sposobu zarządzania stanem w widoku, określenie, czy wymagany jest customowy hook. +7. Integracja API: Wyjaśnienie sposobu integracji z dostarczonym punktem końcowym. Precyzyjnie wskazuje typy żądania i odpowiedzi. +8. Interakcje użytkownika: Szczegółowy opis interakcji użytkownika i sposobu ich obsługi. +9. Warunki i walidacja: Opisz jakie warunki są weryfikowane przez interfejs, których komponentów dotyczą i jak wpływają one na stan interfejsu +10. Obsługa błędów: Opis sposobu obsługi potencjalnych błędów lub przypadków brzegowych. +11. Kroki implementacji: Przewodnik krok po kroku dotyczący implementacji widoku. + +Upewnij się, że Twój plan jest zgodny z PRD, historyjkami użytkownika i uwzględnia dostarczony stack technologiczny. + +Ostateczne wyniki powinny być w języku polskim i zapisane w pliku o nazwie .ai/{view-name}-view-implementation-plan.md. Nie uwzględniaj żadnej analizy i planowania w końcowym wyniku. +``` diff --git a/.gitignore b/.gitignore index bc69988..63bbe16 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ playwright-report/index.html # Generated data files src/data/preparedRules.json mcp-server/src/preparedRules.json + +# Codex MCP settings +codex.context7.toml \ No newline at end of file diff --git a/README.md b/README.md index e43feb7..cef6f21 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Create so called "rules for AI" written in Markdown, used by tools such as GitHu - **Build AI Rules:** Create customized rule sets for different editors (Copilot, Cursor, Windsurf) - **Export Options:** Easily copy to clipboard or download as markdown files - **Smart Import:** Automatically generate rules by dropping package.json or requirements.txt files -- **Editor Integration:** Provides programmatic access to rules via an [MCP Server](./mcp-server/README.md) for integration with AI assistants in editors like Cursor. +- **Editor Integration:** Provides programmatic access to rules via an [MCP Server](./mcp-server/README.md) for integration with AI assistants in editors like Cursor + ## Getting Started @@ -55,9 +56,10 @@ Create so called "rules for AI" written in Markdown, used by tools such as GitHu 3. Verify Supabase is running - ```bash - supabase status - ``` + ```bash + supabase status + ``` + ## Dotenv diff --git a/docs/prompt-library/admin-api.md b/docs/prompt-library/admin-api.md new file mode 100644 index 0000000..62fd99d --- /dev/null +++ b/docs/prompt-library/admin-api.md @@ -0,0 +1,543 @@ +# Prompt Manager Admin API Documentation + +## Overview + +The Prompt Manager Admin API provides endpoints for curators to create, manage, and publish prompts within their organization. All endpoints require: + +- **Feature Flag**: `PROMPT_MANAGER_ENABLED` must be `true` +- **Authentication**: Valid Supabase session +- **Authorization**: Admin role in an organization + +## Authentication + +All requests must include a valid Supabase session cookie. The middleware automatically: +1. Verifies the feature flag is enabled +2. Validates the user's session +3. Checks the user's organization membership +4. Ensures the user has admin role for admin endpoints + +## Base URL + +``` +/api/prompts/admin +``` + +## Common Response Format + +### Success Response +```json +{ + "data": , + "error": null +} +``` + +### Error Response +```json +{ + "data": null, + "error": { + "message": "Human-readable error message", + "code": "ERROR_CODE" + } +} +``` + +## HTTP Status Codes + +- `200 OK` - Successful request +- `201 Created` - Resource successfully created +- `204 No Content` - Resource successfully deleted +- `400 Bad Request` - Invalid request body or parameters +- `401 Unauthorized` - No valid session +- `403 Forbidden` - User lacks required permissions +- `404 Not Found` - Resource not found +- `500 Internal Server Error` - Server-side error + +## Error Codes + +- `UNKNOWN_ERROR` - Generic database or system error +- `INTERNAL_ERROR` - Unexpected exception occurred +- `NOT_FOUND` - Requested resource does not exist +- `VALIDATION_ERROR` - Request body validation failed +- `DB_ERROR` - Database operation failed +- `QUERY_ERROR` - Database query failed +- `UPDATE_ERROR` - Database update failed +- `INSERT_ERROR` - Database insert failed +- `DELETE_ERROR` - Database delete failed +- `FK_VIOLATION` - Foreign key constraint violation + +--- + +## Endpoints + +### 1. Create Prompt + +Create a new draft prompt. + +**Endpoint**: `POST /api/prompts/admin/prompts` + +**Request Body**: +```json +{ + "title": "My Prompt Title", + "collection_id": "uuid-of-collection", + "segment_id": "uuid-of-segment", // optional + "markdown_body": "# Prompt Content\n\nMarkdown content here...", + "created_by": "uuid-of-user" // optional +} +``` + +**Response** (`201 Created`): +```json +{ + "data": { + "id": "prompt-uuid", + "organization_id": "org-uuid", + "collection_id": "collection-uuid", + "segment_id": "segment-uuid", + "title": "My Prompt Title", + "markdown_body": "# Prompt Content\n\nMarkdown content here...", + "status": "draft", + "created_by": "user-uuid", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }, + "error": null +} +``` + +**cURL Example**: +```bash +curl -X POST https://your-domain.com/api/prompts/admin/prompts \ + -H "Content-Type: application/json" \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" \ + -d '{ + "title": "Project Setup Guide", + "collection_id": "coll-123", + "segment_id": "seg-456", + "markdown_body": "# Project Setup\n\nSteps to set up your project..." + }' +``` + +--- + +### 2. List Prompts + +Retrieve all prompts with optional filtering. + +**Endpoint**: `GET /api/prompts/admin/prompts` + +**Query Parameters**: +- `status` (optional): Filter by status (`draft` or `published`) +- `collection_id` (optional): Filter by collection UUID +- `segment_id` (optional): Filter by segment UUID + +**Response** (`200 OK`): +```json +{ + "data": [ + { + "id": "prompt-uuid-1", + "organization_id": "org-uuid", + "collection_id": "collection-uuid", + "segment_id": "segment-uuid", + "title": "Prompt 1", + "markdown_body": "# Content", + "status": "published", + "created_by": "user-uuid", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-02T00:00:00Z" + }, + { + "id": "prompt-uuid-2", + "organization_id": "org-uuid", + "collection_id": "collection-uuid", + "segment_id": null, + "title": "Prompt 2", + "markdown_body": "# Content", + "status": "draft", + "created_by": "user-uuid", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + } + ], + "error": null +} +``` + +**cURL Example**: +```bash +# Get all prompts +curl https://your-domain.com/api/prompts/admin/prompts \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" + +# Get only published prompts +curl "https://your-domain.com/api/prompts/admin/prompts?status=published" \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" + +# Get prompts for a specific collection +curl "https://your-domain.com/api/prompts/admin/prompts?collection_id=coll-123" \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" +``` + +--- + +### 3. Get Single Prompt + +Retrieve a specific prompt by ID. + +**Endpoint**: `GET /api/prompts/admin/prompts/:id` + +**Response** (`200 OK`): +```json +{ + "data": { + "id": "prompt-uuid", + "organization_id": "org-uuid", + "collection_id": "collection-uuid", + "segment_id": "segment-uuid", + "title": "My Prompt", + "markdown_body": "# Content", + "status": "draft", + "created_by": "user-uuid", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }, + "error": null +} +``` + +**Response** (`404 Not Found`): +```json +{ + "data": null, + "error": { + "message": "Prompt not found or access denied", + "code": "NOT_FOUND" + } +} +``` + +**cURL Example**: +```bash +curl https://your-domain.com/api/prompts/admin/prompts/prompt-uuid \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" +``` + +--- + +### 4. Update Prompt + +Update an existing prompt's content. + +**Endpoint**: `PUT /api/prompts/admin/prompts/:id` + +**Request Body** (all fields optional): +```json +{ + "title": "Updated Title", + "markdown_body": "# Updated Content", + "segment_id": "new-segment-uuid" +} +``` + +**Response** (`200 OK`): +```json +{ + "data": { + "id": "prompt-uuid", + "organization_id": "org-uuid", + "collection_id": "collection-uuid", + "segment_id": "new-segment-uuid", + "title": "Updated Title", + "markdown_body": "# Updated Content", + "status": "draft", + "created_by": "user-uuid", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-02T00:00:00Z" + }, + "error": null +} +``` + +**cURL Example**: +```bash +curl -X PUT https://your-domain.com/api/prompts/admin/prompts/prompt-uuid \ + -H "Content-Type: application/json" \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" \ + -d '{ + "title": "Updated Project Setup Guide", + "markdown_body": "# Updated Content\n\nNew steps..." + }' +``` + +--- + +### 5. Delete Prompt + +Delete a prompt permanently. + +**Endpoint**: `DELETE /api/prompts/admin/prompts/:id` + +**Response** (`204 No Content`): +``` +(Empty response body) +``` + +**Response** (`404 Not Found`): +```json +{ + "data": null, + "error": { + "message": "Prompt not found or access denied", + "code": "NOT_FOUND" + } +} +``` + +**cURL Example**: +```bash +curl -X DELETE https://your-domain.com/api/prompts/admin/prompts/prompt-uuid \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" +``` + +--- + +### 6. Toggle Publish Status + +Publish or unpublish a prompt (toggle between `draft` and `published`). + +**Endpoint**: `PATCH /api/prompts/admin/prompts/:id/publish` + +**Request Body**: None required + +**Response** (`200 OK`): +```json +{ + "data": { + "id": "prompt-uuid", + "organization_id": "org-uuid", + "collection_id": "collection-uuid", + "segment_id": "segment-uuid", + "title": "My Prompt", + "markdown_body": "# Content", + "status": "published", // or "draft" + "created_by": "user-uuid", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-03T00:00:00Z" + }, + "error": null +} +``` + +**cURL Example**: +```bash +# Publish a draft prompt +curl -X PATCH https://your-domain.com/api/prompts/admin/prompts/prompt-uuid/publish \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" + +# Unpublish a published prompt (same endpoint) +curl -X PATCH https://your-domain.com/api/prompts/admin/prompts/prompt-uuid/publish \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" +``` + +--- + +### 7. List Collections + +Get all collections for the active organization. + +**Endpoint**: `GET /api/prompts/admin/prompt-collections` + +**Response** (`200 OK`): +```json +{ + "data": [ + { + "id": "collection-uuid-1", + "organization_id": "org-uuid", + "slug": "fundamentals", + "title": "Fundamentals", + "description": "Core concepts and best practices", + "sort_order": 1, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }, + { + "id": "collection-uuid-2", + "organization_id": "org-uuid", + "slug": "advanced", + "title": "Advanced Topics", + "description": "Advanced techniques and patterns", + "sort_order": 2, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + } + ], + "error": null +} +``` + +**cURL Example**: +```bash +curl https://your-domain.com/api/prompts/admin/prompt-collections \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" +``` + +--- + +### 8. List Segments for Collection + +Get all segments within a specific collection. + +**Endpoint**: `GET /api/prompts/admin/prompt-collections/:id/segments` + +**Response** (`200 OK`): +```json +{ + "data": [ + { + "id": "segment-uuid-1", + "collection_id": "collection-uuid", + "slug": "getting-started", + "title": "Getting Started", + "sort_order": 1, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }, + { + "id": "segment-uuid-2", + "collection_id": "collection-uuid", + "slug": "best-practices", + "title": "Best Practices", + "sort_order": 2, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + } + ], + "error": null +} +``` + +**cURL Example**: +```bash +curl https://your-domain.com/api/prompts/admin/prompt-collections/collection-uuid/segments \ + -H "Cookie: sb-access-token=YOUR_SESSION_TOKEN" +``` + +--- + +## Workflow Examples + +### Complete CRUD Workflow + +```bash +# 1. Get available collections +COLLECTIONS=$(curl -s https://your-domain.com/api/prompts/admin/prompt-collections \ + -H "Cookie: sb-access-token=$TOKEN") + +COLLECTION_ID=$(echo $COLLECTIONS | jq -r '.data[0].id') + +# 2. Get segments for the collection +SEGMENTS=$(curl -s "https://your-domain.com/api/prompts/admin/prompt-collections/$COLLECTION_ID/segments" \ + -H "Cookie: sb-access-token=$TOKEN") + +SEGMENT_ID=$(echo $SEGMENTS | jq -r '.data[0].id') + +# 3. Create a new draft prompt +CREATE_RESPONSE=$(curl -s -X POST https://your-domain.com/api/prompts/admin/prompts \ + -H "Content-Type: application/json" \ + -H "Cookie: sb-access-token=$TOKEN" \ + -d "{ + \"title\": \"My New Prompt\", + \"collection_id\": \"$COLLECTION_ID\", + \"segment_id\": \"$SEGMENT_ID\", + \"markdown_body\": \"# Initial Content\" + }") + +PROMPT_ID=$(echo $CREATE_RESPONSE | jq -r '.data.id') + +# 4. Update the prompt +curl -s -X PUT "https://your-domain.com/api/prompts/admin/prompts/$PROMPT_ID" \ + -H "Content-Type: application/json" \ + -H "Cookie: sb-access-token=$TOKEN" \ + -d '{ + "title": "Updated Prompt Title", + "markdown_body": "# Updated Content\n\nMore details..." + }' + +# 5. Publish the prompt +curl -s -X PATCH "https://your-domain.com/api/prompts/admin/prompts/$PROMPT_ID/publish" \ + -H "Cookie: sb-access-token=$TOKEN" + +# 6. List all published prompts +curl -s "https://your-domain.com/api/prompts/admin/prompts?status=published" \ + -H "Cookie: sb-access-token=$TOKEN" + +# 7. Unpublish the prompt +curl -s -X PATCH "https://your-domain.com/api/prompts/admin/prompts/$PROMPT_ID/publish" \ + -H "Cookie: sb-access-token=$TOKEN" + +# 8. Delete the prompt +curl -s -X DELETE "https://your-domain.com/api/prompts/admin/prompts/$PROMPT_ID" \ + -H "Cookie: sb-access-token=$TOKEN" +``` + +--- + +## Security Considerations + +### Organization Scoping +All operations are automatically scoped to the user's active organization. Users cannot: +- View prompts from other organizations +- Update prompts in other organizations +- Delete prompts from other organizations + +### Admin Role Requirement +All admin endpoints require the user to have an `admin` role in their organization. Member-level users will receive a `403 Forbidden` response. + +### Feature Flag +The `PROMPT_MANAGER_ENABLED` feature flag must be enabled. If disabled, all endpoints return `403 Forbidden`. + +### Input Validation +- `title`: Required, non-empty string +- `collection_id`: Required, must be a valid UUID of an existing collection +- `segment_id`: Optional, must be a valid UUID if provided +- `markdown_body`: Required, non-empty string +- All IDs must belong to the user's organization + +--- + +## Rate Limiting + +Currently, there are no rate limits on admin endpoints. This may be added in future versions. + +--- + +## Versioning + +This is version 1 of the Admin API. The API follows semantic versioning principles. Breaking changes will result in a new major version. + +**Current Version**: `v1` (implicit, no version prefix in URL) + +--- + +## Support + +For issues, questions, or feature requests related to the Prompt Manager API: +1. Check the [schema documentation](../../.ai/prompt-manager/phase-3-impl-plan.md) +2. Review the [PRD](../../.ai/prompt-manager/prd.md) +3. Contact the development team + +--- + +## Changelog + +### Phase 3 (Current) +- Initial release of Admin API +- CRUD operations for prompts +- Collection and segment listing +- Publish/unpublish workflow +- Organization scoping +- Admin role enforcement diff --git a/e2e/page-objects/CollectionsSidebarPage.ts b/e2e/page-objects/RuleCollectionsSidebarPage.ts similarity index 95% rename from e2e/page-objects/CollectionsSidebarPage.ts rename to e2e/page-objects/RuleCollectionsSidebarPage.ts index 386bdc6..e3dc2b3 100644 --- a/e2e/page-objects/CollectionsSidebarPage.ts +++ b/e2e/page-objects/RuleCollectionsSidebarPage.ts @@ -1,6 +1,6 @@ import { type Page, type Locator } from '@playwright/test'; -export class CollectionsSidebarPage { +export class RuleCollectionsSidebarPage { readonly page: Page; readonly sidebar: Locator; readonly toggleButton: Locator; diff --git a/e2e/page-objects/SaveCollectionDialog.ts b/e2e/page-objects/SaveRuleCollectionDialog.ts similarity index 95% rename from e2e/page-objects/SaveCollectionDialog.ts rename to e2e/page-objects/SaveRuleCollectionDialog.ts index cbe8cdd..b4f09f2 100644 --- a/e2e/page-objects/SaveCollectionDialog.ts +++ b/e2e/page-objects/SaveRuleCollectionDialog.ts @@ -1,6 +1,6 @@ import { type Page, type Locator } from '@playwright/test'; -export class SaveCollectionDialog { +export class SaveRuleCollectionDialog { readonly page: Page; readonly form: Locator; readonly nameInput: Locator; diff --git a/e2e/tests/collections.spec.ts b/e2e/tests/rule-collections.spec.ts similarity index 79% rename from e2e/tests/collections.spec.ts rename to e2e/tests/rule-collections.spec.ts index 4a72647..5d6a53b 100644 --- a/e2e/tests/collections.spec.ts +++ b/e2e/tests/rule-collections.spec.ts @@ -1,8 +1,8 @@ import { test, expect } from '@playwright/test'; -import { CollectionsSidebarPage } from '../page-objects/CollectionsSidebarPage'; -import { SaveCollectionDialog } from '../page-objects/SaveCollectionDialog'; +import { RuleCollectionsSidebarPage } from '../page-objects/RuleCollectionsSidebarPage'; +import { SaveRuleCollectionDialog } from '../page-objects/SaveRuleCollectionDialog'; -test.describe('Collections Management', () => { +test.describe('Rule Collections Management', () => { /** * Generates a unique collection name for testing */ @@ -13,8 +13,8 @@ test.describe('Collections Management', () => { test('should create a new collection', async ({ page }) => { // Arrange - const sidebarPage = new CollectionsSidebarPage(page); - const saveDialog = new SaveCollectionDialog(page); + const sidebarPage = new RuleCollectionsSidebarPage(page); + const saveDialog = new SaveRuleCollectionDialog(page); const testData = { name: generateUniqueName(), description: 'This is a test collection created by E2E test', @@ -47,8 +47,8 @@ test.describe('Collections Management', () => { test('should show error when creating collection without name', async ({ page }) => { // Arrange - const sidebarPage = new CollectionsSidebarPage(page); - const saveDialog = new SaveCollectionDialog(page); + const sidebarPage = new RuleCollectionsSidebarPage(page); + const saveDialog = new SaveRuleCollectionDialog(page); // Navigate to the main page await page.goto('/'); diff --git a/src/components/NavigationDropdown.tsx b/src/components/NavigationDropdown.tsx new file mode 100644 index 0000000..b44993c --- /dev/null +++ b/src/components/NavigationDropdown.tsx @@ -0,0 +1,104 @@ +import { FileText, Shield, Menu, Home } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface NavigationDropdownProps { + isAdmin: boolean; + hasPromptAccess: boolean; + currentPath: string; +} + +export default function NavigationDropdown({ + isAdmin, + hasPromptAccess, + currentPath, +}: NavigationDropdownProps) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('[data-dropdown]')) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, []); + + // Get current page info for button display + const getCurrentPageInfo = () => { + if (currentPath.startsWith('/prompts/admin')) { + return { icon: Shield, text: 'Prompts Admin' }; + } + if (currentPath === '/prompts') { + return { icon: FileText, text: 'Prompts Library' }; + } + return { icon: Home, text: 'Rules Builder' }; + }; + + const currentPage = getCurrentPageInfo(); + const CurrentIcon = currentPage.icon; + + return ( +
+ + + {isDropdownOpen && ( +
+ + + Rules Builder + + + {hasPromptAccess && ( + <> + + + Prompts Library + + + {isAdmin && ( + + + Prompts Admin + + )} + + )} +
+ )} +
+ ); +} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index ecabbbe..55e4725 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -1,8 +1,9 @@ import { WandSparkles } from 'lucide-react'; -import DependencyUploader from './rule-parser/DependencyUploader'; import { useAuthStore } from '../store/authStore'; +import { usePromptsStore } from '../store/promptsStore'; import { useEffect } from 'react'; import LoginButton from './auth/LoginButton'; +import NavigationDropdown from './NavigationDropdown'; interface TopbarProps { title?: string; @@ -14,6 +15,7 @@ interface TopbarProps { export default function Topbar({ title = '10xRules.ai', initialUser }: TopbarProps) { const { setUser } = useAuthStore(); + const { organizations, fetchOrganizations } = usePromptsStore(); // Initialize auth store with user data from server useEffect(() => { @@ -22,6 +24,24 @@ export default function Topbar({ title = '10xRules.ai', initialUser }: TopbarPro } }, [initialUser, setUser]); + // Fetch organizations to check admin access + useEffect(() => { + if (initialUser) { + fetchOrganizations(); + } + }, [initialUser, fetchOrganizations]); + + // Check if user is admin + const isAdmin = organizations.some((org) => org.role === 'admin'); + const hasPromptAccess = organizations.length > 0; + + // Get current path for active state + const currentPath = typeof window !== 'undefined' ? window.location.pathname : ''; + + // Count available navigation items + const availableNavItems = 1 + (hasPromptAccess ? 1 : 0) + (isAdmin ? 1 : 0); + const showNavigation = availableNavItems > 1; + return (
@@ -35,9 +55,15 @@ export default function Topbar({ title = '10xRules.ai', initialUser }: TopbarPro
-
- -
+ {/* Navigation Dropdown */} + {initialUser && showNavigation && ( + + )} +
diff --git a/src/components/TwoPane.tsx b/src/components/TwoPane.tsx index 070f720..ba176b1 100644 --- a/src/components/TwoPane.tsx +++ b/src/components/TwoPane.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { RuleBuilder } from './rule-builder'; import { RulePreview } from './rule-preview'; -import CollectionsSidebar from './rule-collections/CollectionsSidebar'; +import RuleCollectionsSidebar from './rule-collections/RuleCollectionsSidebar'; import { MobileNavigation } from './MobileNavigation'; import { useNavigationStore } from '../store/navigationStore'; import { isFeatureEnabled } from '../features/featureFlags'; @@ -23,7 +23,7 @@ function RulesPane() { return (
{isCollectionsEnabled && ( - + )}
diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index a915550..5a810c9 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -10,9 +10,10 @@ import { useCaptcha } from '../../hooks/useCaptcha'; interface LoginFormProps { cfCaptchaSiteKey: string; + inviteToken?: string | null; } -export const LoginForm: React.FC = ({ cfCaptchaSiteKey }) => { +export const LoginForm: React.FC = ({ cfCaptchaSiteKey, inviteToken }) => { const { login, error: apiError, isLoading } = useAuth(); const { isCaptchaVerified } = useCaptcha(cfCaptchaSiteKey); @@ -30,7 +31,13 @@ export const LoginForm: React.FC = ({ cfCaptchaSiteKey }) => { throw new Error('Captcha verification failed'); } await login(data); - window.location.href = '/'; + + // Redirect to invite page if invite token is present + if (inviteToken) { + window.location.href = `/invites/${inviteToken}`; + } else { + window.location.href = '/'; + } } catch (error) { console.error(error); } diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx index 4f93cb6..4fe83a2 100644 --- a/src/components/auth/SignupForm.tsx +++ b/src/components/auth/SignupForm.tsx @@ -10,9 +10,10 @@ import { useCaptcha } from '../../hooks/useCaptcha'; interface SignupFormProps { cfCaptchaSiteKey: string; + inviteToken?: string; } -export const SignupForm: React.FC = ({ cfCaptchaSiteKey }) => { +export const SignupForm: React.FC = ({ cfCaptchaSiteKey, inviteToken }) => { const { signup, error: apiError, isLoading } = useAuth(); const { isCaptchaVerified } = useCaptcha(cfCaptchaSiteKey); const { @@ -28,7 +29,12 @@ export const SignupForm: React.FC = ({ cfCaptchaSiteKey }) => { if (!isCaptchaVerified) { throw new Error('Captcha verification failed'); } - await signup(data); + const result = await signup(data, inviteToken); + + // If invite token was used and we got organization info, redirect to prompts + if (result?.organization?.slug) { + window.location.href = `/prompts?organization=${result.organization.slug}`; + } } catch (error) { console.error(error); } diff --git a/src/components/invites/InviteLanding.tsx b/src/components/invites/InviteLanding.tsx new file mode 100644 index 0000000..f57ffb2 --- /dev/null +++ b/src/components/invites/InviteLanding.tsx @@ -0,0 +1,202 @@ +import React, { useState, useEffect } from 'react'; +import { CheckCircle, XCircle, Loader2 } from 'lucide-react'; +import type { InviteValidationResult } from '../../types/invites'; + +interface InviteLandingProps { + token: string; + isAuthenticated: boolean; +} + +const InviteLanding: React.FC = ({ token, isAuthenticated }) => { + const [validation, setValidation] = useState(null); + const [isValidating, setIsValidating] = useState(true); + const [isRedeeming, setIsRedeeming] = useState(false); + const [redemptionError, setRedemptionError] = useState(null); + + useEffect(() => { + validateToken(); + }, [token]); + + const validateToken = async () => { + setIsValidating(true); + try { + const response = await fetch('/api/invites/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + const result = await response.json(); + setValidation(result); + } catch { + setValidation({ + valid: false, + error: 'Failed to validate invite. Please try again.', + }); + } finally { + setIsValidating(false); + } + }; + + const handleRedeem = async () => { + if (!validation?.valid) return; + + setIsRedeeming(true); + setRedemptionError(null); + + try { + const response = await fetch('/api/invites/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to join organization'); + } + + // Redirect to prompts page for the organization + if (result.organization?.slug) { + window.location.href = `/prompts?organization=${result.organization.slug}`; + } else { + window.location.href = '/prompts'; + } + } catch (err) { + setRedemptionError(err instanceof Error ? err.message : 'Failed to join organization'); + setIsRedeeming(false); + } + }; + + const handleSignup = () => { + // Redirect to signup with invite token + window.location.href = `/auth/signup?invite=${encodeURIComponent(token)}`; + }; + + if (isValidating) { + return ( +
+
+ +

Validating Invite...

+

Please wait while we check your invite link.

+
+
+ ); + } + + if (!validation?.valid) { + return ( +
+
+ +

Invalid Invite

+

{validation?.error}

+ + Go to Homepage + +
+
+ ); + } + + const { organization } = validation; + + if (isAuthenticated) { + // User is logged in - show join button + return ( +
+
+
+ +

Join Organization

+

+ You've been invited to join{' '} + {organization?.name} +

+
+ + {redemptionError && ( +
+ {redemptionError} +
+ )} + + + +

+ You will be granted {validation.invite?.role}{' '} + access. +

+
+
+ ); + } + + // User is not logged in - show signup prompt + return ( +
+
+
+ +

You're Invited!

+

+ You've been invited to join {organization?.name} +

+
+ +
+

What happens next?

+
    +
  • + + Create your account +
  • +
  • + + Automatically join {organization?.name} +
  • +
  • + + Start collaborating with the team +
  • +
+
+ + + +

+ Already have an account?{' '} + + Log in + +

+ +

+ You will be granted {validation.invite?.role}{' '} + access. +

+
+
+ ); +}; + +export default InviteLanding; diff --git a/src/components/prompt-library/LanguageSwitcher.tsx b/src/components/prompt-library/LanguageSwitcher.tsx new file mode 100644 index 0000000..d90ed49 --- /dev/null +++ b/src/components/prompt-library/LanguageSwitcher.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import type { Language } from '../../services/prompt-library/language'; + +export const LanguageSwitcher: React.FC = () => { + const preferredLanguage = usePromptsStore((state) => state.preferredLanguage); + const setPreferredLanguage = usePromptsStore((state) => state.setPreferredLanguage); + + const handleLanguageChange = (lang: Language) => { + setPreferredLanguage(lang); + }; + + return ( +
+ Language: + + +
+ ); +}; + +export default LanguageSwitcher; diff --git a/src/components/prompt-library/OrganizationSelector.tsx b/src/components/prompt-library/OrganizationSelector.tsx new file mode 100644 index 0000000..e1930af --- /dev/null +++ b/src/components/prompt-library/OrganizationSelector.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { Dropdown, type DropdownOption } from '../ui/Dropdown'; + +export const OrganizationSelector: React.FC = () => { + const { organizations, activeOrganization, setActiveOrganization } = usePromptsStore(); + + const options: DropdownOption[] = organizations.map((org) => ({ + value: org.id, + label: org.name, + })); + + const handleChange = (organizationId: string) => { + const selectedOrg = organizations.find((org) => org.id === organizationId); + if (selectedOrg) { + setActiveOrganization(selectedOrg); + } + }; + + if (organizations.length === 0) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default OrganizationSelector; diff --git a/src/components/prompt-library/PromptCard.tsx b/src/components/prompt-library/PromptCard.tsx new file mode 100644 index 0000000..497ddc7 --- /dev/null +++ b/src/components/prompt-library/PromptCard.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import type { Prompt } from '../../store/promptsStore'; +import { usePromptsStore } from '../../store/promptsStore'; +import { getLocalizedTitle, getLocalizedBody } from '../../services/prompt-library/language'; + +interface PromptCardProps { + prompt: Prompt; +} + +export const PromptCard: React.FC = ({ prompt }) => { + const { selectPrompt, collections, segments, preferredLanguage } = usePromptsStore(); + + const collection = collections.find((c) => c.id === prompt.collection_id); + const segment = segments.find((s) => s.id === prompt.segment_id); + + const handleClick = () => { + selectPrompt(prompt.id); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + selectPrompt(prompt.id); + } + }; + + // Truncate markdown body for preview (first 150 characters) + // Use user's preferred language with fallback to English + const title = getLocalizedTitle(prompt, preferredLanguage); + const body = getLocalizedBody(prompt, preferredLanguage); + const preview = body.substring(0, 150) + (body.length > 150 ? '...' : ''); + + return ( +
+

{title}

+ +
+ {collection && ( + + {collection.title} + + )} + {segment && ( + + {segment.title} + + )} +
+ +

{preview}

+ +
+ Updated: {new Date(prompt.updated_at).toLocaleDateString()} +
+
+ ); +}; + +export default PromptCard; diff --git a/src/components/prompt-library/PromptDetail.tsx b/src/components/prompt-library/PromptDetail.tsx new file mode 100644 index 0000000..a03efe7 --- /dev/null +++ b/src/components/prompt-library/PromptDetail.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useState } from 'react'; +import { X, Share2, Check } from 'lucide-react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { MarkdownRenderer } from '../ui/MarkdownRenderer'; +import { CopyDownloadActions } from '../ui/CopyDownloadActions'; +import { buildPromptUrl } from '../../utils/urlParams'; + +export const PromptDetail: React.FC = () => { + const { + selectedPromptId, + prompts, + collections, + segments, + selectPrompt, + preferredLanguage, + activeOrganization, + } = usePromptsStore(); + const [language, setLanguage] = useState<'en' | 'pl'>(preferredLanguage); + const [linkCopied, setLinkCopied] = useState(false); + + const selectedPrompt = prompts.find((p) => p.id === selectedPromptId); + + // Close modal on ESC key + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && selectedPromptId) { + selectPrompt(null); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [selectedPromptId, selectPrompt]); + + // Lock body scroll when modal is open + useEffect(() => { + if (selectedPromptId) { + document.body.style.overflow = 'hidden'; + // Reset to user's preferred language when a new prompt is opened + setLanguage(preferredLanguage); + } else { + document.body.style.overflow = ''; + } + + return () => { + document.body.style.overflow = ''; + }; + }, [selectedPromptId, preferredLanguage]); + + if (!selectedPrompt) { + return null; + } + + const collection = collections.find((c) => c.id === selectedPrompt.collection_id); + const segment = segments.find((s) => s.id === selectedPrompt.segment_id); + + const handleClose = () => { + selectPrompt(null); + }; + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + handleClose(); + } + }; + + const handleShare = async () => { + if (!activeOrganization) return; + + const url = buildPromptUrl({ + org: activeOrganization.slug, + collection: collection?.slug, + segment: segment?.slug, + prompt: selectedPrompt.id, + }); + + try { + await navigator.clipboard.writeText(url); + setLinkCopied(true); + console.log('[PromptDetail] Link copied to clipboard:', url); + + // Reset the "copied" state after 2 seconds + setTimeout(() => { + setLinkCopied(false); + }, 2000); + } catch (error) { + console.error('[PromptDetail] Failed to copy link:', error); + } + }; + + const hasPolishVersion = selectedPrompt.title_pl && selectedPrompt.markdown_body_pl; + const title = + language === 'pl' && selectedPrompt.title_pl + ? selectedPrompt.title_pl + : selectedPrompt.title_en; + const markdownBody = + language === 'pl' && selectedPrompt.markdown_body_pl + ? selectedPrompt.markdown_body_pl + : selectedPrompt.markdown_body_en; + + // Generate filename for download + const filename = `${title.toLowerCase().replace(/\s+/g, '-')}.md`; + + return ( +
+
+ {/* Header */} +
+
+

{title}

+ + {/* Breadcrumb */} +
+ {collection && {collection.title}} + {collection && segment && } + {segment && {segment.title}} + + {/* Language Switcher */} + {hasPolishVersion && ( +
+ + +
+ )} +
+
+ + +
+ + {/* Content */} +
+ } + /> +
+ + {/* Footer with actions */} +
+
+ Last updated: {new Date(selectedPrompt.updated_at).toLocaleString()} +
+ + +
+
+
+ ); +}; + +export default PromptDetail; diff --git a/src/components/prompt-library/PromptFilters.tsx b/src/components/prompt-library/PromptFilters.tsx new file mode 100644 index 0000000..8d044f9 --- /dev/null +++ b/src/components/prompt-library/PromptFilters.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { Dropdown, type DropdownOption } from '../ui/Dropdown'; + +export const PromptFilters: React.FC = () => { + const { collections, segments, selectedCollectionId, selectedSegmentId, setFilters, prompts } = + usePromptsStore(); + + // Create collection options with "All" option + const collectionOptions: DropdownOption[] = [ + { value: null, label: 'All Collections' }, + ...collections.map((collection) => ({ + value: collection.id, + label: collection.title, + })), + ]; + + // Create segment options with "All" option (only show when a collection is selected) + const segmentOptions: DropdownOption[] = [ + { value: null, label: 'All Segments' }, + ...segments + .filter((segment) => segment.collection_id === selectedCollectionId) + .map((segment) => ({ + value: segment.id, + label: segment.title, + })), + ]; + + const handleCollectionChange = (collectionId: string | null) => { + setFilters(collectionId, null); // Reset segment when collection changes + }; + + const handleSegmentChange = (segmentId: string | null) => { + setFilters(selectedCollectionId, segmentId); + }; + + return ( +
+
+
+ +
+ + {selectedCollectionId && segments.length > 0 && ( +
+ +
+ )} +
+ +
+ + {prompts.length} {prompts.length === 1 ? 'prompt' : 'prompts'} + +
+
+ ); +}; + +export default PromptFilters; diff --git a/src/components/prompt-library/PromptsBrowser.tsx b/src/components/prompt-library/PromptsBrowser.tsx new file mode 100644 index 0000000..5a3b0fc --- /dev/null +++ b/src/components/prompt-library/PromptsBrowser.tsx @@ -0,0 +1,86 @@ +import React, { useEffect } from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { OrganizationSelector } from './OrganizationSelector'; +import { PromptFilters } from './PromptFilters'; +import { SearchBar } from '../ui/SearchBar'; +import { PromptsList } from './PromptsList'; +import { PromptDetail } from './PromptDetail'; +import { LanguageSwitcher } from './LanguageSwitcher'; +import { loadLanguagePreference } from '../../services/prompt-library/language'; +import { parsePromptParams, hasValidParams } from '../../utils/urlParams'; + +export const PromptsBrowser: React.FC = () => { + const { + fetchOrganizations, + searchQuery, + setSearchQuery, + prompts, + selectedPromptId, + setPreferredLanguage, + hydrateFromUrl, + } = usePromptsStore(); + + // Initialize - check for URL params first, otherwise normal initialization + useEffect(() => { + const params = parsePromptParams(new URL(window.location.href)); + + if (hasValidParams(params)) { + // Deep link mode: hydrate from URL parameters + setPreferredLanguage(loadLanguagePreference()); + hydrateFromUrl(params).then((result) => { + if (result.errors.length > 0) { + // Log errors for now - can be enhanced with toast notifications later + console.warn('[PromptsBrowser] URL hydration errors:', result.errors); + } + }); + } else { + // Normal mode: standard initialization + setPreferredLanguage(loadLanguagePreference()); + fetchOrganizations(); + } + }, [fetchOrganizations, setPreferredLanguage, hydrateFromUrl]); + + return ( +
+ {/* Page Header */} +
+

Prompts Library

+

+ Browse and search through your organization's prompt templates +

+
+ + {/* Organization Selector */} +
+ +
+ + {/* Search Bar and Language Switcher */} +
+
+ +
+
+ +
+
+ + {/* Filters */} + + + {/* Prompts List */} + + + {/* Prompt Detail Modal */} + {selectedPromptId && } +
+ ); +}; + +export default PromptsBrowser; diff --git a/src/components/prompt-library/PromptsList.tsx b/src/components/prompt-library/PromptsList.tsx new file mode 100644 index 0000000..7417e51 --- /dev/null +++ b/src/components/prompt-library/PromptsList.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { PromptCard } from './PromptCard'; +import { hasPolishVersion } from '../../services/prompt-library/language'; + +export const PromptsList: React.FC = () => { + const { prompts, isLoading, error, preferredLanguage } = usePromptsStore(); + + if (isLoading) { + return ( +
+
Loading prompts...
+
+ ); + } + + if (error) { + return ( +
+
Error loading prompts: {error}
+
+ ); + } + + // Filter prompts based on language availability + const filteredPrompts = prompts.filter((prompt) => { + if (preferredLanguage === 'en') { + return true; // English is always available + } + return hasPolishVersion(prompt); // For Polish, check if translation exists + }); + + if (filteredPrompts.length === 0) { + return ( +
+
No prompts found
+
Try changing your filters or check back later
+
+ ); + } + + return ( +
+ {filteredPrompts.map((prompt) => ( + + ))} +
+ ); +}; + +export default PromptsList; diff --git a/src/components/prompt-library/admin/AdminPromptCard.tsx b/src/components/prompt-library/admin/AdminPromptCard.tsx new file mode 100644 index 0000000..97850ab --- /dev/null +++ b/src/components/prompt-library/admin/AdminPromptCard.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import { Archive, Loader2, Pencil, Send, Trash2 } from 'lucide-react'; +import type { Prompt, PromptCollection, PromptSegment } from '../../../store/promptsStore'; +import StatusBadge from '../../ui/StatusBadge'; +import { hasPolishVersion } from '../../../services/prompt-library/language'; + +interface AdminPromptCardProps { + prompt: Prompt; + collections: PromptCollection[]; + segments: PromptSegment[]; + onEdit: (prompt: Prompt) => void; + onDelete: (promptId: string) => void; + onTogglePublish: (promptId: string) => void; + isSelected?: boolean; +} + +export const AdminPromptCard: React.FC = ({ + prompt, + collections, + segments, + onEdit, + onDelete, + onTogglePublish, + isSelected = false, +}) => { + const [isToggling, setIsToggling] = useState(false); + + const collection = collections.find((c) => c.id === prompt.collection_id); + const segment = segments.find((s) => s.id === prompt.segment_id); + + const handleTogglePublish = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + setIsToggling(true); + await onTogglePublish(prompt.id); + } catch (error) { + console.error('Error toggling publish status:', error); + } finally { + setIsToggling(false); + } + }; + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onEdit(prompt); + }; + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(prompt.id); + }; + + const handleCardClick = () => { + onEdit(prompt); + }; + + // Get preview of markdown content (first 150 chars) + // Use English version for admin view by default + const title = prompt.title_en; + const body = prompt.markdown_body_en; + const preview = body.slice(0, 150) + (body.length > 150 ? '...' : ''); + + const isPublished = prompt.status === 'published'; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleCardClick(); + } + }} + role="button" + tabIndex={0} + className={`bg-gray-800 border ${ + isSelected ? 'border-blue-400' : 'border-gray-700 hover:border-indigo-500' + } rounded-lg p-4 cursor-pointer transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 group`} + > + {/* Header */} +
+
+

+ {title} +

+
+
+ {/* Action buttons */} + + + + +
+
+ + {/* Preview */} +

{preview}

+ + {/* Metadata */} +
+ {collection && ( + + {collection.title} + + )} + + {segment && ( + + {segment.title} + + )} + + {/* Language indicator badge */} + {hasPolishVersion(prompt) ? ( + 🇬🇧 🇵🇱 + ) : ( + 🇬🇧 + )} +
+ + {/* Status and Updated date */} +
+ + + Updated: {new Date(prompt.updated_at).toLocaleDateString()} + +
+
+ ); +}; + +export default AdminPromptCard; diff --git a/src/components/prompt-library/admin/AdminPromptsList.tsx b/src/components/prompt-library/admin/AdminPromptsList.tsx new file mode 100644 index 0000000..4e11b5b --- /dev/null +++ b/src/components/prompt-library/admin/AdminPromptsList.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import type { Prompt, PromptCollection, PromptSegment } from '../../../store/promptsStore'; +import AdminPromptCard from './AdminPromptCard'; + +interface AdminPromptsListProps { + prompts: Prompt[]; + collections: PromptCollection[]; + segments: PromptSegment[]; + isLoading: boolean; + error: string | null; + selectedPromptId: string | null; + onEdit: (prompt: Prompt) => void; + onDelete: (promptId: string) => void; + onTogglePublish: (promptId: string) => void; +} + +export const AdminPromptsList: React.FC = ({ + prompts, + collections, + segments, + isLoading, + error, + selectedPromptId, + onEdit, + onDelete, + onTogglePublish, +}) => { + if (isLoading) { + return ( +
+
Loading prompts...
+
+ ); + } + + if (error) { + return ( +
+
Error: {error}
+
+ ); + } + + if (prompts.length === 0) { + return ( +
+
No prompts found
+
+ ); + } + + return ( +
+ {prompts.map((prompt) => ( + + ))} +
+ ); +}; + +export default AdminPromptsList; diff --git a/src/components/prompt-library/admin/AdminTabs.tsx b/src/components/prompt-library/admin/AdminTabs.tsx new file mode 100644 index 0000000..94435ea --- /dev/null +++ b/src/components/prompt-library/admin/AdminTabs.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { FileText, Users } from 'lucide-react'; + +interface AdminTabsProps { + activeTab: 'prompts' | 'invites'; + showInvites?: boolean; +} + +const AdminTabs: React.FC = ({ activeTab, showInvites = true }) => { + const allTabs = [ + { + id: 'prompts' as const, + label: 'Prompts', + icon: FileText, + href: '/prompts/admin', + }, + { + id: 'invites' as const, + label: 'Invites', + icon: Users, + href: '/prompts/admin/invites', + }, + ]; + + const tabs = showInvites ? allTabs : allTabs.filter((tab) => tab.id !== 'invites'); + + return ( +
+ +
+ ); +}; + +export default AdminTabs; diff --git a/src/components/prompt-library/admin/InviteCreateDialog.tsx b/src/components/prompt-library/admin/InviteCreateDialog.tsx new file mode 100644 index 0000000..54ba32d --- /dev/null +++ b/src/components/prompt-library/admin/InviteCreateDialog.tsx @@ -0,0 +1,257 @@ +import React, { useState } from 'react'; +import { X } from 'lucide-react'; + +interface InviteCreateDialogProps { + isOpen: boolean; + onClose: () => void; + onCreate: (params: { + expiresInDays: number; + maxUses?: number; + role: 'member' | 'admin'; + }) => Promise; +} + +const InviteCreateDialog: React.FC = ({ isOpen, onClose, onCreate }) => { + const [expirationPreset, setExpirationPreset] = useState<'7' | '30' | '90' | 'custom'>('7'); + const [customDays, setCustomDays] = useState(''); + const [maxUsesPreset, setMaxUsesPreset] = useState<'unlimited' | '10' | '50' | '100' | 'custom'>( + 'unlimited', + ); + const [customMaxUses, setCustomMaxUses] = useState(''); + const [role, setRole] = useState<'member' | 'admin'>('member'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + // Calculate expiration days + let expiresInDays: number; + if (expirationPreset === 'custom') { + expiresInDays = parseInt(customDays, 10); + if (isNaN(expiresInDays) || expiresInDays < 1 || expiresInDays > 365) { + setError('Custom expiration must be between 1 and 365 days'); + return; + } + } else { + expiresInDays = parseInt(expirationPreset, 10); + } + + // Calculate max uses + let maxUses: number | undefined; + if (maxUsesPreset === 'unlimited') { + maxUses = undefined; + } else if (maxUsesPreset === 'custom') { + const parsed = parseInt(customMaxUses, 10); + if (isNaN(parsed) || parsed < 1) { + setError('Custom max uses must be at least 1'); + return; + } + maxUses = parsed; + } else { + maxUses = parseInt(maxUsesPreset, 10); + } + + setIsSubmitting(true); + + try { + await onCreate({ expiresInDays, maxUses, role }); + // Reset form + setExpirationPreset('7'); + setCustomDays(''); + setMaxUsesPreset('unlimited'); + setCustomMaxUses(''); + setRole('member'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create invite'); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (!isSubmitting) { + setError(null); + onClose(); + } + }; + + return ( +
+
+ {/* Header */} +
+

Create Invite Link

+ +
+ + {/* Form */} +
+ {/* Expiration */} +
+ +
+
+ {['7', '30', '90'].map((days) => ( + + ))} +
+
+ + {expirationPreset === 'custom' && ( + setCustomDays(e.target.value)} + placeholder="Days (1-365)" + className="flex-1 rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400" + /> + )} +
+
+
+ + {/* Max Uses */} +
+ +
+
+ {['unlimited', '10', '50', '100'].map((preset) => ( + + ))} +
+
+ + {maxUsesPreset === 'custom' && ( + setCustomMaxUses(e.target.value)} + placeholder="Max uses" + className="flex-1 rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400" + /> + )} +
+
+
+ + {/* Role */} +
+ +
+ + +
+ {role === 'admin' && ( +

+ ⚠️ Warning: Admin invites grant full administrative access to the organization. +

+ )} +
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); +}; + +export default InviteCreateDialog; diff --git a/src/components/prompt-library/admin/InviteStatsDialog.tsx b/src/components/prompt-library/admin/InviteStatsDialog.tsx new file mode 100644 index 0000000..7553fc6 --- /dev/null +++ b/src/components/prompt-library/admin/InviteStatsDialog.tsx @@ -0,0 +1,431 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + X, + Users, + UserPlus, + UserCheck, + TrendingUp, + Search, + Trash2, + AlertTriangle, +} from 'lucide-react'; + +interface InviteUser { + id: string; + email: string; + joinedAt: string; + wasNewUser: boolean; +} + +interface InviteStats { + totalRedemptions: number; + newUsers: number; + existingUsers: number; + users: InviteUser[]; +} + +interface InviteStatsDialogProps { + isOpen: boolean; + onClose: () => void; + inviteId: string; + inviteToken: string; +} + +const InviteStatsDialog: React.FC = ({ + isOpen, + onClose, + inviteId, + inviteToken, +}) => { + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [removingUserId, setRemovingUserId] = useState(null); + const [showRemoveConfirm, setShowRemoveConfirm] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const usersPerPage = 10; + + useEffect(() => { + if (isOpen && inviteId) { + fetchStats(); + } + }, [isOpen, inviteId]); + + const fetchStats = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`/api/prompts/admin/invites/${inviteId}/stats`); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to fetch stats'); + } + + setStats(result.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch stats'); + console.error('Error fetching invite stats:', err); + } finally { + setIsLoading(false); + } + }; + + const handleRemoveUser = async () => { + if (!removingUserId) return; + + try { + const response = await fetch( + `/api/prompts/admin/invites/${inviteId}/users/${removingUserId}`, + { + method: 'DELETE', + }, + ); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to remove user'); + } + + // Refresh stats + await fetchStats(); + setShowRemoveConfirm(false); + setRemovingUserId(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove user'); + console.error('Error removing user:', err); + } + }; + + const filteredUsers = useMemo(() => { + if (!stats?.users) return []; + + if (!searchQuery.trim()) return stats.users; + + const query = searchQuery.toLowerCase(); + return stats.users.filter((user) => user.email.toLowerCase().includes(query)); + }, [stats?.users, searchQuery]); + + // Paginated users + const paginatedUsers = useMemo(() => { + const startIndex = (currentPage - 1) * usersPerPage; + const endIndex = startIndex + usersPerPage; + return filteredUsers.slice(startIndex, endIndex); + }, [filteredUsers, currentPage, usersPerPage]); + + const totalPages = Math.ceil(filteredUsers.length / usersPerPage); + + // Reset to page 1 when search changes + useEffect(() => { + setCurrentPage(1); + }, [searchQuery]); + + if (!isOpen) return null; + + const removingUser = stats?.users.find((u) => u.id === removingUserId); + + return ( +
+
+ {/* Header */} +
+

Invite Statistics

+ +
+ + {/* Invite Token Display */} +
+

Invite Token

+ + {inviteToken} + +
+ + {/* Loading State */} + {isLoading && ( +
+
+

Loading statistics...

+
+ )} + + {/* Error State */} + {error && !isLoading && ( +
+

Error

+

{error}

+
+ )} + + {/* Stats Display */} + {stats && !isLoading && !error && ( +
+ {/* Summary Cards */} +
+ {/* Total Redemptions */} +
+
+
+ +
+
+

Total Redemptions

+

{stats.totalRedemptions}

+
+
+
+ + {/* New Users */} +
+
+
+ +
+
+

New Users

+

{stats.newUsers}

+
+
+
+ + {/* Existing Users */} +
+
+
+ +
+
+

Existing Users

+

{stats.existingUsers}

+
+
+
+
+ + {/* Conversion Rate */} + {stats.totalRedemptions > 0 && ( +
+
+ +
+

New User Conversion Rate

+

+ {Math.round((stats.newUsers / stats.totalRedemptions) * 100)}% +

+
+
+
+
+
+
+ )} + + {/* Users List */} + {stats.totalRedemptions > 0 && ( +
+
+

+ Users ({filteredUsers.length}) +

+
+ + {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="w-full rounded-md border border-gray-600 bg-gray-900 py-2 pl-10 pr-4 text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + {/* Users Table */} +
+ + + + + + + + + + + {paginatedUsers.length > 0 ? ( + paginatedUsers.map((user) => ( + + + + + + + )) + ) : ( + + + + )} + +
+ Email + + Status + + Joined + + Actions +
{user.email} + {user.wasNewUser ? ( + + + New User + + ) : ( + + + Existing + + )} + + {new Date(user.joinedAt).toLocaleDateString()} + + +
+ {searchQuery ? 'No users found matching your search' : 'No users yet'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Showing {(currentPage - 1) * usersPerPage + 1}- + {Math.min(currentPage * usersPerPage, filteredUsers.length)} of{' '} + {filteredUsers.length} +
+
+ +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} +
+ +
+
+ )} +
+ )} + + {/* No Redemptions State */} + {stats.totalRedemptions === 0 && ( +
+ +

No Redemptions Yet

+

+ This invite link hasn't been used yet. Share it to start tracking redemptions. +

+
+ )} +
+ )} + + {/* Close Button */} +
+ +
+ + {/* Remove User Confirmation Dialog */} + {showRemoveConfirm && removingUser && ( +
+
+
+
+ +
+

Remove User

+
+ +

+ Are you sure you want to remove{' '} + {removingUser.email} from the + organization? This action cannot be undone. +

+ +
+ + +
+
+
+ )} +
+
+ ); +}; + +export default InviteStatsDialog; diff --git a/src/components/prompt-library/admin/InvitesAdminPanel.tsx b/src/components/prompt-library/admin/InvitesAdminPanel.tsx new file mode 100644 index 0000000..2121deb --- /dev/null +++ b/src/components/prompt-library/admin/InvitesAdminPanel.tsx @@ -0,0 +1,172 @@ +import React, { useState, useEffect } from 'react'; +import { Plus } from 'lucide-react'; +import OrganizationSelector from '../OrganizationSelector'; +import InvitesList from './InvitesList'; +import InviteCreateDialog from './InviteCreateDialog'; +import { usePromptsStore } from '../../../store/promptsStore'; +import type { OrganizationInvite } from '../../../types/invites'; +import AdminTabs from './AdminTabs'; + +export const InvitesAdminPanel: React.FC = () => { + const activeOrganization = usePromptsStore((state) => state.activeOrganization); + const fetchOrganizations = usePromptsStore((state) => state.fetchOrganizations); + + const [invites, setInvites] = useState<(OrganizationInvite & { inviteUrl: string })[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + + // Initialize and fetch organizations + useEffect(() => { + fetchOrganizations(); + }, [fetchOrganizations]); + + // Fetch invites when organization changes + useEffect(() => { + if (activeOrganization?.id) { + fetchInvites(); + } + }, [activeOrganization]); + + const fetchInvites = async () => { + if (!activeOrganization?.id) return; + + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `/api/prompts/admin/invites?organizationId=${activeOrganization.id}`, + ); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to fetch invites'); + } + + setInvites(result.data ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch invites'); + console.error('Error fetching invites:', err); + } finally { + setIsLoading(false); + } + }; + + const handleCreateInvite = async (params: { + expiresInDays: number; + maxUses?: number; + role: 'member' | 'admin'; + }) => { + const response = await fetch('/api/prompts/admin/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to create invite'); + } + + setIsCreateDialogOpen(false); + await fetchInvites(); // Refresh list + }; + + const handleRevokeInvite = async (inviteId: string) => { + if (!confirm('Are you sure you want to revoke this invite? This cannot be undone.')) { + return; + } + + try { + const response = await fetch(`/api/prompts/admin/invites/${inviteId}`, { + method: 'DELETE', + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to revoke invite'); + } + + await fetchInvites(); // Refresh list + } catch (err) { + alert(`Error: ${err instanceof Error ? err.message : 'Failed to revoke invite'}`); + console.error('Error revoking invite:', err); + } + }; + + if (!activeOrganization) { + return ( +
+
+
+ {isLoading ? 'Loading organizations...' : 'Please select an organization to continue'} +
+
+
+ ); + } + + return ( +
+ {/* Page Header */} +
+

Admin Panel

+

Manage your organization's prompts and invites

+
+ + {/* Admin Tabs */} + + + {/* Organization Selector */} +
+

Invite Management

+ +
+

+ Create and manage organization invite links for {activeOrganization.name} +

+ + {/* Action Bar */} +
+ + + +
+ + {/* Error Message */} + {error && ( +
+

Error

+

{error}

+
+ )} + + {/* Invites List */} + + + {/* Create Invite Dialog */} + setIsCreateDialogOpen(false)} + onCreate={handleCreateInvite} + /> +
+ ); +}; + +export default InvitesAdminPanel; diff --git a/src/components/prompt-library/admin/InvitesList.tsx b/src/components/prompt-library/admin/InvitesList.tsx new file mode 100644 index 0000000..54e2128 --- /dev/null +++ b/src/components/prompt-library/admin/InvitesList.tsx @@ -0,0 +1,208 @@ +import React, { useState } from 'react'; +import { Copy, Trash2, Clock, Users, CheckCircle, XCircle, BarChart3 } from 'lucide-react'; +import type { OrganizationInvite } from '../../../types/invites'; +import InviteStatsDialog from './InviteStatsDialog'; + +interface InvitesListProps { + invites: (OrganizationInvite & { inviteUrl: string })[]; + isLoading: boolean; + onRevoke: (inviteId: string) => void; +} + +const InvitesList: React.FC = ({ invites, isLoading, onRevoke }) => { + const [copiedId, setCopiedId] = useState(null); + const [statsDialogOpen, setStatsDialogOpen] = useState(false); + const [selectedInviteId, setSelectedInviteId] = useState(null); + + const copyToClipboard = async (text: string, inviteId: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedId(inviteId); + setTimeout(() => setCopiedId(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + alert('Failed to copy to clipboard'); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const isExpired = (expiresAt: string) => { + return new Date(expiresAt) < new Date(); + }; + + const isMaxedOut = (invite: OrganizationInvite) => { + return invite.maxUses !== null && invite.currentUses >= invite.maxUses; + }; + + const handleViewStats = (inviteId: string) => { + setSelectedInviteId(inviteId); + setStatsDialogOpen(true); + }; + + const handleCloseStats = () => { + setStatsDialogOpen(false); + setSelectedInviteId(null); + }; + + const getStatusBadge = (invite: OrganizationInvite) => { + if (!invite.isActive) { + return ( + + + Revoked + + ); + } + if (isExpired(invite.expiresAt)) { + return ( + + + Expired + + ); + } + if (isMaxedOut(invite)) { + return ( + + + Max Uses + + ); + } + return ( + + + Active + + ); + }; + + if (isLoading) { + return ( +
+

Loading invites...

+
+ ); + } + + if (invites.length === 0) { + return ( +
+

No Invites Yet

+

Create your first invite link to get started.

+
+ ); + } + + return ( +
+ + + + + + + + + + + + + {invites.map((invite) => ( + + + + + + + + + ))} + +
+ Invite Link + + Status + + Role + + Usage + + Expires + + Actions +
+
+ + {invite.inviteUrl} + + +
+
{getStatusBadge(invite)} + + {invite.role} + + + {invite.currentUses} + {invite.maxUses !== null ? ` / ${invite.maxUses}` : ' / ∞'} + {formatDate(invite.expiresAt)} +
+ + +
+
+ + {/* Stats Dialog */} + {selectedInviteId && ( + i.id === selectedInviteId)?.token || ''} + /> + )} +
+ ); +}; + +export default InvitesList; diff --git a/src/components/prompt-library/admin/PromptEditorDialog.tsx b/src/components/prompt-library/admin/PromptEditorDialog.tsx new file mode 100644 index 0000000..e64ce0f --- /dev/null +++ b/src/components/prompt-library/admin/PromptEditorDialog.tsx @@ -0,0 +1,381 @@ +import React, { useState, useEffect } from 'react'; +import { + ConfirmDialog, + ConfirmDialogHeader, + ConfirmDialogContent, + ConfirmDialogActions, +} from '../../ui/ConfirmDialog'; +import FormInput from '../../ui/FormInput'; +import FormTextarea from '../../ui/FormTextarea'; +import { Dropdown } from '../../ui/Dropdown'; +import InlineEntityForm from '../../ui/InlineEntityForm'; +import { usePromptsStore } from '../../../store/promptsStore'; +import { Plus } from 'lucide-react'; +import type { Prompt, CreatePromptInput, UpdatePromptInput } from '../../../store/promptsStore'; + +interface PromptEditorDialogProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: CreatePromptInput | UpdatePromptInput) => Promise; + initialData?: Prompt; +} + +export const PromptEditorDialog: React.FC = ({ + isOpen, + onClose, + onSave, + initialData, +}) => { + // Get collections and segments directly from store for real-time updates + const collections = usePromptsStore((state) => state.collections ?? []); + const segments = usePromptsStore((state) => state.segments ?? []); + const { createCollection, createSegment, fetchSegments } = usePromptsStore(); + + const [titleEn, setTitleEn] = useState(''); + const [markdownBodyEn, setMarkdownBodyEn] = useState(''); + const [titlePl, setTitlePl] = useState(''); + const [markdownBodyPl, setMarkdownBodyPl] = useState(''); + const [collectionId, setCollectionId] = useState(''); + const [segmentId, setSegmentId] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [hasAttemptedSave, setHasAttemptedSave] = useState(false); + const [validationErrors, setValidationErrors] = useState<{ + titleEn?: string; + collectionId?: string; + segmentId?: string; + markdownBodyEn?: string; + }>({}); + + // Inline creation states + const [isCreatingCollection, setIsCreatingCollection] = useState(false); + const [isCreatingSegment, setIsCreatingSegment] = useState(false); + + // Initialize form with initial data + useEffect(() => { + if (initialData) { + setTitleEn(initialData.title_en); + setMarkdownBodyEn(initialData.markdown_body_en); + setTitlePl(initialData.title_pl || ''); + setMarkdownBodyPl(initialData.markdown_body_pl || ''); + setCollectionId(initialData.collection_id); + setSegmentId(initialData.segment_id || ''); + + // Ensure segments are loaded for this collection + if (initialData.collection_id) { + fetchSegments(initialData.collection_id); + } + } else { + setTitleEn(''); + setMarkdownBodyEn(''); + setTitlePl(''); + setMarkdownBodyPl(''); + setCollectionId(''); + setSegmentId(''); + } + setError(null); + setValidationErrors({}); + setHasAttemptedSave(false); + setIsCreatingCollection(false); + setIsCreatingSegment(false); + }, [initialData, isOpen, fetchSegments]); + + const validate = (): boolean => { + const errors: typeof validationErrors = {}; + + if (!titleEn.trim()) { + errors.titleEn = 'English title is required'; + } + + if (!collectionId) { + errors.collectionId = 'Collection is required'; + } + + if (!segmentId) { + errors.segmentId = 'Segment is required'; + } + + if (!markdownBodyEn.trim()) { + errors.markdownBodyEn = 'English content is required'; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSave = async () => { + setHasAttemptedSave(true); + + if (!validate()) { + return; + } + + try { + setIsSaving(true); + setError(null); + + const data: CreatePromptInput | UpdatePromptInput = { + title_en: titleEn.trim(), + markdown_body_en: markdownBodyEn.trim(), + title_pl: titlePl.trim(), + markdown_body_pl: markdownBodyPl.trim(), + collection_id: collectionId, + segment_id: segmentId, + }; + + await onSave(data); + onClose(); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to save prompt'); + } finally { + setIsSaving(false); + } + }; + + // Handle collection creation + const handleCreateCollection = async (data: { + title: string; + description?: string; + slug?: string; + }) => { + const newCollection = await createCollection(data); + setCollectionId(newCollection.id); + setIsCreatingCollection(false); + // Fetch segments for the new collection + await fetchSegments(newCollection.id); + }; + + // Handle segment creation + const handleCreateSegment = async (data: { title: string; slug?: string }) => { + if (!collectionId) return; + const newSegment = await createSegment(collectionId, data); + setSegmentId(newSegment.id); + setIsCreatingSegment(false); + }; + + // Filter segments for selected collection + const filteredSegments = segments.filter((s) => s.collection_id === collectionId); + + // Disable segment dropdown if no collection is selected + const isSegmentDisabled = !collectionId || collectionId === '__CREATE_NEW__'; + + // Create options for dropdowns with special "Create New" option + const collectionOptions = [ + { value: '__CREATE_NEW__', label: 'Create New Collection' }, + ...collections.map((c) => ({ + value: c.id, + label: c.title, + })), + ]; + + const segmentOptions = [ + { value: '__CREATE_NEW__', label: 'Create New Segment' }, + ...filteredSegments.map((s) => ({ + value: s.id, + label: s.title, + })), + ]; + + // Handle collection change + const handleCollectionChange = async (value: string) => { + if (value === '__CREATE_NEW__') { + setIsCreatingCollection(true); + return; + } + setCollectionId(value); + setSegmentId(''); + setIsCreatingCollection(false); + if (hasAttemptedSave && validationErrors.collectionId) { + setValidationErrors((prev) => ({ ...prev, collectionId: undefined })); + } + // Fetch segments for the selected collection + await fetchSegments(value); + }; + + // Handle segment change + const handleSegmentChange = (value: string) => { + if (value === '__CREATE_NEW__') { + setIsCreatingSegment(true); + return; + } + setSegmentId(value); + setIsCreatingSegment(false); + if (hasAttemptedSave && validationErrors.segmentId) { + setValidationErrors((prev) => ({ ...prev, segmentId: undefined })); + } + }; + + // Custom option renderer to add Plus icon for "Create New" options + const renderCollectionOption = ( + option: { value: string; label: string }, + isSelected: boolean, + ) => { + if (option.value === '__CREATE_NEW__') { + return ( + + + {option.label} + + ); + } + return ( + <> + {option.label} + {isSelected && } + + ); + }; + + const renderSegmentOption = (option: { value: string; label: string }, isSelected: boolean) => { + if (option.value === '__CREATE_NEW__') { + return ( + + + {option.label} + + ); + } + return ( + <> + {option.label} + {isSelected && } + + ); + }; + + return ( + + {initialData ? 'Edit Prompt' : 'Create New Prompt'} + +
{ + e.preventDefault(); + handleSave(); + }} + className="space-y-4" + > + { + setTitleEn(e.target.value); + if (hasAttemptedSave && validationErrors.titleEn) { + setValidationErrors((prev) => ({ ...prev, titleEn: undefined })); + } + }} + error={hasAttemptedSave ? validationErrors.titleEn : undefined} + placeholder="Enter prompt title in English" + /> + setTitlePl(e.target.value)} + placeholder="Enter prompt title in Polish" + /> + +
+ + {hasAttemptedSave && validationErrors.collectionId && ( +

{validationErrors.collectionId}

+ )} +
+ + {isCreatingCollection && ( + { + setIsCreatingCollection(false); + setCollectionId(''); + }} + /> + )} + +
+ + {hasAttemptedSave && validationErrors.segmentId && ( +

{validationErrors.segmentId}

+ )} +
+ + {isCreatingSegment && collectionId && ( + { + setIsCreatingSegment(false); + setSegmentId(''); + }} + /> + )} + + { + setMarkdownBodyEn(e.target.value); + if (hasAttemptedSave && validationErrors.markdownBodyEn) { + setValidationErrors((prev) => ({ ...prev, markdownBodyEn: undefined })); + } + }} + error={hasAttemptedSave ? validationErrors.markdownBodyEn : undefined} + placeholder="Enter prompt content in English markdown format..." + rows={10} + /> + + setMarkdownBodyPl(e.target.value)} + placeholder="Enter prompt content in Polish markdown format..." + rows={10} + /> + + {error && ( +
+ {error} +
+ )} + +
+ + + + +
+ ); +}; + +export default PromptEditorDialog; diff --git a/src/components/prompt-library/admin/PromptsAdminPanel.tsx b/src/components/prompt-library/admin/PromptsAdminPanel.tsx new file mode 100644 index 0000000..9b3427f --- /dev/null +++ b/src/components/prompt-library/admin/PromptsAdminPanel.tsx @@ -0,0 +1,302 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Plus } from 'lucide-react'; +import { usePromptsStore } from '../../../store/promptsStore'; +import type { Prompt, CreatePromptInput, UpdatePromptInput } from '../../../store/promptsStore'; +import OrganizationSelector from '../OrganizationSelector'; +import AdminPromptsList from './AdminPromptsList'; +import PromptEditorDialog from './PromptEditorDialog'; +import DeletionDialog from '../../rule-collections/DeletionDialog'; +import { Dropdown } from '../../ui/Dropdown'; +import { SearchBar } from '../../ui/SearchBar'; +import AdminTabs from './AdminTabs'; + +interface PromptsAdminPanelProps { + showInvites?: boolean; +} + +export const PromptsAdminPanel: React.FC = ({ showInvites = false }) => { + // Store state - using individual selectors for better type safety + const adminPrompts = usePromptsStore((state) => state.adminPrompts ?? []); + const isLoading = usePromptsStore((state) => state.isLoading); + const error = usePromptsStore((state) => state.error); + const activeOrganization = usePromptsStore((state) => state.activeOrganization); + const collections = usePromptsStore((state) => state.collections ?? []); + const segments = usePromptsStore((state) => state.segments ?? []); + const statusFilter = usePromptsStore((state) => state.statusFilter); + const selectedPromptId = usePromptsStore((state) => state.selectedPromptId); + const searchQuery = usePromptsStore((state) => state.searchQuery); + + // Actions + const fetchOrganizations = usePromptsStore((state) => state.fetchOrganizations); + const fetchAdminPrompts = usePromptsStore((state) => state.fetchAdminPrompts); + const fetchSegments = usePromptsStore((state) => state.fetchSegments); + const createPrompt = usePromptsStore((state) => state.createPrompt); + const updatePrompt = usePromptsStore((state) => state.updatePrompt); + const deletePrompt = usePromptsStore((state) => state.deletePrompt); + const togglePublishStatus = usePromptsStore((state) => state.togglePublishStatus); + const setStatusFilter = usePromptsStore((state) => state.setStatusFilter); + const setAdminMode = usePromptsStore((state) => state.setAdminMode); + const setSearchQuery = usePromptsStore((state) => state.setSearchQuery); + + // Local state + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [editingPrompt, setEditingPrompt] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deletingPromptId, setDeletingPromptId] = useState(null); + const [selectedCollectionId, setSelectedCollectionId] = useState(null); + const [selectedSegmentId, setSelectedSegmentId] = useState(null); + + // Initialize admin mode + useEffect(() => { + setAdminMode(true); + fetchOrganizations(); + + return () => { + setAdminMode(false); + }; + }, [setAdminMode, fetchOrganizations]); + + // Fetch admin prompts when organization changes + useEffect(() => { + if (activeOrganization) { + fetchAdminPrompts(); + } + }, [activeOrganization, fetchAdminPrompts]); + + // Fetch all segments for the active organization (in parallel for better performance) + useEffect(() => { + const loadAllSegments = async () => { + await Promise.all(collections.map((collection) => fetchSegments(collection.id))); + }; + + if (collections.length > 0) { + loadAllSegments(); + } + }, [collections, fetchSegments]); + + // Create collection options with "All" option + const collectionOptions = useMemo( + () => [ + { value: null, label: 'All Collections' }, + ...collections.map((collection) => ({ + value: collection.id, + label: collection.title, + })), + ], + [collections], + ); + + // Create segment options with "All" option (only show when a collection is selected) + const segmentOptions = useMemo( + () => [ + { value: null, label: 'All Segments' }, + ...segments + .filter((segment) => segment.collection_id === selectedCollectionId) + .map((segment) => ({ + value: segment.id, + label: segment.title, + })), + ], + [segments, selectedCollectionId], + ); + + const handleCollectionChange = (collectionId: string | null) => { + setSelectedCollectionId(collectionId); + setSelectedSegmentId(null); // Reset segment when collection changes + }; + + const handleSegmentChange = (segmentId: string | null) => { + setSelectedSegmentId(segmentId); + }; + + // Filter prompts based on selected collection and segment + const filteredPrompts = useMemo(() => { + return adminPrompts.filter((prompt) => { + // Filter by collection + if (selectedCollectionId && prompt.collection_id !== selectedCollectionId) { + return false; + } + // Filter by segment + if (selectedSegmentId && prompt.segment_id !== selectedSegmentId) { + return false; + } + return true; + }); + }, [adminPrompts, selectedCollectionId, selectedSegmentId]); + + // Handlers + const handleCreateNew = () => { + setEditingPrompt(null); + setIsEditorOpen(true); + }; + + const handleEdit = (prompt: Prompt) => { + setEditingPrompt(prompt); + setIsEditorOpen(true); + }; + + const handleDelete = (promptId: string) => { + setDeletingPromptId(promptId); + setIsDeleteDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + if (deletingPromptId) { + try { + await deletePrompt(deletingPromptId); + setIsDeleteDialogOpen(false); + setDeletingPromptId(null); + } catch (error) { + console.error('Error deleting prompt:', error); + } + } + }; + + const handleCancelDelete = () => { + setIsDeleteDialogOpen(false); + setDeletingPromptId(null); + }; + + const handleSave = async (data: CreatePromptInput | UpdatePromptInput) => { + if (editingPrompt) { + await updatePrompt(editingPrompt.id, data); + } else { + await createPrompt(data as CreatePromptInput); + } + setIsEditorOpen(false); + setEditingPrompt(null); + }; + + const handleTogglePublish = async (promptId: string) => { + await togglePublishStatus(promptId); + }; + + // Status filter options + const statusFilterOptions = [ + { value: 'all', label: 'All Prompts' }, + { value: 'draft', label: 'Drafts Only' }, + { value: 'published', label: 'Published Only' }, + ]; + + // Get the prompt being deleted for the dialog + const deletingPrompt = adminPrompts.find((p) => p.id === deletingPromptId); + + return ( +
+ {/* Page Header */} +
+

Admin Panel

+

Manage your organization's prompts and invites

+
+ + {/* Admin Tabs */} + + + {/* Organization Selector and Create Button */} +
+ + +
+ + {/* Search Bar */} +
+ +
+ + {/* Filters */} +
+
+ setStatusFilter(value as 'all' | 'draft' | 'published')} + /> +
+ +
+ +
+ + {selectedCollectionId && segments.length > 0 && ( +
+ +
+ )} + +
+ + {filteredPrompts.length} {filteredPrompts.length === 1 ? 'prompt' : 'prompts'} + +
+
+ + {/* Main Content */} + {activeOrganization ? ( + + ) : ( +
+
+ {isLoading ? 'Loading organizations...' : 'Please select an organization to continue'} +
+
+ )} + + {/* Editor Dialog */} + { + setIsEditorOpen(false); + setEditingPrompt(null); + }} + onSave={handleSave} + initialData={editingPrompt || undefined} + /> + + {/* Delete Confirmation Dialog */} + +
+ ); +}; + +export default PromptsAdminPanel; diff --git a/src/components/rule-builder/LibraryItem.tsx b/src/components/rule-builder/LibraryItem.tsx index d4df935..b74e8f2 100644 --- a/src/components/rule-builder/LibraryItem.tsx +++ b/src/components/rule-builder/LibraryItem.tsx @@ -1,11 +1,11 @@ import { Check } from 'lucide-react'; -import type { KeyboardEvent } from 'react'; import React from 'react'; import { Library } from '../../data/dictionaries'; import { getLibraryTranslation } from '../../i18n/translations'; import type { LayerType } from '../../styles/theme'; import { getLayerClasses } from '../../styles/theme'; import { useAccordionContentOpen } from '../ui/Accordion'; +import { useKeyboardActivation } from '../../hooks/useKeyboardActivation'; interface LibraryItemProps { library: Library; @@ -19,12 +19,7 @@ export const LibraryItem: React.FC = React.memo( const isParentAccordionOpen = useAccordionContentOpen(); const itemClasses = getLayerClasses.libraryItem(layerType, isSelected); - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggle(library); - } - }; + const createKeyboardActivationHandler = useKeyboardActivation(); return (
@@ -117,12 +133,7 @@ export const CollectionListEntry: React.FC = ({ tabIndex={0} className="p-1.5 rounded-md text-gray-400 hover:text-red-400 hover:bg-gray-700/50 opacity-0 group-hover:opacity-100 cursor-pointer transition-colors" aria-label={`Delete ${collection.name}`} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleDeleteClick(e as unknown as React.MouseEvent); - } - }} + onKeyDown={handleDeleteKeyDown} >
@@ -160,15 +171,15 @@ export const CollectionListEntry: React.FC = ({ title="Delete Collection" /> - setIsEditDialogOpen(false)} onSave={handleEditSave} initialName={collection.name} - initialDescription={collection.description} + initialDescription={collection.description || ''} /> ); }; -export default CollectionListEntry; +export default RuleCollectionListEntry; diff --git a/src/components/rule-collections/CollectionsList.tsx b/src/components/rule-collections/RuleCollectionsList.tsx similarity index 79% rename from src/components/rule-collections/CollectionsList.tsx rename to src/components/rule-collections/RuleCollectionsList.tsx index d507a3d..fe94ffa 100644 --- a/src/components/rule-collections/CollectionsList.tsx +++ b/src/components/rule-collections/RuleCollectionsList.tsx @@ -1,13 +1,14 @@ import React, { useState } from 'react'; -import { useCollectionsStore, type Collection } from '../../store/collectionsStore'; +import { useRuleCollectionsStore } from '../../store/ruleCollectionsStore'; +import { type RuleCollection } from '../../types/ruleCollection.types'; import { useTechStackStore } from '../../store/techStackStore'; -import CollectionListEntry from './CollectionListEntry'; -import SaveCollectionDialog from './SaveCollectionDialog'; -import UnsavedChangesDialog from './UnsavedChangesDialog'; +import RuleCollectionListEntry from './RuleCollectionListEntry'; +import SaveRuleCollectionDialog from './SaveRuleCollectionDialog'; +import UnsavedRuleCollectionChangesDialog from './UnsavedRuleCollectionChangesDialog'; import { AlertCircle, Loader2, Plus } from 'lucide-react'; import { Library } from '../../data/dictionaries'; -export const CollectionsList: React.FC = () => { +export const RuleCollectionsList: React.FC = () => { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const { collections, @@ -20,10 +21,10 @@ export const CollectionsList: React.FC = () => { closeUnsavedChangesDialog, selectedCollection, fetchCollections, - } = useCollectionsStore(); + } = useRuleCollectionsStore(); const { selectedLibraries } = useTechStackStore(); - const handleCollectionSelect = (collection: Collection) => { + const handleCollectionSelect = (collection: RuleCollection) => { handlePendingCollectionSelect(collection); }; @@ -52,7 +53,7 @@ export const CollectionsList: React.FC = () => { }; try { - const response = await fetch('/api/collections', { + const response = await fetch('/api/rule-collections', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -64,7 +65,7 @@ export const CollectionsList: React.FC = () => { throw new Error('Failed to create collection'); } - const savedCollection = await response.json(); + const savedCollection = (await response.json()) as RuleCollection; // Refresh collections list await fetchCollections(); @@ -99,7 +100,7 @@ export const CollectionsList: React.FC = () => { <>
{collections.map((collection) => ( - {
- setIsCreateDialogOpen(false)} onSave={handleCreateCollection} /> - { ); }; -export default CollectionsList; +export default RuleCollectionsList; diff --git a/src/components/rule-collections/CollectionsSidebar.tsx b/src/components/rule-collections/RuleCollectionsSidebar.tsx similarity index 83% rename from src/components/rule-collections/CollectionsSidebar.tsx rename to src/components/rule-collections/RuleCollectionsSidebar.tsx index 5794e03..1d89e70 100644 --- a/src/components/rule-collections/CollectionsSidebar.tsx +++ b/src/components/rule-collections/RuleCollectionsSidebar.tsx @@ -1,18 +1,21 @@ import React, { useEffect } from 'react'; import { Album, ChevronLeft, LogIn } from 'lucide-react'; import { transitions } from '../../styles/theme'; -import { CollectionsList } from './CollectionsList'; -import { useCollectionsStore } from '../../store/collectionsStore'; +import { RuleCollectionsList } from './RuleCollectionsList'; +import { useRuleCollectionsStore } from '../../store/ruleCollectionsStore'; import { useAuthStore } from '../../store/authStore'; import { useNavigationStore } from '../../store/navigationStore'; -interface CollectionsSidebarProps { +interface RuleCollectionsSidebarProps { isOpen: boolean; onToggle: () => void; } -export const CollectionsSidebar: React.FC = ({ isOpen, onToggle }) => { - const fetchCollections = useCollectionsStore((state) => state.fetchCollections); +export const RuleCollectionsSidebar: React.FC = ({ + isOpen, + onToggle, +}) => { + const fetchCollections = useRuleCollectionsStore((state) => state.fetchCollections); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const { activePanel } = useNavigationStore(); const isMobileCollectionsActive = activePanel === 'collections'; @@ -39,7 +42,7 @@ export const CollectionsSidebar: React.FC = ({ isOpen,

Collections

{isAuthenticated ? ( - + ) : (
@@ -69,4 +72,4 @@ export const CollectionsSidebar: React.FC = ({ isOpen, ); }; -export default CollectionsSidebar; +export default RuleCollectionsSidebar; diff --git a/src/components/rule-collections/SaveDefaultDialog.tsx b/src/components/rule-collections/SaveDefaultRuleCollectionDialog.tsx similarity index 91% rename from src/components/rule-collections/SaveDefaultDialog.tsx rename to src/components/rule-collections/SaveDefaultRuleCollectionDialog.tsx index f0ac997..0b579af 100644 --- a/src/components/rule-collections/SaveDefaultDialog.tsx +++ b/src/components/rule-collections/SaveDefaultRuleCollectionDialog.tsx @@ -6,7 +6,7 @@ import { ConfirmDialogActions, } from '../ui/ConfirmDialog'; -interface SaveCollectionDialogProps { +interface SaveDefaultRuleCollectionDialogProps { isOpen: boolean; onClose: () => void; onSave: (name: string, description: string) => Promise; @@ -14,7 +14,7 @@ interface SaveCollectionDialogProps { initialDescription?: string; } -export const SaveCollectionDialog: React.FC = ({ +export const SaveDefaultRuleCollectionDialog: React.FC = ({ isOpen, onClose, onSave, @@ -99,4 +99,4 @@ export const SaveCollectionDialog: React.FC = ({ ); }; -export default SaveCollectionDialog; +export default SaveDefaultRuleCollectionDialog; diff --git a/src/components/rule-collections/SaveCollectionDialog.tsx b/src/components/rule-collections/SaveRuleCollectionDialog.tsx similarity index 92% rename from src/components/rule-collections/SaveCollectionDialog.tsx rename to src/components/rule-collections/SaveRuleCollectionDialog.tsx index 0a17d4f..0a3248c 100644 --- a/src/components/rule-collections/SaveCollectionDialog.tsx +++ b/src/components/rule-collections/SaveRuleCollectionDialog.tsx @@ -6,7 +6,7 @@ import { ConfirmDialogActions, } from '../ui/ConfirmDialog'; -interface SaveCollectionDialogProps { +interface SaveRuleCollectionDialogProps { isOpen: boolean; onClose: () => void; onSave: (name: string, description: string) => Promise; @@ -14,7 +14,7 @@ interface SaveCollectionDialogProps { initialDescription?: string; } -export const SaveCollectionDialog: React.FC = ({ +export const SaveRuleCollectionDialog: React.FC = ({ isOpen, onClose, onSave, @@ -115,4 +115,4 @@ export const SaveCollectionDialog: React.FC = ({ ); }; -export default SaveCollectionDialog; +export default SaveRuleCollectionDialog; diff --git a/src/components/rule-collections/UnsavedChangesDialog.tsx b/src/components/rule-collections/UnsavedRuleCollectionChangesDialog.tsx similarity index 85% rename from src/components/rule-collections/UnsavedChangesDialog.tsx rename to src/components/rule-collections/UnsavedRuleCollectionChangesDialog.tsx index 2cf881c..370d6c9 100644 --- a/src/components/rule-collections/UnsavedChangesDialog.tsx +++ b/src/components/rule-collections/UnsavedRuleCollectionChangesDialog.tsx @@ -6,7 +6,7 @@ import { ConfirmDialogActions, } from '../ui/ConfirmDialog'; -interface UnsavedChangesDialogProps { +interface UnsavedRuleCollectionChangesDialogProps { isOpen: boolean; onClose: () => void; onSave: () => Promise; @@ -14,13 +14,9 @@ interface UnsavedChangesDialogProps { collectionName: string; } -export const UnsavedChangesDialog: React.FC = ({ - isOpen, - onClose, - onSave, - onSkip, - collectionName, -}) => { +export const UnsavedRuleCollectionChangesDialog: React.FC< + UnsavedRuleCollectionChangesDialogProps +> = ({ isOpen, onClose, onSave, onSkip, collectionName }) => { const [isSaving, setIsSaving] = useState(false); const handleSave = async () => { @@ -68,4 +64,4 @@ export const UnsavedChangesDialog: React.FC = ({ ); }; -export default UnsavedChangesDialog; +export default UnsavedRuleCollectionChangesDialog; diff --git a/src/components/rule-parser/DependencyUploader.tsx b/src/components/rule-parser/DependencyUploader.tsx deleted file mode 100644 index 9d94a89..0000000 --- a/src/components/rule-parser/DependencyUploader.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Upload } from 'lucide-react'; -import { useDependencyUpload } from './useDependencyUpload'; - -export default function DependencyUploader() { - const { isUploading, uploadStatus, uploadDependencyFile } = useDependencyUpload(); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - - const form = event.currentTarget; - const fileInput = form.querySelector('input[type="file"]') as HTMLInputElement; - const file = fileInput?.files?.[0]; - - if (file) { - await uploadDependencyFile(file); - // Reset the form - form.reset(); - } - }; - - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - // Automatically submit the form when a file is selected - const form = event.target.closest('form'); - if (form) { - form.requestSubmit(); - } - } - }; - - return ( -
-
- - -
- - {uploadStatus.message && ( - - )} -
- ); -} diff --git a/src/components/rule-preview/RulesPreviewActions.tsx b/src/components/rule-preview/RulesPreviewActions.tsx index 4204240..1a60221 100644 --- a/src/components/rule-preview/RulesPreviewActions.tsx +++ b/src/components/rule-preview/RulesPreviewActions.tsx @@ -1,28 +1,68 @@ -import { ExternalLink } from 'lucide-react'; +import { ExternalLink, Upload } from 'lucide-react'; import React from 'react'; import { aiEnvironmentConfig } from '../../data/ai-environments.ts'; import { useProjectStore } from '../../store/projectStore'; +import { useDependencyUpload } from '../rule-parser/useDependencyUpload'; import Tooltip from '../ui/Tooltip.tsx'; export const RulesPreviewActions: React.FC = () => { const { selectedEnvironment } = useProjectStore(); + const { isUploading, uploadStatus, uploadDependencyFile } = useDependencyUpload(); const config = aiEnvironmentConfig[selectedEnvironment]; if (!config) { return null; } + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + await uploadDependencyFile(file); + // Reset the input + event.target.value = ''; + } + }; + + const getUploadTooltipContent = () => { + if (uploadStatus.message) { + return uploadStatus.message; + } + return isUploading ? 'Uploading dependencies...' : 'Upload dependencies file'; + }; + return ( - - - - - + <> + + + + + + + + + + ); }; diff --git a/src/components/ui/Accordion.tsx b/src/components/ui/Accordion.tsx index 426b398..8e62d81 100644 --- a/src/components/ui/Accordion.tsx +++ b/src/components/ui/Accordion.tsx @@ -1,7 +1,7 @@ import { ChevronDown } from 'lucide-react'; -import type { KeyboardEvent } from 'react'; -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useMemo } from 'react'; import { transitions } from '../../styles/theme'; +import { useKeyboardActivation } from '../../hooks/useKeyboardActivation'; // Create a context to track accordion open state const AccordionContentContext = createContext(false); @@ -67,12 +67,11 @@ export const AccordionTrigger: React.FC = React.memo( // Only nested triggers should check parent state const shouldBeFocusable = isRoot || isParentAccordionOpen; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick?.(); - } - }; + const createKeyboardActivationHandler = useKeyboardActivation(); + const handleKeyDown = useMemo( + () => createKeyboardActivationHandler(() => onClick?.()), + [createKeyboardActivationHandler, onClick], + ); return (
= ({ isOpen, onClose, c // Handle click outside to close useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dialogRef.current && !dialogRef.current.contains(event.target as Node)) { - onClose(); + const target = event.target as Node; + + // Check if click is inside the dialog + if (dialogRef.current && dialogRef.current.contains(target)) { + return; } + + // Check if click is inside a dropdown menu (rendered via portal) + const isInDropdown = (target as Element).closest?.('[role="listbox"]'); + if (isInDropdown) { + return; + } + + // Click is outside both dialog and dropdowns, so close + onClose(); }; if (isOpen) { @@ -66,7 +78,7 @@ export const ConfirmDialog: React.FC = ({ isOpen, onClose, c >
@@ -89,7 +101,7 @@ export const ConfirmDialogHeader: React.FC = ({ childr

{children}

+ + + + +
+ + ); +}; + +export default CopyDownloadActions; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..5f06cf7 --- /dev/null +++ b/src/components/ui/Dropdown.tsx @@ -0,0 +1,172 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronDown, Check } from 'lucide-react'; + +export interface DropdownOption { + value: T; + label: string; +} + +interface DropdownProps { + options: DropdownOption[]; + value: T; + onChange: (value: T) => void; + label?: string; + renderOption?: (option: DropdownOption, isSelected: boolean) => React.ReactNode; + className?: string; + placeholder?: string; + disabled?: boolean; +} + +export function Dropdown({ + options, + value, + onChange, + label, + renderOption, + className = '', + placeholder = 'Select option', + disabled = false, +}: DropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + // Calculate dropdown position + const updateDropdownPosition = () => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, // 4px gap + left: rect.left + window.scrollX, + width: Math.max(rect.width, 256), // Min width of 256px (sm:w-64) + }); + } + }; + + // Update position when opening + useEffect(() => { + if (isOpen) { + updateDropdownPosition(); + } + }, [isOpen]); + + // Update position on window resize/scroll + useEffect(() => { + if (!isOpen) return; + + const handleResize = () => updateDropdownPosition(); + const handleScroll = () => updateDropdownPosition(); + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', handleScroll, true); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', handleScroll, true); + }; + }, [isOpen]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Handle keyboard navigation + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setIsOpen(!isOpen); + } + }; + + const handleOptionSelect = (optionValue: T) => { + onChange(optionValue); + setIsOpen(false); + }; + + const selectedOption = options.find((opt) => opt.value === value); + + // Create dropdown menu component + const dropdownMenu = isOpen && !disabled && ( +
+
    + {options.map((option) => { + const isSelected = value === option.value; + + return ( +
  • + +
  • + ); + })} +
+
+ ); + + return ( +
+ {label && } + {/* Dropdown trigger button */} + + + {/* Render dropdown menu via portal */} + {typeof document !== 'undefined' && createPortal(dropdownMenu, document.body)} +
+ ); +} + +export default Dropdown; diff --git a/src/components/ui/FormInput.tsx b/src/components/ui/FormInput.tsx new file mode 100644 index 0000000..1e40df5 --- /dev/null +++ b/src/components/ui/FormInput.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { transitions } from '../../styles/theme'; + +interface FormInputProps extends React.InputHTMLAttributes { + label: string; + error?: string; +} + +const FormInput = React.forwardRef( + ({ label, error, id, ...inputProps }, ref) => { + return ( +
+ + + {error &&

{error}

} +
+ ); + }, +); +FormInput.displayName = 'FormInput'; + +export default FormInput; diff --git a/src/components/ui/FormTextarea.tsx b/src/components/ui/FormTextarea.tsx new file mode 100644 index 0000000..d19c56c --- /dev/null +++ b/src/components/ui/FormTextarea.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { transitions } from '../../styles/theme'; + +interface FormTextareaProps extends React.TextareaHTMLAttributes { + label: string; + error?: string; +} + +const FormTextarea = React.forwardRef( + ({ label, error, id, className = '', ...textareaProps }, ref) => { + return ( +
+ +