mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 18:06: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,21 +0,0 @@
|
|||
{
|
||||
"name": "@contacts/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:*",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"hono": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { app } from './index';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── vCard Import ──────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/import/vcard', () => {
|
||||
it('parses a single vCard', async () => {
|
||||
const vcard = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:Max Mustermann',
|
||||
'EMAIL:max@example.com',
|
||||
'TEL:+49 170 1234567',
|
||||
'ORG:ACME Corp',
|
||||
'TITLE:Engineer',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'contacts.vcf', { type: 'text/vcard' }));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.contacts[0].name).toBe('Max Mustermann');
|
||||
expect(data.contacts[0].email).toBe('max@example.com');
|
||||
expect(data.contacts[0].phone).toBe('+49 170 1234567');
|
||||
expect(data.contacts[0].company).toBe('ACME Corp');
|
||||
expect(data.contacts[0].title).toBe('Engineer');
|
||||
});
|
||||
|
||||
it('parses multiple vCards', async () => {
|
||||
const vcard = [
|
||||
'BEGIN:VCARD',
|
||||
'FN:Alice',
|
||||
'EMAIL:alice@example.com',
|
||||
'END:VCARD',
|
||||
'BEGIN:VCARD',
|
||||
'FN:Bob',
|
||||
'EMAIL:bob@example.com',
|
||||
'END:VCARD',
|
||||
'BEGIN:VCARD',
|
||||
'FN:Charlie',
|
||||
'TEL:+1 555 0123',
|
||||
'END:VCARD',
|
||||
].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'multi.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(3);
|
||||
expect(data.contacts[0].name).toBe('Alice');
|
||||
expect(data.contacts[1].name).toBe('Bob');
|
||||
expect(data.contacts[2].name).toBe('Charlie');
|
||||
});
|
||||
|
||||
it('handles vCard with EMAIL type parameters', async () => {
|
||||
const vcard = [
|
||||
'BEGIN:VCARD',
|
||||
'FN:Test User',
|
||||
'EMAIL;type=INTERNET;type=WORK:work@example.com',
|
||||
'TEL;type=CELL:+49 170 0000000',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'typed.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
// EMAIL;type=...:work@example.com → split(':').pop() → 'work@example.com'
|
||||
expect(data.contacts[0].email).toBe('work@example.com');
|
||||
expect(data.contacts[0].phone).toBe('+49 170 0000000');
|
||||
});
|
||||
|
||||
it('skips contacts without name or email', async () => {
|
||||
const vcard = [
|
||||
'BEGIN:VCARD',
|
||||
'TEL:+49 170 0000000',
|
||||
'ORG:Company Only',
|
||||
'END:VCARD',
|
||||
'BEGIN:VCARD',
|
||||
'FN:Valid Contact',
|
||||
'END:VCARD',
|
||||
].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'partial.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.contacts[0].name).toBe('Valid Contact');
|
||||
});
|
||||
|
||||
it('includes contact with only email (no name)', async () => {
|
||||
const vcard = ['BEGIN:VCARD', 'EMAIL:noreply@example.com', 'END:VCARD'].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'email-only.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.contacts[0].email).toBe('noreply@example.com');
|
||||
});
|
||||
|
||||
it('returns empty array for empty vCard file', async () => {
|
||||
const form = new FormData();
|
||||
form.append('file', new File([''], 'empty.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(0);
|
||||
expect(data.contacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 400 if no file provided', async () => {
|
||||
const form = new FormData();
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('No file');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Avatar Upload ─────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/contacts/:id/avatar', () => {
|
||||
it('returns 400 if no file provided', async () => {
|
||||
const form = new FormData();
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('No file');
|
||||
});
|
||||
|
||||
it('returns 400 if file exceeds 5MB', async () => {
|
||||
// Create a file > 5MB
|
||||
const bigContent = new Uint8Array(6 * 1024 * 1024);
|
||||
const form = new FormData();
|
||||
form.append('file', new File([bigContent], 'big.jpg', { type: 'image/jpeg' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('Max 5MB');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid file type', async () => {
|
||||
const form = new FormData();
|
||||
form.append('file', new File(['data'], 'doc.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('Invalid file type');
|
||||
});
|
||||
|
||||
it('accepts JPEG files', async () => {
|
||||
vi.mock('@manacore/shared-storage', () => ({
|
||||
createContactsStorage: () => ({
|
||||
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.jpg' }),
|
||||
}),
|
||||
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.jpg'),
|
||||
getContentType: vi.fn().mockReturnValue('image/jpeg'),
|
||||
}));
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File(['image-data'], 'photo.jpg', { type: 'image/jpeg' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.avatarUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts PNG files', async () => {
|
||||
vi.mock('@manacore/shared-storage', () => ({
|
||||
createContactsStorage: () => ({
|
||||
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.png' }),
|
||||
}),
|
||||
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.png'),
|
||||
getContentType: vi.fn().mockReturnValue('image/png'),
|
||||
}));
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File(['image-data'], 'photo.png', { type: 'image/png' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('accepts WebP files', async () => {
|
||||
vi.mock('@manacore/shared-storage', () => ({
|
||||
createContactsStorage: () => ({
|
||||
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.webp' }),
|
||||
}),
|
||||
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.webp'),
|
||||
getContentType: vi.fn().mockReturnValue('image/webp'),
|
||||
}));
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File(['image-data'], 'photo.webp', { type: 'image/webp' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
/**
|
||||
* Contacts Hono Server — Photo upload + vCard/CSV import
|
||||
*
|
||||
* CRUD for contacts handled by mana-sync.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import {
|
||||
authMiddleware,
|
||||
healthRoute,
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
rateLimitMiddleware,
|
||||
} from '@manacore/shared-hono';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3004', 10);
|
||||
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
|
||||
|
||||
const ALLOWED_AVATAR_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
]);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.notFound(notFoundHandler);
|
||||
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
|
||||
app.route('/health', healthRoute('contacts-server'));
|
||||
app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
|
||||
app.use('/api/*', authMiddleware());
|
||||
|
||||
// ─── Avatar Upload (server-only: S3) ─────────────────────────
|
||||
|
||||
app.post('/api/v1/contacts/:id/avatar', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) return c.json({ error: 'No file' }, 400);
|
||||
if (file.size > 5 * 1024 * 1024) return c.json({ error: 'Max 5MB' }, 400);
|
||||
if (!ALLOWED_AVATAR_TYPES.has(file.type)) {
|
||||
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, SVG' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const { createContactsStorage, generateUserFileKey, getContentType } = await import(
|
||||
'@manacore/shared-storage'
|
||||
);
|
||||
const storage = createContactsStorage();
|
||||
const key = generateUserFileKey(
|
||||
userId,
|
||||
`avatar-${c.req.param('id')}.${file.name.split('.').pop()}`
|
||||
);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const result = await storage.upload(key, buffer, {
|
||||
contentType: getContentType(file.name),
|
||||
public: true,
|
||||
});
|
||||
|
||||
return c.json({ avatarUrl: result.url }, 201);
|
||||
} catch (_err) {
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── vCard Import (server-only: parsing) ─────────────────────
|
||||
|
||||
app.post('/api/v1/import/vcard', 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 contacts = parseVCard(text);
|
||||
return c.json({ contacts, count: contacts.length });
|
||||
});
|
||||
|
||||
function parseVCard(text: string): Array<Record<string, string>> {
|
||||
const contacts: Array<Record<string, string>> = [];
|
||||
const cards = text.split('BEGIN:VCARD').filter((c) => c.includes('END:VCARD'));
|
||||
|
||||
for (const card of cards) {
|
||||
const contact: Record<string, string> = {};
|
||||
const lines = card.split(/\r?\n/);
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('FN:')) contact.name = line.slice(3);
|
||||
if (line.startsWith('EMAIL')) contact.email = line.split(':').pop() || '';
|
||||
if (line.startsWith('TEL')) contact.phone = line.split(':').pop() || '';
|
||||
if (line.startsWith('ORG:')) contact.company = line.slice(4);
|
||||
if (line.startsWith('TITLE:')) contact.title = line.slice(6);
|
||||
}
|
||||
|
||||
if (contact.name || contact.email) contacts.push(contact);
|
||||
}
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
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