managarten/apps/api/src/modules/presi/routes.ts
Till JS a91a6076cc refactor: rename planta → plants, clean up codebase
- 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>
2026-04-12 18:59:44 +02:00

187 lines
5 KiB
TypeScript

/**
* Presi module — Share link lookups
* Ported from apps/presi/apps/server
*
* All CRUD (decks, slides, themes) is handled client-side via local-first + sync.
* This module handles public share links and share management.
*/
import { Hono } from 'hono';
import { eq, and, gt, or, isNull, asc } from 'drizzle-orm';
import { HTTPException } from 'hono/http-exception';
import { authMiddleware } from '@mana/shared-hono/auth';
import type { AuthVariables } from '@mana/shared-hono';
import { drizzle } from 'drizzle-orm/postgres-js';
import { getConnection } from '../../lib/db';
import {
decks,
slides,
themes,
sharedDecks,
decksRelations,
slidesRelations,
sharedDecksRelations,
} from './schema.js';
// ─── DB Connection ─────────────────────────────────────────
const db = drizzle(getConnection(), {
schema: {
decks,
slides,
themes,
sharedDecks,
decksRelations,
slidesRelations,
sharedDecksRelations,
},
});
// ─── Helpers ────────────────────────────────────────────────
function generateShareCode(): string {
const bytes = new Uint8Array(6);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ─── Routes ─────────────────────────────────────────────────
const routes = new Hono<{ Variables: AuthVariables }>();
// ─── Public endpoint (no auth) ──────────────────────────────
routes.get('/share/:code', async (c) => {
const code = c.req.param('code');
const share = await db.query.sharedDecks.findFirst({
where: and(
eq(sharedDecks.shareCode, code),
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date()))
),
});
if (!share) {
throw new HTTPException(404, { message: 'Shared deck not found or link has expired' });
}
// Load deck with slides and theme
const deck = await db.query.decks.findFirst({
where: eq(decks.id, share.deckId),
});
if (!deck) {
throw new HTTPException(404, { message: 'Deck not found' });
}
const deckSlides = await db.query.slides.findMany({
where: eq(slides.deckId, deck.id),
orderBy: [asc(slides.order)],
});
let theme = null;
if (deck.themeId) {
theme = await db.query.themes.findFirst({
where: eq(themes.id, deck.themeId),
});
}
return c.json({
...deck,
slides: deckSlides,
theme,
});
});
// ─── Authenticated endpoints ────────────────────────────────
routes.use('/share/deck/*', authMiddleware());
routes.post('/share/deck/:deckId', async (c) => {
const userId = c.get('userId');
const deckId = c.req.param('deckId');
// Verify ownership
const deck = await db.query.decks.findFirst({
where: and(eq(decks.id, deckId), eq(decks.userId, userId)),
});
if (!deck) {
throw new HTTPException(403, { message: 'You do not own this deck' });
}
// Check for existing valid share
const existing = await db.query.sharedDecks.findFirst({
where: and(
eq(sharedDecks.deckId, deckId),
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date()))
),
});
if (existing) {
return c.json(existing);
}
// Parse optional expiry
const body = (await c.req.json<{ expiresAt?: string }>().catch(() => ({}))) as {
expiresAt?: string;
};
const [share] = await db
.insert(sharedDecks)
.values({
deckId,
shareCode: generateShareCode(),
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
})
.returning();
return c.json(share, 201);
});
routes.get('/share/deck/:deckId/links', async (c) => {
const userId = c.get('userId');
const deckId = c.req.param('deckId');
// Verify ownership
const deck = await db.query.decks.findFirst({
where: and(eq(decks.id, deckId), eq(decks.userId, userId)),
});
if (!deck) {
throw new HTTPException(403, { message: 'You do not own this deck' });
}
const links = await db.query.sharedDecks.findMany({
where: eq(sharedDecks.deckId, deckId),
});
return c.json(links);
});
routes.delete('/share/:shareId', authMiddleware(), async (c) => {
const userId = c.get('userId');
const shareId = c.req.param('shareId');
if (!shareId) throw new HTTPException(400, { message: 'shareId required' });
const share = await db.query.sharedDecks.findFirst({
where: eq(sharedDecks.id, shareId),
});
if (!share) {
throw new HTTPException(404, { message: 'Share not found' });
}
// Verify ownership of the deck
const deck = await db.query.decks.findFirst({
where: eq(decks.id, share.deckId),
});
if (!deck || deck.userId !== userId) {
throw new HTTPException(403, { message: 'You do not own this deck' });
}
await db.delete(sharedDecks).where(eq(sharedDecks.id, shareId));
return c.json({ success: true });
});
export { routes as presiRoutes };