diff --git a/AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md b/AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md deleted file mode 100644 index f1017b4..0000000 --- a/AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md +++ /dev/null @@ -1,91 +0,0 @@ -# Auth and Settings Migration Notes (Feb 21, 2026) - -This document captures the auth/settings stabilization work completed during the SQLite -> Supabase migration cleanup. - -## Why this was needed - -The app had overlapping auth models: - -- Supabase Auth tables (`auth.users`) -- App tables (`public.users`, `public.sessions`, `public.password_reset_tokens`) - -The app was still expecting `public.users.password_hash`, but that column was not present in the migrated schema, which caused: - -- login failures -- reset password failures (`Could not find the 'password_hash' column...`) -- settings profile/session issues - -## Environment key mapping that must be correct - -Use different values for these: - -- `NEXT_PUBLIC_SUPABASE_ANON_KEY` -> anon/publishable key -- `SUPABASE_SERVICE_ROLE_KEY` -> service_role/secret key - -If these are the same value, auth/session/profile flows break. - -## Database work completed (Phase 1) - -Phase 1 schema alignment was completed in Supabase: - -- created `public.profiles` with `id` referencing `auth.users(id)` -- enabled RLS policies for own profile access -- backfilled profile rows from existing data -- optional trigger created to auto-create profile rows for new auth users - -`public.users` was retained for compatibility during transition. - -## Code changes made - -### 1) `src/lib/server/auth.ts` - -- `registerUser` now creates credentials in Supabase Auth (`auth.users`) via admin API. -- `authenticateUser` now verifies credentials using `signInWithPassword` instead of `public.users.password_hash`. -- `updateUserAccount` now updates password/email in Supabase Auth admin API. -- Profile fields are mirrored to: - - `public.users` - - `public.profiles` -- Existing custom `gantt_session` flow remains in place for compatibility. - -### 2) `src/app/api/auth/reset-password/route.ts` - -- Removed dependency on `public.users.password_hash`. -- Password reset now updates Supabase Auth user password through admin API. -- Added fallback for legacy records: if auth user is missing, create it with the same UUID, then set password. - -### 3) `src/app/settings/page.tsx` - -- Hardened response parsing for session/profile/password requests to avoid client runtime errors on malformed responses. -- Fixed avatar preset rendering key collisions: - - deduped generated preset URLs - - switched to stable numeric keys (not data URL strings) - -## Current state (important) - -Auth is currently hybrid but stable: - -- credentials/passwords: Supabase Auth (`auth.users`) -- app profile fields: `public.profiles` (and mirrored `public.users`) -- app session guard cookie: custom `gantt_session` is still used by this codebase - -This is intentional as an intermediate compatibility stage. - -## Known behavior - -Forgot password route currently generates reset links for dev/testing flow and does not send production email by itself unless an email provider flow is added. - -## Validation checklist used - -- login works after env fix -- reset password no longer fails on missing `password_hash` -- settings screen loads name/email/avatar -- avatar save no longer returns auth/session-related errors -- duplicate React key warning in avatar presets resolved - -## Next cleanup (recommended) - -To fully finish migration and remove duplication: - -1. Move API auth/session checks to Supabase JWT/session directly. -2. Remove custom session table/cookie flow (`public.sessions`, `gantt_session`) after cutover. -3. Keep `public.profiles` as the only app profile table and retire compatibility mirrors. diff --git a/MIGRATION_SUMMARY.md b/MIGRATION_SUMMARY.md deleted file mode 100644 index 550ec65..0000000 --- a/MIGRATION_SUMMARY.md +++ /dev/null @@ -1,156 +0,0 @@ -# Supabase Migration Summary - -## ✅ What Was Created - -### 1. Documentation -- **`SUPABASE_SETUP.md`** - Complete setup guide with step-by-step instructions -- **`supabase/schema.sql`** - Full database schema with tables, indexes, RLS policies, and functions - -### 2. Migration Script -- **`scripts/migrate-to-supabase.ts`** - TypeScript script to migrate all data from SQLite to Supabase - -### 3. New Supabase Client Code -- **`src/lib/supabase/client.ts`** - Supabase client configuration -- **`src/lib/supabase/database.types.ts`** - TypeScript types for database tables - -### 4. Updated Server Modules -- **`src/lib/server/auth.ts`** - Completely rewritten to use Supabase instead of SQLite -- **`src/lib/server/taskDb.ts`** - Completely rewritten to use Supabase instead of SQLite - -### 5. Environment Template -- **`.env.local.example`** - Template for required environment variables - -## 📊 Your Current Data -- **2 users** → Will be migrated -- **19 tasks** → Will be migrated -- **3 projects** → Will be migrated -- **3 sprints** → Will be migrated - -## 🚀 Next Steps (In Order) - -### Step 1: Create Supabase Project -1. Go to https://supabase.com/dashboard -2. Click "New Project" -3. Fill in details: - - Name: `gantt-board` (or your choice) - - Database Password: Generate a strong password - - Region: Choose closest to you (e.g., `us-east-1`) -4. Wait for creation (~2 minutes) - -### Step 2: Get Credentials -1. Go to **Project Settings** → **API** -2. Copy: - - Project URL - - `anon` public key - - `service_role` secret key - -### Step 3: Set Up Environment Variables -```bash -cd /Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board -cp .env.local.example .env.local -``` - -Edit `.env.local` and fill in your actual Supabase credentials: -```bash -NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here -``` - -### Step 4: Run the Database Schema -1. Go to Supabase Dashboard → **SQL Editor** -2. Click "New Query" -3. Copy contents of `supabase/schema.sql` -4. Click "Run" - -### Step 5: Migrate Your Data -```bash -cd /Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board -npx tsx scripts/migrate-to-supabase.ts -``` - -You should see output like: -``` -🚀 Starting SQLite → Supabase migration -✅ Connected to Supabase -📦 Migrating users... - ✓ user@example.com - ✅ Migrated 2 users -📦 Migrating sessions... - ✅ Migrated X sessions -... -✅ Migration Complete! -``` - -### Step 6: Test Locally -```bash -npm run dev -``` - -Test all functionality: -- Login/logout -- Create/edit tasks -- Create/edit projects -- Create/edit sprints - -### Step 7: Deploy to Vercel -1. Push code to git (the Supabase code is already in place) -2. Add environment variables in Vercel dashboard: - - `NEXT_PUBLIC_SUPABASE_URL` - - `NEXT_PUBLIC_SUPABASE_ANON_KEY` - - `SUPABASE_SERVICE_ROLE_KEY` -3. Deploy! - -## 🔐 Security Notes - -1. **Never commit `.env.local`** - It's already in `.gitignore` -2. **Service Role Key** - Only used server-side, never expose to browser -3. **Row Level Security** - Enabled on all tables with appropriate policies -4. **Password Hashing** - Uses same scrypt algorithm as before - -## 📁 Files Modified/Created - -### New Files: -- `SUPABASE_SETUP.md` -- `supabase/schema.sql` -- `scripts/migrate-to-supabase.ts` -- `src/lib/supabase/client.ts` -- `src/lib/supabase/database.types.ts` -- `.env.local.example` - -### Modified Files: -- `package.json` - Added `@supabase/supabase-js` and `dotenv` -- `src/lib/server/auth.ts` - Rewritten for Supabase -- `src/lib/server/taskDb.ts` - Rewritten for Supabase - -## 🔄 Rollback Plan - -If something goes wrong: -1. Keep your `data/tasks.db` file - it's untouched -2. You can revert the code changes with git: - ```bash - git checkout src/lib/server/auth.ts src/lib/server/taskDb.ts - ``` -3. Remove Supabase env vars to fall back to SQLite - -## ❓ Troubleshooting - -### Migration fails with connection error -- Check that your Supabase URL and keys are correct -- Ensure your Supabase project is active (not paused) - -### Data doesn't appear after migration -- Check the migration script output for errors -- Verify tables were created by checking Supabase Table Editor - -### Auth issues after migration -- Users will need to log in again (sessions aren't migrated by default) -- Passwords are preserved - same login credentials work - -## 🎉 You're All Set! - -Once you complete the steps above, your Gantt Board will be running on Supabase with: -- ✅ Persistent data that survives server restarts -- ✅ Works on Vercel (no file system dependencies) -- ✅ Can scale to multiple servers -- ✅ Real-time capabilities (future enhancement possible) diff --git a/README.md b/README.md index 72ba6e4..afffde8 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,11 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi ### Feb 21, 2026 updates -- Stabilized auth and settings migration after SQLite -> Supabase transition. +- Stabilized auth and settings flows with Supabase Auth credentials. - Login/password validation now uses Supabase Auth credentials. - Reset password flow no longer depends on `public.users.password_hash`. - Settings API response handling was hardened to avoid client script errors. - Avatar preset key collision warning in settings was fixed. -- Detailed notes: `AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md` ### Feb 22, 2026 updates diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index c065e18..abd6082 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -74,37 +74,16 @@ export async function POST(request: Request) { const userEmail = Array.isArray(resetToken.users) ? resetToken.users[0]?.email : resetToken.users?.email; - const userName = Array.isArray(resetToken.users) - ? resetToken.users[0]?.name - : resetToken.users?.name; - if (userEmail?.toLowerCase() !== email) { return NextResponse.json({ error: "Invalid reset token" }, { status: 400 }); } - // Update Supabase Auth password. If auth user doesn't exist yet (legacy migration), - // create it with the same UUID so app foreign keys remain valid. + // Strict mode: reset requires an existing Supabase Auth user. const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, { password, }); - if (updateError) { - const updateMessage = updateError.message || ""; - if (updateMessage.toLowerCase().includes("not found")) { - const { error: createError } = await supabase.auth.admin.createUser({ - id: resetToken.user_id, - email, - password, - email_confirm: true, - user_metadata: { - name: typeof userName === "string" && userName.trim().length > 0 ? userName : undefined, - }, - }); - if (createError) throw createError; - } else { - throw updateError; - } - } + if (updateError) throw updateError; // Mark token as used await supabase diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 8d47e96..29053ca 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -74,21 +74,34 @@ export async function PATCH(request: Request) { } const body = await request.json(); - const { id, ...updates } = body; + const { id, ...rawUpdates } = body; if (!id) { return NextResponse.json({ error: "Missing project id" }, { status: 400 }); } + const updates: Record = {}; + if (typeof rawUpdates.name === "string" && rawUpdates.name.trim().length > 0) { + updates.name = rawUpdates.name.trim(); + } + if (Object.prototype.hasOwnProperty.call(rawUpdates, "description")) { + updates.description = + typeof rawUpdates.description === "string" && rawUpdates.description.trim().length > 0 + ? rawUpdates.description + : null; + } + if (typeof rawUpdates.color === "string" && rawUpdates.color.trim().length > 0) { + updates.color = rawUpdates.color; + } + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No valid project updates provided" }, { status: 400 }); + } + const supabase = getServiceSupabase(); - const now = new Date().toISOString(); const { data, error } = await supabase .from("projects") - .update({ - ...updates, - updated_at: now, - }) + .update(updates) .eq("id", id) .select() .single(); @@ -117,9 +130,15 @@ export async function DELETE(request: Request) { } const supabase = getServiceSupabase(); - const { error } = await supabase.from("projects").delete().eq("id", id); + const { error, count } = await supabase + .from("projects") + .delete({ count: "exact" }) + .eq("id", id); if (error) throw error; + if ((count ?? 0) === 0) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/sprints/route.ts b/src/app/api/sprints/route.ts index eaa9452..0f9aa42 100644 --- a/src/app/api/sprints/route.ts +++ b/src/app/api/sprints/route.ts @@ -7,6 +7,20 @@ export const runtime = "nodejs"; // Sprint dates are stored as SQL DATE values (YYYY-MM-DD). We accept either // date-only or ISO datetime inputs and normalize to the date prefix. const DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SPRINT_STATUSES = ["planning", "active", "completed"] as const; +type SprintStatus = (typeof SPRINT_STATUSES)[number]; + +class HttpError extends Error { + readonly status: number; + readonly details?: Record; + + constructor(status: number, message: string, details?: Record) { + super(message); + this.status = status; + this.details = details; + } +} function toDateOnlyInput(value: unknown): string | null { if (typeof value !== "string") return null; @@ -16,8 +30,38 @@ function toDateOnlyInput(value: unknown): string | null { return match?.[1] ?? null; } -function currentDateOnly(): string { - return new Date().toISOString().split("T")[0]; +function requireNonEmptyString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new HttpError(400, `${field} is required`, { field, value }); + } + return value.trim(); +} + +function requireUuid(value: unknown, field: string): string { + const normalized = requireNonEmptyString(value, field); + if (!UUID_PATTERN.test(normalized)) { + throw new HttpError(400, `${field} must be a UUID`, { field, value }); + } + return normalized; +} + +function requireSprintStatus(value: unknown, field: string): SprintStatus { + if (typeof value !== "string" || !SPRINT_STATUSES.includes(value as SprintStatus)) { + throw new HttpError(400, `${field} must be one of: ${SPRINT_STATUSES.join(", ")}`, { field, value }); + } + return value as SprintStatus; +} + +async function requireExistingProjectId( + supabase: ReturnType, + projectId: string +): Promise { + const { data, error } = await supabase.from("projects").select("id").eq("id", projectId).maybeSingle(); + if (error) throw error; + if (!data?.id) { + throw new HttpError(400, "projectId does not exist", { projectId }); + } + return data.id; } // GET - fetch all sprints (optionally filtered by status) @@ -31,6 +75,9 @@ export async function GET(request: Request) { // Parse query params const { searchParams } = new URL(request.url); const status = searchParams.get("status"); + if (status && !SPRINT_STATUSES.includes(status as SprintStatus)) { + throw new HttpError(400, `status must be one of: ${SPRINT_STATUSES.join(", ")}`, { status }); + } const supabase = getServiceSupabase(); let query = supabase @@ -39,7 +86,7 @@ export async function GET(request: Request) { .order("start_date", { ascending: true }); // Filter by status if provided - if (status && ["planning", "active", "completed"].includes(status)) { + if (status) { query = query.eq("status", status); } @@ -50,6 +97,9 @@ export async function GET(request: Request) { return NextResponse.json({ sprints: sprints || [] }); } catch (error) { console.error(">>> API GET /sprints error:", error); + if (error instanceof HttpError) { + return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); + } return NextResponse.json({ error: "Failed to fetch sprints" }, { status: 500 }); } } @@ -65,25 +115,32 @@ export async function POST(request: Request) { const body = await request.json(); const { name, goal, startDate, endDate, status, projectId } = body; - if (!name || typeof name !== "string") { - return NextResponse.json({ error: "Missing sprint name" }, { status: 400 }); - } - const supabase = getServiceSupabase(); + const resolvedName = requireNonEmptyString(name, "name"); + const normalizedStartDate = toDateOnlyInput(startDate); + if (!normalizedStartDate) { + throw new HttpError(400, "startDate must be YYYY-MM-DD or ISO date-time", { startDate }); + } + const normalizedEndDate = toDateOnlyInput(endDate); + if (!normalizedEndDate) { + throw new HttpError(400, "endDate must be YYYY-MM-DD or ISO date-time", { endDate }); + } + const resolvedStatus = requireSprintStatus(status, "status"); + const resolvedProjectId = await requireExistingProjectId( + supabase, + requireUuid(projectId, "projectId") + ); const now = new Date().toISOString(); - const fallbackDate = currentDateOnly(); - const normalizedStartDate = toDateOnlyInput(startDate) ?? fallbackDate; - const normalizedEndDate = toDateOnlyInput(endDate) ?? normalizedStartDate; const { data, error } = await supabase .from("sprints") .insert({ - name, + name: resolvedName, goal: goal || null, start_date: normalizedStartDate, end_date: normalizedEndDate, - status: status || "planning", - project_id: projectId || null, + status: resolvedStatus, + project_id: resolvedProjectId, created_at: now, }) .select() @@ -94,6 +151,9 @@ export async function POST(request: Request) { return NextResponse.json({ success: true, sprint: data }); } catch (error) { console.error(">>> API POST /sprints error:", error); + if (error instanceof HttpError) { + return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); + } return NextResponse.json({ error: "Failed to create sprint" }, { status: 500 }); } } @@ -109,39 +169,37 @@ export async function PATCH(request: Request) { const body = await request.json(); const { id, ...updates } = body; - if (!id) { - return NextResponse.json({ error: "Missing sprint id" }, { status: 400 }); - } - const supabase = getServiceSupabase(); - const now = new Date().toISOString(); + const sprintId = requireUuid(id, "id"); // Map camelCase to snake_case for database and keep date-only semantics. const dbUpdates: Record = {}; - if (updates.name !== undefined) dbUpdates.name = updates.name; + if (updates.name !== undefined) dbUpdates.name = requireNonEmptyString(updates.name, "name"); if (updates.goal !== undefined) dbUpdates.goal = updates.goal; if (updates.startDate !== undefined) { const normalizedStartDate = toDateOnlyInput(updates.startDate); if (!normalizedStartDate) { - return NextResponse.json({ error: "Invalid startDate format" }, { status: 400 }); + throw new HttpError(400, "startDate must be YYYY-MM-DD or ISO date-time", { startDate: updates.startDate }); } dbUpdates.start_date = normalizedStartDate; } if (updates.endDate !== undefined) { const normalizedEndDate = toDateOnlyInput(updates.endDate); if (!normalizedEndDate) { - return NextResponse.json({ error: "Invalid endDate format" }, { status: 400 }); + throw new HttpError(400, "endDate must be YYYY-MM-DD or ISO date-time", { endDate: updates.endDate }); } dbUpdates.end_date = normalizedEndDate; } - if (updates.status !== undefined) dbUpdates.status = updates.status; - if (updates.projectId !== undefined) dbUpdates.project_id = updates.projectId; - dbUpdates.updated_at = now; + if (updates.status !== undefined) dbUpdates.status = requireSprintStatus(updates.status, "status"); + if (updates.projectId !== undefined) { + const projectId = requireUuid(updates.projectId, "projectId"); + dbUpdates.project_id = await requireExistingProjectId(supabase, projectId); + } const { data, error } = await supabase .from("sprints") .update(dbUpdates) - .eq("id", id) + .eq("id", sprintId) .select() .single(); @@ -150,6 +208,9 @@ export async function PATCH(request: Request) { return NextResponse.json({ success: true, sprint: data }); } catch (error) { console.error(">>> API PATCH /sprints error:", error); + if (error instanceof HttpError) { + return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); + } return NextResponse.json({ error: "Failed to update sprint" }, { status: 500 }); } } @@ -163,19 +224,25 @@ export async function DELETE(request: Request) { } const { id } = await request.json(); - - if (!id) { - return NextResponse.json({ error: "Missing sprint id" }, { status: 400 }); - } + const sprintId = requireUuid(id, "id"); const supabase = getServiceSupabase(); - const { error } = await supabase.from("sprints").delete().eq("id", id); + const { error, count } = await supabase + .from("sprints") + .delete({ count: "exact" }) + .eq("id", sprintId); if (error) throw error; + if ((count ?? 0) === 0) { + throw new HttpError(404, "Sprint not found", { id: sprintId }); + } return NextResponse.json({ success: true }); } catch (error) { console.error(">>> API DELETE /sprints error:", error); + if (error instanceof HttpError) { + return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); + } return NextResponse.json({ error: "Failed to delete sprint" }, { status: 500 }); } } diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 5977359..be653f1 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import { randomUUID } from "crypto"; import { getServiceSupabase } from "@/lib/supabase/client"; import { getAuthenticatedUser } from "@/lib/server/auth"; @@ -77,41 +76,39 @@ const TASK_FIELDS_LIGHT = [ "created_at", "updated_at", "created_by_id", - "created_by_name", - "created_by_avatar_url", "updated_by_id", - "updated_by_name", - "updated_by_avatar_url", "assignee_id", - "assignee_name", - "assignee_email", - "assignee_avatar_url", "due_date", "tags", ]; -// Fields for full task detail (when opening a task) -const TASK_FIELDS_FULL = [ - ...TASK_FIELDS_LIGHT, - "description", - "comments", - "attachments", -]; +class HttpError extends Error { + readonly status: number; + readonly details?: Record; -function isTaskType(value: unknown): value is Task["type"] { - return typeof value === "string" && TASK_TYPES.includes(value as Task["type"]); + constructor(status: number, message: string, details?: Record) { + super(message); + this.status = status; + this.details = details; + } } -function isTaskStatus(value: unknown): value is Task["status"] { - return typeof value === "string" && TASK_STATUSES.includes(value as Task["status"]); +function toErrorDetails(error: unknown): Record | undefined { + if (!error || typeof error !== "object") return undefined; + const candidate = error as { code?: unknown; details?: unknown; hint?: unknown; message?: unknown }; + return { + code: candidate.code, + details: candidate.details, + hint: candidate.hint, + message: candidate.message, + }; } -function isTaskPriority(value: unknown): value is Task["priority"] { - return typeof value === "string" && TASK_PRIORITIES.includes(value as Task["priority"]); -} - -function isSprintStatus(value: unknown): value is Sprint["status"] { - return typeof value === "string" && SPRINT_STATUSES.includes(value as Sprint["status"]); +function throwQueryError(scope: string, error: unknown): never { + throw new HttpError(500, `${scope} query failed`, { + scope, + ...toErrorDetails(error), + }); } function toNonEmptyString(value: unknown): string | undefined { @@ -126,124 +123,92 @@ function stripUndefined>(value: T): Record entry !== undefined)); } -function getMissingColumnFromError(error: unknown): string | null { - if (!error || typeof error !== "object") return null; - const candidate = error as { code?: string; message?: string }; - if (typeof candidate.message !== "string") return null; - - if (candidate.code === "PGRST204") { - const postgrestMatch = candidate.message.match(/Could not find the '([^']+)' column/); - if (postgrestMatch?.[1]) return postgrestMatch[1]; +function requireNonEmptyString(value: unknown, field: string, status = 400): string { + const normalized = toNonEmptyString(value); + if (!normalized) { + throw new HttpError(status, `${field} is required`, { field, value }); } - - if (candidate.code === "42703") { - const postgresMatch = candidate.message.match(/column\s+(?:\w+\.)?\"?([a-zA-Z0-9_]+)\"?\s+does not exist/i); - if (postgresMatch?.[1]) return postgresMatch[1]; - } - - return null; + return normalized; } -function getForeignKeyColumnFromError(error: unknown): string | null { - if (!error || typeof error !== "object") return null; - const candidate = error as { code?: string; details?: string }; - if (candidate.code !== "23503" || typeof candidate.details !== "string") return null; - const match = candidate.details.match(/\(([^)]+)\)=/); - return match?.[1] ?? null; +function requireUuid(value: unknown, field: string, status = 400): string { + const normalized = requireNonEmptyString(value, field, status); + if (!isUuid(normalized)) { + throw new HttpError(status, `${field} must be a UUID`, { field, value }); + } + return normalized; } -async function fetchTasksWithColumnFallback( - supabase: ReturnType -): Promise<{ rows: Record[]; droppedColumns: string[] }> { - let selectedFields = [...TASK_FIELDS_LIGHT]; - const droppedColumns: string[] = []; - - while (selectedFields.length > 0) { - const { data, error } = await supabase - .from("tasks") - .select(selectedFields.join(", ")) - .order("updated_at", { ascending: false }); - - if (!error) { - return { - rows: (data as unknown as Record[] | null) ?? [], - droppedColumns, - }; - } - - const missingColumn = getMissingColumnFromError(error); - if (missingColumn && selectedFields.includes(missingColumn)) { - droppedColumns.push(missingColumn); - selectedFields = selectedFields.filter((field) => field !== missingColumn); - continue; - } - - throw error; +function requireEnum(value: unknown, allowed: readonly T[], field: string, status = 400): T { + if (typeof value !== "string" || !allowed.includes(value as T)) { + throw new HttpError(status, `Invalid ${field} value`, { field, value }); } - - return { rows: [], droppedColumns }; + return value as T; } async function resolveRequiredProjectId( supabase: ReturnType, requestedProjectId?: string ): Promise { - const requested = toNonEmptyString(requestedProjectId); - if (requested) { - const { data } = await supabase.from("projects").select("id").eq("id", requested).maybeSingle(); - if (data?.id) return data.id as string; + const projectId = requireUuid(requestedProjectId, "task.projectId"); + const { data, error } = await supabase.from("projects").select("id").eq("id", projectId).maybeSingle(); + if (error) throw error; + if (!data?.id) { + throw new HttpError(400, "task.projectId does not exist", { projectId }); } - - const { data: firstProject } = await supabase - .from("projects") - .select("id") - .order("created_at", { ascending: true }) - .limit(1) - .maybeSingle(); - - if (firstProject?.id) return firstProject.id as string; - throw new Error("No projects available to assign task"); + return data.id as string; } async function resolveOptionalForeignId( supabase: ReturnType, table: "sprints" | "users", - id?: string + id: string | undefined, + field: string ): Promise { const requested = toNonEmptyString(id); if (!requested) return null; - const { data } = await supabase.from(table).select("id").eq("id", requested).maybeSingle(); - return data?.id ? (data.id as string) : null; + if (!isUuid(requested)) { + throw new HttpError(400, `${field} must be a UUID when provided`, { field, value: id }); + } + + const { data, error } = await supabase + .from(table) + .select("id") + .eq("id", requested) + .maybeSingle(); + if (error) throw error; + if (!data?.id) { + throw new HttpError(400, `${field} does not exist`, { field, value: requested }); + } + return data.id as string; } function mapProjectRow(row: Record): Project { return { - id: String(row.id ?? ""), - name: toNonEmptyString(row.name) ?? "Untitled Project", + id: requireNonEmptyString(row.id, "projects.id", 500), + name: requireNonEmptyString(row.name, "projects.name", 500), description: toNonEmptyString(row.description), - color: toNonEmptyString(row.color) ?? "#3b82f6", - createdAt: toNonEmptyString(row.created_at) ?? new Date().toISOString(), + color: requireNonEmptyString(row.color, "projects.color", 500), + createdAt: requireNonEmptyString(row.created_at, "projects.created_at", 500), }; } function mapSprintRow(row: Record): Sprint { - const fallbackDate = new Date().toISOString(); return { - id: String(row.id ?? ""), - name: toNonEmptyString(row.name) ?? "Untitled Sprint", + id: requireNonEmptyString(row.id, "sprints.id", 500), + name: requireNonEmptyString(row.name, "sprints.name", 500), goal: toNonEmptyString(row.goal), - startDate: toNonEmptyString(row.start_date) ?? fallbackDate, - endDate: toNonEmptyString(row.end_date) ?? fallbackDate, - status: isSprintStatus(row.status) ? row.status : "planning", - projectId: String(row.project_id ?? ""), - createdAt: toNonEmptyString(row.created_at) ?? fallbackDate, + startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500), + endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500), + status: requireEnum(row.status, SPRINT_STATUSES, "sprints.status", 500), + projectId: requireNonEmptyString(row.project_id, "sprints.project_id", 500), + createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500), }; } -function mapUserRow(row: Record): UserProfile | null { - const id = toNonEmptyString(row.id); - const name = toNonEmptyString(row.name); - if (!id || !name) return null; +function mapUserRow(row: Record): UserProfile { + const id = requireNonEmptyString(row.id, "users.id", 500); + const name = requireNonEmptyString(row.name, "users.name", 500); return { id, name, @@ -253,7 +218,6 @@ function mapUserRow(row: Record): UserProfile | null { } function mapTaskRow(row: Record, usersById: Map, includeFullData = false): Task { - const fallbackDate = new Date().toISOString(); const createdById = toNonEmptyString(row.created_by_id); const updatedById = toNonEmptyString(row.updated_by_id); const assigneeId = toNonEmptyString(row.assignee_id); @@ -261,31 +225,43 @@ function mapTaskRow(row: Record, usersById: Map typeof tag === "string") : [], - attachments: includeFullData && Array.isArray(row.attachments) ? row.attachments : [], + comments: includeFullData ? (row.comments as unknown[] | undefined) ?? [] : [], + tags: (row.tags as unknown[]).filter((tag): tag is string => typeof tag === "string"), + attachments: includeFullData ? (row.attachments as unknown[] | undefined) ?? [] : [], }; return task; @@ -295,11 +271,10 @@ function mapTaskRow(row: Record, usersById: Map>> API GET /tasks users query failed, continuing without user lookup:", usersError); - } - - const { rows: taskRows, droppedColumns } = await fetchTasksWithColumnFallback(supabase); - if (droppedColumns.length > 0) { - console.warn(">>> API GET /tasks fallback: omitted missing task columns:", droppedColumns.join(", ")); - } + if (projectsError) throwQueryError("projects", projectsError); + if (sprintsError) throwQueryError("sprints", sprintsError); + if (tasksError) throwQueryError("tasks", tasksError); + if (usersError) throwQueryError("users", usersError); const usersById = new Map(); for (const row of users || []) { const mapped = mapUserRow(row as Record); - if (mapped) usersById.set(mapped.id, mapped); + usersById.set(mapped.id, mapped); } return NextResponse.json({ projects: (projects || []).map((row) => mapProjectRow(row as Record)), sprints: (sprints || []).map((row) => mapSprintRow(row as Record)), - tasks: taskRows.map((row) => mapTaskRow(row, usersById, false)), - lastUpdated: Number(meta?.value ?? Date.now()), + tasks: ((taskRows as unknown as Record[] | null) || []).map((row) => mapTaskRow(row, usersById, false)), + currentUser: { + id: user.id, + name: user.name, + email: user.email, + avatarUrl: user.avatarUrl, + }, + lastUpdated: Date.now(), }, { headers: { - // Enable caching for 30 seconds to reduce repeated requests - 'Cache-Control': 'private, max-age=30, stale-while-revalidate=60', + 'Cache-Control': 'no-store', } }); } catch (error) { console.error(">>> API GET error:", error); - return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 }); + if (error instanceof HttpError) { + return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); + } + const message = error instanceof Error ? error.message : "Failed to fetch data"; + return NextResponse.json({ error: message, details: toErrorDetails(error) }, { status: 500 }); } } // POST - create or update a single task export async function POST(request: Request) { try { - // TODO: Re-enable auth after fixing cookie issue on Vercel - // const user = await getAuthenticatedUser(); - // if (!user) { - // console.error(">>> API POST: No authenticated user"); - // return NextResponse.json({ error: "Unauthorized - please log in again" }, { status: 401 }); - // } - // console.log(">>> API POST: Authenticated as", user.email); - const user = { id: 'temp-user', email: 'temp@example.com', name: 'Temp User', createdAt: new Date().toISOString() }; + const user = await getAuthenticatedUser(); + if (!user) { + console.error(">>> API POST: No authenticated user"); + return NextResponse.json({ error: "Unauthorized - please log in again" }, { status: 401 }); + } + console.log(">>> API POST: Authenticated as", user.email); const body = await request.json(); const { task } = body as { task?: Task }; @@ -372,30 +347,31 @@ export async function POST(request: Request) { const supabase = getServiceSupabase(); const now = new Date().toISOString(); - const clientTaskId = toNonEmptyString(task.id); - const canonicalTaskId = isUuid(clientTaskId) ? clientTaskId : randomUUID(); + const taskId = requireUuid(task.id, "task.id"); const resolvedProjectId = await resolveRequiredProjectId(supabase, task.projectId); - const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId); - const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId); + const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId, "task.sprintId"); + const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId, "task.assigneeId"); // Check if task exists - const { data: existing } = await supabase + let existing: { id: string } | null = null; + const { data: byId, error: byIdError } = await supabase .from("tasks") .select("id") - .eq("id", canonicalTaskId) + .eq("id", taskId) .maybeSingle(); + if (byIdError) throw byIdError; + existing = (byId as { id: string } | null) ?? null; - let taskData = stripUndefined({ - id: canonicalTaskId, - legacy_id: clientTaskId && !isUuid(clientTaskId) ? clientTaskId : null, - title: task.title, + const taskData = stripUndefined({ + id: taskId, + title: requireNonEmptyString(task.title, "task.title"), description: task.description || null, - type: task.type || "task", - status: task.status || "todo", - priority: task.priority || "medium", + type: task.type ? requireEnum(task.type, TASK_TYPES, "task.type") : "task", + status: task.status ? requireEnum(task.status, TASK_STATUSES, "task.status") : "todo", + priority: task.priority ? requireEnum(task.priority, TASK_PRIORITIES, "task.priority") : "medium", project_id: resolvedProjectId, sprint_id: resolvedSprintId, - created_at: existing ? undefined : (task.createdAt || now), + created_at: existing ? undefined : toNonEmptyString(task.createdAt), updated_at: now, created_by_id: existing ? undefined : user.id, updated_by_id: user.id, @@ -406,50 +382,19 @@ export async function POST(request: Request) { attachments: task.attachments || [], }); - let result; - for (let attempt = 0; attempt < 8; attempt += 1) { - const query = existing - ? supabase.from("tasks").update(taskData).eq("id", canonicalTaskId).select().single() - : supabase.from("tasks").insert(taskData).select().single(); - - const { data, error } = await query; - if (!error) { - result = data; - break; - } - - const missingColumn = getMissingColumnFromError(error); - if (missingColumn && missingColumn in taskData) { - const nextPayload = { ...taskData }; - delete nextPayload[missingColumn]; - taskData = nextPayload; - console.warn(`>>> API POST: removing unsupported tasks column '${missingColumn}' and retrying`); - continue; - } - - const foreignKeyColumn = getForeignKeyColumnFromError(error); - if (foreignKeyColumn && foreignKeyColumn in taskData) { - const nextPayload = { ...taskData, [foreignKeyColumn]: null }; - taskData = nextPayload; - console.warn(`>>> API POST: clearing invalid foreign key '${foreignKeyColumn}' and retrying`); - continue; - } - - throw error; - } - - if (!result) throw new Error("Failed to save task after schema fallback attempts"); - - // Update lastUpdated - await supabase.from("meta").upsert({ - key: "lastUpdated", - value: String(Date.now()), - updated_at: now, - }); + const query = existing + ? supabase.from("tasks").update(taskData).eq("id", taskId).select().single() + : supabase.from("tasks").insert(taskData).select().single(); + const { data: result, error: saveError } = await query; + if (saveError) throw saveError; + if (!result) throw new HttpError(500, "Task save succeeded without returning a row"); return NextResponse.json({ success: true, task: result }); } catch (error: unknown) { console.error(">>> API POST error:", error); + if (error instanceof HttpError) { + return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); + } const message = error instanceof Error ? error.message : "Failed to save"; const details = error && typeof error === "object" @@ -466,29 +411,31 @@ export async function POST(request: Request) { // DELETE - remove a task export async function DELETE(request: Request) { try { - // TODO: Re-enable auth after fixing cookie issue on Vercel - // const user = await getAuthenticatedUser(); - // if (!user) { - // return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - // } + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } const { id } = (await request.json()) as { id: string }; + const taskId = requireUuid(id, "id"); const supabase = getServiceSupabase(); - const { error } = await supabase.from("tasks").delete().eq("id", id); + const { error, count } = await supabase + .from("tasks") + .delete({ count: "exact" }) + .eq("id", taskId); if (error) throw error; - - // Update lastUpdated - await supabase.from("meta").upsert({ - key: "lastUpdated", - value: String(Date.now()), - updated_at: new Date().toISOString(), - }); + if ((count ?? 0) === 0) { + throw new HttpError(404, "Task not found", { id: taskId }); + } return NextResponse.json({ success: true }); } catch (error: unknown) { console.error(">>> API DELETE error:", error); + if (error instanceof HttpError) { + return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); + } const message = error instanceof Error ? error.message : "Failed to delete"; return NextResponse.json({ error: message }, { status: 500 }); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2be9493..c001728 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, type FormEvent } from "react" import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" @@ -70,6 +70,11 @@ export default function LoginPage() { } } + const handleAuthSubmit = (event: FormEvent) => { + event.preventDefault() + void submit() + } + return (
@@ -93,7 +98,7 @@ export default function LoginPage() {
-
+
{mode === "register" && (
@@ -152,10 +157,10 @@ export default function LoginPage() { {error &&

{error}

} - -
+
{/* Forgot Password Modal */} {showForgot && ( @@ -225,6 +230,11 @@ function ForgotPasswordModal({ } } + const handleResetSubmit = (event: FormEvent) => { + event.preventDefault() + void handleSubmit() + } + return (
@@ -233,7 +243,7 @@ function ForgotPasswordModal({ Enter your email and we'll send you a link to reset your password.

-
+
Cancel -
-
+
) diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx index 37f14a9..c0df0df 100644 --- a/src/app/tasks/[taskId]/page.tsx +++ b/src/app/tasks/[taskId]/page.tsx @@ -352,6 +352,14 @@ export default function TaskDetailPage() { return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)) }, [users, currentUser]) + const sortedSprints = useMemo( + () => + sprints + .slice() + .sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()), + [sprints] + ) + const handleAttachmentUpload = async (event: ChangeEvent) => { const files = Array.from(event.target.files || []) if (files.length === 0) return @@ -777,7 +785,7 @@ export default function TaskDetailPage() { className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500" > - {sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => ( + {sortedSprints.map((sprint) => ( diff --git a/src/lib/server/taskDb.ts b/src/lib/server/taskDb.ts deleted file mode 100644 index de29c13..0000000 --- a/src/lib/server/taskDb.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { getServiceSupabase } from "@/lib/supabase/client"; - -export interface TaskAttachment { - id: string; - name: string; - type: string; - size: number; - dataUrl: string; - uploadedAt: string; -} - -export interface TaskCommentAuthor { - id: string; - name: string; - email?: string; - avatarUrl?: string; - type: "human" | "assistant"; -} - -export interface TaskComment { - id: string; - text: string; - createdAt: string; - author: TaskCommentAuthor | "user" | "assistant"; - replies?: TaskComment[]; -} - -export interface Task { - id: string; - title: string; - description?: string; - type: "idea" | "task" | "bug" | "research" | "plan"; - status: "open" | "todo" | "blocked" | "in-progress" | "review" | "validate" | "archived" | "canceled" | "done"; - priority: "low" | "medium" | "high" | "urgent"; - projectId: string; - sprintId?: string; - createdAt: string; - updatedAt: string; - createdById?: string; - createdByName?: string; - createdByAvatarUrl?: string; - updatedById?: string; - updatedByName?: string; - updatedByAvatarUrl?: string; - assigneeId?: string; - assigneeName?: string; - assigneeEmail?: string; - assigneeAvatarUrl?: string; - dueDate?: string; - comments: TaskComment[]; - tags: string[]; - attachments: TaskAttachment[]; -} - -export interface Project { - id: string; - name: string; - description?: string; - color: string; - createdAt: string; -} - -export interface Sprint { - id: string; - name: string; - goal?: string; - startDate: string; - endDate: string; - status: "planning" | "active" | "completed"; - projectId: string; - createdAt: string; -} - -export interface DataStore { - projects: Project[]; - tasks: Task[]; - sprints: Sprint[]; - lastUpdated: number; -} - -const defaultData: DataStore = { - projects: [ - { id: "1", name: "OpenClaw iOS", description: "Main iOS app development", color: "#8b5cf6", createdAt: new Date().toISOString() }, - { id: "2", name: "Web Projects", description: "Web tools and dashboards", color: "#3b82f6", createdAt: new Date().toISOString() }, - { id: "3", name: "Research", description: "Experiments and learning", color: "#10b981", createdAt: new Date().toISOString() }, - ], - tasks: [], - sprints: [], - lastUpdated: Date.now(), -}; - -// Helper to safely parse JSON arrays -function safeParseArray(value: unknown, fallback: T[]): T[] { - if (!value) return fallback; - if (Array.isArray(value)) return value as T[]; - try { - const parsed = JSON.parse(String(value)); - return Array.isArray(parsed) ? (parsed as T[]) : fallback; - } catch { - return fallback; - } -} - -// Normalize attachments -function normalizeAttachments(attachments: unknown): TaskAttachment[] { - if (!Array.isArray(attachments)) return []; - - return attachments - .map((attachment: unknown) => { - if (!attachment || typeof attachment !== "object") return null; - const value = attachment as Partial; - const name = typeof value.name === "string" ? value.name.trim() : ""; - const dataUrl = typeof value.dataUrl === "string" ? value.dataUrl : ""; - if (!name || !dataUrl) return null; - - return { - id: typeof value.id === "string" && value.id ? value.id : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name, - type: typeof value.type === "string" ? value.type : "application/octet-stream", - size: typeof value.size === "number" && Number.isFinite(value.size) ? value.size : 0, - dataUrl, - uploadedAt: typeof value.uploadedAt === "string" && value.uploadedAt ? value.uploadedAt : new Date().toISOString(), - }; - }) - .filter((attachment): attachment is TaskAttachment => attachment !== null); -} - -// Normalize comment author -function normalizeCommentAuthor(author: unknown): TaskCommentAuthor { - if (author === "assistant") { - return { id: "assistant", name: "Assistant", type: "assistant" }; - } - if (author === "user") { - return { id: "legacy-user", name: "User", type: "human" }; - } - - if (!author || typeof author !== "object") { - return { id: "legacy-user", name: "User", type: "human" }; - } - - const value = author as Partial; - const type: TaskCommentAuthor["type"] = - value.type === "assistant" || value.id === "assistant" ? "assistant" : "human"; - const id = typeof value.id === "string" && value.id.trim().length > 0 - ? value.id - : type === "assistant" ? "assistant" : "legacy-user"; - const name = typeof value.name === "string" && value.name.trim().length > 0 - ? value.name.trim() - : type === "assistant" ? "Assistant" : "User"; - const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined; - const avatarUrl = typeof value.avatarUrl === "string" && value.avatarUrl.trim().length > 0 ? value.avatarUrl : undefined; - - return { id, name, email, avatarUrl, type }; -} - -// Normalize comments -function normalizeComments(comments: unknown): TaskComment[] { - if (!Array.isArray(comments)) return []; - - const normalized: TaskComment[] = []; - for (const entry of comments) { - if (!entry || typeof entry !== "object") continue; - const value = entry as Partial; - if (typeof value.id !== "string" || typeof value.text !== "string") continue; - - normalized.push({ - id: value.id, - text: value.text, - createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(), - author: normalizeCommentAuthor(value.author), - replies: normalizeComments(value.replies), - }); - } - - return normalized; -} - -// Normalize a task from database row -function normalizeTask(task: Record): Task { - const comments = safeParseArray(task.comments, []); - const tags = safeParseArray(task.tags, []); - const attachments = safeParseArray(task.attachments, []); - - return { - id: String(task.id ?? ""), - title: String(task.title ?? ""), - description: task.description ? String(task.description) : undefined, - type: (task.type as Task["type"]) ?? "task", - status: (task.status as Task["status"]) ?? "open", - priority: (task.priority as Task["priority"]) ?? "medium", - projectId: String(task.project_id ?? ""), - sprintId: task.sprint_id ? String(task.sprint_id) : undefined, - createdAt: String(task.created_at ?? new Date().toISOString()), - updatedAt: String(task.updated_at ?? new Date().toISOString()), - createdById: task.created_by_id ? String(task.created_by_id) : undefined, - createdByName: task.created_by_name ? String(task.created_by_name) : undefined, - createdByAvatarUrl: task.created_by_avatar_url ? String(task.created_by_avatar_url) : undefined, - updatedById: task.updated_by_id ? String(task.updated_by_id) : undefined, - updatedByName: task.updated_by_name ? String(task.updated_by_name) : undefined, - updatedByAvatarUrl: task.updated_by_avatar_url ? String(task.updated_by_avatar_url) : undefined, - assigneeId: task.assignee_id ? String(task.assignee_id) : undefined, - assigneeName: task.assignee_name ? String(task.assignee_name) : undefined, - assigneeEmail: task.assignee_email ? String(task.assignee_email) : undefined, - assigneeAvatarUrl: task.assignee_avatar_url ? String(task.assignee_avatar_url) : undefined, - dueDate: task.due_date ? String(task.due_date) : undefined, - comments: normalizeComments(comments), - tags: tags.filter((tag): tag is string => typeof tag === "string"), - attachments: normalizeAttachments(attachments), - }; -} - -// Fetch user lookup map -async function getUserLookup(): Promise> { - const supabase = getServiceSupabase(); - const { data: users } = await supabase - .from("users") - .select("id, name, email, avatar_url"); - - const lookup = new Map(); - for (const user of users || []) { - lookup.set(user.id, { - id: user.id, - name: user.name, - email: user.email ?? undefined, - avatarUrl: user.avatar_url ?? undefined, - }); - } - return lookup; -} - -export async function getData(): Promise { - const supabase = getServiceSupabase(); - const usersById = await getUserLookup(); - - // Fetch all data in parallel - const [{ data: projects }, { data: sprints }, { data: tasks }, { data: meta }] = await Promise.all([ - supabase.from("projects").select("*").order("created_at", { ascending: true }), - supabase.from("sprints").select("*").order("start_date", { ascending: true }), - supabase.from("tasks").select("*").order("created_at", { ascending: true }), - supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(), - ]); - - // If no data exists, seed with defaults - if ((projects?.length ?? 0) === 0 && (tasks?.length ?? 0) === 0 && (sprints?.length ?? 0) === 0) { - await seedDefaultData(); - return getData(); - } - - return { - projects: (projects || []).map((p) => ({ - id: p.id, - name: p.name, - description: p.description ?? undefined, - color: p.color, - createdAt: p.created_at, - })), - sprints: (sprints || []).map((s) => ({ - id: s.id, - name: s.name, - goal: s.goal ?? undefined, - startDate: s.start_date, - endDate: s.end_date, - status: s.status, - projectId: s.project_id, - createdAt: s.created_at, - })), - tasks: (tasks || []).map((t) => { - const createdByUser = t.created_by_id ? usersById.get(t.created_by_id) : undefined; - const updatedByUser = t.updated_by_id ? usersById.get(t.updated_by_id) : undefined; - const assigneeUser = t.assignee_id ? usersById.get(t.assignee_id) : undefined; - - return { - id: t.id, - title: t.title, - description: t.description ?? undefined, - type: t.type, - status: t.status, - priority: t.priority, - projectId: t.project_id, - sprintId: t.sprint_id ?? undefined, - createdAt: t.created_at, - updatedAt: t.updated_at, - createdById: t.created_by_id ?? undefined, - createdByName: t.created_by_name ?? createdByUser?.name ?? undefined, - createdByAvatarUrl: createdByUser?.avatarUrl ?? t.created_by_avatar_url ?? undefined, - updatedById: t.updated_by_id ?? undefined, - updatedByName: t.updated_by_name ?? updatedByUser?.name ?? undefined, - updatedByAvatarUrl: updatedByUser?.avatarUrl ?? t.updated_by_avatar_url ?? undefined, - assigneeId: t.assignee_id ?? undefined, - assigneeName: assigneeUser?.name ?? t.assignee_name ?? undefined, - assigneeEmail: assigneeUser?.email ?? t.assignee_email ?? undefined, - assigneeAvatarUrl: assigneeUser?.avatarUrl ?? undefined, - dueDate: t.due_date ?? undefined, - comments: normalizeComments(t.comments), - tags: safeParseArray(t.tags, []).filter((tag): tag is string => typeof tag === "string"), - attachments: normalizeAttachments(t.attachments), - }; - }), - lastUpdated: Number(meta?.value ?? Date.now()), - }; -} - -async function seedDefaultData(): Promise { - const supabase = getServiceSupabase(); - const now = new Date().toISOString(); - - // Insert default projects - for (const project of defaultData.projects) { - await supabase.from("projects").insert({ - id: project.id, - name: project.name, - description: project.description, - color: project.color, - created_at: project.createdAt, - }); - } - - // Update lastUpdated - await supabase.from("meta").upsert({ - key: "lastUpdated", - value: String(Date.now()), - updated_at: now, - }); -} - -export async function saveData(data: DataStore): Promise { - const supabase = getServiceSupabase(); - const now = new Date().toISOString(); - const lastUpdated = Date.now(); - - // Delete existing data (in correct order due to FK constraints) - const { error: deleteTasksError } = await supabase.from("tasks").delete().neq("id", ""); - const { error: deleteSprintsError } = await supabase.from("sprints").delete().neq("id", ""); - const { error: deleteProjectsError } = await supabase.from("projects").delete().neq("id", ""); - - if (deleteTasksError) console.error("Failed to delete tasks:", deleteTasksError); - if (deleteSprintsError) console.error("Failed to delete sprints:", deleteSprintsError); - if (deleteProjectsError) console.error("Failed to delete projects:", deleteProjectsError); - - // Insert projects - if (data.projects.length > 0) { - const { error: projectError } = await supabase.from("projects").insert( - data.projects.map((p) => ({ - id: p.id, - name: p.name, - description: p.description, - color: p.color, - created_at: p.createdAt, - })) - ); - if (projectError) { - console.error("Failed to insert projects:", projectError); - throw new Error(`Failed to insert projects: ${projectError.message}`); - } - } - - // Insert sprints - if (data.sprints.length > 0) { - const { error: sprintError } = await supabase.from("sprints").insert( - data.sprints.map((s) => ({ - id: s.id, - name: s.name, - goal: s.goal, - start_date: s.startDate, - end_date: s.endDate, - status: s.status, - project_id: s.projectId, - created_at: s.createdAt, - })) - ); - if (sprintError) { - console.error("Failed to insert sprints:", sprintError); - throw new Error(`Failed to insert sprints: ${sprintError.message}`); - } - } - - // Insert tasks - if (data.tasks.length > 0) { - const { error: taskError } = await supabase.from("tasks").insert( - data.tasks.map((t) => ({ - id: t.id, - title: t.title, - description: t.description, - type: t.type, - status: t.status, - priority: t.priority, - project_id: t.projectId, - sprint_id: t.sprintId, - created_at: t.createdAt, - updated_at: now, - created_by_id: t.createdById, - updated_by_id: t.updatedById, - assignee_id: t.assigneeId, - due_date: t.dueDate, - comments: t.comments, - tags: t.tags, - attachments: t.attachments, - })) - ); - if (taskError) { - console.error("Failed to insert tasks:", taskError); - throw new Error(`Failed to insert tasks: ${taskError.message}`); - } - } - - // Update lastUpdated - const { error: metaError } = await supabase.from("meta").upsert({ - key: "lastUpdated", - value: String(lastUpdated), - updated_at: now, - }); - - return getData(); -} diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index b595d89..a73a162 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -9,32 +9,49 @@ export interface Database { users: { Row: { id: string; - legacy_id: string | null; name: string; email: string; avatar_url: string | null; - password_hash: string; created_at: string; }; Insert: { id?: string; - legacy_id?: string | null; name: string; email: string; avatar_url?: string | null; - password_hash: string; created_at?: string; }; Update: { id?: string; - legacy_id?: string | null; name?: string; email?: string; avatar_url?: string | null; - password_hash?: string; created_at?: string; }; }; + profiles: { + Row: { + id: string; + name: string | null; + avatar_url: string | null; + created_at: string; + updated_at: string; + }; + Insert: { + id: string; + name?: string | null; + avatar_url?: string | null; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + name?: string | null; + avatar_url?: string | null; + created_at?: string; + updated_at?: string; + }; + }; sessions: { Row: { id: string; @@ -87,7 +104,6 @@ export interface Database { projects: { Row: { id: string; - legacy_id: string | null; name: string; description: string | null; color: string; @@ -95,7 +111,6 @@ export interface Database { }; Insert: { id?: string; - legacy_id?: string | null; name: string; description?: string | null; color: string; @@ -103,7 +118,6 @@ export interface Database { }; Update: { id?: string; - legacy_id?: string | null; name?: string; description?: string | null; color?: string; @@ -113,7 +127,6 @@ export interface Database { sprints: { Row: { id: string; - legacy_id: string | null; name: string; goal: string | null; start_date: string; @@ -124,7 +137,6 @@ export interface Database { }; Insert: { id?: string; - legacy_id?: string | null; name: string; goal?: string | null; start_date: string; @@ -135,7 +147,6 @@ export interface Database { }; Update: { id?: string; - legacy_id?: string | null; name?: string; goal?: string | null; start_date?: string; @@ -148,7 +159,6 @@ export interface Database { tasks: { Row: { id: string; - legacy_id: string | null; title: string; description: string | null; type: 'idea' | 'task' | 'bug' | 'research' | 'plan'; @@ -159,15 +169,8 @@ export interface Database { created_at: string; updated_at: string; created_by_id: string | null; - created_by_name: string | null; - created_by_avatar_url: string | null; updated_by_id: string | null; - updated_by_name: string | null; - updated_by_avatar_url: string | null; assignee_id: string | null; - assignee_name: string | null; - assignee_email: string | null; - assignee_avatar_url: string | null; due_date: string | null; comments: Json; tags: Json; @@ -175,7 +178,6 @@ export interface Database { }; Insert: { id?: string; - legacy_id?: string | null; title: string; description?: string | null; type: 'idea' | 'task' | 'bug' | 'research' | 'plan'; @@ -186,15 +188,8 @@ export interface Database { created_at?: string; updated_at?: string; created_by_id?: string | null; - created_by_name?: string | null; - created_by_avatar_url?: string | null; updated_by_id?: string | null; - updated_by_name?: string | null; - updated_by_avatar_url?: string | null; assignee_id?: string | null; - assignee_name?: string | null; - assignee_email?: string | null; - assignee_avatar_url?: string | null; due_date?: string | null; comments?: Json; tags?: Json; @@ -202,7 +197,6 @@ export interface Database { }; Update: { id?: string; - legacy_id?: string | null; title?: string; description?: string | null; type?: 'idea' | 'task' | 'bug' | 'research' | 'plan'; @@ -213,38 +207,14 @@ export interface Database { created_at?: string; updated_at?: string; created_by_id?: string | null; - created_by_name?: string | null; - created_by_avatar_url?: string | null; updated_by_id?: string | null; - updated_by_name?: string | null; - updated_by_avatar_url?: string | null; assignee_id?: string | null; - assignee_name?: string | null; - assignee_email?: string | null; - assignee_avatar_url?: string | null; due_date?: string | null; comments?: Json; tags?: Json; attachments?: Json; }; }; - meta: { - Row: { - key: string; - value: string; - updated_at: string; - }; - Insert: { - key: string; - value: string; - updated_at?: string; - }; - Update: { - key?: string; - value?: string; - updated_at?: string; - }; - }; }; }; } diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index 03415e3..1a0b4b3 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -94,10 +94,10 @@ interface TaskStore { currentUser: UserProfile isLoading: boolean lastSynced: number | null + syncError: string | null // Sync actions syncFromServer: () => Promise - syncToServer: () => Promise setCurrentUser: (user: Partial) => void // Project actions @@ -129,296 +129,10 @@ interface TaskStore { getTaskById: (id: string) => Task | undefined } -// Sprint 1: Mon Feb 16 - Sun Feb 22, 2026 (current week) -const sprint1Start = new Date('2026-02-16T00:00:00.000Z') -const sprint1End = new Date('2026-02-22T23:59:59.999Z') - -const defaultSprints: Sprint[] = [ - { - id: 'sprint-1', - name: 'Sprint 1', - goal: 'Foundation and core features', - startDate: sprint1Start.toISOString(), - endDate: sprint1End.toISOString(), - status: 'active', - projectId: '2', - createdAt: new Date().toISOString(), - }, -] - -const defaultProjects: Project[] = [ - { id: '1', name: 'OpenClaw iOS', description: 'Main iOS app development', color: '#8b5cf6', createdAt: new Date().toISOString() }, - { id: '2', name: 'Web Projects', description: 'Web tools and dashboards', color: '#3b82f6', createdAt: new Date().toISOString() }, - { id: '3', name: 'Research', description: 'Experiments and learning', color: '#10b981', createdAt: new Date().toISOString() }, -] - -const defaultTasks: Task[] = [ - { - id: '1', - title: 'Redesign Gantt Board', - description: 'Make it actually work with proper notes system', - type: 'task', - status: 'in-progress', - priority: 'high', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c1', text: 'Need 1-to-many notes, not one big text field', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c2', text: 'Agreed - will rebuild with proper comment threads', createdAt: new Date().toISOString(), author: 'assistant' }, - ], - tags: ['ui', 'rewrite'] - }, - { - id: '2', - title: 'MoodWeave App Idea', - description: 'Social mood tracking with woven visualizations', - type: 'idea', - status: 'open', - priority: 'medium', - projectId: '1', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [], - tags: ['ios', 'social'] - }, - { - id: '3', - title: 'Set up Gitea integration for code pushes', - description: 'Create bot account on Gitea (192.168.1.128:3000) and configure git remotes for all OpenClaw projects. Decide on account name, permissions, and auth method (SSH vs token). User prefers dedicated bot account over using their personal account for audit trail.', - type: 'task', - status: 'done', - priority: 'medium', - projectId: '2', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c3', text: 'User has local Gitea at http://192.168.1.128:3000', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c4', text: 'Options: 1) Create dedicated bot account (recommended), 2) Use existing account', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c5', text: 'Account created: mbruce@topdoglabs.com / !7883Gitea (username: ai-agent)', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c6', text: 'Git configured for all 3 projects. Gitea remotes added.', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c7', text: '✅ All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['gitea', 'git', 'automation', 'infrastructure'] - }, - { - id: '4', - title: 'Redesign Heartbeat Monitor to match UptimeRobot', - description: 'Completely redesign the Heartbeat Monitor website to be a competitor to https://uptimerobot.com. Study their design, layout, color scheme, typography, and functionality. Match their look, feel, and style as closely as possible. Include: modern dashboard, status pages, uptime charts, incident history, public status pages.', - type: 'task', - status: 'done', - priority: 'high', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c8', text: 'Reference: https://uptimerobot.com - study their homepage, dashboard, and status page designs', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c9', text: 'Focus on: clean modern UI, blue/green color scheme, card-based layouts, uptime percentage displays, incident timelines', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c29', text: 'COMPLETED: Full rebuild with Next.js + shadcn/ui + Framer Motion. Dark OLED theme, glass-morphism cards, animated status indicators, sparkline visualizations, grid/list views, tooltips, progress bars. Production-grade at http://localhost:3005', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['ui', 'ux', 'redesign', 'dashboard', 'monitoring'] - }, - { - id: '8', - title: 'Fix Kanban board - dynamic sync without hard refresh', - description: 'Current board uses localStorage persistence which requires hard refresh (Cmd+Shift+R) to see task updates from code changes. Need to add: server-side storage (API + database/file), or sync mechanism that checks for updates on regular refresh, or real-time updates via WebSocket/polling. User should see updates on normal page refresh without clearing cache.', - type: 'task', - status: 'in-progress', - priority: 'medium', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c21', text: 'Issue: Task status changes require hard refresh to see', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c22', text: 'Current: localStorage persistence via Zustand', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c23', text: 'Need: Server-side storage or sync on regular refresh', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c44', text: 'COMPLETED: Added /api/tasks endpoint with file-based storage', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c45', text: 'COMPLETED: Store now syncs from server on load and auto-syncs changes', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c46', text: 'COMPLETED: Falls back to localStorage if server unavailable', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['ui', 'sync', 'localstorage', 'real-time'] - }, - { - id: '5', - title: 'Fix Blog Backup links to be clickable', - description: 'Make links in the Daily Digest clickable in the blog backup UI. Currently links are just text that require copy-paste. Need to render markdown links properly so users can click directly. Consider different formatting for Telegram vs Blog - Telegram gets plain text summary with "Read more at [link]", Blog gets full formatted content with clickable links.', - type: 'task', - status: 'done', - priority: 'medium', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c10', text: 'Blog should show: [headline](url) as clickable markdown links', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c11', text: 'Telegram gets summary + "Full digest at: http://localhost:3003"', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c41', text: 'COMPLETED: Fixed parseDigest to extract URLs from markdown links [Title](url) in title lines', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c42', text: 'COMPLETED: Title is now the clickable link with external link icon on hover', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c43', text: 'COMPLETED: Better hover states - title turns blue, external link icon appears', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['blog', 'ui', 'markdown', 'links'] - }, - { - id: '6', - title: 'Fix monitoring schedule - 2 of 3 sites are down', - description: 'The cron job running every 10 minutes to check heartbeat website is failing. Currently 2 of 3 websites are down and not being auto-restarted. Debug and fix the monitoring schedule to ensure all 3 sites (gantt-board, blog-backup, heartbeat-monitor) are checked and auto-restarted properly.', - type: 'bug', - status: 'done', - priority: 'urgent', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c12', text: 'Issue: Cron job exists but sites are still going down without restart', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c13', text: 'Need to verify: cron is running, checks all 3 ports, restart logic works, permissions correct', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c14', text: 'ALL SITES BACK UP - manually restarted at 14:19. Now investigating why auto-restart failed.', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c15', text: 'Problem: Port 3005 was still in use (EADDRINUSE), need better process cleanup in restart logic', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c16', text: 'FIXED: Updated cron job with pkill cleanup before restart + 2s delay. Created backup script: monitor-restart.sh', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c17', text: 'All 3 sites stable. Cron job now properly kills old processes before restarting to avoid port conflicts.', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['monitoring', 'cron', 'bug', 'infrastructure', 'urgent'] - }, - { - id: '7', - title: 'Investigate root cause - why are websites dying?', - description: 'Currently monitoring only treats the symptom (restart when down). Need to investigate what is actually killing the Next.js dev servers. Check: system logs, memory usage, file watcher limits, zombie processes, macOS power management, SSH timeout, OOM killer. Set up logging to capture what happens right before crashes.', - type: 'research', - status: 'done', - priority: 'high', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c18', text: 'Problem: Sites go down randomly - what is killing them?', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c19', text: 'Suspects: Memory leaks, file watcher hitting limits, SSH session timeout, macOS power nap, OOM killer', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c20', text: 'Need to add logging/capture to see what kills processes', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c27', text: 'COMPLETED: Root cause analysis done. Primary suspect: Next.js dev server memory leaks. Secondary: SSH timeout, OOM killer, power mgmt. Full report: root-cause-analysis.md. Monitoring script deployed.', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['debugging', 'research', 'infrastructure', 'root-cause'] - }, - { - id: '9', - title: 'Add ability to edit task priority in Kanban board', - description: 'Currently users cannot change task priority (Low/Medium/High/Urgent) from the UI. Need to add priority editing capability to the task detail view or task card. Should be a dropdown selector allowing users to re-prioritize tasks on the fly without editing code.', - type: 'task', - status: 'done', - priority: 'high', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c24', text: 'User cannot currently change priority from Medium to High in UI', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c25', text: 'Need priority dropdown/editor in task detail view', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c28', text: 'COMPLETED: Added priority buttons to task detail dialog. Click any task to see Low/Medium/High/Urgent buttons with color coding. Changes apply immediately.', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['ui', 'kanban', 'feature', 'priority'] - }, - { - id: '10', - title: 'RESEARCH: Find viable screenshot solution for OpenClaw on macOS', - description: 'INVESTIGATION NEEDED: Find a reliable, persistent way for OpenClaw AI to capture screenshots of local websites running on macOS. Current browser tool requires Chrome extension which is not connected. Puppeteer workaround is temporary. Need to research and document ALL possible options including: macOS native screenshot tools (screencapture, automator), alternative browser automation tools, canvas/headless options, or any other method that works on macOS without requiring Chrome extension.', - type: 'research', - status: 'done', - priority: 'high', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c29', text: 'PROBLEM: User needs to share screenshots of local websites with friends who cannot access home network. Browser tool unavailable (Chrome extension not connected).', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c30', text: 'INVESTIGATE: macOS native screenshot capabilities - screencapture CLI, Automator workflows, AppleScript', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c31', text: 'INVESTIGATE: Alternative browser automation - Playwright, Selenium, WebDriver without Chrome extension requirement', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c32', text: 'INVESTIGATE: OpenClaw Gateway configuration - browser profiles, node setup, gateway settings', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c33', text: 'INVESTIGATE: Third-party screenshot APIs or services that could work locally', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c34', text: 'DELIVERABLE: Document ALL options found with pros/cons, setup requirements, and recommendation for best solution', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c35', text: 'FINDING: /usr/sbin/screencapture exists but requires interactive mode or captures full screen - cannot target specific URL', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c36', text: 'FINDING: Google Chrome is installed at /Applications/Google Chrome.app - Playwright can use this for headless screenshots', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c37', text: 'FINDING: Playwright v1.58.2 is already available via npx - can automate Chrome without extension', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c38', text: 'FINDING: OpenClaw Gateway is running on port 18789 with browser profile "chrome" but extension not attached to any tab', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c39', text: 'SUCCESS: Playwright + Google Chrome works! Successfully captured screenshot of http://localhost:3005 using headless Chrome', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c40', text: 'RECOMMENDATION: Install Playwright globally or in workspace so screenshots work without temporary installs. Command: npm install -g playwright', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c47', text: 'COMPLETED: Playwright installed globally. Screenshots now work anytime without setup.', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['research', 'screenshot', 'macos', 'openclaw', 'investigation'] - }, - { - id: '11', - title: 'RESEARCH: Find iOS side projects with MRR potential', - description: 'Research and identify iOS app ideas that have strong Monthly Recurring Revenue (MRR) opportunities. Focus on apps that are well-designed, multi-screen experiences (not single-screen utilities), and have viral potential. Look at current App Store trends, successful indie apps, and underserved niches. Consider: subscription models, freemium tiers, in-app purchases. Target ideas that leverage iOS-specific features (widgets, Live Activities, Siri, CoreML, etc.) and could generate $1K-$10K+ MRR.', - type: 'research', - status: 'done', - priority: 'low', - projectId: '3', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c48', text: 'Focus: iOS apps with MRR potential (subscriptions, recurring revenue)', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c49', text: 'Requirements: Well-thought-out, multi-screen, viral potential', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c50', text: 'Target: $1K-$10K+ MRR opportunities', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c51', text: 'Research areas: App Store trends, indie success stories, underserved niches', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c52', text: 'COMPLETED: Full research report saved to memory/ios-mrr-opportunities.md', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c53', text: 'TOP 10 IDEAS: (1) AI Translator Keyboard - $15K/mo potential, (2) Finance Widget Suite, (3) Focus Timer with Live Activities - RECOMMENDED, (4) AI Photo Enhancer, (5) Habit Tracker with Social, (6) Local Business Review Widget, (7) Audio Journal with Voice-to-Text, (8) Plant Care Tracker, (9) Sleep Sounds with HomeKit, (10) Family Password Manager', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c54', text: 'RECOMMENDATION: Focus Timer with Live Activities (#3) - Best first project. Technically achievable, proven market, high viral potential through focus streak sharing, leverages iOS 16+ features.', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['ios', 'mrr', 'research', 'side-project', 'entrepreneurship', 'app-ideas'] - }, - { - id: '12', - title: 'Add markdown rendering to Blog Backup', - description: 'The blog backup page currently shows raw markdown text instead of rendered HTML. This means links appear as [text](url) instead of clickable links. Need to install a markdown renderer (like react-markdown) and update the page component to properly render markdown content as HTML with clickable links, formatted headers, lists, etc.', - type: 'task', - status: 'done', - priority: 'high', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c55', text: 'Issue: Blog shows raw markdown [text](url) instead of clickable links', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c56', text: 'Solution: Install react-markdown and render content as HTML', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c57', text: 'Expected: Properly formatted markdown with clickable links, headers, lists', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c58', text: 'COMPLETED: Installed react-markdown and remark-gfm', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c59', text: 'COMPLETED: Installed @tailwindcss/typography for prose styling', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c60', text: 'COMPLETED: Updated page.tsx to render markdown as HTML with clickable links', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c61', text: 'COMPLETED: Links now open in new tab with blue styling and hover effects', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['blog', 'ui', 'markdown', 'frontend'] - }, - { - id: '13', - title: 'Research TTS options for Daily Digest podcast', - description: 'Research free text-to-speech (TTS) tools to convert the daily digest blog posts into an audio podcast format. Matt wants to listen to the digest during his morning dog walks with Tully and Remy. Look into: free TTS APIs (ElevenLabs free tier, Google TTS, AWS Polly), open-source solutions (Piper, Coqui TTS), browser-based options, RSS feed generation for podcast apps, and file hosting options. The solution should be cost-effective or free since budget is a concern.', - type: 'research', - status: 'open', - priority: 'medium', - projectId: '2', - sprintId: 'sprint-1', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - comments: [ - { id: 'c62', text: 'Goal: Convert daily digest text to audio for dog walks', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c63', text: 'Requirement: Free or very low cost solution', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c64', text: 'Look into: ElevenLabs free tier, Google TTS, AWS Polly, Piper, Coqui TTS', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c65', text: 'Also research: RSS feed generation, podcast hosting options', createdAt: new Date().toISOString(), author: 'assistant' } - ], - tags: ['research', 'tts', 'podcast', 'audio', 'digest', 'accessibility'] - } -] - -const createLocalUserProfile = (): UserProfile => ({ - id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name: 'Local User', -}) - -const defaultCurrentUser = createLocalUserProfile() +const defaultCurrentUser: UserProfile = { + id: '', + name: 'Unknown User', +} const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => { if (!value || typeof value !== 'object') return fallback @@ -556,7 +270,24 @@ const generateTaskId = (): string => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID() } - return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + throw new Error('Unable to generate task UUID in this browser') +} + +const getErrorMessage = (payload: unknown, fallback: string): string => { + if (!payload || typeof payload !== 'object') return fallback + const candidate = payload as { error?: unknown } + return typeof candidate.error === 'string' && candidate.error.trim().length > 0 ? candidate.error : fallback +} + +async function requestApi(path: string, init: RequestInit): Promise { + const response = await fetch(path, init) + if (response.ok) { + return response.json().catch(() => ({})) + } + + const payload = await response.json().catch(() => null) + const message = getErrorMessage(payload, `${path} failed with status ${response.status}`) + throw new Error(message) } // Helper to sync a single task to server (lightweight) @@ -604,123 +335,142 @@ async function deleteTaskFromServer(taskId: string) { } } -// Legacy bulk sync (for projects/sprints only) -async function syncToServer(projects: Project[], tasks: Task[], sprints: Sprint[]) { - console.log('>>> syncToServer: legacy bulk sync - projects/sprints only') - // Only sync projects and sprints in bulk, tasks are handled individually - try { - const res = await fetch('/api/projects', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ projects, sprints }), - }) - if (res.ok) { - console.log('>>> syncToServer: saved projects/sprints successfully') - } else { - console.error('>>> syncToServer: failed with status', res.status) - } - } catch (error) { - console.error('>>> syncToServer: Failed to sync:', error) - } -} - export const useTaskStore = create()( persist( (set, get) => ({ - projects: defaultProjects, - tasks: defaultTasks, - sprints: defaultSprints, - selectedProjectId: '1', + projects: [], + tasks: [], + sprints: [], + selectedProjectId: null, selectedTaskId: null, selectedSprintId: null, currentUser: defaultCurrentUser, isLoading: false, lastSynced: null, + syncError: null, syncFromServer: async () => { console.log('>>> syncFromServer START') - set({ isLoading: true }) + set({ isLoading: true, syncError: null }) try { - const res = await fetch('/api/tasks') + const res = await fetch('/api/tasks', { cache: 'no-store' }) console.log('>>> syncFromServer: API response status:', res.status) - if (res.ok) { - const data = await res.json() - console.log('>>> syncFromServer: fetched data:', { - projectsCount: data.projects?.length, - tasksCount: data.tasks?.length, - sprintsCount: data.sprints?.length, - firstTaskTitle: data.tasks?.[0]?.title, - lastUpdated: data.lastUpdated, - }) - console.log('>>> syncFromServer: current store tasks count BEFORE set:', get().tasks.length) - // ALWAYS use server data if API returns successfully - set({ - projects: data.projects || [], - tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)), - sprints: data.sprints || [], - lastSynced: Date.now(), - }) - console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length) - } else { - console.error('>>> syncFromServer: API returned error status:', res.status) + if (!res.ok) { + const errorPayload = await res.json().catch(() => ({})) + console.error('>>> syncFromServer: API error payload:', errorPayload) + const message = typeof errorPayload?.error === 'string' + ? errorPayload.error + : `syncFromServer failed with status ${res.status}` + throw new Error(message) } + + const data = await res.json() + const serverProjects: Project[] = data.projects || [] + const serverCurrentUser = normalizeUserProfile(data.currentUser, get().currentUser) + const currentSelectedProjectId = get().selectedProjectId + const nextSelectedProjectId = + currentSelectedProjectId && serverProjects.some((project) => project.id === currentSelectedProjectId) + ? currentSelectedProjectId + : (serverProjects[0]?.id ?? null) + + console.log('>>> syncFromServer: fetched data:', { + projectsCount: serverProjects.length, + tasksCount: data.tasks?.length, + sprintsCount: data.sprints?.length, + firstTaskTitle: data.tasks?.[0]?.title, + lastUpdated: data.lastUpdated, + }) + console.log('>>> syncFromServer: current store tasks count BEFORE set:', get().tasks.length) + set({ + projects: serverProjects, + tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)), + sprints: data.sprints || [], + currentUser: serverCurrentUser, + selectedProjectId: nextSelectedProjectId, + syncError: null, + lastSynced: Date.now(), + }) + console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length) } catch (error) { console.error('>>> syncFromServer: Failed to sync from server:', error) - // Keep local data if server fails + const message = error instanceof Error ? error.message : 'Unknown sync error' + set({ + projects: [], + tasks: [], + sprints: [], + selectedProjectId: null, + selectedTaskId: null, + selectedSprintId: null, + currentUser: defaultCurrentUser, + syncError: message, + }) } finally { set({ isLoading: false }) console.log('>>> syncFromServer END') } }, - syncToServer: async () => { - const { projects, tasks, sprints } = get() - await syncToServer(projects, tasks, sprints) - set({ lastSynced: Date.now() }) - }, - setCurrentUser: (user) => { set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) })) }, addProject: (name, description) => { const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] - const newProject: Project = { - id: Date.now().toString(), - name, - description, - color: colors[Math.floor(Math.random() * colors.length)], - createdAt: new Date().toISOString(), - } - set((state) => { - const newState = { projects: [...state.projects, newProject] } - // Sync to server - syncToServer(newState.projects, state.tasks, state.sprints) - return newState - }) + const color = colors[Math.floor(Math.random() * colors.length)] + + void (async () => { + try { + await requestApi('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description, color }), + }) + await get().syncFromServer() + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create project' + console.error('>>> addProject failed:', error) + set({ syncError: message }) + } + })() }, updateProject: (id, updates) => { - set((state) => { - const newProjects = state.projects.map((p) => (p.id === id ? { ...p, ...updates } : p)) - syncToServer(newProjects, state.tasks, state.sprints) - return { projects: newProjects } - }) + const payload: Record = { id } + if (updates.name !== undefined) payload.name = updates.name + if (updates.description !== undefined) payload.description = updates.description + if (updates.color !== undefined) payload.color = updates.color + + void (async () => { + try { + await requestApi('/api/projects', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + await get().syncFromServer() + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update project' + console.error('>>> updateProject failed:', error) + set({ syncError: message }) + } + })() }, deleteProject: (id) => { - set((state) => { - const newProjects = state.projects.filter((p) => p.id !== id) - const newTasks = state.tasks.filter((t) => t.projectId !== id) - const newSprints = state.sprints.filter((s) => s.projectId !== id) - syncToServer(newProjects, newTasks, newSprints) - return { - projects: newProjects, - tasks: newTasks, - sprints: newSprints, - selectedProjectId: state.selectedProjectId === id ? null : state.selectedProjectId, + void (async () => { + try { + await requestApi('/api/projects', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + await get().syncFromServer() + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete project' + console.error('>>> deleteProject failed:', error) + set({ syncError: message }) } - }) + })() }, selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }), @@ -738,17 +488,21 @@ export const useTaskStore = create()( updatedById: actor.id, updatedByName: actor.name, updatedByAvatarUrl: actor.avatarUrl, - assigneeId: task.assigneeId || actor.id, - assigneeName: task.assigneeName || actor.name, - assigneeEmail: task.assigneeEmail || actor.email, - assigneeAvatarUrl: task.assigneeAvatarUrl || actor.avatarUrl, + assigneeId: task.assigneeId, + assigneeName: task.assigneeName, + assigneeEmail: task.assigneeEmail, + assigneeAvatarUrl: task.assigneeAvatarUrl, comments: normalizeComments([]), attachments: normalizeAttachments(task.attachments), } set((state) => { const newTasks = [...state.tasks, newTask] - // Sync individual task to server (lightweight) - syncTaskToServer(newTask) + void (async () => { + const success = await syncTaskToServer(newTask) + if (!success) { + await get().syncFromServer() + } + })() return { tasks: newTasks } }) }, @@ -782,6 +536,9 @@ export const useTaskStore = create()( // Sync individual task to server (lightweight) if (updatedTask) { syncSuccess = await syncTaskToServer(updatedTask) + if (!syncSuccess) { + await get().syncFromServer() + } } return syncSuccess @@ -790,8 +547,12 @@ export const useTaskStore = create()( deleteTask: (id) => { set((state) => { const newTasks = state.tasks.filter((t) => t.id !== id) - // Delete individual task from server (lightweight) - deleteTaskFromServer(id) + void (async () => { + const success = await deleteTaskFromServer(id) + if (!success) { + await get().syncFromServer() + } + })() return { tasks: newTasks, selectedTaskId: state.selectedTaskId === id ? null : state.selectedTaskId, @@ -803,41 +564,54 @@ export const useTaskStore = create()( // Sprint actions addSprint: (sprint) => { - const newSprint: Sprint = { - ...sprint, - id: Date.now().toString(), - createdAt: new Date().toISOString(), - } - set((state) => { - const newSprints = [...state.sprints, newSprint] - syncToServer(state.projects, state.tasks, newSprints) - return { sprints: newSprints } - }) + void (async () => { + try { + await requestApi('/api/sprints', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sprint), + }) + await get().syncFromServer() + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create sprint' + console.error('>>> addSprint failed:', error) + set({ syncError: message }) + } + })() }, updateSprint: (id, updates) => { - set((state) => { - const newSprints = state.sprints.map((s) => - s.id === id ? { ...s, ...updates } : s - ) - syncToServer(state.projects, state.tasks, newSprints) - return { sprints: newSprints } - }) + void (async () => { + try { + await requestApi('/api/sprints', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...updates }), + }) + await get().syncFromServer() + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update sprint' + console.error('>>> updateSprint failed:', error) + set({ syncError: message }) + } + })() }, deleteSprint: (id) => { - set((state) => { - const newSprints = state.sprints.filter((s) => s.id !== id) - const newTasks = state.tasks.map((t) => - t.sprintId === id ? { ...t, sprintId: undefined } : t - ) - syncToServer(state.projects, newTasks, newSprints) - return { - sprints: newSprints, - tasks: newTasks, - selectedSprintId: state.selectedSprintId === id ? null : state.selectedSprintId, + void (async () => { + try { + await requestApi('/api/sprints', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + await get().syncFromServer() + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete sprint' + console.error('>>> deleteSprint failed:', error) + set({ syncError: message }) } - }) + })() }, selectSprint: (id) => set({ selectedSprintId: id }), @@ -871,7 +645,12 @@ export const useTaskStore = create()( ) const updatedTask = newTasks.find(t => t.id === taskId) if (updatedTask) { - syncTaskToServer(updatedTask) + void (async () => { + const success = await syncTaskToServer(updatedTask) + if (!success) { + await get().syncFromServer() + } + })() } return { tasks: newTasks } }) @@ -894,7 +673,12 @@ export const useTaskStore = create()( ) const updatedTask = newTasks.find(t => t.id === taskId) if (updatedTask) { - syncTaskToServer(updatedTask) + void (async () => { + const success = await syncTaskToServer(updatedTask) + if (!success) { + await get().syncFromServer() + } + })() } return { tasks: newTasks } }) @@ -914,9 +698,26 @@ export const useTaskStore = create()( }), { name: 'task-store', + version: 1, + migrate: (persistedState) => { + if (!persistedState || typeof persistedState !== 'object') { + return {} + } + + const state = persistedState as { + selectedProjectId?: unknown + selectedTaskId?: unknown + selectedSprintId?: unknown + } + + return { + selectedProjectId: typeof state.selectedProjectId === 'string' ? state.selectedProjectId : null, + selectedTaskId: typeof state.selectedTaskId === 'string' ? state.selectedTaskId : null, + selectedSprintId: typeof state.selectedSprintId === 'string' ? state.selectedSprintId : null, + } + }, partialize: (state) => ({ - // Persist user identity and UI state, not task data - currentUser: state.currentUser, + // Persist UI selection state only. All business data comes from Supabase. selectedProjectId: state.selectedProjectId, selectedTaskId: state.selectedTaskId, selectedSprintId: state.selectedSprintId, @@ -928,10 +729,9 @@ export const useTaskStore = create()( console.log('>>> PERSIST: Rehydration error:', error) } else { console.log('>>> PERSIST: Rehydrated state:', { - currentUser: state?.currentUser?.name, selectedProjectId: state?.selectedProjectId, selectedTaskId: state?.selectedTaskId, - tasksCount: state?.tasks?.length, + selectedSprintId: state?.selectedSprintId, }) } } diff --git a/supabase/schema.sql b/supabase/schema.sql index f540376..0b275b0 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -9,17 +9,14 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- ============================================ CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - legacy_id TEXT UNIQUE, -- For migration from SQLite name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, avatar_url TEXT, - password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Create index on email for faster lookups CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); -CREATE INDEX IF NOT EXISTS idx_users_legacy_id ON users(legacy_id); -- Enable RLS ALTER TABLE users ENABLE ROW LEVEL SECURITY; @@ -32,6 +29,17 @@ CREATE POLICY "Users can read own data" ON users CREATE POLICY "Users can update own data" ON users FOR UPDATE USING (auth.uid() = id); +-- ============================================ +-- PROFILES TABLE +-- ============================================ +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id), + name TEXT, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- ============================================ -- SESSIONS TABLE -- ============================================ @@ -83,16 +91,12 @@ CREATE POLICY "Service role manages reset tokens" ON password_reset_tokens -- ============================================ CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - legacy_id TEXT UNIQUE, -- For migration from SQLite name TEXT NOT NULL, description TEXT, color TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Create index for legacy ID lookups -CREATE INDEX IF NOT EXISTS idx_projects_legacy_id ON projects(legacy_id); - -- Enable RLS ALTER TABLE projects ENABLE ROW LEVEL SECURITY; @@ -117,7 +121,6 @@ CREATE POLICY "Authenticated users can delete projects" ON projects -- ============================================ CREATE TABLE IF NOT EXISTS sprints ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - legacy_id TEXT UNIQUE, -- For migration from SQLite name TEXT NOT NULL, goal TEXT, start_date DATE NOT NULL, @@ -129,7 +132,6 @@ CREATE TABLE IF NOT EXISTS sprints ( -- Create indexes CREATE INDEX IF NOT EXISTS idx_sprints_project_id ON sprints(project_id); -CREATE INDEX IF NOT EXISTS idx_sprints_legacy_id ON sprints(legacy_id); CREATE INDEX IF NOT EXISTS idx_sprints_dates ON sprints(start_date, end_date); -- Enable RLS @@ -144,7 +146,6 @@ CREATE POLICY "Authenticated users can manage sprints" ON sprints -- ============================================ CREATE TABLE IF NOT EXISTS tasks ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - legacy_id TEXT UNIQUE, -- For migration from SQLite title TEXT NOT NULL, description TEXT, type TEXT NOT NULL CHECK (type IN ('idea', 'task', 'bug', 'research', 'plan')), @@ -155,15 +156,8 @@ CREATE TABLE IF NOT EXISTS tasks ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by_id UUID REFERENCES users(id) ON DELETE SET NULL, - created_by_name TEXT, - created_by_avatar_url TEXT, updated_by_id UUID REFERENCES users(id) ON DELETE SET NULL, - updated_by_name TEXT, - updated_by_avatar_url TEXT, assignee_id UUID REFERENCES users(id) ON DELETE SET NULL, - assignee_name TEXT, - assignee_email TEXT, - assignee_avatar_url TEXT, due_date DATE, comments JSONB NOT NULL DEFAULT '[]'::jsonb, tags JSONB NOT NULL DEFAULT '[]'::jsonb, @@ -177,7 +171,6 @@ CREATE INDEX IF NOT EXISTS idx_tasks_assignee_id ON tasks(assignee_id); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); -CREATE INDEX IF NOT EXISTS idx_tasks_legacy_id ON tasks(legacy_id); CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC); -- Create trigger to auto-update updated_at @@ -202,26 +195,6 @@ ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; CREATE POLICY "Authenticated users can manage tasks" ON tasks FOR ALL USING (auth.role() = 'authenticated'); --- ============================================ --- META TABLE (for app state like lastUpdated) --- ============================================ -CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Enable RLS -ALTER TABLE meta ENABLE ROW LEVEL SECURITY; - --- Policy: All authenticated users can manage meta -CREATE POLICY "Authenticated users can manage meta" ON meta - FOR ALL USING (auth.role() = 'authenticated'); - --- Insert initial lastUpdated value -INSERT INTO meta (key, value) VALUES ('lastUpdated', extract(epoch from now()) * 1000) -ON CONFLICT (key) DO UPDATE SET value = excluded.value; - -- ============================================ -- FUNCTIONS -- ============================================