diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84f7261 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Database URL for Prisma +DATABASE_URL="file:./dev.db" +SESSION_PASSWORD=some-long-random-secret-at-least-32-chars + +# Secret key to authorize the cleanup task endpoint +# This should be a strong, unique secret in production +CLEANUP_SECRET="changeme" diff --git a/README.md b/README.md index 81a895f..1915852 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,42 @@ Expected response: --- -## 8. Deployment Notes +## 8. Scheduled Cleanup Task + +To manage anonymous user data, a cleanup task can be triggered via a protected API endpoint. This task deletes anonymous users who were created more than 24 hours ago and have not registered. + +### Environment Variable + +The cleanup endpoint is protected by a secret key. Ensure the following environment variable is set in your `.env` file (and in production environments): + +```env +CLEANUP_SECRET="your_strong_secret_key_here" +``` + +**Replace `"your_strong_secret_key_here"` with a strong, unique secret.** + +### Manual Trigger (for Development/Testing) + +You can manually trigger the cleanup task using `curl` (or Postman, Insomnia, etc.): + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "x-cleanup-secret: your_strong_secret_key_here" \ + http://localhost:3000/api/admin/cleanup +``` + +**Important:** +- Replace `http://localhost:3000` with your application's actual URL if not running locally. +- Replace `your_strong_secret_key_here` with the value set in your `CLEANUP_SECRET` environment variable. + +### Production Setup + +In a production environment, this endpoint should be called by an **external scheduler** (e.g., a cron job service provided by your hosting platform, GitHub Actions, etc.) on a regular basis (e.g., daily). This ensures reliable, automatic cleanup without impacting user experience. + +--- + +## 9. Deployment Notes - Use Node 20 on hosting - Run Prisma migrations during deployment @@ -186,7 +221,7 @@ Expected response: --- -## 9. AI / Gemini Rules (IMPORTANT) +## 10. AI / Gemini Rules (IMPORTANT) When using Gemini / AI tools: @@ -203,7 +238,7 @@ When using Gemini / AI tools: --- -## 10. Why these constraints exist +## 11. Why these constraints exist This setup was intentionally chosen to: - avoid unstable Prisma 7 API diff --git a/app/app.vue b/app/app.vue index 7b58bca..ab8b31b 100644 --- a/app/app.vue +++ b/app/app.vue @@ -10,12 +10,12 @@ diff --git a/app/components/HabitCard.vue b/app/components/HabitCard.vue new file mode 100644 index 0000000..30ab9cc --- /dev/null +++ b/app/components/HabitCard.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/app/components/OnboardingFunnel.vue b/app/components/OnboardingFunnel.vue new file mode 100644 index 0000000..4d54618 --- /dev/null +++ b/app/components/OnboardingFunnel.vue @@ -0,0 +1,319 @@ + + + + + \ No newline at end of file diff --git a/app/components/VillageGrid.vue b/app/components/VillageGrid.vue new file mode 100644 index 0000000..2ec865c --- /dev/null +++ b/app/components/VillageGrid.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index 9052fcd..1befc69 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -1,9 +1,9 @@ // /composables/useAuth.ts interface User { - id: string; - email: string; - nickname: string; + id: number; + email: string | null; // Can be null for anonymous users + nickname: string | null; avatar: string | null; coins: number; exp: number; @@ -12,56 +12,86 @@ interface User { confettiOn: boolean; createdAt: string; updatedAt: string; + isAnonymous?: boolean; // Flag to distinguish anonymous users + anonymousSessionId?: string; } export function useAuth() { - // All Nuxt composables that require instance access MUST be called inside the setup function. - // useState is how we create shared, SSR-safe state in Nuxt. const user = useState('user', () => null); const initialized = useState('auth_initialized', () => false); - const loading = ref(false); // This is a local, non-shared loading ref for fetchMe's internal use - + const api = useApi(); - const isAuthenticated = computed(() => !!user.value); - const fetchMe = async () => { - // This function can be called multiple times, but the logic inside - // will only run once thanks to the initialized flag. + // A user is fully authenticated only if they exist and are NOT anonymous. + const isAuthenticated = computed(() => !!user.value && !user.value.isAnonymous); + // A user is anonymous if they exist and have the isAnonymous flag. + const isAnonymous = computed(() => !!user.value && !!user.value.isAnonymous); + + /** + * Initializes the authentication state for EXISTING users. + * It should be called once in app.vue. + * It will only try to fetch a logged-in user via /api/auth/me. + * If it fails, the user state remains null. + */ + const initAuth = async () => { if (initialized.value) return; - - loading.value = true; + try { - // The backend returns the user object nested under a 'user' key. - const response = await api<{ user: User }>('/auth/me', { method: 'GET' }); - user.value = response.user; // Correctly assign the nested user object + const response = await api<{ user: User }>('/auth/me'); + if (response.user) { + user.value = { ...response.user, isAnonymous: false }; + } else { + user.value = null; + } } catch (error) { - user.value = null; // Silently set user to null on 401 + // It's expected this will fail for non-logged-in users. + user.value = null; } finally { - loading.value = false; - initialized.value = true; // Mark as initialized after the first attempt + initialized.value = true; } }; + /** + * Starts the onboarding process by creating a new anonymous user. + */ + const startOnboarding = async () => { + try { + const anonymousUserData = await api('/onboarding/initiate', { method: 'POST' }); + // Explicitly set isAnonymous to true for robustness + user.value = { ...anonymousUserData, isAnonymous: true }; + } catch (anonError) { + console.error('Could not initiate anonymous session:', anonError); + // Optionally, show an error message to the user + user.value = null; + } + }; + + const register = async (email, password, nickname) => { + await api('/auth/register', { + method: 'POST', + body: { email, password, nickname }, + }); + // After a successful registration, force a re-fetch of the new user state. + initialized.value = false; + await initAuth(); + }; + const login = async (email, password) => { - // The calling component is responsible for its own loading state. - // This function just performs the action. await api('/auth/login', { method: 'POST', body: { email, password }, }); - // After a successful login, allow a re-fetch of the user state. + // After a successful login, force a re-fetch of the new user state. initialized.value = false; - await fetchMe(); + await initAuth(); }; const logout = async () => { try { await api('/auth/logout', { method: 'POST' }); } finally { - // Always clear state and redirect, regardless of API call success. user.value = null; - initialized.value = false; - await navigateTo('/login'); + await navigateTo('/'); } }; @@ -71,12 +101,14 @@ export function useAuth() { } }; - // Expose the state and methods. return { user, isAuthenticated, + isAnonymous, // Expose this new state initialized, - fetchMe, + initAuth, // Called from app.vue + startOnboarding, // Called from index.vue + register, // Expose register function login, logout, updateUser, diff --git a/app/composables/useVillageHelpers.ts b/app/composables/useVillageHelpers.ts index 18da496..c112581 100644 --- a/app/composables/useVillageHelpers.ts +++ b/app/composables/useVillageHelpers.ts @@ -1,33 +1,56 @@ export const useVillageHelpers = () => { /** - * Converts numeric coordinates to a chess-like format (e.g., 0,0 -> A7). - * The grid is 5 columns (A-E) and 7 rows (1-7). - * Rows are numbered from bottom to top, so y=6 is row '1'. - * @param x The column index (0-4). - * @param y The row index (0-6). + * Converts data-coordinates (x, y) into a chess-like UI format (e.g. A7). + * + * DATA CONTRACT: + * - x: 0..4 (left → right) + * - y: 0..6 (bottom → top) + * + * UI CONTRACT: + * - rows are shown top → bottom + * - row number = 7 - y */ const formatCoordinates = (x: number, y: number): string => { + if ( + typeof x !== 'number' || + typeof y !== 'number' || + x < 0 || + y < 0 + ) { + return ''; + } + const col = String.fromCharCode('A'.charCodeAt(0) + x); const row = 7 - y; + return `${col}${row}`; }; /** - * Finds and replaces all occurrences of numeric coordinates like (x, y) - * in a string with the chess-like format. - * @param message The message string. + * Formats backend event messages that already contain + * raw data-coordinates in the form "(x, y)". + * + * IMPORTANT: + * - This function is PRESENTATION-ONLY + * - It assumes (x, y) are DATA coordinates + * - It does NOT change semantics, only visual output */ const formatMessageCoordinates = (message: string): string => { if (!message) return ''; - // Regex to find coordinates like (1, 2) - return message.replace(/\((\d+), (\d+)\)/g, (match, xStr, yStr) => { - const x = parseInt(xStr, 10); - const y = parseInt(yStr, 10); - if (!isNaN(x) && !isNaN(y)) { + + return message.replace( + /\((\d+),\s*(\d+)\)/g, + (_match, xStr, yStr) => { + const x = Number(xStr); + const y = Number(yStr); + + if (Number.isNaN(x) || Number.isNaN(y)) { + return _match; + } + return formatCoordinates(x, y); } - return match; // Return original if parsing fails - }); + ); }; return { diff --git a/app/pages/habits.vue b/app/pages/habits.vue index 225007a..fa6a2c8 100644 --- a/app/pages/habits.vue +++ b/app/pages/habits.vue @@ -85,7 +85,6 @@ \ No newline at end of file diff --git a/app/pages/leaderboard.vue b/app/pages/leaderboard.vue index 3eb582b..d5d86a6 100644 --- a/app/pages/leaderboard.vue +++ b/app/pages/leaderboard.vue @@ -31,8 +31,6 @@