feat: migrate from PocketBase to PostgreSQL with Drizzle ORM
- Add @project-e/db package with Drizzle schema and migrations - Replace PocketBase client with PostgreSQL-based database client - Migrate auth from custom to NextAuth.js - Add Docker Compose with PostgreSQL container - Update worker to use new database client - Remove PocketBase-specific files and migrations - Add drizzle config and initial migration
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
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';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
session: { strategy: 'jwt' },
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const email = credentials?.email?.trim().toLowerCase();
|
||||
const password = credentials?.password;
|
||||
if (!email || !password) return null;
|
||||
|
||||
let [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
const [{ total }] = await db.select({ total: count() }).from(users);
|
||||
const initialEmail = process.env.INITIAL_ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const initialPassword = process.env.INITIAL_ADMIN_PASSWORD;
|
||||
if (total === 0 && email === initialEmail && password === initialPassword) {
|
||||
[user] = await db.insert(users).values({
|
||||
email,
|
||||
name: process.env.INITIAL_ADMIN_NAME || email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
}).returning();
|
||||
}
|
||||
}
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.passwordHash))) return null;
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) token.id = user.id;
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user) session.user.id = token.id as string;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: { signIn: '/login' },
|
||||
};
|
||||
+10
-28
@@ -1,4 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from './auth-config';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -10,7 +12,9 @@ export interface AuthUser {
|
||||
* Extract auth token from request cookies
|
||||
*/
|
||||
export function getAuthToken(request: NextRequest): string | null {
|
||||
return request.cookies.get('pb_auth')?.value || null;
|
||||
return request.cookies.get('next-auth.session-token')?.value
|
||||
|| request.cookies.get('__Secure-next-auth.session-token')?.value
|
||||
|| null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,32 +22,10 @@ export function getAuthToken(request: NextRequest): string | null {
|
||||
* Returns null if not authenticated
|
||||
*/
|
||||
export async function getAuthUser(request: NextRequest): Promise<AuthUser | null> {
|
||||
const token = getAuthToken(request);
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
// Decode JWT to get user ID
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
||||
const userId = payload.id;
|
||||
|
||||
// Use raw fetch with admin token to get the user record
|
||||
// (avoids PocketBase SDK authStore issues with superuser tokens)
|
||||
const adminToken = process.env.POCKETBASE_ADMIN_TOKEN || '';
|
||||
const pbUrl = process.env.POCKETBASE_URL || 'http://localhost:8090';
|
||||
const res = await fetch(pbUrl + '/api/collections/users/records/' + userId, {
|
||||
headers: { Authorization: adminToken },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const record = await res.json();
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
email: record.email,
|
||||
name: record.name || record.email,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!getAuthToken(request)) return null;
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id || !session.user.email) return null;
|
||||
return { id: session.user.id, email: session.user.email, name: session.user.name || session.user.email };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,4 +127,4 @@ export function createErrorResponse(
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, records, sql } from '@project-e/db';
|
||||
|
||||
export const collectionNames = [
|
||||
'domains', 'tags', 'projects', 'project_settings', 'milestones',
|
||||
'milestone_dependencies', 'milestone_templates', 'milestone_history', 'tasks',
|
||||
'task_subtasks', 'task_dependencies', 'task_attachments', 'task_time_entries',
|
||||
'time_entries', 'habits', 'habit_logs', 'habit_skip_days', 'notes', 'note_links',
|
||||
'note_task_links', 'report_templates', 'reports', 'canvases', 'canvas_cards',
|
||||
'agents', 'agent_activity', 'webhooks', 'webhook_deliveries', 'agent_tasks',
|
||||
'notifications', 'error_logs', 'queue_jobs',
|
||||
] as const;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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),
|
||||
page,
|
||||
perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDatabaseClient() {
|
||||
return {
|
||||
collection(name: string) {
|
||||
if (!collectionNames.includes(name as (typeof collectionNames)[number])) {
|
||||
throw new Error(`Unknown collection: ${name}`);
|
||||
}
|
||||
return new CollectionRepository(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createAdminClient() {
|
||||
return createDatabaseClient();
|
||||
}
|
||||
+19
-38
@@ -1,37 +1,18 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import { createAdminClient as createDatabaseAdminClient, createDatabaseClient } from './database';
|
||||
|
||||
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://localhost:8090';
|
||||
|
||||
/**
|
||||
* Create a PocketBase client instance
|
||||
* @param token - Optional auth token for authenticated requests
|
||||
*/
|
||||
export function createPocketBaseClient(token?: string): PocketBase {
|
||||
const pb = new PocketBase(pocketbaseUrl);
|
||||
if (token) {
|
||||
pb.authStore.save(token, null);
|
||||
}
|
||||
return pb;
|
||||
/** @deprecated Import from `@/lib/database` in new code. */
|
||||
export function createPocketBaseClient(_token?: string) {
|
||||
return createDatabaseClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin token from environment
|
||||
*/
|
||||
/** @deprecated PostgreSQL access is authenticated by the application session. */
|
||||
export function getAdminToken(): string {
|
||||
return process.env.POCKETBASE_ADMIN_TOKEN || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an admin-authenticated PocketBase client
|
||||
* Used for server-side operations that need admin privileges
|
||||
*/
|
||||
export function createAdminClient(): PocketBase {
|
||||
const pb = new PocketBase(pocketbaseUrl);
|
||||
const adminToken = getAdminToken();
|
||||
if (adminToken) {
|
||||
pb.authStore.save(adminToken, { id: 'admin', email: 'admin@projecte.local', collectionId: '_superusers', collectionName: '_superusers' } as any);
|
||||
}
|
||||
return pb;
|
||||
/** @deprecated Import from `@/lib/database` in new code. */
|
||||
export function createAdminClient() {
|
||||
return createDatabaseAdminClient();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,8 +23,8 @@ export async function getRecord<T extends Record<string, unknown>>(
|
||||
id: string,
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const pb = createPocketBaseClient(token);
|
||||
return pb.collection(collection).getOne(id) as Promise<T>;
|
||||
const db = createPocketBaseClient(token);
|
||||
return db.collection(collection).getOne(id) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,8 +40,8 @@ export async function listRecords<T extends Record<string, unknown>>(
|
||||
token?: string;
|
||||
}
|
||||
): Promise<{ items: T[]; totalItems: number; totalPages: number }> {
|
||||
const pb = createPocketBaseClient(options?.token);
|
||||
const result = await pb.collection(collection).getList(
|
||||
const db = createPocketBaseClient(options?.token);
|
||||
const result = await db.collection(collection).getList(
|
||||
options?.page || 1,
|
||||
options?.perPage || 50,
|
||||
{
|
||||
@@ -83,8 +64,8 @@ export async function createRecord<T extends Record<string, unknown>>(
|
||||
data: Partial<T>,
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const pb = createPocketBaseClient(token);
|
||||
return pb.collection(collection).create(data) as Promise<T>;
|
||||
const db = createPocketBaseClient(token);
|
||||
return db.collection(collection).create(data) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,8 +77,8 @@ export async function updateRecord<T extends Record<string, unknown>>(
|
||||
data: Partial<T>,
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const pb = createPocketBaseClient(token);
|
||||
return pb.collection(collection).update(id, data) as Promise<T>;
|
||||
const db = createPocketBaseClient(token);
|
||||
return db.collection(collection).update(id, data) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +89,6 @@ export async function deleteRecord(
|
||||
id: string,
|
||||
token?: string
|
||||
): Promise<boolean> {
|
||||
const pb = createPocketBaseClient(token);
|
||||
return pb.collection(collection).delete(id);
|
||||
const db = createPocketBaseClient(token);
|
||||
return db.collection(collection).delete(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user