routes fix
extensive loging added
This commit is contained in:
parent
36315ea5cf
commit
22c274bb83
366
AGENTS.md
Normal file
366
AGENTS.md
Normal file
@ -0,0 +1,366 @@
|
||||
# Agent Development Guide for FitAI
|
||||
|
||||
This document provides essential guidelines for AI coding agents working in the FitAI codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
FitAI is a monorepo containing a fitness AI platform with:
|
||||
|
||||
- **Admin App** (`apps/admin`): Next.js 16 web application for gym management
|
||||
- **Mobile App** (`apps/mobile`): React Native/Expo mobile app for clients
|
||||
- **Shared Packages**: Database (`@fitai/database`) and utilities (`@fitai/shared`)
|
||||
|
||||
**Tech Stack**: TypeScript, Next.js 16, React 19, React Native, Expo 54, Drizzle ORM, Clerk Auth, TanStack Query, Tailwind CSS
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
### From Repository Root (`/home/echo/dev/prototype`)
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start both admin and mobile dev servers
|
||||
npm run dev:admin # Start admin dev server (Next.js)
|
||||
npm run dev:mobile # Start mobile dev server (Expo)
|
||||
|
||||
# Build
|
||||
npm run build # Build both apps
|
||||
npm run build:admin # Build admin app
|
||||
npm run build:mobile # Build mobile app
|
||||
|
||||
# Linting
|
||||
npm run lint # Lint both apps
|
||||
npm run lint:admin # Lint admin app
|
||||
npm run lint:mobile # Lint mobile app
|
||||
|
||||
# Type Checking
|
||||
npm run typecheck # Check types in both apps
|
||||
npm run typecheck:admin # Check types in admin
|
||||
npm run typecheck:mobile # Check types in mobile
|
||||
|
||||
# Testing
|
||||
npm run test # Run all tests
|
||||
npm run test:admin # Run admin tests
|
||||
npm run test:mobile # Run mobile tests
|
||||
```
|
||||
|
||||
### Admin App (`apps/admin`)
|
||||
|
||||
```bash
|
||||
cd apps/admin
|
||||
|
||||
# Development
|
||||
npm run dev # Start Next.js dev server (port 3000)
|
||||
|
||||
# Testing
|
||||
npm test # Run all tests
|
||||
npx jest # Run all tests
|
||||
npx jest path/to/file.test.ts # Run specific test file
|
||||
npx jest --testPathPattern=drizzle # Run tests matching pattern
|
||||
npx jest src/lib/database/__tests__/drizzle.test.ts # Run specific test
|
||||
|
||||
# Build & Lint
|
||||
npm run build # Build production bundle
|
||||
npm run lint # Run ESLint
|
||||
npm run typecheck # Type check without emitting
|
||||
```
|
||||
|
||||
### Mobile App (`apps/mobile`)
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
|
||||
# Development
|
||||
npm start # Start Expo dev server
|
||||
npm run android # Start on Android emulator
|
||||
npm run ios # Start on iOS simulator
|
||||
|
||||
# Testing
|
||||
npm test # Run all tests
|
||||
npx jest path/to/file.test.ts # Run specific test file
|
||||
npx jest --testPathPattern=component # Run tests matching pattern
|
||||
|
||||
# Build & Lint
|
||||
npm run build # Build with Expo
|
||||
npm run lint # Run ESLint
|
||||
npm run typecheck # Type check
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Import Organization
|
||||
|
||||
**Order**: External libraries → Internal imports, grouped logically
|
||||
|
||||
```typescript
|
||||
// External: React, Next.js, React Native
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import React from "react";
|
||||
|
||||
// External: Third-party libraries
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// Monorepo packages
|
||||
import { db, sql } from "@fitai/database";
|
||||
|
||||
// Internal: Path aliases or relative imports
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import type { User } from "./types";
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
|
||||
- Use `import type { ... }` for type-only imports
|
||||
- Admin app uses path aliases: `@/*` → `./src/*`
|
||||
- Mobile app uses relative paths or `@/*` alias
|
||||
- No automatic import sorting; manual grouping by category
|
||||
|
||||
### Component Structure & Naming
|
||||
|
||||
**Components**: PascalCase function components with named exports
|
||||
|
||||
```typescript
|
||||
// Feature components - Named export (function)
|
||||
export function UserManagement() {
|
||||
return <div>...</div>
|
||||
}
|
||||
|
||||
// UI library components - Named export (forwardRef constant)
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("rounded-lg", className)} {...props} />
|
||||
)
|
||||
)
|
||||
Card.displayName = "Card"
|
||||
export { Card }
|
||||
```
|
||||
|
||||
**Naming Conventions**:
|
||||
|
||||
- Components: `UserManagement`, `GoalProgressCard`
|
||||
- Functions: `handleEditUser`, `getGreeting`
|
||||
- Custom hooks: `useUser`, `useAuth`
|
||||
- API routes: Named exports `GET`, `POST`, `PUT`, `DELETE`
|
||||
|
||||
### Type Definitions
|
||||
|
||||
**Interfaces** for object shapes and React props; **Types** for unions and aliases
|
||||
|
||||
```typescript
|
||||
// Props interfaces (ComponentNameProps pattern)
|
||||
interface UserManagementProps {
|
||||
userId: string;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
// Domain interfaces
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
// Type aliases and unions
|
||||
type UserRole = "admin" | "trainer" | "client";
|
||||
type Status = "active" | "inactive" | "pending";
|
||||
|
||||
// No I prefix or T prefix
|
||||
```
|
||||
|
||||
### Function Patterns
|
||||
|
||||
**Arrow functions** for components and handlers; **function declarations** for utilities
|
||||
|
||||
```typescript
|
||||
// Component functions
|
||||
export function UserCard() {
|
||||
const handleClick = () => { /* ... */ } // Arrow function for handlers
|
||||
return <button onClick={handleClick}>Click</button>
|
||||
}
|
||||
|
||||
// Exported utility functions
|
||||
export async function setUserRole(userId: string, role: UserRole) {
|
||||
// ... implementation
|
||||
}
|
||||
|
||||
// Always use async/await (never .then() chains)
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**API Routes** (Next.js):
|
||||
|
||||
```typescript
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
// Early returns for validation
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email and password are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ... logic
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**React Components**:
|
||||
|
||||
```typescript
|
||||
// Admin: Use try-catch with console.error
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateUser(userId, data);
|
||||
} catch (error) {
|
||||
console.error("Failed to update user:", error);
|
||||
// Show error to user (toast, alert, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
// Mobile: Use Alert for confirmations
|
||||
const handleDelete = () => {
|
||||
Alert.alert("Delete Goal", "Are you sure?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Delete", style: "destructive", onPress: onDelete },
|
||||
]);
|
||||
};
|
||||
```
|
||||
|
||||
### Comments & Documentation
|
||||
|
||||
Use JSDoc for public APIs; inline comments for complex logic only
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Set a user's role in Clerk public metadata
|
||||
*
|
||||
* @param userId - Clerk user ID
|
||||
* @param role - Role to assign (admin, trainer, or client)
|
||||
* @returns Updated user object
|
||||
*
|
||||
* @example
|
||||
* await setUserRole('user_abc123', 'admin')
|
||||
*/
|
||||
export async function setUserRole(userId: string, role: UserRole) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
// Inline comments for clarification
|
||||
// Optimistically update local state so grid reflects changes immediately
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...updates } : u)));
|
||||
```
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
```
|
||||
apps/admin/src/
|
||||
├── app/
|
||||
│ ├── api/*/route.ts # API routes (lowercase route.ts)
|
||||
│ └── */page.tsx # Pages (lowercase page.tsx)
|
||||
├── components/
|
||||
│ ├── ui/button.tsx # UI primitives (kebab-case)
|
||||
│ └── users/UserManagement.tsx # Feature components (PascalCase)
|
||||
├── lib/
|
||||
│ ├── database/index.ts # Utilities (kebab-case)
|
||||
│ └── clerk-helpers.ts # Helpers (kebab-case)
|
||||
|
||||
apps/mobile/src/
|
||||
├── app/
|
||||
│ └── (tabs)/index.tsx # Routes (lowercase)
|
||||
├── components/
|
||||
│ └── GoalProgressCard.tsx # All components (PascalCase)
|
||||
└── services/
|
||||
└── fitnessGoals.ts # Services (camelCase)
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
**Admin App**: Tailwind CSS with utility classes
|
||||
|
||||
```typescript
|
||||
<div className="flex items-center gap-2 rounded-lg border p-4">
|
||||
<Button className="bg-primary text-white hover:bg-primary/90">
|
||||
Click me
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Mobile App**: StyleSheet.create() at file bottom with theme system
|
||||
|
||||
```typescript
|
||||
import { theme } from '../styles/theme'
|
||||
|
||||
export function MyComponent() {
|
||||
return <View style={styles.container}>...</View>
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing.md,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## ESLint Rules (Admin)
|
||||
|
||||
- `@typescript-eslint/no-unused-vars`: error
|
||||
- `@typescript-eslint/no-explicit-any`: warn (allowed but discouraged)
|
||||
- `prefer-const`: error
|
||||
- `no-var`: error
|
||||
- Extends: `next/core-web-vitals`, `@typescript-eslint/recommended`
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
- **Strict mode enabled**: All strict type checking options on
|
||||
- **Path aliases**: `@/*` maps to `./src/*`
|
||||
- **Module resolution**: `bundler` (admin), `node` (mobile)
|
||||
- **Target**: ES5 (admin), ESNext (mobile)
|
||||
- Always provide explicit return types for exported functions
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
- Tests in `__tests__/` directories or `*.test.ts` files
|
||||
- Use `@jest-environment node` comment for Node.js API tests
|
||||
- Admin: Jest + ts-jest + @testing-library/react
|
||||
- Mobile: Jest + react-native preset + @testing-library/react-native
|
||||
- Test file naming: `component.test.ts` or `feature.test.tsx`
|
||||
- Always test error cases and edge cases
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **State Management**: Multiple `useState` declarations grouped together
|
||||
2. **Destructuring**: Props in function signature, responses inline
|
||||
3. **Type Safety**: Explicit return types, const assertions for readonly arrays
|
||||
4. **Database**: Factory pattern with singleton (`getDatabase()`)
|
||||
5. **Forms**: React Hook Form + Zod validation
|
||||
6. **Data Fetching**: TanStack Query for server state
|
||||
7. **Authentication**: Clerk for both admin and mobile (different packages)
|
||||
|
||||
## Node & Package Manager
|
||||
|
||||
- **Node**: >=18.0.0
|
||||
- **Package Manager**: npm (>=9.0.0)
|
||||
- Use `npm install` (not yarn or pnpm)
|
||||
@ -1,39 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { getDatabase } from '@/lib/database';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
|
||||
// OPTIONS - Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return NextResponse.json({}, { headers: corsHeaders() });
|
||||
}
|
||||
|
||||
// POST - Mark goal as complete
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
const completedGoal = await db.completeGoal(id);
|
||||
|
||||
return NextResponse.json(completedGoal);
|
||||
} catch (error) {
|
||||
console.error('Error completing fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
const completedGoal = await db.completeGoal(id);
|
||||
|
||||
return NextResponse.json(completedGoal, { headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("Error completing fitness goal:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,119 +1,163 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { getDatabase } from '@/lib/database';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
|
||||
// OPTIONS - Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return NextResponse.json({}, { headers: corsHeaders() });
|
||||
}
|
||||
|
||||
// GET - Get specific goal
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
const goal = await db.getFitnessGoalById(id);
|
||||
|
||||
if (!goal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (goal.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(goal);
|
||||
} catch (error) {
|
||||
console.error('Error fetching fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
const goal = await db.getFitnessGoalById(id);
|
||||
|
||||
if (!goal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (goal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(goal, { headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("Error fetching fitness goal:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Update goal
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const updates = await req.json();
|
||||
|
||||
// Don't allow changing userId or id
|
||||
delete updates.userId;
|
||||
delete updates.id;
|
||||
delete updates.createdAt;
|
||||
|
||||
const updatedGoal = await db.updateFitnessGoal(id, updates);
|
||||
|
||||
return NextResponse.json(updatedGoal);
|
||||
} catch (error) {
|
||||
console.error('Error updating fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
const updates = await req.json();
|
||||
|
||||
// Don't allow changing userId or id
|
||||
delete updates.userId;
|
||||
delete updates.id;
|
||||
delete updates.createdAt;
|
||||
|
||||
const updatedGoal = await db.updateFitnessGoal(id, updates);
|
||||
|
||||
return NextResponse.json(updatedGoal, { headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("Error updating fitness goal:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete goal
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const deleted = await db.deleteFitnessGoal(id);
|
||||
|
||||
if (deleted) {
|
||||
return NextResponse.json({ success: true });
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Failed to delete goal' }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = await db.deleteFitnessGoal(id);
|
||||
|
||||
if (deleted) {
|
||||
return NextResponse.json({ success: true }, { headers: corsHeaders() });
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete goal" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting fitness goal:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,95 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { getDatabase } from '@/lib/database';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to get user ID from auth (works with both web sessions and mobile tokens)
|
||||
async function getAuthenticatedUserId(
|
||||
req: NextRequest,
|
||||
): Promise<string | null> {
|
||||
// The auth() function from Clerk should handle Bearer tokens automatically
|
||||
// when called within an API route that's processed by clerkMiddleware
|
||||
const { userId } = await auth();
|
||||
return userId;
|
||||
}
|
||||
|
||||
// OPTIONS - Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return NextResponse.json({}, { headers: corsHeaders() });
|
||||
}
|
||||
|
||||
// GET - List user's fitness goals
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const userId = await getAuthenticatedUserId(req);
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const targetUserId = searchParams.get('userId') || userId;
|
||||
const status = searchParams.get('status'); // active, completed, all
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Fetch goals with optional status filter
|
||||
const goals = await db.getFitnessGoalsByUserId(
|
||||
targetUserId,
|
||||
status && status !== 'all' ? status : undefined
|
||||
);
|
||||
|
||||
return NextResponse.json(goals);
|
||||
} catch (error) {
|
||||
console.error('Error fetching fitness goals:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
if (!userId) {
|
||||
console.error("[Fitness Goals API] Authentication failed");
|
||||
console.error(
|
||||
"[Fitness Goals API] Headers:",
|
||||
Object.fromEntries(req.headers.entries()),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Fitness Goals API] Authenticated user:", userId);
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const targetUserId = searchParams.get("userId") || userId;
|
||||
const status = searchParams.get("status"); // active, completed, all
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Fetch goals with optional status filter
|
||||
const goals = await db.getFitnessGoalsByUserId(
|
||||
targetUserId,
|
||||
status && status !== "all" ? status : undefined,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Fitness Goals API] Found ${goals.length} goals for user ${targetUserId}`,
|
||||
);
|
||||
return NextResponse.json(goals, { headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("[Fitness Goals API] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create new fitness goal
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const userId = await getAuthenticatedUserId(req);
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
goalType,
|
||||
title,
|
||||
description,
|
||||
targetValue,
|
||||
currentValue,
|
||||
unit,
|
||||
targetDate,
|
||||
priority,
|
||||
notes,
|
||||
fitnessProfileId
|
||||
} = body;
|
||||
|
||||
// Validation
|
||||
if (!goalType || !title) {
|
||||
return NextResponse.json(
|
||||
{ error: 'goalType and title are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Create the goal
|
||||
const goal = await db.createFitnessGoal({
|
||||
id: randomBytes(16).toString('hex'),
|
||||
userId,
|
||||
fitnessProfileId: fitnessProfileId || undefined,
|
||||
goalType,
|
||||
title,
|
||||
description: description || undefined,
|
||||
targetValue: targetValue || undefined,
|
||||
currentValue: currentValue || 0,
|
||||
unit: unit || undefined,
|
||||
startDate: new Date(),
|
||||
targetDate: targetDate ? new Date(targetDate) : undefined,
|
||||
status: 'active',
|
||||
progress: 0,
|
||||
priority: priority || 'medium',
|
||||
notes: notes || undefined
|
||||
});
|
||||
|
||||
return NextResponse.json(goal, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
if (!userId) {
|
||||
console.error("[Fitness Goals API] Authentication failed for POST");
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
goalType,
|
||||
title,
|
||||
description,
|
||||
targetValue,
|
||||
currentValue,
|
||||
unit,
|
||||
targetDate,
|
||||
priority,
|
||||
notes,
|
||||
fitnessProfileId,
|
||||
} = body;
|
||||
|
||||
// Validation
|
||||
if (!goalType || !title) {
|
||||
return NextResponse.json(
|
||||
{ error: "goalType and title are required" },
|
||||
{ status: 400, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Create the goal
|
||||
const goal = await db.createFitnessGoal({
|
||||
id: randomBytes(16).toString("hex"),
|
||||
userId,
|
||||
fitnessProfileId: fitnessProfileId || undefined,
|
||||
goalType,
|
||||
title,
|
||||
description: description || undefined,
|
||||
targetValue: targetValue || undefined,
|
||||
currentValue: currentValue || 0,
|
||||
unit: unit || undefined,
|
||||
startDate: new Date(),
|
||||
targetDate: targetDate ? new Date(targetDate) : undefined,
|
||||
status: "active",
|
||||
progress: 0,
|
||||
priority: priority || "medium",
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Fitness Goals API] Created goal ${goal.id} for user ${userId}`,
|
||||
);
|
||||
return NextResponse.json(goal, { status: 201, headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("[Fitness Goals API] Error creating goal:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
5
apps/admin/src/app/api/test/route.ts
Normal file
5
apps/admin/src/app/api/test/route.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ message: "Test endpoint works!" });
|
||||
}
|
||||
47
apps/admin/src/lib/auth-helper.ts
Normal file
47
apps/admin/src/lib/auth-helper.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { auth, currentUser } from "@clerk/nextjs/server";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Get authenticated user ID from request
|
||||
* Handles both session-based auth (web) and Bearer token auth (mobile)
|
||||
*
|
||||
* For mobile apps using Clerk Expo, tokens should be passed as:
|
||||
* Authorization: Bearer <token>
|
||||
*/
|
||||
export async function getAuthUserId(req: NextRequest): Promise<string | null> {
|
||||
try {
|
||||
// Clerk's auth() should handle both cookies and Bearer tokens automatically
|
||||
// when the request is properly formatted
|
||||
const { userId } = await auth();
|
||||
|
||||
if (userId) {
|
||||
console.log("✓ Authenticated user:", userId);
|
||||
return userId;
|
||||
}
|
||||
|
||||
console.log("✗ No authentication found");
|
||||
|
||||
// Log headers for debugging
|
||||
const authHeader = req.headers.get("authorization");
|
||||
console.log("Authorization header:", authHeader ? "Present" : "Missing");
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Authentication error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified version that just uses Clerk's built-in auth
|
||||
* This should work with both session cookies and Bearer tokens
|
||||
*/
|
||||
export async function requireAuth(req: NextRequest): Promise<string> {
|
||||
const userId = await getAuthUserId(req);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return userId;
|
||||
}
|
||||
@ -1,30 +1,43 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
|
||||
// Define routes that should be publicly accessible
|
||||
// Define routes that should be publicly accessible (no auth required)
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
"/sign-in(.*)",
|
||||
"/sign-up(.*)",
|
||||
"/api/webhooks(.*)",
|
||||
"/api/attendance(.*)",
|
||||
"/api/health(.*)",
|
||||
]);
|
||||
|
||||
// Define routes that require authentication
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
"/",
|
||||
"/users(.*)",
|
||||
"/analytics(.*)",
|
||||
"/profile(.*)",
|
||||
"/api/users(.*)",
|
||||
"/api/profile(.*)",
|
||||
"/api/payments(.*)",
|
||||
"/api/notifications(.*)",
|
||||
]);
|
||||
// Define API routes that need auth but should be handled in the route itself
|
||||
// This prevents auth.protect() from blocking before the route handler runs
|
||||
const isApiRoute = createRouteMatcher(["/api/(.*)"]);
|
||||
|
||||
export default clerkMiddleware(async (auth, req) => {
|
||||
// Protect all routes except public ones
|
||||
if (!isPublicRoute(req)) {
|
||||
await auth.protect();
|
||||
// Log for debugging
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (authHeader) {
|
||||
console.log(
|
||||
"[Middleware] Authorization header present:",
|
||||
authHeader.substring(0, 20) + "...",
|
||||
);
|
||||
}
|
||||
|
||||
// Don't protect public routes
|
||||
if (isPublicRoute(req)) {
|
||||
console.log("[Middleware] Public route, skipping auth");
|
||||
return;
|
||||
}
|
||||
|
||||
// For API routes, let the route handler check auth
|
||||
// This allows API routes to handle both web sessions and mobile Bearer tokens
|
||||
if (isApiRoute(req)) {
|
||||
console.log("[Middleware] API route, auth will be checked in handler");
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other routes (web pages), enforce authentication
|
||||
console.log("[Middleware] Protected route, requiring auth");
|
||||
await auth.protect();
|
||||
});
|
||||
|
||||
export const config = {
|
||||
|
||||
@ -1,290 +1,392 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, RefreshControl, TouchableOpacity, Animated, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { theme } from '../../styles/theme';
|
||||
import { GoalProgressCard } from '../../components/GoalProgressCard';
|
||||
import { GoalCreationModal } from '../../components/GoalCreationModal';
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { theme } from "../../styles/theme";
|
||||
import { GoalProgressCard } from "../../components/GoalProgressCard";
|
||||
import { GoalCreationModal } from "../../components/GoalCreationModal";
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from '../../services/fitnessGoals';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import {
|
||||
fitnessGoalsService,
|
||||
type FitnessGoal,
|
||||
type CreateGoalData,
|
||||
} from "../../services/fitnessGoals";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
export default function GoalsScreen() {
|
||||
const { user } = useUser();
|
||||
const { getToken } = useAuth();
|
||||
const [goals, setGoals] = useState<FitnessGoal[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const fabScale = useRef(new Animated.Value(1)).current;
|
||||
const { user } = useUser();
|
||||
const { getToken } = useAuth();
|
||||
const [goals, setGoals] = useState<FitnessGoal[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const fabScale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const loadGoals = useCallback(async () => {
|
||||
if (!user?.id) return;
|
||||
const loadGoals = useCallback(async () => {
|
||||
if (!user?.id) return;
|
||||
try {
|
||||
const token = await getToken();
|
||||
console.log(
|
||||
"Token obtained:",
|
||||
token ? "Yes (" + token.substring(0, 20) + "...)" : "No",
|
||||
);
|
||||
console.log("User ID:", user.id);
|
||||
|
||||
// Decode and log token details for debugging
|
||||
if (token) {
|
||||
try {
|
||||
const token = await getToken();
|
||||
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
|
||||
setGoals(loadedGoals);
|
||||
} catch (error) {
|
||||
console.error('Error loading goals:', error);
|
||||
const parts = token.split(".");
|
||||
if (parts.length === 3) {
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
console.log("Token issuer:", payload.iss);
|
||||
console.log(
|
||||
"Token kid from header:",
|
||||
JSON.parse(atob(parts[0])).kid,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Could not decode token");
|
||||
}
|
||||
}, [user?.id]); // Removed getToken from dependencies
|
||||
}
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadGoals();
|
||||
}, [loadGoals])
|
||||
);
|
||||
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
|
||||
setGoals(loadedGoals);
|
||||
} catch (error) {
|
||||
console.error("Error loading goals:", error);
|
||||
}
|
||||
}, [user?.id, getToken]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadGoals();
|
||||
setRefreshing(false);
|
||||
};
|
||||
const clearClerkCache = async () => {
|
||||
Alert.alert(
|
||||
"Clear Clerk Cache",
|
||||
"This will clear all cached Clerk tokens. You will need to sign out and sign back in.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Clear Cache",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
// Clear all possible Clerk token keys
|
||||
const keysToDelete = [
|
||||
"__clerk_client_jwt",
|
||||
"__clerk_db_jwt",
|
||||
"__clerk_client_uat",
|
||||
"__clerk_session_id",
|
||||
"__clerk_refresh_token",
|
||||
"__clerk_session_jwt",
|
||||
];
|
||||
|
||||
const handleCreateGoal = async (newGoal: CreateGoalData) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.createGoal(newGoal, token);
|
||||
await loadGoals();
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCompleteGoal = async (goal: FitnessGoal) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.completeGoal(goal.id, token);
|
||||
await loadGoals();
|
||||
};
|
||||
|
||||
const handleDeleteGoal = async (goalId: string) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.deleteGoal(goalId, token);
|
||||
await loadGoals();
|
||||
};
|
||||
|
||||
const activeGoals = goals.filter(g => g.status === 'active');
|
||||
const completedGoals = goals.filter(g => g.status === 'completed');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||
for (const key of keysToDelete) {
|
||||
try {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
} catch (e) {
|
||||
// Key might not exist
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.header}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>Fitness Goals</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Track your fitness journey progress
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
}
|
||||
|
||||
{/* Stats Summary */}
|
||||
{goals.length > 0 && (
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{activeGoals.length}</Text>
|
||||
<Text style={styles.statLabel}>Active</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{completedGoals.length}</Text>
|
||||
<Text style={styles.statLabel}>Completed</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>
|
||||
{activeGoals.length > 0
|
||||
? Math.round(
|
||||
activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
||||
activeGoals.length
|
||||
)
|
||||
: 0}%
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Avg Progress</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Active Goals */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Active Goals ({activeGoals.length})
|
||||
</Text>
|
||||
{activeGoals.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="flag-outline" size={48} color="#d1d5db" />
|
||||
<Text style={styles.emptyText}>No active goals yet</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Tap the + button to create your first goal
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
activeGoals.map((goal) => (
|
||||
<GoalProgressCard
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
onComplete={() => handleCompleteGoal(goal)}
|
||||
onDelete={() => handleDeleteGoal(goal.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Completed Goals */}
|
||||
{completedGoals.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Completed Goals ({completedGoals.length})
|
||||
</Text>
|
||||
{completedGoals.map((goal) => (
|
||||
<GoalProgressCard
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
onDelete={() => handleDeleteGoal(goal.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Animated.View style={[styles.fabContainer, { transform: [{ scale: fabScale }] }]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
onPressIn={() => {
|
||||
Animated.spring(fabScale, {
|
||||
toValue: 0.9,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}}
|
||||
onPressOut={() => {
|
||||
Animated.spring(fabScale, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.fab}
|
||||
>
|
||||
<Ionicons name="add" size={28} color="#fff" />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Create Goal Modal */}
|
||||
<GoalCreationModal
|
||||
visible={isModalVisible}
|
||||
onClose={() => setIsModalVisible(false)}
|
||||
onSubmit={handleCreateGoal}
|
||||
/>
|
||||
</View>
|
||||
Alert.alert(
|
||||
"Success",
|
||||
"Cache cleared! Please sign out and sign back in.",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error clearing cache:", error);
|
||||
Alert.alert("Error", "Failed to clear cache");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadGoals();
|
||||
}, [loadGoals]),
|
||||
);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadGoals();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleCreateGoal = async (newGoal: CreateGoalData) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.createGoal(newGoal, token);
|
||||
await loadGoals();
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCompleteGoal = async (goal: FitnessGoal) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.completeGoal(goal.id, token);
|
||||
await loadGoals();
|
||||
};
|
||||
|
||||
const handleDeleteGoal = async (goalId: string) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.deleteGoal(goalId, token);
|
||||
await loadGoals();
|
||||
};
|
||||
|
||||
const activeGoals = goals.filter((g) => g.status === "active");
|
||||
const completedGoals = goals.filter((g) => g.status === "completed");
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LinearGradient colors={theme.gradients.primary} style={styles.header}>
|
||||
<View style={styles.headerContent}>
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>Fitness Goals</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Track your fitness journey progress
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={clearClerkCache}
|
||||
style={styles.debugButton}
|
||||
>
|
||||
<Ionicons name="refresh-circle-outline" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{goals.length > 0 && (
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{activeGoals.length}</Text>
|
||||
<Text style={styles.statLabel}>Active</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{completedGoals.length}</Text>
|
||||
<Text style={styles.statLabel}>Completed</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>
|
||||
{activeGoals.length > 0
|
||||
? Math.round(
|
||||
activeGoals.reduce(
|
||||
(sum, g) => sum + (g.progress || 0),
|
||||
0,
|
||||
) / activeGoals.length,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Avg Progress</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Active Goals */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Active Goals ({activeGoals.length})
|
||||
</Text>
|
||||
{activeGoals.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="flag-outline" size={48} color="#d1d5db" />
|
||||
<Text style={styles.emptyText}>No active goals yet</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Tap the + button to create your first goal
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
activeGoals.map((goal) => (
|
||||
<GoalProgressCard
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
onComplete={() => handleCompleteGoal(goal)}
|
||||
onDelete={() => handleDeleteGoal(goal.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Completed Goals */}
|
||||
{completedGoals.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Completed Goals ({completedGoals.length})
|
||||
</Text>
|
||||
{completedGoals.map((goal) => (
|
||||
<GoalProgressCard
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
onDelete={() => handleDeleteGoal(goal.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Animated.View
|
||||
style={[styles.fabContainer, { transform: [{ scale: fabScale }] }]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
onPressIn={() => {
|
||||
Animated.spring(fabScale, {
|
||||
toValue: 0.9,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}}
|
||||
onPressOut={() => {
|
||||
Animated.spring(fabScale, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.fab}
|
||||
>
|
||||
<Ionicons name="add" size={28} color="#fff" />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Create Goal Modal */}
|
||||
<GoalCreationModal
|
||||
visible={isModalVisible}
|
||||
onClose={() => setIsModalVisible(false)}
|
||||
onSubmit={handleCreateGoal}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
marginBottom: 10,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.typography.fontSize['3xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: "rgba(255, 255, 255, 0.9)",
|
||||
marginTop: 4,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: "row",
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: 16,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
alignItems: 'center',
|
||||
...theme.shadows.medium,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
statValue: {
|
||||
fontSize: theme.typography.fontSize['2xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.primary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
fontWeight: "500",
|
||||
},
|
||||
section: {
|
||||
padding: 20,
|
||||
paddingTop: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 40,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "#6b7280",
|
||||
marginTop: 12,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: "#9ca3af",
|
||||
marginTop: 4,
|
||||
},
|
||||
footer: {
|
||||
height: 100,
|
||||
},
|
||||
fabContainer: {
|
||||
position: "absolute",
|
||||
right: 20,
|
||||
bottom: 110, // Adjusted for tab bar height
|
||||
},
|
||||
fab: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
...theme.shadows.glow,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
marginBottom: 10,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
debugButton: {
|
||||
padding: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.typography.fontSize["3xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: "rgba(255, 255, 255, 0.9)",
|
||||
marginTop: 4,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: "row",
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: 16,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
alignItems: "center",
|
||||
...theme.shadows.medium,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
statValue: {
|
||||
fontSize: theme.typography.fontSize["2xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.primary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
fontWeight: "500",
|
||||
},
|
||||
section: {
|
||||
padding: 20,
|
||||
paddingTop: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 40,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "#6b7280",
|
||||
marginTop: 12,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: "#9ca3af",
|
||||
marginTop: 4,
|
||||
},
|
||||
footer: {
|
||||
height: 100,
|
||||
},
|
||||
fabContainer: {
|
||||
position: "absolute",
|
||||
right: 20,
|
||||
bottom: 110, // Adjusted for tab bar height
|
||||
},
|
||||
fab: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
...theme.shadows.glow,
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,12 +2,26 @@ import { ClerkProvider, ClerkLoaded } from "@clerk/clerk-expo";
|
||||
import { Stack } from "expo-router";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { View, Text } from "react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
console.log("========================================");
|
||||
console.log("🚀 _layout.tsx loaded at:", new Date().toISOString());
|
||||
console.log(
|
||||
"📦 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY:",
|
||||
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
);
|
||||
console.log("========================================");
|
||||
|
||||
// Token cache for Clerk
|
||||
const tokenCache = {
|
||||
async getToken(key: string) {
|
||||
try {
|
||||
return SecureStore.getItemAsync(key);
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
console.log(`[TokenCache] Getting key: ${key}, exists: ${!!value}`);
|
||||
if (value && value.length > 50) {
|
||||
console.log(`[TokenCache] Value preview: ${value.substring(0, 50)}...`);
|
||||
}
|
||||
return value;
|
||||
} catch (err) {
|
||||
console.error("Error getting token:", err);
|
||||
return null;
|
||||
@ -15,6 +29,10 @@ const tokenCache = {
|
||||
},
|
||||
async saveToken(key: string, value: string) {
|
||||
try {
|
||||
console.log(`[TokenCache] Saving key: ${key}`);
|
||||
if (value && value.length > 50) {
|
||||
console.log(`[TokenCache] Value preview: ${value.substring(0, 50)}...`);
|
||||
}
|
||||
return SecureStore.setItemAsync(key, value);
|
||||
} catch (err) {
|
||||
console.error("Error saving token:", err);
|
||||
@ -24,8 +42,71 @@ const tokenCache = {
|
||||
|
||||
export default function RootLayout() {
|
||||
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||
const [cacheCleared, setCacheCleared] = useState(false);
|
||||
|
||||
console.log(
|
||||
"🔑 [RootLayout] Component rendering with publishableKey:",
|
||||
publishableKey,
|
||||
);
|
||||
|
||||
// TEMPORARY: Clear Clerk cache on app start to fix instance mismatch
|
||||
useEffect(() => {
|
||||
console.log("⚡ [RootLayout] useEffect triggered - starting cache cleanup");
|
||||
|
||||
const clearOldClerkCache = async () => {
|
||||
try {
|
||||
console.log("🔍 [RootLayout] Checking for old Clerk tokens...");
|
||||
|
||||
const keysToCheck = [
|
||||
"__clerk_client_jwt",
|
||||
"__clerk_db_jwt",
|
||||
"__clerk_client_uat",
|
||||
"__clerk_session_id",
|
||||
"__clerk_refresh_token",
|
||||
"__clerk_session_jwt",
|
||||
];
|
||||
|
||||
for (const key of keysToCheck) {
|
||||
try {
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
if (value) {
|
||||
console.log(`📌 [RootLayout] Found token at ${key}`);
|
||||
console.log(
|
||||
`📝 [RootLayout] Token preview: ${value.substring(0, 80)}...`,
|
||||
);
|
||||
|
||||
// Check if it's from the old instance
|
||||
if (value.includes("pleasing-pheasant-20")) {
|
||||
console.log(`🗑️ [RootLayout] DELETING old token from ${key}`);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
} else if (value.includes("needed-elephant-64")) {
|
||||
console.log(
|
||||
`✅ [RootLayout] Token is from correct instance (needed-elephant-64)`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ [RootLayout] Token doesn't match known instances`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`❌ [RootLayout] Error checking key ${key}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ [RootLayout] Old token cleanup complete");
|
||||
setCacheCleared(true);
|
||||
} catch (error) {
|
||||
console.error("❌ [RootLayout] Error clearing old cache:", error);
|
||||
setCacheCleared(true); // Continue anyway
|
||||
}
|
||||
};
|
||||
|
||||
clearOldClerkCache();
|
||||
}, []);
|
||||
|
||||
if (!publishableKey) {
|
||||
console.log("❌ [RootLayout] No publishable key found!");
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Missing Clerk Publishable Key</Text>
|
||||
@ -38,6 +119,23 @@ export default function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!cacheCleared) {
|
||||
console.log("⏳ [RootLayout] Waiting for cache to clear...");
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Clearing old authentication cache...</Text>
|
||||
<Text style={{ marginTop: 8, fontSize: 12, color: "#666" }}>
|
||||
Check the terminal for logs...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"🚀 [RootLayout] Rendering ClerkProvider with key:",
|
||||
publishableKey?.substring(0, 20) + "...",
|
||||
);
|
||||
|
||||
return (
|
||||
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
||||
<ClerkLoaded>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user