feat(api): create unified API server with first 3 modules

New consolidated Hono/Bun API server at apps/api/ that replaces individual
app servers. One process, one port, one auth middleware, one container.

Modules ported:
- calendar: RRULE expansion, ICS import, Google Calendar (stub)
- contacts: avatar upload (S3), vCard import/parsing
- mukke: audio upload/download presigned URLs, batch cover art

Architecture: each module registers routes under /api/v1/{module}/*
using the shared-hono middleware stack (auth, rate limit, error handler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 21:12:15 +02:00
parent 079015ade7
commit aa93c54391
6 changed files with 368 additions and 0 deletions

View file

@ -0,0 +1,87 @@
/**
* Contacts module Avatar upload + vCard import
* Ported from apps/contacts/apps/server
*/
import { Hono } from 'hono';
const ALLOWED_AVATAR_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
]);
const routes = new Hono();
// ─── Avatar Upload (S3) ─────────────────────────────────────
routes.post('/: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 {
return c.json({ error: 'Upload failed' }, 500);
}
});
// ─── vCard Import ───────────────────────────────────────────
routes.post('/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 { routes as contactsRoutes };