refactor: consolidate codebase — remove archived code, deduplicate packages, standardize middleware

- Delete 17 server-archived/ directories (consolidated into apps/api/)
- Delete apps-archived/ (clock, wisekeep) and services-archived/ (it-landing, ollama-metrics-proxy)
- Fix type safety: replace all `any` casts in cross-app-queries.ts with proper Local* types
- Remove duplicate shared-auth-stores package (identical copy of shared-auth-ui/stores/)
- Remove duplicate theme store from shared-stores (canonical version in shared-theme)
- Migrate memoro-server rate-limiter to shared-hono/rateLimitMiddleware
- Migrate uload-server JWT auth + error handler to shared-hono (authMiddleware, errorHandler)
- Migrate arcade-server error handling to shared-hono
- Merge shared-profile-ui and shared-app-onboarding into shared-ui
- Unify /clock route into /times/clock, remove redirect stubs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 12:55:58 +02:00
parent 7ee57b7afd
commit d8ce4eaf34
309 changed files with 172 additions and 21667 deletions

View file

@ -1,40 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@todo/server",
"dependencies": {
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"postgres": "^3.4.5",
"rrule": "^2.8.1",
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"rrule": ["rrule@2.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View file

@ -1,27 +0,0 @@
{
"name": "@todo/server",
"version": "0.1.0",
"private": true,
"description": "Todo server-side compute (Hono + Bun) — RRULE, reminders, admin",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "bun x tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"postgres": "^3.4.5",
"rrule": "^2.8.1",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.9.3",
"vitest": "^3.0.0"
}
}

View file

@ -1,90 +0,0 @@
/**
* Database connection via Drizzle ORM + postgres.js
*
* Minimal schema only tables needed by server-side compute.
* CRUD tables (tasks, projects, labels) are handled client-side.
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import {
pgSchema,
uuid,
text,
timestamp,
varchar,
integer,
boolean,
jsonb,
index,
} from 'drizzle-orm/pg-core';
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/mana_platform';
const connection = postgres(DATABASE_URL, {
max: 5,
idle_timeout: 20,
});
// ─── Schema ────────────────
export const todoSchema = pgSchema('todo');
// ─── Minimal Schema (only what server needs) ────────────────
export const tasks = todoSchema.table('tasks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
projectId: uuid('project_id'),
title: varchar('title', { length: 500 }).notNull(),
description: text('description'),
dueDate: timestamp('due_date', { withTimezone: true }),
dueTime: varchar('due_time', { length: 5 }),
startDate: timestamp('start_date', { withTimezone: true }),
priority: varchar('priority', { length: 20 }).default('medium'),
status: varchar('status', { length: 20 }).default('pending'),
isCompleted: boolean('is_completed').default(false),
completedAt: timestamp('completed_at', { withTimezone: true }),
order: integer('order').default(0),
recurrenceRule: varchar('recurrence_rule', { length: 500 }),
recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }),
lastOccurrence: timestamp('last_occurrence', { withTimezone: true }),
parentTaskId: uuid('parent_task_id'),
subtasks: jsonb('subtasks'),
metadata: jsonb('metadata'),
columnId: uuid('column_id'),
columnOrder: integer('column_order'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const projects = todoSchema.table('projects', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
});
export const reminders = todoSchema.table(
'reminders',
{
id: uuid('id').primaryKey().defaultRandom(),
taskId: uuid('task_id').notNull(),
userId: text('user_id').notNull(),
minutesBefore: integer('minutes_before').notNull(),
reminderTime: timestamp('reminder_time', { withTimezone: true }).notNull(),
type: varchar('type', { length: 20 }).default('push'),
status: varchar('status', { length: 20 }).default('pending'),
sentAt: timestamp('sent_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
taskIdx: index('reminders_task_idx_hono').on(table.taskId),
userIdx: index('reminders_user_idx_hono').on(table.userId),
})
);
export const db = drizzle(connection, {
schema: { tasks, projects, reminders },
});
export type Database = typeof db;

View file

@ -1,62 +0,0 @@
/**
* Todo Server Hono + Bun
*
* Lightweight server for compute-only endpoints:
* - RRULE expansion (recurring tasks)
* - Reminders (push/email notifications)
* - Admin (GDPR compliance)
*
* All CRUD is handled client-side via local-first + sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import {
authMiddleware,
healthRoute,
errorHandler,
notFoundHandler,
rateLimitMiddleware,
} from '@manacore/shared-hono';
import { rruleRoutes } from './routes/rrule';
import { reminderRoutes } from './routes/reminders';
import { adminRoutes } from './routes/admin';
import { startReminderWorker } from './lib/reminder-worker';
const app = new Hono();
// Middleware
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', logger());
app.use(
'*',
cors({
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:5188,http://localhost:5173').split(','),
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Authorization', 'Content-Type', 'X-Service-Key', 'X-Client-Id'],
credentials: true,
})
);
app.route('/health', healthRoute('todo-server'));
app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
app.use('/api/*', authMiddleware());
// Routes
app.route('/api/v1/compute', rruleRoutes);
app.route('/api/v1', reminderRoutes);
app.route('/api/v1/admin', adminRoutes);
// Start
const port = Number(process.env.PORT ?? 3019);
// Start background worker for reminder notifications
startReminderWorker();
console.log(`🚀 Todo server (Hono + Bun) starting on port ${port}`);
export default {
port,
fetch: app.fetch,
};

View file

@ -1,73 +0,0 @@
/**
* JWT authentication middleware for Hono.
* Validates EdDSA JWTs from mana-core-auth via JWKS.
*/
import type { Context, Next } from 'hono';
import { HTTPException } from 'hono/http-exception';
const AUTH_URL = process.env.MANA_CORE_AUTH_URL ?? 'http://localhost:3001';
const _JWKS_URL = `${AUTH_URL}/api/auth/jwks`; // Used for future EdDSA verification
const SERVICE_KEY = process.env.MANA_CORE_SERVICE_KEY ?? '';
interface JWTPayload {
sub: string;
email: string;
role: string;
sid: string;
exp: number;
iss: string;
aud: string;
}
// Simple JWT decode (validation via JWKS would need jose library — for now we trust the token structure
// since the Go sync server already validates. In production, use jose/jwtVerify with EdDSA.)
function decodeJWT(token: string): JWTPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
// Check expiration
if (payload.exp && payload.exp < Date.now() / 1000) return null;
return payload;
} catch {
return null;
}
}
/**
* JWT auth middleware extracts and validates Bearer token.
* Sets `userId` and `userEmail` on the context.
*/
export function authMiddleware() {
return async (c: Context, next: Next) => {
const auth = c.req.header('Authorization');
if (!auth?.startsWith('Bearer ')) {
throw new HTTPException(401, { message: 'Missing authorization header' });
}
const token = auth.slice(7);
const payload = decodeJWT(token);
if (!payload || !payload.sub) {
throw new HTTPException(401, { message: 'Invalid or expired token' });
}
c.set('userId', payload.sub);
c.set('userEmail', payload.email);
await next();
};
}
/**
* Service key auth middleware validates X-Service-Key header.
* Used for admin endpoints called by mana-core-auth.
*/
export function serviceAuthMiddleware() {
return async (c: Context, next: Next) => {
const key = c.req.header('X-Service-Key');
if (!key || key !== SERVICE_KEY) {
throw new HTTPException(401, { message: 'Invalid service key' });
}
await next();
};
}

View file

@ -1,142 +0,0 @@
/**
* Reminder Worker Background cron that processes due reminders.
*
* Runs every 60 seconds, finds pending reminders whose reminderTime
* has passed, and dispatches them via mana-notify. Updates status
* to 'sent' or 'failed' accordingly.
*/
import { eq, and, lte } from 'drizzle-orm';
import { db, reminders, tasks } from '../db';
const MANA_NOTIFY_URL = process.env.MANA_NOTIFY_URL || 'http://localhost:3040';
const SERVICE_KEY =
process.env.MANA_NOTIFY_SERVICE_KEY || process.env.SERVICE_KEY || 'dev-service-key';
const TODO_WEB_URL = process.env.TODO_WEB_URL || 'http://localhost:5188';
const CHECK_INTERVAL_MS = 60_000; // 1 minute
let timer: ReturnType<typeof setInterval> | null = null;
async function processReminders() {
try {
const now = new Date();
// Find all pending reminders whose time has arrived
const dueReminders = await db.query.reminders.findMany({
where: and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, now)),
});
if (dueReminders.length === 0) return;
console.log(`[reminder-worker] Processing ${dueReminders.length} due reminder(s)`);
for (const reminder of dueReminders) {
try {
// Fetch the associated task for context
const task = await db.query.tasks.findFirst({
where: eq(tasks.id, reminder.taskId),
});
if (!task) {
// Task was deleted — mark reminder as failed
await db
.update(reminders)
.set({ status: 'failed', sentAt: now })
.where(eq(reminders.id, reminder.id));
continue;
}
// Send notification via mana-notify
const channels: string[] = [];
if (reminder.type === 'push' || reminder.type === 'both') channels.push('push');
if (reminder.type === 'email' || reminder.type === 'both') channels.push('email');
for (const channel of channels) {
await sendNotification({
userId: reminder.userId,
channel,
taskTitle: task.title,
taskId: task.id,
dueDate: task.dueDate ? new Date(task.dueDate).toISOString() : undefined,
});
}
// Mark as sent
await db
.update(reminders)
.set({ status: 'sent', sentAt: now })
.where(eq(reminders.id, reminder.id));
} catch (err) {
console.error(`[reminder-worker] Failed to process reminder ${reminder.id}:`, err);
// Mark as failed
await db.update(reminders).set({ status: 'failed' }).where(eq(reminders.id, reminder.id));
}
}
} catch (err) {
console.error('[reminder-worker] Error in processing loop:', err);
}
}
async function sendNotification(params: {
userId: string;
channel: string;
taskTitle: string;
taskId: string;
dueDate?: string;
}) {
const { userId, channel, taskTitle, taskId, dueDate } = params;
const body = {
userId,
channel,
templateSlug: 'task-reminder',
variables: {
taskTitle,
taskUrl: `${TODO_WEB_URL}/task/${taskId}`,
dueDate: dueDate
? new Date(dueDate).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
: '',
},
// Fallback if template not found — send direct
subject: `Erinnerung: ${taskTitle}`,
body: `Aufgabe "${taskTitle}" ist ${dueDate ? `fällig am ${new Date(dueDate).toLocaleString('de-DE')}` : 'bald fällig'}.`,
};
const response = await fetch(`${MANA_NOTIFY_URL}/api/v1/notifications/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': SERVICE_KEY,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text().catch(() => 'unknown');
throw new Error(`mana-notify responded with ${response.status}: ${text}`);
}
}
export function startReminderWorker() {
if (timer) return;
console.log(`[reminder-worker] Started (checking every ${CHECK_INTERVAL_MS / 1000}s)`);
// Run immediately on startup
processReminders();
// Then run on interval
timer = setInterval(processReminders, CHECK_INTERVAL_MS);
}
export function stopReminderWorker() {
if (timer) {
clearInterval(timer);
timer = null;
console.log('[reminder-worker] Stopped');
}
}

View file

@ -1,111 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { Hono } from 'hono';
// Mock drizzle-orm operators
vi.mock('drizzle-orm', () => ({
eq: vi.fn((_col, _val) => ({ type: 'eq' })),
sql: vi.fn((strings: TemplateStringsArray) => strings.join('')),
}));
const mockSelectFromWhere = vi.fn();
const mockDeleteWhere = vi.fn();
vi.mock('../db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: () => mockSelectFromWhere(),
})),
})),
delete: vi.fn(() => ({
where: () => mockDeleteWhere(),
})),
},
tasks: { userId: 'user_id' },
projects: { userId: 'user_id' },
reminders: { userId: 'user_id' },
}));
// Mock serviceAuthMiddleware to pass through
vi.mock('@manacore/shared-hono', () => ({
serviceAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
const { adminRoutes } = await import('./admin');
const app = new Hono();
app.route('/admin', adminRoutes);
function get(path: string) {
return app.request(path);
}
function del(path: string) {
return app.request(path, { method: 'DELETE' });
}
// ─── GET /admin/user-data/:userId ──────────────────────────────
describe('GET /admin/user-data/:userId', () => {
it('returns user data counts', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([{ count: 42 }]) // tasks
.mockResolvedValueOnce([{ count: 3 }]) // projects
.mockResolvedValueOnce([{ count: 5 }]); // reminders
const res = await get('/admin/user-data/user-123');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.userId).toBe('user-123');
expect(data.counts.tasks).toBe(42);
expect(data.counts.projects).toBe(3);
expect(data.counts.reminders).toBe(5);
});
it('returns zero counts for user with no data', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([{ count: 0 }])
.mockResolvedValueOnce([{ count: 0 }])
.mockResolvedValueOnce([{ count: 0 }]);
const res = await get('/admin/user-data/empty-user');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.counts.tasks).toBe(0);
expect(data.counts.projects).toBe(0);
expect(data.counts.reminders).toBe(0);
});
it('handles null count results', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([undefined])
.mockResolvedValueOnce([undefined])
.mockResolvedValueOnce([undefined]);
const res = await get('/admin/user-data/user-x');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.counts.tasks).toBe(0);
expect(data.counts.projects).toBe(0);
expect(data.counts.reminders).toBe(0);
});
});
// ─── DELETE /admin/user-data/:userId ───────────────────────────
describe('DELETE /admin/user-data/:userId', () => {
it('deletes all user data (GDPR)', async () => {
mockDeleteWhere.mockResolvedValue(undefined);
const res = await del('/admin/user-data/user-123');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.userId).toBe('user-123');
expect(data.deleted).toBe(true);
expect(data.message).toBe('All user data deleted');
});
});

View file

@ -1,57 +0,0 @@
/**
* Admin route GDPR compliance + user data aggregation.
* Called by mana-core-auth, protected by service key.
*/
import { Hono } from 'hono';
import { eq, sql } from 'drizzle-orm';
import { serviceAuthMiddleware } from '@manacore/shared-hono';
import { db, tasks, projects, reminders } from '../db';
const adminRoutes = new Hono();
adminRoutes.use('/*', serviceAuthMiddleware());
/** Get user data counts. */
adminRoutes.get('/user-data/:userId', async (c) => {
const userId = c.req.param('userId');
const [taskCount] = await db
.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(eq(tasks.userId, userId));
const [projectCount] = await db
.select({ count: sql<number>`count(*)` })
.from(projects)
.where(eq(projects.userId, userId));
const [reminderCount] = await db
.select({ count: sql<number>`count(*)` })
.from(reminders)
.where(eq(reminders.userId, userId));
return c.json({
userId,
counts: {
tasks: Number(taskCount?.count ?? 0),
projects: Number(projectCount?.count ?? 0),
reminders: Number(reminderCount?.count ?? 0),
},
});
});
/** Delete all user data (GDPR right to be forgotten). */
adminRoutes.delete('/user-data/:userId', async (c) => {
const userId = c.req.param('userId');
await db.delete(reminders).where(eq(reminders.userId, userId));
await db.delete(tasks).where(eq(tasks.userId, userId));
await db.delete(projects).where(eq(projects.userId, userId));
return c.json({
userId,
deleted: true,
message: 'All user data deleted',
});
});
export { adminRoutes };

View file

@ -1,205 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';
// Mock drizzle-orm operators before any imports that use them
vi.mock('drizzle-orm', () => ({
eq: vi.fn((_col, _val) => ({ type: 'eq' })),
and: vi.fn((..._args) => ({ type: 'and' })),
asc: vi.fn((_col) => ({ type: 'asc' })),
}));
const mockFindFirstTask = vi.fn();
const mockFindManyReminders = vi.fn();
const mockInsertReturning = vi.fn();
const mockDeleteWhere = vi.fn();
vi.mock('../db', () => ({
db: {
query: {
tasks: { findFirst: (...args: unknown[]) => mockFindFirstTask(...args) },
reminders: { findMany: (...args: unknown[]) => mockFindManyReminders(...args) },
},
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: () => mockInsertReturning(),
})),
})),
delete: vi.fn(() => ({
where: () => mockDeleteWhere(),
})),
},
tasks: { id: 'id', userId: 'user_id' },
reminders: {
id: 'id',
taskId: 'task_id',
userId: 'user_id',
minutesBefore: 'minutes_before',
},
}));
// Import AFTER mocks
const { reminderRoutes } = await import('./reminders');
const TEST_USER_ID = 'test-user-id';
function createApp() {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('userId', TEST_USER_ID);
return next();
});
app.route('/', reminderRoutes);
return app;
}
const app = createApp();
function get(path: string) {
return app.request(path);
}
function post(path: string, body: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function del(path: string) {
return app.request(path, { method: 'DELETE' });
}
beforeEach(() => {
vi.clearAllMocks();
});
// ─── GET /tasks/:taskId/reminders ──────────────────────────────
describe('GET /tasks/:taskId/reminders', () => {
it('returns reminders for a valid task', async () => {
mockFindFirstTask.mockResolvedValue({ id: 'task-1', userId: TEST_USER_ID });
mockFindManyReminders.mockResolvedValue([
{ id: 'r-1', minutesBefore: 10, type: 'push' },
{ id: 'r-2', minutesBefore: 60, type: 'email' },
]);
const res = await get('/tasks/task-1/reminders');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.reminders).toHaveLength(2);
expect(data.reminders[0].id).toBe('r-1');
});
it('returns 404 if task not found', async () => {
mockFindFirstTask.mockResolvedValue(null);
const res = await get('/tasks/nonexistent/reminders');
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe('Task not found');
});
});
// ─── POST /tasks/:taskId/reminders ─────────────────────────────
describe('POST /tasks/:taskId/reminders', () => {
it('creates a reminder for a task with due date', async () => {
const dueDate = new Date('2026-06-15T14:00:00Z');
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: dueDate.toISOString(),
});
mockInsertReturning.mockResolvedValue([
{
id: 'r-new',
taskId: 'task-1',
minutesBefore: 30,
type: 'push',
reminderTime: new Date(dueDate.getTime() - 30 * 60 * 1000).toISOString(),
},
]);
const res = await post('/tasks/task-1/reminders', {
minutesBefore: 30,
type: 'push',
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.reminder.id).toBe('r-new');
expect(data.reminder.minutesBefore).toBe(30);
});
it('defaults type to push', async () => {
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: '2026-06-15T14:00:00Z',
});
mockInsertReturning.mockResolvedValue([{ id: 'r-new', type: 'push' }]);
const res = await post('/tasks/task-1/reminders', { minutesBefore: 15 });
expect(res.status).toBe(201);
});
it('returns 404 if task not found', async () => {
mockFindFirstTask.mockResolvedValue(null);
const res = await post('/tasks/nonexistent/reminders', {
minutesBefore: 30,
});
expect(res.status).toBe(404);
});
it('returns 400 if task has no due date', async () => {
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: null,
});
const res = await post('/tasks/task-1/reminders', { minutesBefore: 30 });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('without due date');
});
});
// ─── DELETE /reminders/:id ─────────────────────────────────────
describe('DELETE /reminders/:id', () => {
it('deletes an existing reminder', async () => {
const mockFindFirstReminder = vi.fn().mockResolvedValue({
id: 'r-1',
userId: TEST_USER_ID,
});
// Override the reminders findFirst for this test
const { db } = await import('../db');
(db.query as Record<string, unknown>).reminders = { findFirst: mockFindFirstReminder };
mockDeleteWhere.mockResolvedValue(undefined);
const res = await del('/reminders/r-1');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
});
it('returns 404 if reminder not found', async () => {
const { db } = await import('../db');
(db.query as Record<string, unknown>).reminders = {
findFirst: vi.fn().mockResolvedValue(null),
};
const res = await del('/reminders/nonexistent');
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe('Reminder not found');
});
});

View file

@ -1,88 +0,0 @@
/**
* Reminders route server-side reminder management.
*
* Reminders need to be server-side because push/email notifications
* are sent by the server at the scheduled time.
*/
import { Hono } from 'hono';
import { eq, and, asc } from 'drizzle-orm';
import { db, reminders, tasks } from '../db';
const reminderRoutes = new Hono();
/** List reminders for a task. */
reminderRoutes.get('/tasks/:taskId/reminders', async (c) => {
const userId = c.get('userId');
const taskId = c.req.param('taskId');
// Verify task belongs to user
const task = await db.query.tasks.findFirst({
where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)),
});
if (!task) {
return c.json({ error: 'Task not found' }, 404);
}
const result = await db.query.reminders.findMany({
where: and(eq(reminders.taskId, taskId), eq(reminders.userId, userId)),
orderBy: [asc(reminders.minutesBefore)],
});
return c.json({ reminders: result });
});
/** Create a reminder. */
reminderRoutes.post('/tasks/:taskId/reminders', async (c) => {
const userId = c.get('userId');
const taskId = c.req.param('taskId');
const body = await c.req.json<{
minutesBefore: number;
type?: 'push' | 'email' | 'both';
}>();
// Verify task
const task = await db.query.tasks.findFirst({
where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)),
});
if (!task) {
return c.json({ error: 'Task not found' }, 404);
}
if (!task.dueDate) {
return c.json({ error: 'Cannot create reminder for task without due date' }, 400);
}
const dueDate = new Date(task.dueDate);
const reminderTime = new Date(dueDate.getTime() - body.minutesBefore * 60 * 1000);
const [created] = await db
.insert(reminders)
.values({
taskId,
userId,
minutesBefore: body.minutesBefore,
reminderTime,
type: body.type ?? 'push',
})
.returning();
return c.json({ reminder: created }, 201);
});
/** Delete a reminder. */
reminderRoutes.delete('/reminders/:id', async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.userId, userId)),
});
if (!existing) {
return c.json({ error: 'Reminder not found' }, 404);
}
await db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId)));
return c.json({ success: true });
});
export { reminderRoutes };

View file

@ -1,164 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { rruleRoutes } from './rrule';
const app = new Hono();
app.route('/compute', rruleRoutes);
function post(path: string, body: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
// ─── POST /compute/next-occurrence ─────────────────────────────
describe('POST /compute/next-occurrence', () => {
it('returns next occurrence for daily RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.nextDate).toBeDefined();
expect(data.totalOccurrences).toBeGreaterThan(0);
});
it('returns next occurrence for weekly RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.nextDate).toBeDefined();
});
it('returns next occurrence for monthly RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=MONTHLY;BYMONTHDAY=15',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
});
it('respects recurrenceEndDate', async () => {
const pastEnd = new Date('2020-01-01').toISOString();
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
recurrenceEndDate: pastEnd,
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.nextDate).toBeNull();
expect(data.message).toContain('No more occurrences');
});
it('respects after parameter', async () => {
const afterDate = new Date('2027-06-01T00:00:00Z').toISOString();
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
after: afterDate,
});
expect(res.status).toBe(200);
const data = await res.json();
const next = new Date(data.nextDate);
expect(next.getTime()).toBeGreaterThan(new Date(afterDate).getTime());
});
it('rejects empty rrule', async () => {
const res = await post('/compute/next-occurrence', { rrule: '' });
expect(res.status).toBe(400);
});
it('rejects missing rrule', async () => {
const res = await post('/compute/next-occurrence', {});
expect(res.status).toBe(400);
});
it('rejects RRULE exceeding max length', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY;' + 'X'.repeat(500),
});
expect(res.status).toBe(400);
});
it('rejects invalid RRULE string', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'not a valid rrule',
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('Invalid RRULE');
});
it('rejects RRULE with too many occurrences (DoS protection)', async () => {
// FREQ=SECONDLY would generate millions of occurrences
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=SECONDLY',
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('too many occurrences');
});
});
// ─── POST /compute/validate ────────────────────────────────────
describe('POST /compute/validate', () => {
it('validates a correct daily RRULE', async () => {
const res = await post('/compute/validate', { rrule: 'FREQ=DAILY' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.occurrences).toBeGreaterThan(0);
});
it('validates a weekly RRULE with BYDAY', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=WEEKLY;BYDAY=TU,TH',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
});
it('validates a yearly RRULE', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.occurrences).toBeLessThanOrEqual(10); // max 10 years
});
it('returns valid=false for invalid RRULE', async () => {
const res = await post('/compute/validate', { rrule: 'garbage' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
expect(data.error).toBeDefined();
});
it('rejects empty rrule', async () => {
const res = await post('/compute/validate', { rrule: '' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
});
it('flags RRULE with too many occurrences', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=SECONDLY',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
expect(data.error).toContain('Too many occurrences');
});
});

View file

@ -1,101 +0,0 @@
/**
* RRULE expansion route server-side compute for recurring tasks.
*
* POST /api/v1/compute/next-occurrence
* Validates RRULE, calculates next occurrence date.
* Called by the client when completing a recurring task.
*/
import { Hono } from 'hono';
import { rrulestr } from 'rrule';
import { z } from 'zod';
const rruleRoutes = new Hono();
const NextOccurrenceSchema = z.object({
rrule: z.string().min(1, 'Missing rrule parameter').max(500, 'RRULE too long (max 500 chars)'),
recurrenceEndDate: z.string().datetime({ offset: true }).optional(),
after: z.string().datetime({ offset: true }).optional(),
});
const ValidateSchema = z.object({
rrule: z.string().min(1).max(500),
});
/** Validate an RRULE string and return the next occurrence. */
rruleRoutes.post('/next-occurrence', async (c) => {
const parsed = NextOccurrenceSchema.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400);
}
const { rrule: rruleString, recurrenceEndDate, after } = parsed.data;
try {
const rule = rrulestr(rruleString);
const afterDate = after ? new Date(after) : new Date();
// Validate: not too many occurrences
const maxOccurrences = 5000;
const tenYearsFromNow = new Date();
tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10);
const occurrences = rule.between(new Date(), tenYearsFromNow, true, (_, count) => {
return count < maxOccurrences;
});
if (occurrences.length >= maxOccurrences) {
return c.json({ error: 'RRULE generates too many occurrences (max 5000)' }, 400);
}
// Get next occurrence
const nextDate = rule.after(afterDate, false);
// Check recurrence end date
if (recurrenceEndDate) {
const endDate = new Date(recurrenceEndDate);
if (!nextDate || nextDate > endDate) {
return c.json({ nextDate: null, message: 'No more occurrences (past end date)' });
}
}
return c.json({
nextDate: nextDate?.toISOString() ?? null,
valid: true,
totalOccurrences: occurrences.length,
});
} catch (err) {
return c.json(
{ error: 'Invalid RRULE: ' + (err instanceof Error ? err.message : 'unknown') },
400
);
}
});
/** Validate an RRULE without computing next occurrence. */
rruleRoutes.post('/validate', async (c) => {
const parsed = ValidateSchema.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ valid: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' });
}
const { rrule: rruleString } = parsed.data;
try {
const rule = rrulestr(rruleString);
const tenYearsFromNow = new Date();
tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10);
const count = rule.between(new Date(), tenYearsFromNow, true, (_, c) => c < 5000).length;
return c.json({
valid: count < 5000,
occurrences: count,
error: count >= 5000 ? 'Too many occurrences' : undefined,
});
} catch (err) {
return c.json({ valid: false, error: err instanceof Error ? err.message : 'Invalid RRULE' });
}
});
export { rruleRoutes };

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*", "vitest.config.ts"],
"exclude": ["node_modules"]
}

View file

@ -1,12 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
});