mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +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,21 +0,0 @@
|
|||
{
|
||||
"name": "@calendar/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-hono": "workspace:*",
|
||||
"hono": "^4.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { app } from './index';
|
||||
|
||||
function post(path: string, body: unknown) {
|
||||
return app.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function formPost(path: string, formData: FormData) {
|
||||
return app.request(path, { method: 'POST', body: formData });
|
||||
}
|
||||
|
||||
// ─── Health ────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns healthy status', async () => {
|
||||
const res = await app.request('/health');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RRULE Expansion ───────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/events/expand', () => {
|
||||
it('expands daily RRULE', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
maxOccurrences: 7,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.occurrences).toHaveLength(7);
|
||||
expect(data.count).toBe(7);
|
||||
expect(data.occurrences[0]).toContain('2026-01-01');
|
||||
});
|
||||
|
||||
it('expands weekly RRULE', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=WEEKLY',
|
||||
dtstart: '2026-01-05T10:00:00Z',
|
||||
maxOccurrences: 4,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.occurrences).toHaveLength(4);
|
||||
// Each occurrence should be 7 days apart
|
||||
const dates = data.occurrences.map((d: string) => new Date(d).getTime());
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
expect(dates[1] - dates[0]).toBe(weekMs);
|
||||
});
|
||||
|
||||
it('expands monthly RRULE', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=MONTHLY',
|
||||
dtstart: '2026-01-15T09:00:00Z',
|
||||
maxOccurrences: 3,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(3);
|
||||
expect(data.occurrences[0]).toContain('2026-01-15');
|
||||
expect(data.occurrences[1]).toContain('2026-02-15');
|
||||
expect(data.occurrences[2]).toContain('2026-03-15');
|
||||
});
|
||||
|
||||
it('expands yearly RRULE', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=YEARLY',
|
||||
dtstart: '2026-06-01T00:00:00Z',
|
||||
maxOccurrences: 3,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(3);
|
||||
});
|
||||
|
||||
it('respects INTERVAL', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY;INTERVAL=3',
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
maxOccurrences: 4,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
const dates = data.occurrences.map((d: string) => new Date(d).getTime());
|
||||
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
|
||||
expect(dates[1] - dates[0]).toBe(threeDaysMs);
|
||||
});
|
||||
|
||||
it('stops at until date', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
until: '2026-01-05T00:00:00Z',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(5); // Jan 1-5 inclusive
|
||||
});
|
||||
|
||||
it('enforces max 5000 occurrences', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: '2000-01-01T00:00:00Z',
|
||||
maxOccurrences: 10000,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it('rejects missing rrule', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects missing dtstart', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects empty rrule', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: '',
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects rrule exceeding max length', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'F'.repeat(501),
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Google Calendar Sync ──────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/sync/google', () => {
|
||||
it('returns 501 Not Implemented', async () => {
|
||||
const res = await post('/api/v1/sync/google', {});
|
||||
expect(res.status).toBe(501);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('not yet implemented');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ICS Import ────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/import/ics', () => {
|
||||
it('parses a valid ICS file with one event', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Team Meeting',
|
||||
'DTSTART:20260615T140000Z',
|
||||
'DTEND:20260615T150000Z',
|
||||
'DESCRIPTION:Weekly sync',
|
||||
'LOCATION:Room 42',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'cal.ics', { type: 'text/calendar' }));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.events[0].title).toBe('Team Meeting');
|
||||
expect(data.events[0].start).toBe('20260615T140000Z');
|
||||
expect(data.events[0].end).toBe('20260615T150000Z');
|
||||
expect(data.events[0].description).toBe('Weekly sync');
|
||||
expect(data.events[0].location).toBe('Room 42');
|
||||
});
|
||||
|
||||
it('parses multiple events', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Event One',
|
||||
'DTSTART:20260601T090000Z',
|
||||
'DTEND:20260601T100000Z',
|
||||
'END:VEVENT',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Event Two',
|
||||
'DTSTART:20260602T110000Z',
|
||||
'DTEND:20260602T120000Z',
|
||||
'END:VEVENT',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Event Three',
|
||||
'DTSTART:20260603T130000Z',
|
||||
'DTEND:20260603T140000Z',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'multi.ics', { type: 'text/calendar' }));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(3);
|
||||
expect(data.events[0].title).toBe('Event One');
|
||||
expect(data.events[2].title).toBe('Event Three');
|
||||
});
|
||||
|
||||
it('parses event with RRULE', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Daily Standup',
|
||||
'DTSTART:20260101T090000Z',
|
||||
'RRULE:FREQ=DAILY;COUNT=5',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'recurring.ics'));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.events[0].rrule).toBe('FREQ=DAILY;COUNT=5');
|
||||
});
|
||||
|
||||
it('handles ICS with DTSTART parameters', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:All Day Event',
|
||||
'DTSTART;VALUE=DATE:20260701',
|
||||
'DTEND;VALUE=DATE:20260702',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'allday.ics'));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
// DTSTART;VALUE=DATE:20260701 → split(':').pop() → '20260701'
|
||||
expect(data.events[0].start).toBe('20260701');
|
||||
});
|
||||
|
||||
it('skips events without title and start', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'DESCRIPTION:No title or start',
|
||||
'END:VEVENT',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Valid Event',
|
||||
'DTSTART:20260101T090000Z',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'partial.ics'));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.events[0].title).toBe('Valid Event');
|
||||
});
|
||||
|
||||
it('returns empty array for ICS without events', async () => {
|
||||
const ics = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'empty.ics'));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(0);
|
||||
expect(data.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 400 if no file provided', async () => {
|
||||
const form = new FormData();
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('No file');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
/**
|
||||
* Calendar Hono Server — RRULE expansion + Google Calendar sync
|
||||
*
|
||||
* CRUD for calendars/events handled by mana-sync.
|
||||
* This server handles recurring event expansion and external calendar sync.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import {
|
||||
authMiddleware,
|
||||
healthRoute,
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
rateLimitMiddleware,
|
||||
} from '@manacore/shared-hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3003', 10);
|
||||
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.notFound(notFoundHandler);
|
||||
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
|
||||
app.route('/health', healthRoute('calendar-server'));
|
||||
app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
|
||||
app.use('/api/*', authMiddleware());
|
||||
|
||||
// ─── RRULE Expansion (server-only: DoS protection) ──────────
|
||||
|
||||
const ExpandSchema = z.object({
|
||||
rrule: z.string().min(1).max(500),
|
||||
dtstart: z.string().min(1),
|
||||
until: z.string().optional(),
|
||||
maxOccurrences: z.number().int().min(1).max(5000).optional(),
|
||||
});
|
||||
|
||||
app.post('/api/v1/events/expand', async (c) => {
|
||||
const parsed = ExpandSchema.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400);
|
||||
}
|
||||
|
||||
const { rrule, dtstart, until, maxOccurrences } = parsed.data;
|
||||
|
||||
const max = Math.min(maxOccurrences || 365, 5000);
|
||||
|
||||
try {
|
||||
// Simple RRULE expansion (daily, weekly, monthly, yearly)
|
||||
const start = new Date(dtstart);
|
||||
const end = until ? new Date(until) : new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
const occurrences: string[] = [];
|
||||
|
||||
const parts = rrule.replace('RRULE:', '').split(';');
|
||||
const freq = parts.find((p: string) => p.startsWith('FREQ='))?.split('=')[1];
|
||||
const interval = parseInt(
|
||||
parts.find((p: string) => p.startsWith('INTERVAL='))?.split('=')[1] || '1',
|
||||
10
|
||||
);
|
||||
|
||||
let current = new Date(start);
|
||||
|
||||
while (current <= end && occurrences.length < max) {
|
||||
occurrences.push(current.toISOString());
|
||||
|
||||
switch (freq) {
|
||||
case 'DAILY':
|
||||
current = new Date(current.getTime() + interval * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'WEEKLY':
|
||||
current = new Date(current.getTime() + interval * 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'MONTHLY':
|
||||
current = new Date(current.setMonth(current.getMonth() + interval));
|
||||
break;
|
||||
case 'YEARLY':
|
||||
current = new Date(current.setFullYear(current.getFullYear() + interval));
|
||||
break;
|
||||
default:
|
||||
occurrences.push(current.toISOString());
|
||||
current = end; // Break loop
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ occurrences, count: occurrences.length });
|
||||
} catch (_err) {
|
||||
return c.json({ error: 'RRULE expansion failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Google Calendar Import (server-only: OAuth) ─────────────
|
||||
|
||||
app.post('/api/v1/sync/google', async (c) => {
|
||||
// TODO: Implement Google Calendar OAuth flow
|
||||
// This requires server-side OAuth token exchange
|
||||
return c.json({ error: 'Google Calendar sync not yet implemented' }, 501);
|
||||
});
|
||||
|
||||
// ─── ICS Import (server-only: parsing) ───────────────────────
|
||||
|
||||
app.post('/api/v1/import/ics', async (c) => {
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) return c.json({ error: 'No file' }, 400);
|
||||
|
||||
const text = await file.text();
|
||||
const events = parseICS(text);
|
||||
return c.json({ events, count: events.length });
|
||||
});
|
||||
|
||||
function parseICS(text: string): Array<Record<string, string>> {
|
||||
const events: Array<Record<string, string>> = [];
|
||||
const blocks = text.split('BEGIN:VEVENT').filter((b) => b.includes('END:VEVENT'));
|
||||
|
||||
for (const block of blocks) {
|
||||
const event: Record<string, string> = {};
|
||||
const lines = block.split(/\r?\n/);
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('SUMMARY:')) event.title = line.slice(8);
|
||||
if (line.startsWith('DTSTART')) event.start = line.split(':').pop() || '';
|
||||
if (line.startsWith('DTEND')) event.end = line.split(':').pop() || '';
|
||||
if (line.startsWith('DESCRIPTION:')) event.description = line.slice(12);
|
||||
if (line.startsWith('LOCATION:')) event.location = line.slice(9);
|
||||
if (line.startsWith('RRULE:')) event.rrule = line.slice(6);
|
||||
}
|
||||
|
||||
if (event.title || event.start) events.push(event);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export { app };
|
||||
export default { port: PORT, fetch: app.fetch };
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
process.env.DEV_BYPASS_AUTH = 'true';
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
clearMocks: true,
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue