Compare commits
No commits in common. "027496baf048fd89da3632058aa734f0218e6c97" and "eba669f1873487afb5d81be2dbe05ae21656b8e6" have entirely different histories.
027496baf0
...
eba669f187
91
AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md
Normal file
91
AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# 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.
|
||||||
156
MIGRATION_SUMMARY.md
Normal file
156
MIGRATION_SUMMARY.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# 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)
|
||||||
25
README.md
25
README.md
@ -16,11 +16,12 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
|
|
||||||
### Feb 21, 2026 updates
|
### Feb 21, 2026 updates
|
||||||
|
|
||||||
- Stabilized auth and settings flows with Supabase Auth credentials.
|
- Stabilized auth and settings migration after SQLite -> Supabase transition.
|
||||||
- Login/password validation now uses Supabase Auth credentials.
|
- Login/password validation now uses Supabase Auth credentials.
|
||||||
- Reset password flow no longer depends on `public.users.password_hash`.
|
- Reset password flow no longer depends on `public.users.password_hash`.
|
||||||
- Settings API response handling was hardened to avoid client script errors.
|
- Settings API response handling was hardened to avoid client script errors.
|
||||||
- Avatar preset key collision warning in settings was fixed.
|
- Avatar preset key collision warning in settings was fixed.
|
||||||
|
- Detailed notes: `AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md`
|
||||||
|
|
||||||
### Feb 22, 2026 updates
|
### Feb 22, 2026 updates
|
||||||
|
|
||||||
@ -28,13 +29,6 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
- Standardized sprint date parsing across Kanban, Backlog, Sprint Board, Archive, and task detail sprint pickers.
|
- Standardized sprint date parsing across Kanban, Backlog, Sprint Board, Archive, and task detail sprint pickers.
|
||||||
- `POST /api/sprints` and `PATCH /api/sprints` now normalize `startDate`/`endDate` to `YYYY-MM-DD` before writing to Supabase.
|
- `POST /api/sprints` and `PATCH /api/sprints` now normalize `startDate`/`endDate` to `YYYY-MM-DD` before writing to Supabase.
|
||||||
|
|
||||||
### Feb 23, 2026 updates
|
|
||||||
|
|
||||||
- Removed non-live fallback data paths from the task store; board data now comes from Supabase-backed APIs only.
|
|
||||||
- Removed legacy schema expectations from API code and docs (`legacy_id`, `meta` dependency, non-existent task columns).
|
|
||||||
- Task detail now includes explicit Project + Sprint selection; selecting a sprint auto-aligns `projectId`.
|
|
||||||
- API validation now accepts UUID-shaped IDs used by existing data and returns clearer error payloads for failed writes.
|
|
||||||
|
|
||||||
### Sprint date semantics (important)
|
### Sprint date semantics (important)
|
||||||
|
|
||||||
- Sprint dates are treated as local calendar-day boundaries:
|
- Sprint dates are treated as local calendar-day boundaries:
|
||||||
@ -60,8 +54,8 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
|
|
||||||
- Tasks use labels (`tags: string[]`) and can have multiple labels.
|
- Tasks use labels (`tags: string[]`) and can have multiple labels.
|
||||||
- Tasks support attachments (`attachments: TaskAttachment[]`).
|
- Tasks support attachments (`attachments: TaskAttachment[]`).
|
||||||
- Tasks persist identity references via `createdById`, `updatedById`, and `assigneeId`.
|
- Tasks now track `createdById`, `createdByName`, `createdByAvatarUrl`, `updatedById`, `updatedByName`, and `updatedByAvatarUrl`.
|
||||||
- Display fields (`createdByName`, `updatedByName`, `assigneeName`, avatar/email) are resolved from `users` data in API/UI, not stored as separate task table columns.
|
- Tasks now track assignment via `assigneeId`, `assigneeName`, `assigneeEmail`, and `assigneeAvatarUrl`.
|
||||||
- There is no active `backlog` status in workflow logic.
|
- There is no active `backlog` status in workflow logic.
|
||||||
- A task is considered in Backlog when `sprintId` is empty.
|
- A task is considered in Backlog when `sprintId` is empty.
|
||||||
- Current status values:
|
- Current status values:
|
||||||
@ -125,7 +119,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
|
|
||||||
### Labels
|
### Labels
|
||||||
|
|
||||||
- Labels are independent of project/sprint selection.
|
- Project selection UI for tasks was removed in favor of labels.
|
||||||
- You can add/remove labels inline in:
|
- You can add/remove labels inline in:
|
||||||
- New Task modal
|
- New Task modal
|
||||||
- Task Detail page
|
- Task Detail page
|
||||||
@ -141,7 +135,6 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
- `/tasks/{taskId}`
|
- `/tasks/{taskId}`
|
||||||
- Task detail is no longer popup-only behavior.
|
- Task detail is no longer popup-only behavior.
|
||||||
- Each task now has a shareable deep link.
|
- Each task now has a shareable deep link.
|
||||||
- Task detail includes explicit Project and Sprint dropdowns.
|
|
||||||
|
|
||||||
### Attachments
|
### Attachments
|
||||||
|
|
||||||
@ -208,12 +201,8 @@ During drag, the active target column shows expanded status drop zones for clari
|
|||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
- Client state is managed with Zustand.
|
- Client state is managed with Zustand.
|
||||||
- Persistence is handled through Supabase-backed API routes:
|
- Persistence is done via `/api/tasks`.
|
||||||
- `/api/tasks`
|
- API reads/writes Supabase tables.
|
||||||
- `/api/projects`
|
|
||||||
- `/api/sprints`
|
|
||||||
- No local task/project/sprint fallback dataset is used.
|
|
||||||
- API reads/writes Supabase tables only.
|
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
|
|||||||
@ -149,7 +149,7 @@ Or push to git and let Vercel auto-deploy.
|
|||||||
|
|
||||||
### After (Supabase):
|
### After (Supabase):
|
||||||
- PostgreSQL database hosted by Supabase
|
- PostgreSQL database hosted by Supabase
|
||||||
- Supabase Auth credentials + app-managed `gantt_session` cookie sessions
|
- JWT-based session tokens
|
||||||
- Works on multiple servers/Vercel edge functions
|
- Works on multiple servers/Vercel edge functions
|
||||||
- Real-time subscriptions possible (future enhancement)
|
- Real-time subscriptions possible (future enhancement)
|
||||||
|
|
||||||
@ -164,7 +164,6 @@ Or push to git and let Vercel auto-deploy.
|
|||||||
- Users can only see/modify their own data
|
- Users can only see/modify their own data
|
||||||
- Admin operations use service role key
|
- Admin operations use service role key
|
||||||
|
|
||||||
3. **Password handling**
|
3. **Password hashing remains the same**
|
||||||
- Passwords are managed by Supabase Auth.
|
- We use scrypt hashing (same as SQLite version)
|
||||||
- Do not add `public.users.password_hash`; keep password data out of public tables.
|
- Passwords are never stored in plain text
|
||||||
- App login state uses secure, HTTP-only session cookies.
|
|
||||||
|
|||||||
@ -41,17 +41,17 @@ A unified CLI that covers all API operations.
|
|||||||
|
|
||||||
# Create task
|
# Create task
|
||||||
./scripts/gantt.sh task create <title> [status] [priority] [project-id]
|
./scripts/gantt.sh task create <title> [status] [priority] [project-id]
|
||||||
./scripts/gantt.sh task create "Fix bug" open high <project-uuid>
|
./scripts/gantt.sh task create "Fix bug" open high 1
|
||||||
|
|
||||||
# Create from natural language
|
# Create from natural language
|
||||||
./scripts/gantt.sh task natural "Fix login bug by Friday, assign to Matt, high priority"
|
./scripts/gantt.sh task natural "Fix login bug by Friday, assign to Matt, high priority"
|
||||||
|
|
||||||
# Update any field
|
# Update any field
|
||||||
./scripts/gantt.sh task update <task-id> <field> <value>
|
./scripts/gantt.sh task update <task-id> <field> <value>
|
||||||
./scripts/gantt.sh task update <task-uuid> status done
|
./scripts/gantt.sh task update abc-123 status done
|
||||||
./scripts/gantt.sh task update <task-uuid> priority urgent
|
./scripts/gantt.sh task update abc-123 priority urgent
|
||||||
./scripts/gantt.sh task update <task-uuid> title "New title"
|
./scripts/gantt.sh task update abc-123 title "New title"
|
||||||
./scripts/gantt.sh task update <task-uuid> assigneeId <user-uuid>
|
./scripts/gantt.sh task update abc-123 assigneeId <user-id>
|
||||||
|
|
||||||
# Delete task
|
# Delete task
|
||||||
./scripts/gantt.sh task delete <task-id>
|
./scripts/gantt.sh task delete <task-id>
|
||||||
@ -61,7 +61,7 @@ A unified CLI that covers all API operations.
|
|||||||
|
|
||||||
# Attach file
|
# Attach file
|
||||||
./scripts/gantt.sh task attach <task-id> <file-path>
|
./scripts/gantt.sh task attach <task-id> <file-path>
|
||||||
./scripts/gantt.sh task attach <task-uuid> ./research.pdf
|
./scripts/gantt.sh task attach abc-123 ./research.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
### Project Commands
|
### Project Commands
|
||||||
@ -78,9 +78,9 @@ A unified CLI that covers all API operations.
|
|||||||
|
|
||||||
# Update project field
|
# Update project field
|
||||||
./scripts/gantt.sh project update <project-id> <field> <value>
|
./scripts/gantt.sh project update <project-id> <field> <value>
|
||||||
./scripts/gantt.sh project update <project-uuid> name "New Name"
|
./scripts/gantt.sh project update abc-123 name "New Name"
|
||||||
./scripts/gantt.sh project update <project-uuid> description "New desc"
|
./scripts/gantt.sh project update abc-123 description "New desc"
|
||||||
./scripts/gantt.sh project update <project-uuid> color "#ff0000"
|
./scripts/gantt.sh project update abc-123 color "#ff0000"
|
||||||
|
|
||||||
# Delete project
|
# Delete project
|
||||||
./scripts/gantt.sh project delete <project-id>
|
./scripts/gantt.sh project delete <project-id>
|
||||||
@ -100,9 +100,9 @@ A unified CLI that covers all API operations.
|
|||||||
|
|
||||||
# Update sprint field
|
# Update sprint field
|
||||||
./scripts/gantt.sh sprint update <sprint-id> <field> <value>
|
./scripts/gantt.sh sprint update <sprint-id> <field> <value>
|
||||||
./scripts/gantt.sh sprint update <sprint-uuid> name "New Sprint Name"
|
./scripts/gantt.sh sprint update abc-123 name "New Sprint Name"
|
||||||
./scripts/gantt.sh sprint update <sprint-uuid> status active
|
./scripts/gantt.sh sprint update abc-123 status active
|
||||||
./scripts/gantt.sh sprint update <sprint-uuid> startDate "2026-02-25"
|
./scripts/gantt.sh sprint update abc-123 startDate "2026-02-25"
|
||||||
|
|
||||||
# Delete sprint
|
# Delete sprint
|
||||||
./scripts/gantt.sh sprint delete <sprint-id>
|
./scripts/gantt.sh sprint delete <sprint-id>
|
||||||
@ -242,13 +242,13 @@ export API_URL=http://localhost:3001/api
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add completion comment
|
# Add completion comment
|
||||||
./scripts/gantt.sh task comment <task-uuid> "Completed. See attached notes."
|
./scripts/gantt.sh task comment abc-123 "Completed. See attached notes."
|
||||||
|
|
||||||
# Attach notes
|
# Attach notes
|
||||||
./scripts/gantt.sh task attach <task-uuid> ./completion-notes.md
|
./scripts/gantt.sh task attach abc-123 ./completion-notes.md
|
||||||
|
|
||||||
# Mark done
|
# Mark done
|
||||||
./scripts/gantt.sh task update <task-uuid> status done
|
./scripts/gantt.sh task update abc-123 status done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Find Tasks
|
### Find Tasks
|
||||||
@ -294,7 +294,6 @@ Task IDs are UUIDs like `33ebc71e-7d40-456c-8f98-bb3578d2bb2b`. Find them:
|
|||||||
- In the URL when viewing a task
|
- In the URL when viewing a task
|
||||||
- From `task list` output
|
- From `task list` output
|
||||||
- From `task create` output
|
- From `task create` output
|
||||||
- `projectId`, `sprintId`, and `assigneeId` fields are UUID values as well.
|
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
|
|||||||
@ -74,16 +74,37 @@ export async function POST(request: Request) {
|
|||||||
const userEmail = Array.isArray(resetToken.users)
|
const userEmail = Array.isArray(resetToken.users)
|
||||||
? resetToken.users[0]?.email
|
? resetToken.users[0]?.email
|
||||||
: resetToken.users?.email;
|
: resetToken.users?.email;
|
||||||
|
const userName = Array.isArray(resetToken.users)
|
||||||
|
? resetToken.users[0]?.name
|
||||||
|
: resetToken.users?.name;
|
||||||
|
|
||||||
if (userEmail?.toLowerCase() !== email) {
|
if (userEmail?.toLowerCase() !== email) {
|
||||||
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict mode: reset requires an existing Supabase Auth user.
|
// 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.
|
||||||
const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, {
|
const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, {
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mark token as used
|
// Mark token as used
|
||||||
await supabase
|
await supabase
|
||||||
|
|||||||
@ -74,34 +74,21 @@ export async function PATCH(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { id, ...rawUpdates } = body;
|
const { id, ...updates } = body;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: "Missing project id" }, { status: 400 });
|
return NextResponse.json({ error: "Missing project id" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
|
||||||
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 supabase = getServiceSupabase();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("projects")
|
.from("projects")
|
||||||
.update(updates)
|
.update({
|
||||||
|
...updates,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
.eq("id", id)
|
.eq("id", id)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@ -130,15 +117,9 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const { error, count } = await supabase
|
const { error } = await supabase.from("projects").delete().eq("id", id);
|
||||||
.from("projects")
|
|
||||||
.delete({ count: "exact" })
|
|
||||||
.eq("id", id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if ((count ?? 0) === 0) {
|
|
||||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -7,20 +7,6 @@ export const runtime = "nodejs";
|
|||||||
// Sprint dates are stored as SQL DATE values (YYYY-MM-DD). We accept either
|
// 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.
|
// date-only or ISO datetime inputs and normalize to the date prefix.
|
||||||
const DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/;
|
const DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/;
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[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<string, unknown>;
|
|
||||||
|
|
||||||
constructor(status: number, message: string, details?: Record<string, unknown>) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
this.details = details;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDateOnlyInput(value: unknown): string | null {
|
function toDateOnlyInput(value: unknown): string | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
@ -30,38 +16,8 @@ function toDateOnlyInput(value: unknown): string | null {
|
|||||||
return match?.[1] ?? null;
|
return match?.[1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireNonEmptyString(value: unknown, field: string): string {
|
function currentDateOnly(): string {
|
||||||
if (typeof value !== "string" || value.trim().length === 0) {
|
return new Date().toISOString().split("T")[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<typeof getServiceSupabase>,
|
|
||||||
projectId: string
|
|
||||||
): Promise<string> {
|
|
||||||
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)
|
// GET - fetch all sprints (optionally filtered by status)
|
||||||
@ -75,9 +31,6 @@ export async function GET(request: Request) {
|
|||||||
// Parse query params
|
// Parse query params
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const status = searchParams.get("status");
|
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();
|
const supabase = getServiceSupabase();
|
||||||
let query = supabase
|
let query = supabase
|
||||||
@ -86,7 +39,7 @@ export async function GET(request: Request) {
|
|||||||
.order("start_date", { ascending: true });
|
.order("start_date", { ascending: true });
|
||||||
|
|
||||||
// Filter by status if provided
|
// Filter by status if provided
|
||||||
if (status) {
|
if (status && ["planning", "active", "completed"].includes(status)) {
|
||||||
query = query.eq("status", status);
|
query = query.eq("status", status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,9 +50,6 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ sprints: sprints || [] });
|
return NextResponse.json({ sprints: sprints || [] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API GET /sprints error:", 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 });
|
return NextResponse.json({ error: "Failed to fetch sprints" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,32 +65,25 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, goal, startDate, endDate, status, projectId } = body;
|
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 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 now = new Date().toISOString();
|
||||||
|
const fallbackDate = currentDateOnly();
|
||||||
|
const normalizedStartDate = toDateOnlyInput(startDate) ?? fallbackDate;
|
||||||
|
const normalizedEndDate = toDateOnlyInput(endDate) ?? normalizedStartDate;
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("sprints")
|
.from("sprints")
|
||||||
.insert({
|
.insert({
|
||||||
name: resolvedName,
|
name,
|
||||||
goal: goal || null,
|
goal: goal || null,
|
||||||
start_date: normalizedStartDate,
|
start_date: normalizedStartDate,
|
||||||
end_date: normalizedEndDate,
|
end_date: normalizedEndDate,
|
||||||
status: resolvedStatus,
|
status: status || "planning",
|
||||||
project_id: resolvedProjectId,
|
project_id: projectId || null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
@ -151,9 +94,6 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ success: true, sprint: data });
|
return NextResponse.json({ success: true, sprint: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API POST /sprints error:", 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 });
|
return NextResponse.json({ error: "Failed to create sprint" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -169,37 +109,39 @@ export async function PATCH(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { id, ...updates } = body;
|
const { id, ...updates } = body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Missing sprint id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const sprintId = requireUuid(id, "id");
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Map camelCase to snake_case for database and keep date-only semantics.
|
// Map camelCase to snake_case for database and keep date-only semantics.
|
||||||
const dbUpdates: Record<string, unknown> = {};
|
const dbUpdates: Record<string, unknown> = {};
|
||||||
if (updates.name !== undefined) dbUpdates.name = requireNonEmptyString(updates.name, "name");
|
if (updates.name !== undefined) dbUpdates.name = updates.name;
|
||||||
if (updates.goal !== undefined) dbUpdates.goal = updates.goal;
|
if (updates.goal !== undefined) dbUpdates.goal = updates.goal;
|
||||||
if (updates.startDate !== undefined) {
|
if (updates.startDate !== undefined) {
|
||||||
const normalizedStartDate = toDateOnlyInput(updates.startDate);
|
const normalizedStartDate = toDateOnlyInput(updates.startDate);
|
||||||
if (!normalizedStartDate) {
|
if (!normalizedStartDate) {
|
||||||
throw new HttpError(400, "startDate must be YYYY-MM-DD or ISO date-time", { startDate: updates.startDate });
|
return NextResponse.json({ error: "Invalid startDate format" }, { status: 400 });
|
||||||
}
|
}
|
||||||
dbUpdates.start_date = normalizedStartDate;
|
dbUpdates.start_date = normalizedStartDate;
|
||||||
}
|
}
|
||||||
if (updates.endDate !== undefined) {
|
if (updates.endDate !== undefined) {
|
||||||
const normalizedEndDate = toDateOnlyInput(updates.endDate);
|
const normalizedEndDate = toDateOnlyInput(updates.endDate);
|
||||||
if (!normalizedEndDate) {
|
if (!normalizedEndDate) {
|
||||||
throw new HttpError(400, "endDate must be YYYY-MM-DD or ISO date-time", { endDate: updates.endDate });
|
return NextResponse.json({ error: "Invalid endDate format" }, { status: 400 });
|
||||||
}
|
}
|
||||||
dbUpdates.end_date = normalizedEndDate;
|
dbUpdates.end_date = normalizedEndDate;
|
||||||
}
|
}
|
||||||
if (updates.status !== undefined) dbUpdates.status = requireSprintStatus(updates.status, "status");
|
if (updates.status !== undefined) dbUpdates.status = updates.status;
|
||||||
if (updates.projectId !== undefined) {
|
if (updates.projectId !== undefined) dbUpdates.project_id = updates.projectId;
|
||||||
const projectId = requireUuid(updates.projectId, "projectId");
|
dbUpdates.updated_at = now;
|
||||||
dbUpdates.project_id = await requireExistingProjectId(supabase, projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("sprints")
|
.from("sprints")
|
||||||
.update(dbUpdates)
|
.update(dbUpdates)
|
||||||
.eq("id", sprintId)
|
.eq("id", id)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@ -208,9 +150,6 @@ export async function PATCH(request: Request) {
|
|||||||
return NextResponse.json({ success: true, sprint: data });
|
return NextResponse.json({ success: true, sprint: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API PATCH /sprints error:", 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 });
|
return NextResponse.json({ error: "Failed to update sprint" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -224,25 +163,19 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
const sprintId = requireUuid(id, "id");
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Missing sprint id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const { error, count } = await supabase
|
const { error } = await supabase.from("sprints").delete().eq("id", id);
|
||||||
.from("sprints")
|
|
||||||
.delete({ count: "exact" })
|
|
||||||
.eq("id", sprintId);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if ((count ?? 0) === 0) {
|
|
||||||
throw new HttpError(404, "Sprint not found", { id: sprintId });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API DELETE /sprints error:", 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 });
|
return NextResponse.json({ error: "Failed to delete sprint" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
import { getAuthenticatedUser } from "@/lib/server/auth";
|
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||||
|
|
||||||
@ -61,7 +62,7 @@ const TASK_TYPES: Task["type"][] = ["idea", "task", "bug", "research", "plan"];
|
|||||||
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
|
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
|
||||||
const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
||||||
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
|
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
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;
|
||||||
|
|
||||||
// Optimized field selection - only fetch fields needed for board display
|
// Optimized field selection - only fetch fields needed for board display
|
||||||
// Full task details (description, comments, attachments) fetched lazily
|
// Full task details (description, comments, attachments) fetched lazily
|
||||||
@ -76,39 +77,41 @@ const TASK_FIELDS_LIGHT = [
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"created_by_id",
|
"created_by_id",
|
||||||
|
"created_by_name",
|
||||||
|
"created_by_avatar_url",
|
||||||
"updated_by_id",
|
"updated_by_id",
|
||||||
|
"updated_by_name",
|
||||||
|
"updated_by_avatar_url",
|
||||||
"assignee_id",
|
"assignee_id",
|
||||||
|
"assignee_name",
|
||||||
|
"assignee_email",
|
||||||
|
"assignee_avatar_url",
|
||||||
"due_date",
|
"due_date",
|
||||||
"tags",
|
"tags",
|
||||||
];
|
];
|
||||||
|
|
||||||
class HttpError extends Error {
|
// Fields for full task detail (when opening a task)
|
||||||
readonly status: number;
|
const TASK_FIELDS_FULL = [
|
||||||
readonly details?: Record<string, unknown>;
|
...TASK_FIELDS_LIGHT,
|
||||||
|
"description",
|
||||||
|
"comments",
|
||||||
|
"attachments",
|
||||||
|
];
|
||||||
|
|
||||||
constructor(status: number, message: string, details?: Record<string, unknown>) {
|
function isTaskType(value: unknown): value is Task["type"] {
|
||||||
super(message);
|
return typeof value === "string" && TASK_TYPES.includes(value as Task["type"]);
|
||||||
this.status = status;
|
|
||||||
this.details = details;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toErrorDetails(error: unknown): Record<string, unknown> | undefined {
|
function isTaskStatus(value: unknown): value is Task["status"] {
|
||||||
if (!error || typeof error !== "object") return undefined;
|
return typeof value === "string" && TASK_STATUSES.includes(value as Task["status"]);
|
||||||
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 throwQueryError(scope: string, error: unknown): never {
|
function isTaskPriority(value: unknown): value is Task["priority"] {
|
||||||
throw new HttpError(500, `${scope} query failed`, {
|
return typeof value === "string" && TASK_PRIORITIES.includes(value as Task["priority"]);
|
||||||
scope,
|
}
|
||||||
...toErrorDetails(error),
|
|
||||||
});
|
function isSprintStatus(value: unknown): value is Sprint["status"] {
|
||||||
|
return typeof value === "string" && SPRINT_STATUSES.includes(value as Sprint["status"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNonEmptyString(value: unknown): string | undefined {
|
function toNonEmptyString(value: unknown): string | undefined {
|
||||||
@ -123,92 +126,124 @@ function stripUndefined<T extends Record<string, unknown>>(value: T): Record<str
|
|||||||
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireNonEmptyString(value: unknown, field: string, status = 400): string {
|
function getMissingColumnFromError(error: unknown): string | null {
|
||||||
const normalized = toNonEmptyString(value);
|
if (!error || typeof error !== "object") return null;
|
||||||
if (!normalized) {
|
const candidate = error as { code?: string; message?: string };
|
||||||
throw new HttpError(status, `${field} is required`, { field, value });
|
if (typeof candidate.message !== "string") return null;
|
||||||
}
|
|
||||||
return normalized;
|
if (candidate.code === "PGRST204") {
|
||||||
|
const postgrestMatch = candidate.message.match(/Could not find the '([^']+)' column/);
|
||||||
|
if (postgrestMatch?.[1]) return postgrestMatch[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireUuid(value: unknown, field: string, status = 400): string {
|
if (candidate.code === "42703") {
|
||||||
const normalized = requireNonEmptyString(value, field, status);
|
const postgresMatch = candidate.message.match(/column\s+(?:\w+\.)?\"?([a-zA-Z0-9_]+)\"?\s+does not exist/i);
|
||||||
if (!isUuid(normalized)) {
|
if (postgresMatch?.[1]) return postgresMatch[1];
|
||||||
throw new HttpError(status, `${field} must be a UUID`, { field, value });
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireEnum<T extends string>(value: unknown, allowed: readonly T[], field: string, status = 400): T {
|
return null;
|
||||||
if (typeof value !== "string" || !allowed.includes(value as T)) {
|
|
||||||
throw new HttpError(status, `Invalid ${field} value`, { field, value });
|
|
||||||
}
|
}
|
||||||
return value as T;
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTasksWithColumnFallback(
|
||||||
|
supabase: ReturnType<typeof getServiceSupabase>
|
||||||
|
): Promise<{ rows: Record<string, unknown>[]; 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<string, unknown>[] | null) ?? [],
|
||||||
|
droppedColumns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingColumn = getMissingColumnFromError(error);
|
||||||
|
if (missingColumn && selectedFields.includes(missingColumn)) {
|
||||||
|
droppedColumns.push(missingColumn);
|
||||||
|
selectedFields = selectedFields.filter((field) => field !== missingColumn);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows: [], droppedColumns };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveRequiredProjectId(
|
async function resolveRequiredProjectId(
|
||||||
supabase: ReturnType<typeof getServiceSupabase>,
|
supabase: ReturnType<typeof getServiceSupabase>,
|
||||||
requestedProjectId?: string
|
requestedProjectId?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const projectId = requireUuid(requestedProjectId, "task.projectId");
|
const requested = toNonEmptyString(requestedProjectId);
|
||||||
const { data, error } = await supabase.from("projects").select("id").eq("id", projectId).maybeSingle();
|
if (requested) {
|
||||||
if (error) throw error;
|
const { data } = await supabase.from("projects").select("id").eq("id", requested).maybeSingle();
|
||||||
if (!data?.id) {
|
if (data?.id) return data.id as string;
|
||||||
throw new HttpError(400, "task.projectId does not exist", { projectId });
|
|
||||||
}
|
}
|
||||||
return data.id as string;
|
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOptionalForeignId(
|
async function resolveOptionalForeignId(
|
||||||
supabase: ReturnType<typeof getServiceSupabase>,
|
supabase: ReturnType<typeof getServiceSupabase>,
|
||||||
table: "sprints" | "users",
|
table: "sprints" | "users",
|
||||||
id: string | undefined,
|
id?: string
|
||||||
field: string
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const requested = toNonEmptyString(id);
|
const requested = toNonEmptyString(id);
|
||||||
if (!requested) return null;
|
if (!requested) return null;
|
||||||
if (!isUuid(requested)) {
|
const { data } = await supabase.from(table).select("id").eq("id", requested).maybeSingle();
|
||||||
throw new HttpError(400, `${field} must be a UUID when provided`, { field, value: id });
|
return data?.id ? (data.id as string) : null;
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown>): Project {
|
function mapProjectRow(row: Record<string, unknown>): Project {
|
||||||
return {
|
return {
|
||||||
id: requireNonEmptyString(row.id, "projects.id", 500),
|
id: String(row.id ?? ""),
|
||||||
name: requireNonEmptyString(row.name, "projects.name", 500),
|
name: toNonEmptyString(row.name) ?? "Untitled Project",
|
||||||
description: toNonEmptyString(row.description),
|
description: toNonEmptyString(row.description),
|
||||||
color: requireNonEmptyString(row.color, "projects.color", 500),
|
color: toNonEmptyString(row.color) ?? "#3b82f6",
|
||||||
createdAt: requireNonEmptyString(row.created_at, "projects.created_at", 500),
|
createdAt: toNonEmptyString(row.created_at) ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapSprintRow(row: Record<string, unknown>): Sprint {
|
function mapSprintRow(row: Record<string, unknown>): Sprint {
|
||||||
|
const fallbackDate = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
id: requireNonEmptyString(row.id, "sprints.id", 500),
|
id: String(row.id ?? ""),
|
||||||
name: requireNonEmptyString(row.name, "sprints.name", 500),
|
name: toNonEmptyString(row.name) ?? "Untitled Sprint",
|
||||||
goal: toNonEmptyString(row.goal),
|
goal: toNonEmptyString(row.goal),
|
||||||
startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500),
|
startDate: toNonEmptyString(row.start_date) ?? fallbackDate,
|
||||||
endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500),
|
endDate: toNonEmptyString(row.end_date) ?? fallbackDate,
|
||||||
status: requireEnum(row.status, SPRINT_STATUSES, "sprints.status", 500),
|
status: isSprintStatus(row.status) ? row.status : "planning",
|
||||||
projectId: requireNonEmptyString(row.project_id, "sprints.project_id", 500),
|
projectId: String(row.project_id ?? ""),
|
||||||
createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500),
|
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapUserRow(row: Record<string, unknown>): UserProfile {
|
function mapUserRow(row: Record<string, unknown>): UserProfile | null {
|
||||||
const id = requireNonEmptyString(row.id, "users.id", 500);
|
const id = toNonEmptyString(row.id);
|
||||||
const name = requireNonEmptyString(row.name, "users.name", 500);
|
const name = toNonEmptyString(row.name);
|
||||||
|
if (!id || !name) return null;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@ -218,6 +253,7 @@ function mapUserRow(row: Record<string, unknown>): UserProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserProfile>, includeFullData = false): Task {
|
function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserProfile>, includeFullData = false): Task {
|
||||||
|
const fallbackDate = new Date().toISOString();
|
||||||
const createdById = toNonEmptyString(row.created_by_id);
|
const createdById = toNonEmptyString(row.created_by_id);
|
||||||
const updatedById = toNonEmptyString(row.updated_by_id);
|
const updatedById = toNonEmptyString(row.updated_by_id);
|
||||||
const assigneeId = toNonEmptyString(row.assignee_id);
|
const assigneeId = toNonEmptyString(row.assignee_id);
|
||||||
@ -225,43 +261,31 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
|
|||||||
const updatedByUser = updatedById ? usersById.get(updatedById) : undefined;
|
const updatedByUser = updatedById ? usersById.get(updatedById) : undefined;
|
||||||
const assigneeUser = assigneeId ? usersById.get(assigneeId) : undefined;
|
const assigneeUser = assigneeId ? usersById.get(assigneeId) : undefined;
|
||||||
|
|
||||||
if (!Array.isArray(row.tags)) {
|
|
||||||
throw new HttpError(500, "Invalid tasks.tags value in database", { taskId: row.id, value: row.tags });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeFullData && row.comments !== undefined && !Array.isArray(row.comments)) {
|
|
||||||
throw new HttpError(500, "Invalid tasks.comments value in database", { taskId: row.id, value: row.comments });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeFullData && row.attachments !== undefined && !Array.isArray(row.attachments)) {
|
|
||||||
throw new HttpError(500, "Invalid tasks.attachments value in database", { taskId: row.id, value: row.attachments });
|
|
||||||
}
|
|
||||||
|
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id: requireNonEmptyString(row.id, "tasks.id", 500),
|
id: String(row.id ?? ""),
|
||||||
title: requireNonEmptyString(row.title, "tasks.title", 500),
|
title: toNonEmptyString(row.title) ?? "",
|
||||||
description: includeFullData ? toNonEmptyString(row.description) : undefined,
|
description: includeFullData ? toNonEmptyString(row.description) : undefined,
|
||||||
type: requireEnum(row.type, TASK_TYPES, "tasks.type", 500),
|
type: isTaskType(row.type) ? row.type : "task",
|
||||||
status: requireEnum(row.status, TASK_STATUSES, "tasks.status", 500),
|
status: isTaskStatus(row.status) ? row.status : "todo",
|
||||||
priority: requireEnum(row.priority, TASK_PRIORITIES, "tasks.priority", 500),
|
priority: isTaskPriority(row.priority) ? row.priority : "medium",
|
||||||
projectId: requireNonEmptyString(row.project_id, "tasks.project_id", 500),
|
projectId: String(row.project_id ?? ""),
|
||||||
sprintId: toNonEmptyString(row.sprint_id),
|
sprintId: toNonEmptyString(row.sprint_id),
|
||||||
createdAt: requireNonEmptyString(row.created_at, "tasks.created_at", 500),
|
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
|
||||||
updatedAt: requireNonEmptyString(row.updated_at, "tasks.updated_at", 500),
|
updatedAt: toNonEmptyString(row.updated_at) ?? fallbackDate,
|
||||||
createdById,
|
createdById,
|
||||||
createdByName: createdByUser?.name,
|
createdByName: toNonEmptyString(row.created_by_name) ?? createdByUser?.name,
|
||||||
createdByAvatarUrl: createdByUser?.avatarUrl,
|
createdByAvatarUrl: toNonEmptyString(row.created_by_avatar_url) ?? createdByUser?.avatarUrl,
|
||||||
updatedById,
|
updatedById,
|
||||||
updatedByName: updatedByUser?.name,
|
updatedByName: toNonEmptyString(row.updated_by_name) ?? updatedByUser?.name,
|
||||||
updatedByAvatarUrl: updatedByUser?.avatarUrl,
|
updatedByAvatarUrl: toNonEmptyString(row.updated_by_avatar_url) ?? updatedByUser?.avatarUrl,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
assigneeName: assigneeUser?.name,
|
assigneeName: toNonEmptyString(row.assignee_name) ?? assigneeUser?.name,
|
||||||
assigneeEmail: assigneeUser?.email,
|
assigneeEmail: toNonEmptyString(row.assignee_email) ?? assigneeUser?.email,
|
||||||
assigneeAvatarUrl: assigneeUser?.avatarUrl,
|
assigneeAvatarUrl: toNonEmptyString(row.assignee_avatar_url) ?? assigneeUser?.avatarUrl,
|
||||||
dueDate: toNonEmptyString(row.due_date),
|
dueDate: toNonEmptyString(row.due_date),
|
||||||
comments: includeFullData ? (row.comments as unknown[] | undefined) ?? [] : [],
|
comments: includeFullData && Array.isArray(row.comments) ? row.comments : [],
|
||||||
tags: (row.tags as unknown[]).filter((tag): tag is string => typeof tag === "string"),
|
tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||||
attachments: includeFullData ? (row.attachments as unknown[] | undefined) ?? [] : [],
|
attachments: includeFullData && Array.isArray(row.attachments) ? row.attachments : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return task;
|
return task;
|
||||||
@ -271,10 +295,11 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
|
|||||||
// Uses lightweight fields for faster initial load
|
// Uses lightweight fields for faster initial load
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getAuthenticatedUser();
|
// TODO: Re-enable auth after fixing cookie issue on Vercel
|
||||||
if (!user) {
|
// const user = await getAuthenticatedUser();
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
// if (!user) {
|
||||||
}
|
// return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
// }
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
|
|
||||||
@ -282,61 +307,61 @@ export async function GET() {
|
|||||||
const [
|
const [
|
||||||
{ data: projects, error: projectsError },
|
{ data: projects, error: projectsError },
|
||||||
{ data: sprints, error: sprintsError },
|
{ data: sprints, error: sprintsError },
|
||||||
{ data: taskRows, error: tasksError },
|
{ data: users, error: usersError },
|
||||||
{ data: users, error: usersError }
|
{ data: meta, error: metaError }
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
|
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
|
||||||
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }),
|
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }),
|
||||||
supabase.from("tasks").select(TASK_FIELDS_LIGHT.join(", ")).order("updated_at", { ascending: false }),
|
|
||||||
supabase.from("users").select("id, name, email, avatar_url"),
|
supabase.from("users").select("id, name, email, avatar_url"),
|
||||||
|
supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (projectsError) throwQueryError("projects", projectsError);
|
if (projectsError) throw projectsError;
|
||||||
if (sprintsError) throwQueryError("sprints", sprintsError);
|
if (sprintsError) throw sprintsError;
|
||||||
if (tasksError) throwQueryError("tasks", tasksError);
|
if (metaError) throw metaError;
|
||||||
if (usersError) throwQueryError("users", usersError);
|
if (usersError) {
|
||||||
|
console.warn(">>> 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(", "));
|
||||||
|
}
|
||||||
|
|
||||||
const usersById = new Map<string, UserProfile>();
|
const usersById = new Map<string, UserProfile>();
|
||||||
for (const row of users || []) {
|
for (const row of users || []) {
|
||||||
const mapped = mapUserRow(row as Record<string, unknown>);
|
const mapped = mapUserRow(row as Record<string, unknown>);
|
||||||
usersById.set(mapped.id, mapped);
|
if (mapped) usersById.set(mapped.id, mapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
|
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
|
||||||
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
|
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
|
||||||
tasks: ((taskRows as unknown as Record<string, unknown>[] | null) || []).map((row) => mapTaskRow(row, usersById, false)),
|
tasks: taskRows.map((row) => mapTaskRow(row, usersById, false)),
|
||||||
currentUser: {
|
lastUpdated: Number(meta?.value ?? Date.now()),
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
avatarUrl: user.avatarUrl,
|
|
||||||
},
|
|
||||||
lastUpdated: Date.now(),
|
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-store',
|
// Enable caching for 30 seconds to reduce repeated requests
|
||||||
|
'Cache-Control': 'private, max-age=30, stale-while-revalidate=60',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API GET error:", error);
|
console.error(">>> API GET error:", error);
|
||||||
if (error instanceof HttpError) {
|
return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 });
|
||||||
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
|
// POST - create or update a single task
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const user = await getAuthenticatedUser();
|
// TODO: Re-enable auth after fixing cookie issue on Vercel
|
||||||
if (!user) {
|
// const user = await getAuthenticatedUser();
|
||||||
console.error(">>> API POST: No authenticated user");
|
// if (!user) {
|
||||||
return NextResponse.json({ error: "Unauthorized - please log in again" }, { status: 401 });
|
// 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);
|
// }
|
||||||
|
// 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 body = await request.json();
|
const body = await request.json();
|
||||||
const { task } = body as { task?: Task };
|
const { task } = body as { task?: Task };
|
||||||
@ -347,31 +372,30 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const taskId = requireUuid(task.id, "task.id");
|
const clientTaskId = toNonEmptyString(task.id);
|
||||||
|
const canonicalTaskId = isUuid(clientTaskId) ? clientTaskId : randomUUID();
|
||||||
const resolvedProjectId = await resolveRequiredProjectId(supabase, task.projectId);
|
const resolvedProjectId = await resolveRequiredProjectId(supabase, task.projectId);
|
||||||
const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId, "task.sprintId");
|
const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId);
|
||||||
const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId, "task.assigneeId");
|
const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId);
|
||||||
|
|
||||||
// Check if task exists
|
// Check if task exists
|
||||||
let existing: { id: string } | null = null;
|
const { data: existing } = await supabase
|
||||||
const { data: byId, error: byIdError } = await supabase
|
|
||||||
.from("tasks")
|
.from("tasks")
|
||||||
.select("id")
|
.select("id")
|
||||||
.eq("id", taskId)
|
.eq("id", canonicalTaskId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (byIdError) throw byIdError;
|
|
||||||
existing = (byId as { id: string } | null) ?? null;
|
|
||||||
|
|
||||||
const taskData = stripUndefined({
|
let taskData = stripUndefined({
|
||||||
id: taskId,
|
id: canonicalTaskId,
|
||||||
title: requireNonEmptyString(task.title, "task.title"),
|
legacy_id: clientTaskId && !isUuid(clientTaskId) ? clientTaskId : null,
|
||||||
|
title: task.title,
|
||||||
description: task.description || null,
|
description: task.description || null,
|
||||||
type: task.type ? requireEnum(task.type, TASK_TYPES, "task.type") : "task",
|
type: task.type || "task",
|
||||||
status: task.status ? requireEnum(task.status, TASK_STATUSES, "task.status") : "todo",
|
status: task.status || "todo",
|
||||||
priority: task.priority ? requireEnum(task.priority, TASK_PRIORITIES, "task.priority") : "medium",
|
priority: task.priority || "medium",
|
||||||
project_id: resolvedProjectId,
|
project_id: resolvedProjectId,
|
||||||
sprint_id: resolvedSprintId,
|
sprint_id: resolvedSprintId,
|
||||||
created_at: existing ? undefined : toNonEmptyString(task.createdAt),
|
created_at: existing ? undefined : (task.createdAt || now),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
created_by_id: existing ? undefined : user.id,
|
created_by_id: existing ? undefined : user.id,
|
||||||
updated_by_id: user.id,
|
updated_by_id: user.id,
|
||||||
@ -382,19 +406,50 @@ export async function POST(request: Request) {
|
|||||||
attachments: task.attachments || [],
|
attachments: task.attachments || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let result;
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
const query = existing
|
const query = existing
|
||||||
? supabase.from("tasks").update(taskData).eq("id", taskId).select().single()
|
? supabase.from("tasks").update(taskData).eq("id", canonicalTaskId).select().single()
|
||||||
: supabase.from("tasks").insert(taskData).select().single();
|
: supabase.from("tasks").insert(taskData).select().single();
|
||||||
const { data: result, error: saveError } = await query;
|
|
||||||
if (saveError) throw saveError;
|
const { data, error } = await query;
|
||||||
if (!result) throw new HttpError(500, "Task save succeeded without returning a row");
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true, task: result });
|
return NextResponse.json({ success: true, task: result });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error(">>> API POST error:", error);
|
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 message = error instanceof Error ? error.message : "Failed to save";
|
||||||
const details =
|
const details =
|
||||||
error && typeof error === "object"
|
error && typeof error === "object"
|
||||||
@ -411,31 +466,29 @@ export async function POST(request: Request) {
|
|||||||
// DELETE - remove a task
|
// DELETE - remove a task
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
try {
|
try {
|
||||||
const user = await getAuthenticatedUser();
|
// TODO: Re-enable auth after fixing cookie issue on Vercel
|
||||||
if (!user) {
|
// const user = await getAuthenticatedUser();
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
// if (!user) {
|
||||||
}
|
// return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
// }
|
||||||
|
|
||||||
const { id } = (await request.json()) as { id: string };
|
const { id } = (await request.json()) as { id: string };
|
||||||
const taskId = requireUuid(id, "id");
|
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const { error, count } = await supabase
|
const { error } = await supabase.from("tasks").delete().eq("id", id);
|
||||||
.from("tasks")
|
|
||||||
.delete({ count: "exact" })
|
|
||||||
.eq("id", taskId);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if ((count ?? 0) === 0) {
|
|
||||||
throw new HttpError(404, "Task not found", { id: taskId });
|
// Update lastUpdated
|
||||||
}
|
await supabase.from("meta").upsert({
|
||||||
|
key: "lastUpdated",
|
||||||
|
value: String(Date.now()),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error(">>> API DELETE error:", error);
|
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";
|
const message = error instanceof Error ? error.message : "Failed to delete";
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState, type FormEvent } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
@ -70,11 +70,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAuthSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
void submit()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900/70 p-6">
|
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900/70 p-6">
|
||||||
@ -98,7 +93,7 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={handleAuthSubmit}>
|
<div className="space-y-4">
|
||||||
{mode === "register" && (
|
{mode === "register" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-slate-300 mb-1">Name</label>
|
<label className="block text-sm text-slate-300 mb-1">Name</label>
|
||||||
@ -157,10 +152,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
<Button onClick={submit} className="w-full" disabled={isSubmitting}>
|
||||||
{isSubmitting ? "Please wait..." : mode === "login" ? "Login" : "Create Account"}
|
{isSubmitting ? "Please wait..." : mode === "login" ? "Login" : "Create Account"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
{/* Forgot Password Modal */}
|
{/* Forgot Password Modal */}
|
||||||
{showForgot && (
|
{showForgot && (
|
||||||
@ -230,11 +225,6 @@ function ForgotPasswordModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
void handleSubmit()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900 p-6">
|
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900 p-6">
|
||||||
@ -243,7 +233,7 @@ function ForgotPasswordModal({
|
|||||||
Enter your email and we'll send you a link to reset your password.
|
Enter your email and we'll send you a link to reset your password.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={handleResetSubmit}>
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-slate-300 mb-1">Email</label>
|
<label className="block text-sm text-slate-300 mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
@ -261,11 +251,11 @@ function ForgotPasswordModal({
|
|||||||
<Button onClick={onClose} variant="outline" className="flex-1">
|
<Button onClick={onClose} variant="outline" className="flex-1">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="flex-1" disabled={isSubmitting}>
|
<Button onClick={handleSubmit} className="flex-1" disabled={isSubmitting}>
|
||||||
{isSubmitting ? "Sending..." : "Send Link"}
|
{isSubmitting ? "Sending..." : "Send Link"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import {
|
|||||||
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
|
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
|
||||||
import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
|
import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
|
||||||
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
|
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
// Dynamic imports for heavy view components - only load when needed
|
// Dynamic imports for heavy view components - only load when needed
|
||||||
const BacklogView = dynamic(() => import("@/components/BacklogView").then(mod => mod.BacklogView), {
|
const BacklogView = dynamic(() => import("@/components/BacklogView").then(mod => mod.BacklogView), {
|
||||||
@ -831,14 +830,7 @@ export default function Home() {
|
|||||||
if (newTask.title?.trim()) {
|
if (newTask.title?.trim()) {
|
||||||
// If a specific sprint is selected, use that sprint's project
|
// If a specific sprint is selected, use that sprint's project
|
||||||
const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null
|
const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null
|
||||||
const targetProjectId = selectedSprint?.projectId || selectedProjectId || projects[0]?.id
|
const targetProjectId = selectedSprint?.projectId || selectedProjectId || projects[0]?.id || '2'
|
||||||
if (!targetProjectId) {
|
|
||||||
toast.error("Cannot create task", {
|
|
||||||
description: "No project is available. Create or select a project first.",
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskToCreate: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'> = {
|
const taskToCreate: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'> = {
|
||||||
title: newTask.title.trim(),
|
title: newTask.title.trim(),
|
||||||
|
|||||||
@ -227,7 +227,6 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
projects,
|
|
||||||
sprints,
|
sprints,
|
||||||
currentUser,
|
currentUser,
|
||||||
updateTask,
|
updateTask,
|
||||||
@ -353,14 +352,6 @@ export default function TaskDetailPage() {
|
|||||||
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}, [users, currentUser])
|
}, [users, currentUser])
|
||||||
|
|
||||||
const sortedSprints = useMemo(
|
|
||||||
() =>
|
|
||||||
sprints
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()),
|
|
||||||
[sprints]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(event.target.files || [])
|
const files = Array.from(event.target.files || [])
|
||||||
if (files.length === 0) return
|
if (files.length === 0) return
|
||||||
@ -460,46 +451,8 @@ export default function TaskDetailPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setEditedTaskProject = (projectId: string) => {
|
|
||||||
if (!editedTask) return
|
|
||||||
|
|
||||||
const sprintStillMatchesProject =
|
|
||||||
!editedTask.sprintId || sprints.some((sprint) => sprint.id === editedTask.sprintId && sprint.projectId === projectId)
|
|
||||||
|
|
||||||
setEditedTask({
|
|
||||||
...editedTask,
|
|
||||||
projectId,
|
|
||||||
sprintId: sprintStillMatchesProject ? editedTask.sprintId : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const setEditedTaskSprint = (sprintId: string) => {
|
|
||||||
if (!editedTask) return
|
|
||||||
if (!sprintId) {
|
|
||||||
setEditedTask({
|
|
||||||
...editedTask,
|
|
||||||
sprintId: undefined,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sprint = sprints.find((entry) => entry.id === sprintId)
|
|
||||||
setEditedTask({
|
|
||||||
...editedTask,
|
|
||||||
sprintId,
|
|
||||||
projectId: sprint?.projectId || editedTask.projectId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editedTask) return
|
if (!editedTask) return
|
||||||
if (!editedTask.projectId) {
|
|
||||||
toast.error("Project is required", {
|
|
||||||
description: "Select a project before saving this task.",
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
setSaveSuccess(false)
|
setSaveSuccess(false)
|
||||||
|
|
||||||
@ -816,31 +769,15 @@ export default function TaskDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-slate-400">Project</Label>
|
|
||||||
<select
|
|
||||||
value={editedTask.projectId}
|
|
||||||
onChange={(event) => setEditedTaskProject(event.target.value)}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select project</option>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<option key={project.id} value={project.id}>
|
|
||||||
{project.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-400">Sprint</Label>
|
<Label className="text-slate-400">Sprint</Label>
|
||||||
<select
|
<select
|
||||||
value={editedTask.sprintId || ""}
|
value={editedTask.sprintId || ""}
|
||||||
onChange={(event) => setEditedTaskSprint(event.target.value)}
|
onChange={(event) => setEditedTask({ ...editedTask, sprintId: event.target.value || undefined })}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<option value="">No Sprint</option>
|
<option value="">No Sprint</option>
|
||||||
{sortedSprints.map((sprint) => (
|
{sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => (
|
||||||
<option key={sprint.id} value={sprint.id}>
|
<option key={sprint.id} value={sprint.id}>
|
||||||
{sprint.name} ({parseSprintStart(sprint.startDate).toLocaleDateString()})
|
{sprint.name} ({parseSprintStart(sprint.startDate).toLocaleDateString()})
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@ -309,14 +309,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
// Get current active sprint
|
// Get current active sprint
|
||||||
// Treat end date as end-of-day (23:59:59) to handle timezone issues
|
// Treat end date as end-of-day (23:59:59) to handle timezone issues
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const projectSprints = selectedProjectId
|
const currentSprint = sprints.find((s) => {
|
||||||
? sprints.filter((s) => s.projectId === selectedProjectId)
|
|
||||||
: sprints
|
|
||||||
const projectTasks = selectedProjectId
|
|
||||||
? tasks.filter((t) => t.projectId === selectedProjectId)
|
|
||||||
: tasks
|
|
||||||
|
|
||||||
const currentSprint = projectSprints.find((s) => {
|
|
||||||
if (s.status !== "active") return false
|
if (s.status !== "active") return false
|
||||||
const sprintStart = parseSprintStart(s.startDate)
|
const sprintStart = parseSprintStart(s.startDate)
|
||||||
const sprintEnd = parseSprintEnd(s.endDate)
|
const sprintEnd = parseSprintEnd(s.endDate)
|
||||||
@ -324,20 +317,20 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Get other sprints (not current)
|
// Get other sprints (not current)
|
||||||
const otherSprints = projectSprints.filter((s) => s.id !== currentSprint?.id)
|
const otherSprints = sprints.filter((s) => s.id !== currentSprint?.id)
|
||||||
|
|
||||||
// Sort tasks by updatedAt (descending) - latest first
|
// Sort tasks by updatedAt (descending) - latest first
|
||||||
const sortByUpdated = (a: Task, b: Task) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
const sortByUpdated = (a: Task, b: Task) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
|
|
||||||
// Get tasks by section (sorted by last updated)
|
// Get tasks by section (sorted by last updated)
|
||||||
const currentSprintTasks = currentSprint
|
const currentSprintTasks = currentSprint
|
||||||
? projectTasks.filter((t) => t.sprintId === currentSprint.id && matchesSearch(t)).sort(sortByUpdated)
|
? tasks.filter((t) => t.sprintId === currentSprint.id && matchesSearch(t)).sort(sortByUpdated)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const backlogTasks = projectTasks.filter((t) => !t.sprintId && matchesSearch(t)).sort(sortByUpdated)
|
const backlogTasks = tasks.filter((t) => !t.sprintId && matchesSearch(t)).sort(sortByUpdated)
|
||||||
|
|
||||||
// Get active task for drag overlay
|
// Get active task for drag overlay
|
||||||
const activeTask = activeId ? projectTasks.find((t) => t.id === activeId) : null
|
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as string)
|
setActiveId(event.active.id as string)
|
||||||
@ -351,7 +344,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
|
|
||||||
const taskId = active.id as string
|
const taskId = active.id as string
|
||||||
const overId = over.id as string
|
const overId = over.id as string
|
||||||
const overTask = projectTasks.find((t) => t.id === overId)
|
const overTask = tasks.find((t) => t.id === overId)
|
||||||
|
|
||||||
const destinationId = overTask
|
const destinationId = overTask
|
||||||
? overTask.sprintId
|
? overTask.sprintId
|
||||||
@ -377,7 +370,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateSprint = () => {
|
const handleCreateSprint = () => {
|
||||||
if (!newSprint.name || !selectedProjectId) return
|
if (!newSprint.name) return
|
||||||
|
|
||||||
addSprint({
|
addSprint({
|
||||||
name: newSprint.name,
|
name: newSprint.name,
|
||||||
@ -385,7 +378,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
startDate: newSprint.startDate || toLocalDateInputValue(),
|
startDate: newSprint.startDate || toLocalDateInputValue(),
|
||||||
endDate: newSprint.endDate || toLocalDateInputValue(),
|
endDate: newSprint.endDate || toLocalDateInputValue(),
|
||||||
status: "planning",
|
status: "planning",
|
||||||
projectId: selectedProjectId,
|
projectId: selectedProjectId || "2",
|
||||||
})
|
})
|
||||||
|
|
||||||
setIsCreatingSprint(false)
|
setIsCreatingSprint(false)
|
||||||
@ -425,7 +418,8 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
{otherSprints
|
{otherSprints
|
||||||
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())
|
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())
|
||||||
.map((sprint) => {
|
.map((sprint) => {
|
||||||
const sprintTasks = projectTasks.filter((t) => t.sprintId === sprint.id && matchesSearch(t)).sort(sortByUpdated)
|
const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id && matchesSearch(t)).sort(sortByUpdated)
|
||||||
|
console.log(`Sprint ${sprint.name}: ${sprintTasks.length} tasks`, sprintTasks.map(t => t.title))
|
||||||
return (
|
return (
|
||||||
<SectionDropZone key={sprint.id} id={`sprint-${sprint.id}`}>
|
<SectionDropZone key={sprint.id} id={`sprint-${sprint.id}`}>
|
||||||
<TaskSection
|
<TaskSection
|
||||||
|
|||||||
414
src/lib/server/taskDb.ts
Normal file
414
src/lib/server/taskDb.ts
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
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<T>(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<TaskAttachment>;
|
||||||
|
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<TaskCommentAuthor>;
|
||||||
|
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<TaskComment>;
|
||||||
|
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<string, unknown>): Task {
|
||||||
|
const comments = safeParseArray<unknown>(task.comments, []);
|
||||||
|
const tags = safeParseArray<unknown>(task.tags, []);
|
||||||
|
const attachments = safeParseArray<unknown>(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<Map<string, { id: string; name: string; email?: string; avatarUrl?: string }>> {
|
||||||
|
const supabase = getServiceSupabase();
|
||||||
|
const { data: users } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id, name, email, avatar_url");
|
||||||
|
|
||||||
|
const lookup = new Map<string, { id: string; name: string; email?: string; avatarUrl?: string }>();
|
||||||
|
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<DataStore> {
|
||||||
|
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<unknown>(t.tags, []).filter((tag): tag is string => typeof tag === "string"),
|
||||||
|
attachments: normalizeAttachments(t.attachments),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
lastUpdated: Number(meta?.value ?? Date.now()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaultData(): Promise<void> {
|
||||||
|
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<DataStore> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@ -9,49 +9,32 @@ export interface Database {
|
|||||||
users: {
|
users: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
|
legacy_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
password_hash: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
legacy_id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
|
password_hash: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
legacy_id?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
|
password_hash?: string;
|
||||||
created_at?: 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: {
|
sessions: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
@ -104,6 +87,7 @@ export interface Database {
|
|||||||
projects: {
|
projects: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
|
legacy_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
color: string;
|
color: string;
|
||||||
@ -111,6 +95,7 @@ export interface Database {
|
|||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
legacy_id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
color: string;
|
color: string;
|
||||||
@ -118,6 +103,7 @@ export interface Database {
|
|||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
legacy_id?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
color?: string;
|
color?: string;
|
||||||
@ -127,6 +113,7 @@ export interface Database {
|
|||||||
sprints: {
|
sprints: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
|
legacy_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
goal: string | null;
|
goal: string | null;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
@ -137,6 +124,7 @@ export interface Database {
|
|||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
legacy_id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
goal?: string | null;
|
goal?: string | null;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
@ -147,6 +135,7 @@ export interface Database {
|
|||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
legacy_id?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
goal?: string | null;
|
goal?: string | null;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
@ -159,6 +148,7 @@ export interface Database {
|
|||||||
tasks: {
|
tasks: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
|
legacy_id: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
type: 'idea' | 'task' | 'bug' | 'research' | 'plan';
|
type: 'idea' | 'task' | 'bug' | 'research' | 'plan';
|
||||||
@ -169,8 +159,15 @@ export interface Database {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
created_by_id: string | null;
|
created_by_id: string | null;
|
||||||
|
created_by_name: string | null;
|
||||||
|
created_by_avatar_url: string | null;
|
||||||
updated_by_id: string | null;
|
updated_by_id: string | null;
|
||||||
|
updated_by_name: string | null;
|
||||||
|
updated_by_avatar_url: string | null;
|
||||||
assignee_id: string | null;
|
assignee_id: string | null;
|
||||||
|
assignee_name: string | null;
|
||||||
|
assignee_email: string | null;
|
||||||
|
assignee_avatar_url: string | null;
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
comments: Json;
|
comments: Json;
|
||||||
tags: Json;
|
tags: Json;
|
||||||
@ -178,6 +175,7 @@ export interface Database {
|
|||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
legacy_id?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
type: 'idea' | 'task' | 'bug' | 'research' | 'plan';
|
type: 'idea' | 'task' | 'bug' | 'research' | 'plan';
|
||||||
@ -188,8 +186,15 @@ export interface Database {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
created_by_id?: string | null;
|
created_by_id?: string | null;
|
||||||
|
created_by_name?: string | null;
|
||||||
|
created_by_avatar_url?: string | null;
|
||||||
updated_by_id?: string | null;
|
updated_by_id?: string | null;
|
||||||
|
updated_by_name?: string | null;
|
||||||
|
updated_by_avatar_url?: string | null;
|
||||||
assignee_id?: string | null;
|
assignee_id?: string | null;
|
||||||
|
assignee_name?: string | null;
|
||||||
|
assignee_email?: string | null;
|
||||||
|
assignee_avatar_url?: string | null;
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
comments?: Json;
|
comments?: Json;
|
||||||
tags?: Json;
|
tags?: Json;
|
||||||
@ -197,6 +202,7 @@ export interface Database {
|
|||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
legacy_id?: string | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
type?: 'idea' | 'task' | 'bug' | 'research' | 'plan';
|
type?: 'idea' | 'task' | 'bug' | 'research' | 'plan';
|
||||||
@ -207,14 +213,38 @@ export interface Database {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
created_by_id?: string | null;
|
created_by_id?: string | null;
|
||||||
|
created_by_name?: string | null;
|
||||||
|
created_by_avatar_url?: string | null;
|
||||||
updated_by_id?: string | null;
|
updated_by_id?: string | null;
|
||||||
|
updated_by_name?: string | null;
|
||||||
|
updated_by_avatar_url?: string | null;
|
||||||
assignee_id?: string | null;
|
assignee_id?: string | null;
|
||||||
|
assignee_name?: string | null;
|
||||||
|
assignee_email?: string | null;
|
||||||
|
assignee_avatar_url?: string | null;
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
comments?: Json;
|
comments?: Json;
|
||||||
tags?: Json;
|
tags?: Json;
|
||||||
attachments?: 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ export type TaskType = 'idea' | 'task' | 'bug' | 'research' | 'plan'
|
|||||||
export type TaskStatus = 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done'
|
export type TaskStatus = 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done'
|
||||||
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
|
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
|
||||||
export type SprintStatus = 'planning' | 'active' | 'completed'
|
export type SprintStatus = 'planning' | 'active' | 'completed'
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
||||||
|
|
||||||
export interface Sprint {
|
export interface Sprint {
|
||||||
id: string
|
id: string
|
||||||
@ -95,10 +94,10 @@ interface TaskStore {
|
|||||||
currentUser: UserProfile
|
currentUser: UserProfile
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
lastSynced: number | null
|
lastSynced: number | null
|
||||||
syncError: string | null
|
|
||||||
|
|
||||||
// Sync actions
|
// Sync actions
|
||||||
syncFromServer: () => Promise<void>
|
syncFromServer: () => Promise<void>
|
||||||
|
syncToServer: () => Promise<void>
|
||||||
setCurrentUser: (user: Partial<UserProfile>) => void
|
setCurrentUser: (user: Partial<UserProfile>) => void
|
||||||
|
|
||||||
// Project actions
|
// Project actions
|
||||||
@ -130,10 +129,296 @@ interface TaskStore {
|
|||||||
getTaskById: (id: string) => Task | undefined
|
getTaskById: (id: string) => Task | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCurrentUser: UserProfile = {
|
// Sprint 1: Mon Feb 16 - Sun Feb 22, 2026 (current week)
|
||||||
id: '',
|
const sprint1Start = new Date('2026-02-16T00:00:00.000Z')
|
||||||
name: 'Unknown User',
|
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 normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => {
|
const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => {
|
||||||
if (!value || typeof value !== 'object') return fallback
|
if (!value || typeof value !== 'object') return fallback
|
||||||
@ -271,46 +556,12 @@ const generateTaskId = (): string => {
|
|||||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
return crypto.randomUUID()
|
return crypto.randomUUID()
|
||||||
}
|
}
|
||||||
throw new Error('Unable to generate task UUID in this browser')
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
}
|
|
||||||
|
|
||||||
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<unknown> {
|
|
||||||
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)
|
// Helper to sync a single task to server (lightweight)
|
||||||
async function syncTaskToServer(task: Task) {
|
async function syncTaskToServer(task: Task) {
|
||||||
console.log('>>> syncTaskToServer: saving task', task.id, task.title)
|
console.log('>>> syncTaskToServer: saving task', task.id, task.title)
|
||||||
const isValidUuid = (value: string | undefined) => typeof value === 'string' && UUID_PATTERN.test(value)
|
|
||||||
if (!isValidUuid(task.id)) {
|
|
||||||
console.error('>>> syncTaskToServer: invalid task.id (expected UUID)', task.id)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!isValidUuid(task.projectId)) {
|
|
||||||
console.error('>>> syncTaskToServer: invalid task.projectId (expected UUID)', task.projectId)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (task.sprintId && !isValidUuid(task.sprintId)) {
|
|
||||||
console.error('>>> syncTaskToServer: invalid task.sprintId (expected UUID)', task.sprintId)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (task.assigneeId && !isValidUuid(task.assigneeId)) {
|
|
||||||
console.error('>>> syncTaskToServer: invalid task.assigneeId (expected UUID)', task.assigneeId)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tasks', {
|
const res = await fetch('/api/tasks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -321,15 +572,7 @@ async function syncTaskToServer(task: Task) {
|
|||||||
console.log('>>> syncTaskToServer: saved successfully')
|
console.log('>>> syncTaskToServer: saved successfully')
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
const rawBody = await res.text().catch(() => '')
|
const errorPayload = await res.json().catch(() => null)
|
||||||
let errorPayload: unknown = null
|
|
||||||
if (rawBody) {
|
|
||||||
try {
|
|
||||||
errorPayload = JSON.parse(rawBody)
|
|
||||||
} catch {
|
|
||||||
errorPayload = { rawBody }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error('>>> syncTaskToServer: failed with status', res.status, errorPayload)
|
console.error('>>> syncTaskToServer: failed with status', res.status, errorPayload)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -361,142 +604,123 @@ 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<TaskStore>()(
|
export const useTaskStore = create<TaskStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
projects: [],
|
projects: defaultProjects,
|
||||||
tasks: [],
|
tasks: defaultTasks,
|
||||||
sprints: [],
|
sprints: defaultSprints,
|
||||||
selectedProjectId: null,
|
selectedProjectId: '1',
|
||||||
selectedTaskId: null,
|
selectedTaskId: null,
|
||||||
selectedSprintId: null,
|
selectedSprintId: null,
|
||||||
currentUser: defaultCurrentUser,
|
currentUser: defaultCurrentUser,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastSynced: null,
|
lastSynced: null,
|
||||||
syncError: null,
|
|
||||||
|
|
||||||
syncFromServer: async () => {
|
syncFromServer: async () => {
|
||||||
console.log('>>> syncFromServer START')
|
console.log('>>> syncFromServer START')
|
||||||
set({ isLoading: true, syncError: null })
|
set({ isLoading: true })
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tasks', { cache: 'no-store' })
|
const res = await fetch('/api/tasks')
|
||||||
console.log('>>> syncFromServer: API response status:', res.status)
|
console.log('>>> syncFromServer: API response status:', res.status)
|
||||||
if (!res.ok) {
|
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 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:', {
|
console.log('>>> syncFromServer: fetched data:', {
|
||||||
projectsCount: serverProjects.length,
|
projectsCount: data.projects?.length,
|
||||||
tasksCount: data.tasks?.length,
|
tasksCount: data.tasks?.length,
|
||||||
sprintsCount: data.sprints?.length,
|
sprintsCount: data.sprints?.length,
|
||||||
firstTaskTitle: data.tasks?.[0]?.title,
|
firstTaskTitle: data.tasks?.[0]?.title,
|
||||||
lastUpdated: data.lastUpdated,
|
lastUpdated: data.lastUpdated,
|
||||||
})
|
})
|
||||||
console.log('>>> syncFromServer: current store tasks count BEFORE set:', get().tasks.length)
|
console.log('>>> syncFromServer: current store tasks count BEFORE set:', get().tasks.length)
|
||||||
|
// ALWAYS use server data if API returns successfully
|
||||||
set({
|
set({
|
||||||
projects: serverProjects,
|
projects: data.projects || [],
|
||||||
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
|
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
|
||||||
sprints: data.sprints || [],
|
sprints: data.sprints || [],
|
||||||
currentUser: serverCurrentUser,
|
|
||||||
selectedProjectId: nextSelectedProjectId,
|
|
||||||
syncError: null,
|
|
||||||
lastSynced: Date.now(),
|
lastSynced: Date.now(),
|
||||||
})
|
})
|
||||||
console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length)
|
console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length)
|
||||||
|
} else {
|
||||||
|
console.error('>>> syncFromServer: API returned error status:', res.status)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('>>> syncFromServer: Failed to sync from server:', error)
|
console.error('>>> syncFromServer: Failed to sync from server:', error)
|
||||||
const message = error instanceof Error ? error.message : 'Unknown sync error'
|
// Keep local data if server fails
|
||||||
set({
|
|
||||||
projects: [],
|
|
||||||
tasks: [],
|
|
||||||
sprints: [],
|
|
||||||
selectedProjectId: null,
|
|
||||||
selectedTaskId: null,
|
|
||||||
selectedSprintId: null,
|
|
||||||
currentUser: defaultCurrentUser,
|
|
||||||
syncError: message,
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
set({ isLoading: false })
|
set({ isLoading: false })
|
||||||
console.log('>>> syncFromServer END')
|
console.log('>>> syncFromServer END')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
syncToServer: async () => {
|
||||||
|
const { projects, tasks, sprints } = get()
|
||||||
|
await syncToServer(projects, tasks, sprints)
|
||||||
|
set({ lastSynced: Date.now() })
|
||||||
|
},
|
||||||
|
|
||||||
setCurrentUser: (user) => {
|
setCurrentUser: (user) => {
|
||||||
set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) }))
|
set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) }))
|
||||||
},
|
},
|
||||||
|
|
||||||
addProject: (name, description) => {
|
addProject: (name, description) => {
|
||||||
const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
||||||
const color = colors[Math.floor(Math.random() * colors.length)]
|
const newProject: Project = {
|
||||||
|
id: Date.now().toString(),
|
||||||
void (async () => {
|
name,
|
||||||
try {
|
description,
|
||||||
await requestApi('/api/projects', {
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
method: 'POST',
|
createdAt: new Date().toISOString(),
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
})()
|
set((state) => {
|
||||||
|
const newState = { projects: [...state.projects, newProject] }
|
||||||
|
// Sync to server
|
||||||
|
syncToServer(newState.projects, state.tasks, state.sprints)
|
||||||
|
return newState
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProject: (id, updates) => {
|
updateProject: (id, updates) => {
|
||||||
const payload: Record<string, unknown> = { id }
|
set((state) => {
|
||||||
if (updates.name !== undefined) payload.name = updates.name
|
const newProjects = state.projects.map((p) => (p.id === id ? { ...p, ...updates } : p))
|
||||||
if (updates.description !== undefined) payload.description = updates.description
|
syncToServer(newProjects, state.tasks, state.sprints)
|
||||||
if (updates.color !== undefined) payload.color = updates.color
|
return { projects: newProjects }
|
||||||
|
|
||||||
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) => {
|
deleteProject: (id) => {
|
||||||
void (async () => {
|
set((state) => {
|
||||||
try {
|
const newProjects = state.projects.filter((p) => p.id !== id)
|
||||||
await requestApi('/api/projects', {
|
const newTasks = state.tasks.filter((t) => t.projectId !== id)
|
||||||
method: 'DELETE',
|
const newSprints = state.sprints.filter((s) => s.projectId !== id)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
syncToServer(newProjects, newTasks, newSprints)
|
||||||
body: JSON.stringify({ id }),
|
return {
|
||||||
})
|
projects: newProjects,
|
||||||
await get().syncFromServer()
|
tasks: newTasks,
|
||||||
} catch (error) {
|
sprints: newSprints,
|
||||||
const message = error instanceof Error ? error.message : 'Failed to delete project'
|
selectedProjectId: state.selectedProjectId === id ? null : state.selectedProjectId,
|
||||||
console.error('>>> deleteProject failed:', error)
|
|
||||||
set({ syncError: message })
|
|
||||||
}
|
}
|
||||||
})()
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }),
|
selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }),
|
||||||
@ -514,21 +738,17 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
updatedById: actor.id,
|
updatedById: actor.id,
|
||||||
updatedByName: actor.name,
|
updatedByName: actor.name,
|
||||||
updatedByAvatarUrl: actor.avatarUrl,
|
updatedByAvatarUrl: actor.avatarUrl,
|
||||||
assigneeId: task.assigneeId,
|
assigneeId: task.assigneeId || actor.id,
|
||||||
assigneeName: task.assigneeName,
|
assigneeName: task.assigneeName || actor.name,
|
||||||
assigneeEmail: task.assigneeEmail,
|
assigneeEmail: task.assigneeEmail || actor.email,
|
||||||
assigneeAvatarUrl: task.assigneeAvatarUrl,
|
assigneeAvatarUrl: task.assigneeAvatarUrl || actor.avatarUrl,
|
||||||
comments: normalizeComments([]),
|
comments: normalizeComments([]),
|
||||||
attachments: normalizeAttachments(task.attachments),
|
attachments: normalizeAttachments(task.attachments),
|
||||||
}
|
}
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTasks = [...state.tasks, newTask]
|
const newTasks = [...state.tasks, newTask]
|
||||||
void (async () => {
|
// Sync individual task to server (lightweight)
|
||||||
const success = await syncTaskToServer(newTask)
|
syncTaskToServer(newTask)
|
||||||
if (!success) {
|
|
||||||
await get().syncFromServer()
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return { tasks: newTasks }
|
return { tasks: newTasks }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -562,9 +782,6 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
// Sync individual task to server (lightweight)
|
// Sync individual task to server (lightweight)
|
||||||
if (updatedTask) {
|
if (updatedTask) {
|
||||||
syncSuccess = await syncTaskToServer(updatedTask)
|
syncSuccess = await syncTaskToServer(updatedTask)
|
||||||
if (!syncSuccess) {
|
|
||||||
await get().syncFromServer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return syncSuccess
|
return syncSuccess
|
||||||
@ -573,12 +790,8 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
deleteTask: (id) => {
|
deleteTask: (id) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTasks = state.tasks.filter((t) => t.id !== id)
|
const newTasks = state.tasks.filter((t) => t.id !== id)
|
||||||
void (async () => {
|
// Delete individual task from server (lightweight)
|
||||||
const success = await deleteTaskFromServer(id)
|
deleteTaskFromServer(id)
|
||||||
if (!success) {
|
|
||||||
await get().syncFromServer()
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return {
|
return {
|
||||||
tasks: newTasks,
|
tasks: newTasks,
|
||||||
selectedTaskId: state.selectedTaskId === id ? null : state.selectedTaskId,
|
selectedTaskId: state.selectedTaskId === id ? null : state.selectedTaskId,
|
||||||
@ -590,54 +803,41 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
|
|
||||||
// Sprint actions
|
// Sprint actions
|
||||||
addSprint: (sprint) => {
|
addSprint: (sprint) => {
|
||||||
void (async () => {
|
const newSprint: Sprint = {
|
||||||
try {
|
...sprint,
|
||||||
await requestApi('/api/sprints', {
|
id: Date.now().toString(),
|
||||||
method: 'POST',
|
createdAt: new Date().toISOString(),
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
})()
|
set((state) => {
|
||||||
|
const newSprints = [...state.sprints, newSprint]
|
||||||
|
syncToServer(state.projects, state.tasks, newSprints)
|
||||||
|
return { sprints: newSprints }
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSprint: (id, updates) => {
|
updateSprint: (id, updates) => {
|
||||||
void (async () => {
|
set((state) => {
|
||||||
try {
|
const newSprints = state.sprints.map((s) =>
|
||||||
await requestApi('/api/sprints', {
|
s.id === id ? { ...s, ...updates } : s
|
||||||
method: 'PATCH',
|
)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
syncToServer(state.projects, state.tasks, newSprints)
|
||||||
body: JSON.stringify({ id, ...updates }),
|
return { sprints: newSprints }
|
||||||
})
|
})
|
||||||
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) => {
|
deleteSprint: (id) => {
|
||||||
void (async () => {
|
set((state) => {
|
||||||
try {
|
const newSprints = state.sprints.filter((s) => s.id !== id)
|
||||||
await requestApi('/api/sprints', {
|
const newTasks = state.tasks.map((t) =>
|
||||||
method: 'DELETE',
|
t.sprintId === id ? { ...t, sprintId: undefined } : t
|
||||||
headers: { 'Content-Type': 'application/json' },
|
)
|
||||||
body: JSON.stringify({ id }),
|
syncToServer(state.projects, newTasks, newSprints)
|
||||||
})
|
return {
|
||||||
await get().syncFromServer()
|
sprints: newSprints,
|
||||||
} catch (error) {
|
tasks: newTasks,
|
||||||
const message = error instanceof Error ? error.message : 'Failed to delete sprint'
|
selectedSprintId: state.selectedSprintId === id ? null : state.selectedSprintId,
|
||||||
console.error('>>> deleteSprint failed:', error)
|
|
||||||
set({ syncError: message })
|
|
||||||
}
|
}
|
||||||
})()
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
selectSprint: (id) => set({ selectedSprintId: id }),
|
selectSprint: (id) => set({ selectedSprintId: id }),
|
||||||
@ -671,12 +871,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
)
|
)
|
||||||
const updatedTask = newTasks.find(t => t.id === taskId)
|
const updatedTask = newTasks.find(t => t.id === taskId)
|
||||||
if (updatedTask) {
|
if (updatedTask) {
|
||||||
void (async () => {
|
syncTaskToServer(updatedTask)
|
||||||
const success = await syncTaskToServer(updatedTask)
|
|
||||||
if (!success) {
|
|
||||||
await get().syncFromServer()
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
}
|
||||||
return { tasks: newTasks }
|
return { tasks: newTasks }
|
||||||
})
|
})
|
||||||
@ -699,12 +894,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
)
|
)
|
||||||
const updatedTask = newTasks.find(t => t.id === taskId)
|
const updatedTask = newTasks.find(t => t.id === taskId)
|
||||||
if (updatedTask) {
|
if (updatedTask) {
|
||||||
void (async () => {
|
syncTaskToServer(updatedTask)
|
||||||
const success = await syncTaskToServer(updatedTask)
|
|
||||||
if (!success) {
|
|
||||||
await get().syncFromServer()
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
}
|
||||||
return { tasks: newTasks }
|
return { tasks: newTasks }
|
||||||
})
|
})
|
||||||
@ -724,26 +914,9 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'task-store',
|
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) => ({
|
partialize: (state) => ({
|
||||||
// Persist UI selection state only. All business data comes from Supabase.
|
// Persist user identity and UI state, not task data
|
||||||
|
currentUser: state.currentUser,
|
||||||
selectedProjectId: state.selectedProjectId,
|
selectedProjectId: state.selectedProjectId,
|
||||||
selectedTaskId: state.selectedTaskId,
|
selectedTaskId: state.selectedTaskId,
|
||||||
selectedSprintId: state.selectedSprintId,
|
selectedSprintId: state.selectedSprintId,
|
||||||
@ -755,9 +928,10 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
console.log('>>> PERSIST: Rehydration error:', error)
|
console.log('>>> PERSIST: Rehydration error:', error)
|
||||||
} else {
|
} else {
|
||||||
console.log('>>> PERSIST: Rehydrated state:', {
|
console.log('>>> PERSIST: Rehydrated state:', {
|
||||||
|
currentUser: state?.currentUser?.name,
|
||||||
selectedProjectId: state?.selectedProjectId,
|
selectedProjectId: state?.selectedProjectId,
|
||||||
selectedTaskId: state?.selectedTaskId,
|
selectedTaskId: state?.selectedTaskId,
|
||||||
selectedSprintId: state?.selectedSprintId,
|
tasksCount: state?.tasks?.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,14 +9,17 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|||||||
-- ============================================
|
-- ============================================
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
legacy_id TEXT UNIQUE, -- For migration from SQLite
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create index on email for faster lookups
|
-- 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_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_legacy_id ON users(legacy_id);
|
||||||
|
|
||||||
-- Enable RLS
|
-- Enable RLS
|
||||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||||
@ -29,17 +32,6 @@ CREATE POLICY "Users can read own data" ON users
|
|||||||
CREATE POLICY "Users can update own data" ON users
|
CREATE POLICY "Users can update own data" ON users
|
||||||
FOR UPDATE USING (auth.uid() = id);
|
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
|
-- SESSIONS TABLE
|
||||||
-- ============================================
|
-- ============================================
|
||||||
@ -91,12 +83,16 @@ CREATE POLICY "Service role manages reset tokens" ON password_reset_tokens
|
|||||||
-- ============================================
|
-- ============================================
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
legacy_id TEXT UNIQUE, -- For migration from SQLite
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
color TEXT NOT NULL,
|
color TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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
|
-- Enable RLS
|
||||||
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
@ -121,6 +117,7 @@ CREATE POLICY "Authenticated users can delete projects" ON projects
|
|||||||
-- ============================================
|
-- ============================================
|
||||||
CREATE TABLE IF NOT EXISTS sprints (
|
CREATE TABLE IF NOT EXISTS sprints (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
legacy_id TEXT UNIQUE, -- For migration from SQLite
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
goal TEXT,
|
goal TEXT,
|
||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
@ -132,6 +129,7 @@ CREATE TABLE IF NOT EXISTS sprints (
|
|||||||
|
|
||||||
-- Create indexes
|
-- Create indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_sprints_project_id ON sprints(project_id);
|
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);
|
CREATE INDEX IF NOT EXISTS idx_sprints_dates ON sprints(start_date, end_date);
|
||||||
|
|
||||||
-- Enable RLS
|
-- Enable RLS
|
||||||
@ -146,6 +144,7 @@ CREATE POLICY "Authenticated users can manage sprints" ON sprints
|
|||||||
-- ============================================
|
-- ============================================
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
legacy_id TEXT UNIQUE, -- For migration from SQLite
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
type TEXT NOT NULL CHECK (type IN ('idea', 'task', 'bug', 'research', 'plan')),
|
type TEXT NOT NULL CHECK (type IN ('idea', 'task', 'bug', 'research', 'plan')),
|
||||||
@ -156,8 +155,15 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_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_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_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_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
assignee_name TEXT,
|
||||||
|
assignee_email TEXT,
|
||||||
|
assignee_avatar_url TEXT,
|
||||||
due_date DATE,
|
due_date DATE,
|
||||||
comments JSONB NOT NULL DEFAULT '[]'::jsonb,
|
comments JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
@ -171,6 +177,7 @@ 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_status ON tasks(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
|
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_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 INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC);
|
||||||
|
|
||||||
-- Create trigger to auto-update updated_at
|
-- Create trigger to auto-update updated_at
|
||||||
@ -195,6 +202,26 @@ ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
|
|||||||
CREATE POLICY "Authenticated users can manage tasks" ON tasks
|
CREATE POLICY "Authenticated users can manage tasks" ON tasks
|
||||||
FOR ALL USING (auth.role() = 'authenticated');
|
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
|
-- FUNCTIONS
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user