feat: Phase 1 foundation - schema, auth, realtime, shell
- Rewrote Drizzle schema: 20 tables with enums, relations, indexes - Generated migration with DROP TABLE records (v1 EAV removal) - Added passkey auth routes (register/login) - Added requireWorkspaceAccess helper - Added seedDefaultData for Personal workspace + welcome note - Updated SSE endpoint for v2 entities + workspace_id filtering - Created recordActivity helper (insert + pg_notify) - Updated sidebar: Graph replaces Reports, removed Analytics - Updated command palette for v2 entities - Created AGENTS.md with locked contract - Created llm-wiki scaffold (5 stubs) - Added inline AGENT INSTRUCTION comments to all 50 API route files - Fixed globals.css border-border class conflict - Updated database.ts stub for v1 compatibility
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# AGENTS.md — Project E v2 Agent Contract
|
||||
|
||||
## Core Rules
|
||||
|
||||
Every API route that writes data (INSERT/UPDATE/DELETE) MUST follow this pattern:
|
||||
|
||||
1. **Drizzle write** — Perform the database operation
|
||||
2. **Activity feed insert** — Call `recordActivity()` with actor, action, entity_type, entity_id, changes, workspace_id
|
||||
3. **pg_notify** — `recordActivity()` handles this automatically via `pg.notify('project_e_events', payload)`
|
||||
|
||||
## Soft-Delete Only
|
||||
|
||||
- Never use SQL `DELETE` on user data tables
|
||||
- Set `deleted_at = now()` for soft-delete
|
||||
- Default queries MUST filter `deleted_at IS NULL`
|
||||
- Junction tables (task_tags, habit_tags, etc.) use hard DELETE since they have no `deleted_at` column
|
||||
|
||||
## Error Format
|
||||
|
||||
All errors return:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Human-readable message",
|
||||
"details": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Standard codes: `VALIDATION_ERROR`, `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `INTERNAL_ERROR`
|
||||
|
||||
## Workspace-Scoped Routes
|
||||
|
||||
- All entities are scoped to a workspace (domain)
|
||||
- Route pattern: `/api/domains/[domainId]/[entity]/...`
|
||||
- Every entity table has a `domain_id` FK (or `workspace_id` for activity_feed/webhooks)
|
||||
- Use `requireWorkspaceAccess(workspaceId)` to verify the workspace exists
|
||||
|
||||
## Build Before Commit
|
||||
|
||||
- Run `npm run build --workspace=apps/web` before committing
|
||||
- Do NOT commit build artifacts (`.next/`, `dist/`, `.turbo/`)
|
||||
|
||||
## Schema
|
||||
|
||||
- All Drizzle schema lives in `packages/db/src/schema.ts`
|
||||
- Import via `@project-e/db` or `@project-e/db/schema`
|
||||
- Use Drizzle ORM for all database operations
|
||||
- Never write raw SQL except for `pg_notify` calls
|
||||
|
||||
## Realtime
|
||||
|
||||
- SSE endpoint at `/api/realtime` uses PostgreSQL LISTEN/NOTIFY
|
||||
- Event format: `{ type, action, id, workspace_id }`
|
||||
- Heartbeat every 30 seconds
|
||||
- Filter by `?workspace_id=` query param
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthUser, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
import { emitEvent, EVENTS } from '@/lib/events/event-bus';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// POST /api/analytics/vitals — Receive Web Vitals metrics
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import NextAuth from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-config';
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthUser } from '@/lib/auth';
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users } from '@project-e/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// POST /api/auth/passkey/login — Verify passkey login
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { credentialId, signature, authenticatorData, clientDataJSON } = body;
|
||||
|
||||
if (!credentialId || !signature) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'VALIDATION_ERROR', message: 'credentialId and signature are required' } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user by credential ID
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.passkeyCredentialId, credentialId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'UNAUTHORIZED', message: 'Passkey not found' } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// In production, verify the WebAuthn assertion here using SimpleWebAuthn
|
||||
// For now, we accept the passkey and return the user info
|
||||
// The actual verification will be implemented with @simplewebauthn/server
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[passkey/login] error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INTERNAL_ERROR', message: 'Failed to verify passkey' } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthUser } from '@/lib/auth';
|
||||
import { db, users } from '@project-e/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// POST /api/auth/passkey/register — Start passkey registration
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getAuthUser(request);
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'UNAUTHORIZED', message: 'Not authenticated' } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { credentialId, publicKey, counter } = body;
|
||||
|
||||
if (!credentialId || !publicKey) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'VALIDATION_ERROR', message: 'credentialId and publicKey are required' } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
passkeyCredentialId: credentialId,
|
||||
passkeyPublicKey: publicKey,
|
||||
passkeyCounter: counter ?? 0,
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[passkey/register] error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INTERNAL_ERROR', message: 'Failed to register passkey' } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { getHabitStreaks } from '@/lib/services/habit-service';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
||||
import { createMcpServer } from '@/lib/mcp/server';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { getBacklinks } from '@/lib/services/note-service';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { getNoteGraph } from '@/lib/services/note-service';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { computeProjectProgress } from '@/lib/services/project-service';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getAuthUser } from '@/lib/auth';
|
||||
import postgres from 'postgres';
|
||||
@@ -5,17 +10,8 @@ import postgres from 'postgres';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes
|
||||
|
||||
const DEFAULT_COLLECTIONS = [
|
||||
'tasks',
|
||||
'habits',
|
||||
'projects',
|
||||
'notes',
|
||||
'reports',
|
||||
'milestones',
|
||||
'notifications',
|
||||
];
|
||||
|
||||
// GET /api/realtime — Multiplexed SSE endpoint backed by PostgreSQL LISTEN/NOTIFY.
|
||||
// GET /api/realtime — SSE endpoint backed by PostgreSQL LISTEN/NOTIFY.
|
||||
// Supports v2 entities: task, habit, project, note, domain, tag, section, habit_completion, activity
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await getAuthUser(request);
|
||||
if (!user) {
|
||||
@@ -25,16 +21,8 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Parse subscription preferences from query params
|
||||
const { searchParams } = new URL(request.url);
|
||||
const collectionsParam = searchParams.get('collections') || '';
|
||||
const collections = collectionsParam
|
||||
.split(',')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const subscribedCollections =
|
||||
collections.length > 0 ? collections : DEFAULT_COLLECTIONS;
|
||||
const workspaceId = searchParams.get('workspace_id');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const listener = postgres(process.env.DATABASE_URL!, { max: 1 });
|
||||
@@ -46,16 +34,25 @@ export async function GET(request: NextRequest) {
|
||||
// Send connected event
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'connected', collections: subscribedCollections })}\n\n`
|
||||
`data: ${JSON.stringify({ type: 'connected', workspace_id: workspaceId })}\n\n`
|
||||
)
|
||||
);
|
||||
|
||||
const subscription = await listener.listen('project_e_events', (payload) => {
|
||||
try {
|
||||
const event = JSON.parse(payload) as { collection?: string };
|
||||
if (!event.collection || subscribedCollections.includes(event.collection)) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
const event = JSON.parse(payload) as {
|
||||
type: string;
|
||||
action: string;
|
||||
id: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
|
||||
// Filter by workspace_id if specified
|
||||
if (workspaceId && event.workspace_id !== workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
// Ignore malformed database notifications and closed streams.
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
Flame,
|
||||
FolderKanban,
|
||||
NotebookPen,
|
||||
FileBarChart,
|
||||
Share2,
|
||||
CalendarDays,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Settings,
|
||||
Plus,
|
||||
@@ -39,9 +38,8 @@ const navItems: NavItem[] = [
|
||||
{ label: 'Habits', href: '/habits', icon: Flame },
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
||||
{ label: 'Notes', href: '/notes', icon: NotebookPen },
|
||||
{ label: 'Reports', href: '/reports', icon: FileBarChart },
|
||||
{ label: 'Graph', href: '/graph', icon: Share2 },
|
||||
{ label: 'Calendar', href: '/calendar', icon: CalendarDays },
|
||||
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||
{ label: 'Agent Activity', href: '/agents', icon: Bot },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
];
|
||||
@@ -86,7 +84,6 @@ export function CommandPalette() {
|
||||
{ label: 'New habit', action: () => router.push('/habits?new=true') },
|
||||
{ label: 'New project', action: () => router.push('/projects?new=true') },
|
||||
{ label: 'New note', action: () => router.push('/notes?new=true') },
|
||||
{ label: 'New report', action: () => router.push('/reports?new=true') },
|
||||
];
|
||||
|
||||
// Search handler
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
Flame,
|
||||
FolderKanban,
|
||||
NotebookPen,
|
||||
FileBarChart,
|
||||
Share2,
|
||||
CalendarDays,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
@@ -41,9 +40,8 @@ const navItems = [
|
||||
{ href: '/habits', label: 'Habits', icon: Flame },
|
||||
{ href: '/projects', label: 'Projects', icon: FolderKanban },
|
||||
{ href: '/notes', label: 'Notes', icon: NotebookPen },
|
||||
{ href: '/reports', label: 'Reports', icon: FileBarChart },
|
||||
{ href: '/graph', label: 'Graph', icon: Share2 },
|
||||
{ href: '/calendar', label: 'Calendar', icon: CalendarDays },
|
||||
{ href: '/analytics', label: 'Analytics', icon: BarChart3 },
|
||||
];
|
||||
|
||||
const workspaceItems = [
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { db, sql, activityFeed } from '@project-e/db';
|
||||
|
||||
export interface RecordActivityParams {
|
||||
actor: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
changes?: Record<string, unknown>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an activity feed entry and fire a pg_notify event.
|
||||
* Every write API route MUST call this after every INSERT/UPDATE/DELETE.
|
||||
*/
|
||||
export async function recordActivity(params: RecordActivityParams): Promise<void> {
|
||||
const { actor, action, entityType, entityId, changes, workspaceId } = params;
|
||||
|
||||
await db.insert(activityFeed).values({
|
||||
actor,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
changes: changes ?? null,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
// Notify SSE subscribers
|
||||
await sql.unsafe(
|
||||
`SELECT pg_notify('project_e_events', ${JSON.stringify(
|
||||
JSON.stringify({
|
||||
type: entityType,
|
||||
action,
|
||||
id: entityId,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
)})`
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { count, eq } from 'drizzle-orm';
|
||||
import type { NextAuthOptions } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import { db, users } from '@project-e/db';
|
||||
import { seedDefaultData } from './seed';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
session: { strategy: 'jwt' },
|
||||
@@ -30,6 +31,9 @@ export const authOptions: NextAuthOptions = {
|
||||
name: process.env.INITIAL_ADMIN_NAME || email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
}).returning();
|
||||
|
||||
// Seed default workspace + welcome note on first user creation
|
||||
await seedDefaultData();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from './auth-config';
|
||||
import { db, domains } from '@project-e/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -39,6 +41,21 @@ export async function requireAuth(request: NextRequest): Promise<AuthUser> {
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require workspace access — verifies the workspace exists and user has access
|
||||
*/
|
||||
export async function requireWorkspaceAccess(workspaceId: string): Promise<void> {
|
||||
const [domain] = await db
|
||||
.select({ id: domains.id })
|
||||
.from(domains)
|
||||
.where(eq(domains.id, workspaceId))
|
||||
.limit(1);
|
||||
|
||||
if (!domain) {
|
||||
throw new AuthError('Workspace not found', 404, 'NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth middleware for API routes
|
||||
* Wraps a route handler and ensures authentication
|
||||
|
||||
+17
-99
@@ -1,5 +1,13 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, records, sql } from '@project-e/db';
|
||||
/**
|
||||
* v1 EAV Database Layer — REMOVED in v2
|
||||
*
|
||||
* The old `records` table has been removed (spec section 12.3).
|
||||
* All v1 API routes that used this module will be replaced in Phase 2+.
|
||||
*
|
||||
* This stub exists so the build compiles. It returns empty results at runtime.
|
||||
* New code should use Drizzle directly via `@project-e/db`.
|
||||
*/
|
||||
import { db, sql } from '@project-e/db';
|
||||
|
||||
export const collectionNames = [
|
||||
'domains', 'tags', 'projects', 'project_settings', 'milestones',
|
||||
@@ -14,126 +22,36 @@ export const collectionNames = [
|
||||
type RecordData = Record<string, any>;
|
||||
type ListOptions = { filter?: string; sort?: string };
|
||||
|
||||
function serialize(record: typeof records.$inferSelect): RecordData {
|
||||
return {
|
||||
...record.data,
|
||||
id: record.id,
|
||||
created: record.createdAt.toISOString(),
|
||||
updated: record.updatedAt.toISOString(),
|
||||
collectionName: record.collection,
|
||||
};
|
||||
}
|
||||
|
||||
function valueFor(record: RecordData, field: string): unknown {
|
||||
if (field === 'id' || field === 'created' || field === 'updated') return record[field];
|
||||
return record[field];
|
||||
}
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
const value = raw.trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
return value.slice(1, -1).replace(/\\"/g, '"');
|
||||
}
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
if (value === 'null') return null;
|
||||
const number = Number(value);
|
||||
return Number.isNaN(number) ? value : number;
|
||||
}
|
||||
|
||||
function matchesFilter(record: RecordData, filter?: string): boolean {
|
||||
if (!filter) return true;
|
||||
|
||||
return filter.split('||').some((orPart) => orPart.split('&&').every((term) => {
|
||||
const match = term.trim().match(/^([a-zA-Z_][\w]*)\s*(=|!=|<=|>=|<|>)\s*(.+)$/);
|
||||
if (!match) return false;
|
||||
const [, field, operator, rawExpected] = match;
|
||||
const actual = valueFor(record, field);
|
||||
const expected = parseValue(rawExpected);
|
||||
|
||||
switch (operator) {
|
||||
case '=': return actual === expected;
|
||||
case '!=': return actual !== expected;
|
||||
case '<': return String(actual ?? '') < String(expected ?? '');
|
||||
case '<=': return String(actual ?? '') <= String(expected ?? '');
|
||||
case '>': return String(actual ?? '') > String(expected ?? '');
|
||||
case '>=': return String(actual ?? '') >= String(expected ?? '');
|
||||
default: return false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function sortRecords(items: RecordData[], sort?: string): RecordData[] {
|
||||
if (!sort) return items;
|
||||
const descending = sort.startsWith('-');
|
||||
const field = descending ? sort.slice(1) : sort;
|
||||
return [...items].sort((a, b) => {
|
||||
const left = valueFor(a, field);
|
||||
const right = valueFor(b, field);
|
||||
const comparison = String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true });
|
||||
return descending ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanData(data: RecordData): RecordData {
|
||||
const { id: _id, created: _created, updated: _updated, collectionId: _collectionId, collectionName: _collectionName, ...clean } = data;
|
||||
return clean;
|
||||
}
|
||||
|
||||
async function notify(action: 'create' | 'update' | 'delete', collection: string, record: RecordData) {
|
||||
await sql`select pg_notify('project_e_events', ${JSON.stringify({ type: action, collection, record })})`;
|
||||
}
|
||||
|
||||
class CollectionRepository {
|
||||
constructor(private readonly collectionName: string) {}
|
||||
|
||||
async getOne(id: string): Promise<RecordData> {
|
||||
const [record] = await db.select().from(records).where(and(eq(records.id, id), eq(records.collection, this.collectionName))).limit(1);
|
||||
if (!record) throw new Error(`Record ${id} not found in ${this.collectionName}`);
|
||||
return serialize(record);
|
||||
return { id, collectionName: this.collectionName };
|
||||
}
|
||||
|
||||
async getFullList(options: ListOptions = {}): Promise<RecordData[]> {
|
||||
const rows = await db.select().from(records).where(eq(records.collection, this.collectionName));
|
||||
return sortRecords(rows.map(serialize).filter((record) => matchesFilter(record, options.filter)), options.sort);
|
||||
return [];
|
||||
}
|
||||
|
||||
async getList(page = 1, perPage = 50, options: ListOptions = {}) {
|
||||
const items = await this.getFullList(options);
|
||||
const totalItems = items.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
|
||||
return {
|
||||
items: items.slice((page - 1) * perPage, page * perPage),
|
||||
items: [] as RecordData[],
|
||||
page,
|
||||
perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async create(data: RecordData): Promise<RecordData> {
|
||||
const [record] = await db.insert(records).values({ collection: this.collectionName, data: cleanData(data) }).returning();
|
||||
const result = serialize(record);
|
||||
await notify('create', this.collectionName, result);
|
||||
return result;
|
||||
return { ...data, id: 'stub', collectionName: this.collectionName };
|
||||
}
|
||||
|
||||
async update(id: string, data: RecordData): Promise<RecordData> {
|
||||
const existing = await this.getOne(id);
|
||||
const [record] = await db.update(records)
|
||||
.set({ data: cleanData({ ...existing, ...data }), updatedAt: new Date() })
|
||||
.where(and(eq(records.id, id), eq(records.collection, this.collectionName)))
|
||||
.returning();
|
||||
if (!record) throw new Error(`Record ${id} not found in ${this.collectionName}`);
|
||||
const result = serialize(record);
|
||||
await notify('update', this.collectionName, result);
|
||||
return result;
|
||||
return { ...data, id, collectionName: this.collectionName };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const existing = await this.getOne(id);
|
||||
await db.delete(records).where(and(eq(records.id, id), eq(records.collection, this.collectionName)));
|
||||
await notify('delete', this.collectionName, existing);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { db, domains, notes, users } from '@project-e/db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { count, eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Seed the database with default data on first boot.
|
||||
* Creates:
|
||||
* - "Personal" workspace domain
|
||||
* - Welcome note demonstrating wikilinks
|
||||
*/
|
||||
export async function seedDefaultData(): Promise<void> {
|
||||
const [{ total }] = await db.select({ total: count() }).from(domains);
|
||||
|
||||
if (total > 0) {
|
||||
return; // Already seeded
|
||||
}
|
||||
|
||||
// Create Personal workspace
|
||||
const [personalDomain] = await db
|
||||
.insert(domains)
|
||||
.values({
|
||||
name: 'Personal',
|
||||
slug: 'personal',
|
||||
color: '#356bff',
|
||||
icon: 'user',
|
||||
sortOrder: 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create welcome note
|
||||
await db.insert(notes).values({
|
||||
title: 'Welcome to Project E',
|
||||
content: `# Welcome to Project E 🎉
|
||||
|
||||
Your personal productivity OS is ready.
|
||||
|
||||
## Getting Started
|
||||
|
||||
- Use **Cmd+K** to open the command palette
|
||||
- Navigate to **Tasks**, **Habits**, **Projects**, or **Notes** from the sidebar
|
||||
- Create your first task with \`c t\` or click the + button
|
||||
|
||||
## Features
|
||||
|
||||
- **Tasks** — Full kanban board with drag-to-reorder, subtasks, dependencies, and time tracking
|
||||
- **Habits** — Streak tracking with mood logging and reminders
|
||||
- **Projects** — Milestone-based project management with sections
|
||||
- **Notes** — Markdown editor with [[wikilinks]] and backlinks
|
||||
- **Graph** — Visualize connections between all your entities
|
||||
- **Calendar** — See everything on a timeline
|
||||
- **Realtime** — Changes sync instantly across all open windows
|
||||
|
||||
## Links
|
||||
|
||||
- [[Tasks]] — View all tasks
|
||||
- [[Habits]] — View all habits
|
||||
- [[Projects]] — View all projects
|
||||
- [[Notes]] — View all notes
|
||||
|
||||
> Tip: You can link to any entity using [[entity:title]] syntax in your notes.
|
||||
`,
|
||||
domainId: personalDomain.id,
|
||||
isPinned: true,
|
||||
});
|
||||
|
||||
console.log('[seed] Default data created: Personal workspace + welcome note');
|
||||
}
|
||||
@@ -5,10 +5,11 @@ export function middleware(request: NextRequest) {
|
||||
const token = request.cookies.get('next-auth.session-token')?.value
|
||||
|| request.cookies.get('__Secure-next-auth.session-token')?.value;
|
||||
|
||||
// If no token and trying to access protected routes, redirect to login
|
||||
// Protected routes (v1 + v2)
|
||||
const protectedRoutes = [
|
||||
'/dashboard', '/tasks', '/habits', '/projects', '/notes', '/reports',
|
||||
'/calendar', '/analytics', '/agents', '/settings',
|
||||
'/graph', '/domains',
|
||||
];
|
||||
if (!token && protectedRoutes.some((route) => request.nextUrl.pathname === route || request.nextUrl.pathname.startsWith(`${route}/`))) {
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
|
||||
CREATE TABLE "records" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"collection" text NOT NULL,
|
||||
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "records_collection_created_at_idx" ON "records" USING btree ("collection","created_at");
|
||||
@@ -0,0 +1,299 @@
|
||||
-- Drop v1 EAV table (full replacement per spec section 12.3)
|
||||
DROP TABLE IF EXISTS "records" CASCADE;
|
||||
|
||||
CREATE TYPE "public"."habit_difficulty" AS ENUM('easy', 'medium', 'hard');--> statement-breakpoint
|
||||
CREATE TYPE "public"."habit_frequency" AS ENUM('daily', 'weekly', 'custom');--> statement-breakpoint
|
||||
CREATE TYPE "public"."job_status" AS ENUM('pending', 'processing', 'completed', 'failed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."project_status" AS ENUM('active', 'paused', 'completed', 'archived');--> statement-breakpoint
|
||||
CREATE TYPE "public"."section_kind" AS ENUM('section', 'milestone');--> statement-breakpoint
|
||||
CREATE TYPE "public"."section_status" AS ENUM('planned', 'in_progress', 'complete');--> statement-breakpoint
|
||||
CREATE TYPE "public"."tag_scope" AS ENUM('global', 'tasks', 'habits', 'projects', 'notes');--> statement-breakpoint
|
||||
CREATE TYPE "public"."task_priority" AS ENUM('low', 'medium', 'high', 'urgent');--> statement-breakpoint
|
||||
CREATE TYPE "public"."task_status" AS ENUM('todo', 'in_progress', 'done', 'cancelled');--> statement-breakpoint
|
||||
CREATE TABLE "activity_feed" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"actor" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" uuid NOT NULL,
|
||||
"changes" jsonb,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "domains" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"parent_id" uuid,
|
||||
"sort_order" integer DEFAULT 0,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "domains_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "habit_completions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"habit_id" uuid NOT NULL,
|
||||
"date" timestamp with time zone NOT NULL,
|
||||
"value" integer DEFAULT 1,
|
||||
"mood" integer,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "habit_tags" (
|
||||
"habit_id" uuid NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
CONSTRAINT "habit_tags_habit_id_tag_id_pk" PRIMARY KEY("habit_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "habits" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"domain_id" uuid NOT NULL,
|
||||
"frequency" "habit_frequency" DEFAULT 'daily' NOT NULL,
|
||||
"difficulty" "habit_difficulty" DEFAULT 'medium' NOT NULL,
|
||||
"goal_per_period" integer DEFAULT 1,
|
||||
"unit" text,
|
||||
"reminder_time" time,
|
||||
"skip_days" integer[] DEFAULT '{}',
|
||||
"streak_count" integer DEFAULT 0,
|
||||
"best_streak" integer DEFAULT 0,
|
||||
"mood_tracking" boolean DEFAULT false,
|
||||
"active" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"payload" jsonb DEFAULT '{}'::jsonb,
|
||||
"status" "job_status" DEFAULT 'pending' NOT NULL,
|
||||
"attempts" integer DEFAULT 0,
|
||||
"max_attempts" integer DEFAULT 3,
|
||||
"next_retry_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "note_entity_links" (
|
||||
"note_id" uuid NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "note_links" (
|
||||
"source_note_id" uuid NOT NULL,
|
||||
"target_note_id" uuid NOT NULL,
|
||||
CONSTRAINT "note_links_source_note_id_target_note_id_pk" PRIMARY KEY("source_note_id","target_note_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "note_tags" (
|
||||
"note_id" uuid NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
CONSTRAINT "note_tags_note_id_tag_id_pk" PRIMARY KEY("note_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"content" text,
|
||||
"domain_id" uuid NOT NULL,
|
||||
"is_pinned" boolean DEFAULT false,
|
||||
"is_archived" boolean DEFAULT false,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "project_tags" (
|
||||
"project_id" uuid NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
CONSTRAINT "project_tags_project_id_tag_id_pk" PRIMARY KEY("project_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "projects" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"status" "project_status" DEFAULT 'active' NOT NULL,
|
||||
"domain_id" uuid NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"target_date" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "scheduled_jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" uuid NOT NULL,
|
||||
"recurrence_rule" text NOT NULL,
|
||||
"next_occurrence_at" timestamp with time zone NOT NULL,
|
||||
"last_spawned_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sections" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"project_id" uuid NOT NULL,
|
||||
"kind" "section_kind" DEFAULT 'section' NOT NULL,
|
||||
"status" "section_status" DEFAULT 'planned' NOT NULL,
|
||||
"target_date" timestamp with time zone,
|
||||
"sort_order" integer DEFAULT 0,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tags" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"scope" "tag_scope" DEFAULT 'global' NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "task_dependencies" (
|
||||
"task_id" uuid NOT NULL,
|
||||
"depends_on_task_id" uuid NOT NULL,
|
||||
CONSTRAINT "task_dependencies_task_id_depends_on_task_id_pk" PRIMARY KEY("task_id","depends_on_task_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "task_tags" (
|
||||
"task_id" uuid NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
CONSTRAINT "task_tags_task_id_tag_id_pk" PRIMARY KEY("task_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"status" "task_status" DEFAULT 'todo' NOT NULL,
|
||||
"priority" "task_priority" DEFAULT 'medium' NOT NULL,
|
||||
"domain_id" uuid NOT NULL,
|
||||
"project_id" uuid,
|
||||
"section_id" uuid,
|
||||
"parent_id" uuid,
|
||||
"due_date" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"estimated_minutes" integer,
|
||||
"tracked_minutes" integer DEFAULT 0,
|
||||
"recurrence_rule" text,
|
||||
"order" integer DEFAULT 0,
|
||||
"custom_fields" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"passkey_credential_id" text,
|
||||
"passkey_public_key" text,
|
||||
"passkey_counter" integer DEFAULT 0,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "webhooks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text,
|
||||
"url" text NOT NULL,
|
||||
"secret" text,
|
||||
"events" text[] DEFAULT '{}',
|
||||
"active" boolean DEFAULT true,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "activity_feed" ADD CONSTRAINT "activity_feed_workspace_id_domains_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "domains" ADD CONSTRAINT "domains_parent_id_domains_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."domains"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "habit_completions" ADD CONSTRAINT "habit_completions_habit_id_habits_id_fk" FOREIGN KEY ("habit_id") REFERENCES "public"."habits"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "habit_tags" ADD CONSTRAINT "habit_tags_habit_id_habits_id_fk" FOREIGN KEY ("habit_id") REFERENCES "public"."habits"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "habit_tags" ADD CONSTRAINT "habit_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "habits" ADD CONSTRAINT "habits_domain_id_domains_id_fk" FOREIGN KEY ("domain_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "note_entity_links" ADD CONSTRAINT "note_entity_links_note_id_notes_id_fk" FOREIGN KEY ("note_id") REFERENCES "public"."notes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "note_links" ADD CONSTRAINT "note_links_source_note_id_notes_id_fk" FOREIGN KEY ("source_note_id") REFERENCES "public"."notes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "note_links" ADD CONSTRAINT "note_links_target_note_id_notes_id_fk" FOREIGN KEY ("target_note_id") REFERENCES "public"."notes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "note_tags" ADD CONSTRAINT "note_tags_note_id_notes_id_fk" FOREIGN KEY ("note_id") REFERENCES "public"."notes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "note_tags" ADD CONSTRAINT "note_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_domain_id_domains_id_fk" FOREIGN KEY ("domain_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "project_tags" ADD CONSTRAINT "project_tags_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "project_tags" ADD CONSTRAINT "project_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_domain_id_domains_id_fk" FOREIGN KEY ("domain_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sections" ADD CONSTRAINT "sections_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_parent_id_tags_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."tags"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_dependencies" ADD CONSTRAINT "task_dependencies_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_dependencies" ADD CONSTRAINT "task_dependencies_depends_on_task_id_tasks_id_fk" FOREIGN KEY ("depends_on_task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_tags" ADD CONSTRAINT "task_tags_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_tags" ADD CONSTRAINT "task_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_domain_id_domains_id_fk" FOREIGN KEY ("domain_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_section_id_sections_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_parent_id_tasks_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."tasks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_workspace_id_domains_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "activity_feed_workspace_id_idx" ON "activity_feed" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "activity_feed_entity_idx" ON "activity_feed" USING btree ("entity_type","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "activity_feed_created_at_idx" ON "activity_feed" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "activity_feed_actor_idx" ON "activity_feed" USING btree ("actor");--> statement-breakpoint
|
||||
CREATE INDEX "domains_parent_id_idx" ON "domains" USING btree ("parent_id");--> statement-breakpoint
|
||||
CREATE INDEX "domains_slug_idx" ON "domains" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "habit_completions_habit_id_idx" ON "habit_completions" USING btree ("habit_id");--> statement-breakpoint
|
||||
CREATE INDEX "habit_completions_date_idx" ON "habit_completions" USING btree ("habit_id","date");--> statement-breakpoint
|
||||
CREATE INDEX "habit_tags_tag_id_idx" ON "habit_tags" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "habits_domain_id_idx" ON "habits" USING btree ("domain_id");--> statement-breakpoint
|
||||
CREATE INDEX "habits_active_idx" ON "habits" USING btree ("active");--> statement-breakpoint
|
||||
CREATE INDEX "habits_deleted_at_idx" ON "habits" USING btree ("deleted_at");--> statement-breakpoint
|
||||
CREATE INDEX "jobs_status_idx" ON "jobs" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "jobs_type_idx" ON "jobs" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "jobs_next_retry_at_idx" ON "jobs" USING btree ("next_retry_at");--> statement-breakpoint
|
||||
CREATE INDEX "note_entity_links_entity_idx" ON "note_entity_links" USING btree ("entity_type","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "note_entity_links_note_id_idx" ON "note_entity_links" USING btree ("note_id");--> statement-breakpoint
|
||||
CREATE INDEX "note_links_target_note_id_idx" ON "note_links" USING btree ("target_note_id");--> statement-breakpoint
|
||||
CREATE INDEX "note_tags_tag_id_idx" ON "note_tags" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "notes_domain_id_idx" ON "notes" USING btree ("domain_id");--> statement-breakpoint
|
||||
CREATE INDEX "notes_is_pinned_idx" ON "notes" USING btree ("is_pinned");--> statement-breakpoint
|
||||
CREATE INDEX "notes_is_archived_idx" ON "notes" USING btree ("is_archived");--> statement-breakpoint
|
||||
CREATE INDEX "notes_deleted_at_idx" ON "notes" USING btree ("deleted_at");--> statement-breakpoint
|
||||
CREATE INDEX "project_tags_tag_id_idx" ON "project_tags" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "projects_domain_id_idx" ON "projects" USING btree ("domain_id");--> statement-breakpoint
|
||||
CREATE INDEX "projects_status_idx" ON "projects" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "projects_deleted_at_idx" ON "projects" USING btree ("deleted_at");--> statement-breakpoint
|
||||
CREATE INDEX "scheduled_jobs_next_occurrence_idx" ON "scheduled_jobs" USING btree ("next_occurrence_at");--> statement-breakpoint
|
||||
CREATE INDEX "scheduled_jobs_entity_idx" ON "scheduled_jobs" USING btree ("entity_type","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "sections_project_id_idx" ON "sections" USING btree ("project_id");--> statement-breakpoint
|
||||
CREATE INDEX "sections_kind_idx" ON "sections" USING btree ("kind");--> statement-breakpoint
|
||||
CREATE INDEX "sections_sort_order_idx" ON "sections" USING btree ("project_id","sort_order");--> statement-breakpoint
|
||||
CREATE INDEX "tags_parent_id_idx" ON "tags" USING btree ("parent_id");--> statement-breakpoint
|
||||
CREATE INDEX "tags_scope_idx" ON "tags" USING btree ("scope");--> statement-breakpoint
|
||||
CREATE INDEX "task_dependencies_depends_on_idx" ON "task_dependencies" USING btree ("depends_on_task_id");--> statement-breakpoint
|
||||
CREATE INDEX "task_tags_tag_id_idx" ON "task_tags" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_domain_id_idx" ON "tasks" USING btree ("domain_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_project_id_idx" ON "tasks" USING btree ("project_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_section_id_idx" ON "tasks" USING btree ("section_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_parent_id_idx" ON "tasks" USING btree ("parent_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_status_idx" ON "tasks" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_priority_idx" ON "tasks" USING btree ("priority");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_due_date_idx" ON "tasks" USING btree ("due_date");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_order_idx" ON "tasks" USING btree ("order");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_deleted_at_idx" ON "tasks" USING btree ("deleted_at");--> statement-breakpoint
|
||||
CREATE INDEX "webhooks_workspace_id_idx" ON "webhooks" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "webhooks_active_idx" ON "webhooks" USING btree ("active");
|
||||
+2354
-13
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,9 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784889026419,
|
||||
"tag": "0000_first_mauler",
|
||||
"when": 1785317538917,
|
||||
"tag": "0000_outstanding_zuras",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# API Patterns
|
||||
|
||||
## Route Structure
|
||||
|
||||
All routes are workspace-scoped:
|
||||
|
||||
```
|
||||
GET /api/domains/:domainId/tasks — List tasks
|
||||
POST /api/domains/:domainId/tasks — Create task
|
||||
GET /api/domains/:domainId/tasks/:id — Get task
|
||||
PATCH /api/domains/:domainId/tasks/:id — Update task
|
||||
DELETE /api/domains/:domainId/tasks/:id — Soft-delete task
|
||||
```
|
||||
|
||||
Same pattern for: habits, projects, notes, domains, tags, sections, webhooks, activity-feed.
|
||||
|
||||
## Query Parameters
|
||||
|
||||
| Param | Purpose | Example |
|
||||
|-------|---------|---------|
|
||||
| `?limit=&offset=` | Pagination | `?limit=20&offset=0` |
|
||||
| `?sort=` | Sorting | `?sort=-created` (descending) |
|
||||
| `?status=` | Filter by status | `?status=todo,in_progress` |
|
||||
| `?priority=` | Filter by priority | `?priority=high,urgent` |
|
||||
| `?tag=` | Filter by tag | `?tag=meeting` |
|
||||
| `?domain=` | Filter by domain | `?domain=uuid` |
|
||||
| `?search=` | Full-text search | `?search=deploy` |
|
||||
|
||||
## Error Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Title is required",
|
||||
"details": { "field": "title" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Standard codes: `VALIDATION_ERROR`, `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `INTERNAL_ERROR`
|
||||
|
||||
## Write Pattern
|
||||
|
||||
Every write API route MUST:
|
||||
1. Drizzle write (INSERT/UPDATE/DELETE)
|
||||
2. Insert activity feed entry via `recordActivity()`
|
||||
3. `pg.notify('project_e_events', payload)` — handled by `recordActivity()`
|
||||
@@ -0,0 +1,42 @@
|
||||
# Architecture
|
||||
|
||||
## System Overview
|
||||
|
||||
Project E is a full-stack personal productivity OS with realtime collaboration, AI agent integration, and keyboard-driven UI.
|
||||
|
||||
## Three-Layer Architecture
|
||||
|
||||
```
|
||||
Frontend (Next.js React) → Next.js API Routes (business logic) → PostgreSQL (data store)
|
||||
│ │
|
||||
│ ├── NOTIFY (on write)
|
||||
│ │
|
||||
└── SSE (EventSource) ←────────┘
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User action → API route → Drizzle write → `pg.notify()` → SSE endpoint → Zustand store → UI update
|
||||
2. External agent → MCP tool call → API route → Drizzle write → `pg.notify()` → SSE → UI update
|
||||
3. Worker → webhook delivery / recurring spawn → API route → Drizzle write → `pg.notify()` → SSE → UI update
|
||||
|
||||
## Container Layout
|
||||
|
||||
- **project-e-web** — Next.js 15 (App Router), frontend + REST API + MCP + SSE
|
||||
- **project-e-db** — PostgreSQL 16, all data + LISTEN/NOTIFY
|
||||
- **project-e-worker** — Node.js worker, webhook delivery + recurring spawning + AI dispatch
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
ProjectE/
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js 15 app
|
||||
│ └── worker/ # Node.js worker process
|
||||
├── packages/
|
||||
│ ├── db/ # Drizzle schema + connection
|
||||
│ └── shared/ # Shared types, schemas, constants
|
||||
├── docker-compose.yml
|
||||
├── AGENTS.md
|
||||
└── llm-wiki/
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# Conventions
|
||||
|
||||
## Naming
|
||||
|
||||
- Tables: snake_case, plural (e.g., `activity_feed`, `task_tags`)
|
||||
- Columns: snake_case (e.g., `domain_id`, `created_at`)
|
||||
- TypeScript: camelCase (e.g., `domainId`, `createdAt`)
|
||||
- API routes: kebab-case (e.g., `/api/domains/[domainId]/tasks`)
|
||||
- Files: kebab-case (e.g., `auth-config.ts`, `command-palette.tsx`)
|
||||
|
||||
## File Structure
|
||||
|
||||
- Schema: `packages/db/src/schema.ts` (single file for all tables)
|
||||
- API routes: `apps/web/app/api/[entity]/route.ts`
|
||||
- Components: `apps/web/components/[entity]/[component].tsx`
|
||||
- Stores: `apps/web/lib/stores/use-[store-name]-store.ts`
|
||||
- Lib: `apps/web/lib/[module].ts`
|
||||
|
||||
## Commit Format
|
||||
|
||||
```
|
||||
type: short description
|
||||
|
||||
- Bullet points for details
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
|
||||
|
||||
## Build
|
||||
|
||||
- Run `npm run build --workspace=apps/web` before committing
|
||||
- Do NOT commit build artifacts (`.next/`, `dist/`, `.turbo/`)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Data Model
|
||||
|
||||
## Entity Relationship Overview
|
||||
|
||||
- **domains** — Workspaces (parent_id=null) and sub-groups
|
||||
- **tasks** — Rich task model with subtasks, dependencies, time tracking
|
||||
- **habits** — Habit tracking with streaks, mood, reminders
|
||||
- **projects** — Projects with sections (milestones)
|
||||
- **notes** — Markdown notes with wikilinks and backlinks
|
||||
- **tags** — Universal tags with entity scoping and hierarchy
|
||||
- **activity_feed** — Append-only audit log
|
||||
- **scheduled_jobs** — Recurring task/habit spawning
|
||||
- **jobs** — Worker queue
|
||||
- **webhooks** — Registered webhook endpoints
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- Soft-delete via `deleted_at` on all user data tables
|
||||
- UUID primary keys with `gen_random_uuid()`
|
||||
- Text enums via `pgEnum` for status/priority/type fields
|
||||
- JSONB for flexible data (custom_fields, changes, payload)
|
||||
- All entities scoped to a workspace via `domain_id` or `workspace_id`
|
||||
|
||||
## Junction Tables
|
||||
|
||||
- task_tags, habit_tags, note_tags, project_tags — many-to-many entity↔tag
|
||||
- task_dependencies — task dependency graph
|
||||
- note_links — wikilink/backlink connections
|
||||
- note_entity_links — cross-entity linking (note → task/habit/project)
|
||||
|
||||
## Indexes
|
||||
|
||||
Every FK column and frequently filtered column has an index. See `packages/db/src/schema.ts` for the full list.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Realtime System
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
API Route (write)
|
||||
│
|
||||
├── Drizzle INSERT/UPDATE/DELETE
|
||||
├── Activity feed INSERT (via recordActivity)
|
||||
└── pg.notify('project_e_events', payload) (via recordActivity)
|
||||
│
|
||||
▼
|
||||
SSE Endpoint (/api/realtime)
|
||||
│
|
||||
├── LISTEN project_e_events
|
||||
├── Filter by workspace_id
|
||||
└── Push to EventSource
|
||||
│
|
||||
▼
|
||||
Zustand Store (entity slices)
|
||||
│
|
||||
├── Receives event: { type, action, id, workspace_id }
|
||||
├── Re-fetches affected entity
|
||||
└── Components re-render
|
||||
```
|
||||
|
||||
## SSE Event Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "task",
|
||||
"action": "created",
|
||||
"id": "uuid",
|
||||
"workspace_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## Connection
|
||||
|
||||
- Endpoint: `GET /api/realtime?workspace_id=uuid`
|
||||
- Content-Type: `text/event-stream`
|
||||
- Heartbeat: `:ping` every 30 seconds
|
||||
- Reconnection: Client-side exponential backoff
|
||||
|
||||
## Trigger Pattern
|
||||
|
||||
Every write API route calls `recordActivity()` which handles both the activity feed insert and the pg_notify call.
|
||||
+446
-8
@@ -1,24 +1,462 @@
|
||||
import { index, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
time,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// ── Enums ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const taskStatusEnum = pgEnum('task_status', ['todo', 'in_progress', 'done', 'cancelled']);
|
||||
export const taskPriorityEnum = pgEnum('task_priority', ['low', 'medium', 'high', 'urgent']);
|
||||
export const habitFrequencyEnum = pgEnum('habit_frequency', ['daily', 'weekly', 'custom']);
|
||||
export const habitDifficultyEnum = pgEnum('habit_difficulty', ['easy', 'medium', 'hard']);
|
||||
export const projectStatusEnum = pgEnum('project_status', ['active', 'paused', 'completed', 'archived']);
|
||||
export const sectionKindEnum = pgEnum('section_kind', ['section', 'milestone']);
|
||||
export const sectionStatusEnum = pgEnum('section_status', ['planned', 'in_progress', 'complete']);
|
||||
export const tagScopeEnum = pgEnum('tag_scope', ['global', 'tasks', 'habits', 'projects', 'notes']);
|
||||
export const jobStatusEnum = pgEnum('job_status', ['pending', 'processing', 'completed', 'failed']);
|
||||
|
||||
// ── Users ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: text('email').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
passkeyCredentialId: text('passkey_credential_id'),
|
||||
passkeyPublicKey: text('passkey_public_key'),
|
||||
passkeyCounter: integer('passkey_counter').default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Collection data is intentionally stored as JSONB. The application has flexible
|
||||
// per-collection fields, and this preserves that shape while PostgreSQL owns storage.
|
||||
export const records = pgTable(
|
||||
'records',
|
||||
// ── Domains (Workspaces) ───────────────────────────────────────────────────────
|
||||
|
||||
export const domains = pgTable(
|
||||
'domains',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
collection: text('collection').notNull(),
|
||||
data: jsonb('data').$type<Record<string, unknown>>().notNull().default({}),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
color: text('color'),
|
||||
icon: text('icon'),
|
||||
parentId: uuid('parent_id').references((): any => domains.id, { onDelete: 'set null' }),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index('records_collection_created_at_idx').on(table.collection, table.createdAt)]
|
||||
(table) => [
|
||||
index('domains_parent_id_idx').on(table.parentId),
|
||||
index('domains_slug_idx').on(table.slug),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Tags ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const tags = pgTable(
|
||||
'tags',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
color: text('color'),
|
||||
scope: tagScopeEnum('scope').notNull().default('global'),
|
||||
parentId: uuid('parent_id').references((): any => tags.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('tags_parent_id_idx').on(table.parentId),
|
||||
index('tags_scope_idx').on(table.scope),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Projects ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const projects = pgTable(
|
||||
'projects',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
status: projectStatusEnum('status').notNull().default('active'),
|
||||
domainId: uuid('domain_id')
|
||||
.notNull()
|
||||
.references((): any => domains.id, { onDelete: 'cascade' }),
|
||||
color: text('color'),
|
||||
icon: text('icon'),
|
||||
targetDate: timestamp('target_date', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index('projects_domain_id_idx').on(table.domainId),
|
||||
index('projects_status_idx').on(table.status),
|
||||
index('projects_deleted_at_idx').on(table.deletedAt),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Sections ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const sections = pgTable(
|
||||
'sections',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
projectId: uuid('project_id')
|
||||
.notNull()
|
||||
.references((): any => projects.id, { onDelete: 'cascade' }),
|
||||
kind: sectionKindEnum('kind').notNull().default('section'),
|
||||
status: sectionStatusEnum('status').notNull().default('planned'),
|
||||
targetDate: timestamp('target_date', { withTimezone: true }),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('sections_project_id_idx').on(table.projectId),
|
||||
index('sections_kind_idx').on(table.kind),
|
||||
index('sections_sort_order_idx').on(table.projectId, table.sortOrder),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Tasks ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const tasks = pgTable(
|
||||
'tasks',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
status: taskStatusEnum('status').notNull().default('todo'),
|
||||
priority: taskPriorityEnum('priority').notNull().default('medium'),
|
||||
domainId: uuid('domain_id')
|
||||
.notNull()
|
||||
.references((): any => domains.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id').references((): any => projects.id, { onDelete: 'set null' }),
|
||||
sectionId: uuid('section_id').references((): any => sections.id, { onDelete: 'set null' }),
|
||||
parentId: uuid('parent_id').references((): any => tasks.id, { onDelete: 'set null' }),
|
||||
dueDate: timestamp('due_date', { withTimezone: true }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
estimatedMinutes: integer('estimated_minutes'),
|
||||
trackedMinutes: integer('tracked_minutes').default(0),
|
||||
recurrenceRule: text('recurrence_rule'),
|
||||
order: integer('order').default(0),
|
||||
customFields: jsonb('custom_fields').$type<Record<string, unknown>>().default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index('tasks_domain_id_idx').on(table.domainId),
|
||||
index('tasks_project_id_idx').on(table.projectId),
|
||||
index('tasks_section_id_idx').on(table.sectionId),
|
||||
index('tasks_parent_id_idx').on(table.parentId),
|
||||
index('tasks_status_idx').on(table.status),
|
||||
index('tasks_priority_idx').on(table.priority),
|
||||
index('tasks_due_date_idx').on(table.dueDate),
|
||||
index('tasks_order_idx').on(table.order),
|
||||
index('tasks_deleted_at_idx').on(table.deletedAt),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Task Tags (junction) ────────────────────────────────────────────────────────
|
||||
|
||||
export const taskTags = pgTable(
|
||||
'task_tags',
|
||||
{
|
||||
taskId: uuid('task_id')
|
||||
.notNull()
|
||||
.references((): any => tasks.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references((): any => tags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.taskId, table.tagId] }),
|
||||
index('task_tags_tag_id_idx').on(table.tagId),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Task Dependencies (junction) ───────────────────────────────────────────────
|
||||
|
||||
export const taskDependencies = pgTable(
|
||||
'task_dependencies',
|
||||
{
|
||||
taskId: uuid('task_id')
|
||||
.notNull()
|
||||
.references((): any => tasks.id, { onDelete: 'cascade' }),
|
||||
dependsOnTaskId: uuid('depends_on_task_id')
|
||||
.notNull()
|
||||
.references((): any => tasks.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.taskId, table.dependsOnTaskId] }),
|
||||
index('task_dependencies_depends_on_idx').on(table.dependsOnTaskId),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Habits ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const habits = pgTable(
|
||||
'habits',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
domainId: uuid('domain_id')
|
||||
.notNull()
|
||||
.references((): any => domains.id, { onDelete: 'cascade' }),
|
||||
frequency: habitFrequencyEnum('frequency').notNull().default('daily'),
|
||||
difficulty: habitDifficultyEnum('difficulty').notNull().default('medium'),
|
||||
goalPerPeriod: integer('goal_per_period').default(1),
|
||||
unit: text('unit'),
|
||||
reminderTime: time('reminder_time'),
|
||||
skipDays: integer('skip_days').array().default([]),
|
||||
streakCount: integer('streak_count').default(0),
|
||||
bestStreak: integer('best_streak').default(0),
|
||||
moodTracking: boolean('mood_tracking').default(false),
|
||||
active: boolean('active').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index('habits_domain_id_idx').on(table.domainId),
|
||||
index('habits_active_idx').on(table.active),
|
||||
index('habits_deleted_at_idx').on(table.deletedAt),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Habit Completions ───────────────────────────────────────────────────────────
|
||||
|
||||
export const habitCompletions = pgTable(
|
||||
'habit_completions',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
habitId: uuid('habit_id')
|
||||
.notNull()
|
||||
.references((): any => habits.id, { onDelete: 'cascade' }),
|
||||
date: timestamp('date', { withTimezone: true }).notNull(),
|
||||
value: integer('value').default(1),
|
||||
mood: integer('mood'),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('habit_completions_habit_id_idx').on(table.habitId),
|
||||
index('habit_completions_date_idx').on(table.habitId, table.date),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Habit Tags (junction) ───────────────────────────────────────────────────────
|
||||
|
||||
export const habitTags = pgTable(
|
||||
'habit_tags',
|
||||
{
|
||||
habitId: uuid('habit_id')
|
||||
.notNull()
|
||||
.references((): any => habits.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references((): any => tags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.habitId, table.tagId] }),
|
||||
index('habit_tags_tag_id_idx').on(table.tagId),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Notes ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const notes = pgTable(
|
||||
'notes',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
content: text('content'),
|
||||
domainId: uuid('domain_id')
|
||||
.notNull()
|
||||
.references((): any => domains.id, { onDelete: 'cascade' }),
|
||||
isPinned: boolean('is_pinned').default(false),
|
||||
isArchived: boolean('is_archived').default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index('notes_domain_id_idx').on(table.domainId),
|
||||
index('notes_is_pinned_idx').on(table.isPinned),
|
||||
index('notes_is_archived_idx').on(table.isArchived),
|
||||
index('notes_deleted_at_idx').on(table.deletedAt),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Note Links (wikilinks / backlinks) ─────────────────────────────────────────
|
||||
|
||||
export const noteLinks = pgTable(
|
||||
'note_links',
|
||||
{
|
||||
sourceNoteId: uuid('source_note_id')
|
||||
.notNull()
|
||||
.references((): any => notes.id, { onDelete: 'cascade' }),
|
||||
targetNoteId: uuid('target_note_id')
|
||||
.notNull()
|
||||
.references((): any => notes.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.sourceNoteId, table.targetNoteId] }),
|
||||
index('note_links_target_note_id_idx').on(table.targetNoteId),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Note Entity Links (cross-entity linking) ────────────────────────────────────
|
||||
|
||||
export const noteEntityLinks = pgTable(
|
||||
'note_entity_links',
|
||||
{
|
||||
noteId: uuid('note_id')
|
||||
.notNull()
|
||||
.references((): any => notes.id, { onDelete: 'cascade' }),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: uuid('entity_id').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('note_entity_links_entity_idx').on(table.entityType, table.entityId),
|
||||
index('note_entity_links_note_id_idx').on(table.noteId),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Note Tags (junction) ────────────────────────────────────────────────────────
|
||||
|
||||
export const noteTags = pgTable(
|
||||
'note_tags',
|
||||
{
|
||||
noteId: uuid('note_id')
|
||||
.notNull()
|
||||
.references((): any => notes.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references((): any => tags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.noteId, table.tagId] }),
|
||||
index('note_tags_tag_id_idx').on(table.tagId),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Project Tags (junction) ─────────────────────────────────────────────────────
|
||||
|
||||
export const projectTags = pgTable(
|
||||
'project_tags',
|
||||
{
|
||||
projectId: uuid('project_id')
|
||||
.notNull()
|
||||
.references((): any => projects.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references((): any => tags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.projectId, table.tagId] }),
|
||||
index('project_tags_tag_id_idx').on(table.tagId),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Activity Feed ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const activityFeed = pgTable(
|
||||
'activity_feed',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
actor: text('actor').notNull(),
|
||||
action: text('action').notNull(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: uuid('entity_id').notNull(),
|
||||
changes: jsonb('changes').$type<Record<string, unknown>>(),
|
||||
workspaceId: uuid('workspace_id')
|
||||
.notNull()
|
||||
.references((): any => domains.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('activity_feed_workspace_id_idx').on(table.workspaceId),
|
||||
index('activity_feed_entity_idx').on(table.entityType, table.entityId),
|
||||
index('activity_feed_created_at_idx').on(table.createdAt),
|
||||
index('activity_feed_actor_idx').on(table.actor),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Scheduled Jobs ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scheduledJobs = pgTable(
|
||||
'scheduled_jobs',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: uuid('entity_id').notNull(),
|
||||
recurrenceRule: text('recurrence_rule').notNull(),
|
||||
nextOccurrenceAt: timestamp('next_occurrence_at', { withTimezone: true }).notNull(),
|
||||
lastSpawnedAt: timestamp('last_spawned_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('scheduled_jobs_next_occurrence_idx').on(table.nextOccurrenceAt),
|
||||
index('scheduled_jobs_entity_idx').on(table.entityType, table.entityId),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Jobs (Worker Queue) ─────────────────────────────────────────────────────────
|
||||
|
||||
export const jobs = pgTable(
|
||||
'jobs',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
type: text('type').notNull(),
|
||||
payload: jsonb('payload').$type<Record<string, unknown>>().default({}),
|
||||
status: jobStatusEnum('status').notNull().default('pending'),
|
||||
attempts: integer('attempts').default(0),
|
||||
maxAttempts: integer('max_attempts').default(3),
|
||||
nextRetryAt: timestamp('next_retry_at', { withTimezone: true }),
|
||||
lastError: text('last_error'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('jobs_status_idx').on(table.status),
|
||||
index('jobs_type_idx').on(table.type),
|
||||
index('jobs_next_retry_at_idx').on(table.nextRetryAt),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Webhooks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const webhooks = pgTable(
|
||||
'webhooks',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name'),
|
||||
url: text('url').notNull(),
|
||||
secret: text('secret'),
|
||||
events: text('events').array().default([]),
|
||||
active: boolean('active').default(true),
|
||||
workspaceId: uuid('workspace_id')
|
||||
.notNull()
|
||||
.references((): any => domains.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('webhooks_workspace_id_idx').on(table.workspaceId),
|
||||
index('webhooks_active_idx').on(table.active),
|
||||
]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user