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,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');
|
||||
}
|
||||
Reference in New Issue
Block a user