mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
- Rename planta module to plants everywhere (routes, modules, API, branding, i18n, docker, docs, shared packages) - Fix package name collisions: @mana/credits-service, @mana/subscriptions-service (unblocks turbo) - Extract layout composables: use-ai-tier-items, use-sync-status-items, RouteTierGate (layout 1345→1015 lines) - Create shared DB pool for apps/api (lib/db.ts), migrate 5 modules - Add automations module queries.ts with useAllAutomations/useEnabledAutomations - Remove debug console.log statements from production code - Rename storage display name: Ablage → Speicher Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
300 lines
9.4 KiB
TypeScript
300 lines
9.4 KiB
TypeScript
/**
|
|
* Todo module — RRULE compute + reminders + admin
|
|
* Ported from apps/todo/apps/server
|
|
*
|
|
* All CRUD is handled client-side via local-first + sync.
|
|
* This module provides compute-only endpoints.
|
|
*
|
|
* NOTE: The standalone server also runs a background reminder worker
|
|
* (startReminderWorker) that polls for due reminders and dispatches
|
|
* them via mana-notify. That worker needs to be started separately
|
|
* or integrated into the unified API's startup lifecycle.
|
|
* See: apps/todo/apps/server/src/lib/reminder-worker.ts
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import { rrulestr } from 'rrule';
|
|
import { z } from 'zod';
|
|
import { eq, and, asc, sql } from 'drizzle-orm';
|
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
import { serviceAuthMiddleware, type AuthVariables } from '@mana/shared-hono';
|
|
import { getConnection } from '../../lib/db';
|
|
import {
|
|
pgSchema,
|
|
uuid,
|
|
text,
|
|
timestamp,
|
|
varchar,
|
|
integer,
|
|
boolean,
|
|
jsonb,
|
|
index,
|
|
} from 'drizzle-orm/pg-core';
|
|
|
|
// ─── DB Schema (minimal, server-only) ──────────────────────
|
|
|
|
const todoSchema = pgSchema('todo');
|
|
|
|
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(),
|
|
});
|
|
|
|
const projects = todoSchema.table('projects', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
userId: text('user_id').notNull(),
|
|
});
|
|
|
|
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_api').on(table.taskId),
|
|
userIdx: index('reminders_user_idx_api').on(table.userId),
|
|
})
|
|
);
|
|
|
|
const db = drizzle(getConnection(), { schema: { tasks, projects, reminders } });
|
|
|
|
// ─── Routes ────────────────────────────────────────────────
|
|
|
|
const routes = new Hono<{ Variables: AuthVariables }>();
|
|
|
|
// ─── RRULE Compute ─────────────────────────────────────────
|
|
|
|
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),
|
|
});
|
|
|
|
routes.post('/compute/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
|
|
);
|
|
}
|
|
});
|
|
|
|
routes.post('/compute/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' });
|
|
}
|
|
});
|
|
|
|
// ─── Reminders ─────────────────────────────────────────────
|
|
|
|
routes.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 });
|
|
});
|
|
|
|
routes.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);
|
|
});
|
|
|
|
routes.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 });
|
|
});
|
|
|
|
// ─── Admin (GDPR) ──────────────────────────────────────────
|
|
|
|
const adminSub = new Hono();
|
|
adminSub.use('/*', serviceAuthMiddleware());
|
|
|
|
adminSub.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),
|
|
},
|
|
});
|
|
});
|
|
|
|
adminSub.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',
|
|
});
|
|
});
|
|
|
|
routes.route('/admin', adminSub);
|
|
|
|
export { routes as todoRoutes };
|