mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 19:46:42 +02:00
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:
parent
7ee57b7afd
commit
d8ce4eaf34
309 changed files with 172 additions and 21667 deletions
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue