refactor(presi): replace NestJS backend with lightweight Hono server

The Presi NestJS backend (40 source files, 50 deps) was a CRUD wrapper
around decks, slides, and themes — all now handled by local-first sync.

Only the share-link feature requires server-side state (public URLs
without auth), so a minimal Hono + Bun server replaces the entire
NestJS backend:

- apps/presi/apps/server/ — Hono server with share routes + GDPR admin
  Uses @manacore/shared-hono for auth (JWKS), health, admin, errors
- Web app API client stripped to share-only (was 270 lines → 90 lines)
- Removed from docker-compose, CI/CD, Prometheus, env generation
- NestJS backend deleted (40 TS files, 8 test specs, 3038 lines)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 02:08:40 +01:00
parent 5c33962439
commit dd2f814cf3
62 changed files with 393 additions and 3038 deletions

View file

@ -1,93 +0,0 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY patches ./patches
# Copy shared packages (all required dependencies)
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
COPY packages/shared-nestjs-health ./packages/shared-nestjs-health
COPY packages/shared-nestjs-metrics ./packages/shared-nestjs-metrics
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
COPY packages/shared-tsconfig ./packages/shared-tsconfig
COPY packages/shared-error-tracking ./packages/shared-error-tracking
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
# Copy presi packages and backend
COPY apps/presi/packages ./apps/presi/packages
COPY apps/presi/apps/backend ./apps/presi/apps/backend
# Install dependencies (ignore scripts since generate-env.mjs isn't in Docker context)
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts
# Build shared packages first (in dependency order)
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-health
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-metrics
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-setup
RUN pnpm build
# Build the backend
WORKDIR /app/packages/shared-nestjs-setup
RUN pnpm build
WORKDIR /app/packages/shared-error-tracking
RUN pnpm build
WORKDIR /app/apps/presi/apps/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder (including node_modules)
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/presi ./apps/presi
# Copy entrypoint script
COPY apps/presi/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/apps/presi/apps/backend
# Expose port
EXPOSE 3008
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3008/api/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -1,9 +0,0 @@
#!/bin/sh
set -e
echo "Running database migrations..."
npx drizzle-kit push --config drizzle.config.ts --force || echo "Migration failed, continuing anyway..."
# Start the application
echo "Starting Presi Backend..."
exec "$@"

View file

@ -1,4 +0,0 @@
import 'dotenv/config';
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({ dbName: 'presi' });

View file

@ -1,17 +0,0 @@
// @ts-check
import {
baseConfig,
typescriptConfig,
nestjsConfig,
prettierConfig,
} from '@manacore/eslint-config';
export default [
{
ignores: ['dist/**', 'node_modules/**'],
},
...baseConfig,
...typescriptConfig,
...nestjsConfig,
...prettierConfig,
];

View file

@ -1,16 +0,0 @@
/** @type {import('jest').Config} */
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@presi/shared$': '<rootDir>/../../packages/shared/src',
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
},
};

View file

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,57 +0,0 @@
{
"name": "@presi/backend",
"version": "0.2.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
},
"dependencies": {
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-metrics": "workspace:*",
"@manacore/shared-nestjs-setup": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/swagger": "^11.2.6",
"@presi/shared": "workspace:*",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"@nestjs/throttler": "^6.2.1",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"prom-client": "^15.1.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"nanoid": "^5.0.9"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^11.1.17",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.2",
"jest": "^30.3.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -1,66 +0,0 @@
import { AdminController } from '../admin.controller';
describe('AdminController', () => {
let controller: AdminController;
let service: any;
beforeEach(() => {
service = {
getUserData: jest.fn(),
deleteUserData: jest.fn(),
};
controller = new AdminController(service);
});
afterEach(() => jest.clearAllMocks());
describe('getUserData', () => {
it('should return user data summary', async () => {
const userData = {
entities: [
{ entity: 'decks', count: 5, label: 'Decks' },
{ entity: 'slides', count: 20, label: 'Slides' },
{ entity: 'shared_decks', count: 3, label: 'Shared Links' },
],
totalCount: 28,
lastActivityAt: '2025-06-01T00:00:00.000Z',
};
service.getUserData.mockResolvedValue(userData);
const result = await controller.getUserData('user-1');
expect(result).toEqual(userData);
expect(service.getUserData).toHaveBeenCalledWith('user-1');
});
it('should return empty data for unknown user', async () => {
const emptyData = { entities: [], totalCount: 0, lastActivityAt: undefined };
service.getUserData.mockResolvedValue(emptyData);
const result = await controller.getUserData('unknown');
expect(result.totalCount).toBe(0);
});
});
describe('deleteUserData', () => {
it('should delete all user data and return counts', async () => {
const deleteResult = {
success: true,
deletedCounts: [
{ entity: 'shared_decks', count: 2, label: 'Shared Links' },
{ entity: 'slides', count: 10, label: 'Slides' },
{ entity: 'decks', count: 3, label: 'Decks' },
],
totalDeleted: 15,
};
service.deleteUserData.mockResolvedValue(deleteResult);
const result = await controller.deleteUserData('user-1');
expect(result.success).toBe(true);
expect(result.totalDeleted).toBe(15);
expect(service.deleteUserData).toHaveBeenCalledWith('user-1');
});
});
});

View file

@ -1,107 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AdminService } from '../admin.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
const TEST_USER_ID = 'user-1';
describe('AdminService', () => {
let service: AdminService;
let mockDb: any;
beforeEach(async () => {
mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<AdminService>(AdminService);
});
describe('getUserData', () => {
it('should return user data summary with counts', async () => {
// Count decks
mockDb.where.mockResolvedValueOnce([{ count: 3 }]);
// Get user decks
mockDb.where.mockResolvedValueOnce([{ id: 'deck-1' }, { id: 'deck-2' }, { id: 'deck-3' }]);
// Count slides
mockDb.where.mockResolvedValueOnce([{ count: 10 }]);
// Count shared decks
mockDb.where.mockResolvedValueOnce([{ count: 2 }]);
// Last activity
mockDb.limit.mockResolvedValue([{ updatedAt: new Date('2025-06-01') }]);
const result = await service.getUserData(TEST_USER_ID);
expect(result.totalCount).toBe(15);
expect(result.entities).toHaveLength(3);
expect(result.entities[0]).toEqual({ entity: 'decks', count: 3, label: 'Decks' });
expect(result.entities[1]).toEqual({ entity: 'slides', count: 10, label: 'Slides' });
expect(result.entities[2]).toEqual({
entity: 'shared_decks',
count: 2,
label: 'Shared Links',
});
expect(result.lastActivityAt).toBe('2025-06-01T00:00:00.000Z');
});
it('should return zeros when user has no data', async () => {
// Count decks
mockDb.where.mockResolvedValueOnce([{ count: 0 }]);
// Get user decks (empty)
mockDb.where.mockResolvedValueOnce([]);
// Last activity (none)
mockDb.limit.mockResolvedValue([]);
const result = await service.getUserData(TEST_USER_ID);
expect(result.totalCount).toBe(0);
expect(result.lastActivityAt).toBeUndefined();
});
});
describe('deleteUserData', () => {
it('should delete all user data and return counts', async () => {
// Get user decks
mockDb.where.mockResolvedValueOnce([{ id: 'deck-1' }]);
// Delete shared decks
mockDb.returning.mockResolvedValueOnce([{ id: 'share-1' }]);
// Delete slides
mockDb.returning.mockResolvedValueOnce([{ id: 'slide-1' }, { id: 'slide-2' }]);
// Delete decks
mockDb.returning.mockResolvedValueOnce([{ id: 'deck-1' }]);
const result = await service.deleteUserData(TEST_USER_ID);
expect(result.success).toBe(true);
expect(result.totalDeleted).toBe(4);
expect(result.deletedCounts).toHaveLength(3);
});
it('should handle user with no data gracefully', async () => {
// Get user decks (empty)
mockDb.where.mockResolvedValueOnce([]);
// Delete decks (none)
mockDb.returning.mockResolvedValueOnce([]);
const result = await service.deleteUserData(TEST_USER_ID);
expect(result.success).toBe(true);
expect(result.totalDeleted).toBe(0);
});
});
});

View file

@ -1,44 +0,0 @@
import {
Controller,
Get,
Delete,
Param,
UseGuards,
Logger,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AdminService } from './admin.service';
import { ServiceAuthGuard } from './guards/service-auth.guard';
import { UserDataResponse, DeleteUserDataResponse } from './dto/user-data-response.dto';
@ApiTags('Admin')
@Controller('admin')
@UseGuards(ServiceAuthGuard)
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(private readonly adminService: AdminService) {}
/**
* Get user data summary for this backend
* Called by mana-core-auth admin service to aggregate cross-project data
*/
@Get('user-data/:userId')
async getUserData(@Param('userId') userId: string): Promise<UserDataResponse> {
this.logger.log(`Admin request: getUserData for userId=${userId}`);
return this.adminService.getUserData(userId);
}
/**
* Delete all user data from this backend (GDPR right to be forgotten)
* Called by mana-core-auth admin service during cross-project deletion
*/
@Delete('user-data/:userId')
@HttpCode(HttpStatus.OK)
async deleteUserData(@Param('userId') userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
return this.adminService.deleteUserData(userId);
}
}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { ServiceAuthGuard } from './guards/service-auth.guard';
@Module({
imports: [ConfigModule],
controllers: [AdminController],
providers: [AdminService, ServiceAuthGuard],
})
export class AdminModule {}

View file

@ -1,126 +0,0 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { eq, sql, desc, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import type { Database } from '../db/connection';
import * as schema from '../db/schema';
import {
UserDataResponse,
DeleteUserDataResponse,
EntityCount,
} from './dto/user-data-response.dto';
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: Database
) {}
async getUserData(userId: string): Promise<UserDataResponse> {
this.logger.log(`Getting user data for userId: ${userId}`);
// Count decks
const decksResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.decks)
.where(eq(schema.decks.userId, userId));
const decksCount = decksResult[0]?.count ?? 0;
// Count slides (through decks)
const userDecks = await this.db
.select({ id: schema.decks.id })
.from(schema.decks)
.where(eq(schema.decks.userId, userId));
let slidesCount = 0;
if (userDecks.length > 0) {
const deckIds = userDecks.map((d) => d.id);
const slidesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.slides)
.where(inArray(schema.slides.deckId, deckIds));
slidesCount = slidesResult[0]?.count ?? 0;
}
// Count shared decks (through decks)
let sharedDecksCount = 0;
if (userDecks.length > 0) {
const deckIds = userDecks.map((d) => d.id);
const sharedResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.sharedDecks)
.where(inArray(schema.sharedDecks.deckId, deckIds));
sharedDecksCount = sharedResult[0]?.count ?? 0;
}
// Get last activity
const lastDeck = await this.db
.select({ updatedAt: schema.decks.updatedAt })
.from(schema.decks)
.where(eq(schema.decks.userId, userId))
.orderBy(desc(schema.decks.updatedAt))
.limit(1);
const lastActivityAt = lastDeck[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'decks', count: decksCount, label: 'Decks' },
{ entity: 'slides', count: slidesCount, label: 'Slides' },
{ entity: 'shared_decks', count: sharedDecksCount, label: 'Shared Links' },
];
const totalCount = decksCount + slidesCount + sharedDecksCount;
return { entities, totalCount, lastActivityAt };
}
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Deleting user data for userId: ${userId}`);
const deletedCounts: EntityCount[] = [];
let totalDeleted = 0;
// Get user's decks first
const userDecks = await this.db
.select({ id: schema.decks.id })
.from(schema.decks)
.where(eq(schema.decks.userId, userId));
if (userDecks.length > 0) {
const deckIds = userDecks.map((d) => d.id);
// Delete shared decks
const deletedShared = await this.db
.delete(schema.sharedDecks)
.where(inArray(schema.sharedDecks.deckId, deckIds))
.returning();
deletedCounts.push({
entity: 'shared_decks',
count: deletedShared.length,
label: 'Shared Links',
});
totalDeleted += deletedShared.length;
// Delete slides (cascade should handle this, but let's be explicit)
const deletedSlides = await this.db
.delete(schema.slides)
.where(inArray(schema.slides.deckId, deckIds))
.returning();
deletedCounts.push({ entity: 'slides', count: deletedSlides.length, label: 'Slides' });
totalDeleted += deletedSlides.length;
}
// Delete decks
const deletedDecks = await this.db
.delete(schema.decks)
.where(eq(schema.decks.userId, userId))
.returning();
deletedCounts.push({ entity: 'decks', count: deletedDecks.length, label: 'Decks' });
totalDeleted += deletedDecks.length;
this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`);
return { success: true, deletedCounts, totalDeleted };
}
}

View file

@ -1,17 +0,0 @@
export interface EntityCount {
entity: string;
count: number;
label: string;
}
export interface UserDataResponse {
entities: EntityCount[];
totalCount: number;
lastActivityAt?: string;
}
export interface DeleteUserDataResponse {
success: boolean;
deletedCounts: EntityCount[];
totalDeleted: number;
}

View file

@ -1,40 +0,0 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
/**
* Guard for internal service-to-service authentication using X-Service-Key header
* Used by mana-core-auth to query user data across backends
*/
@Injectable()
export class ServiceAuthGuard implements CanActivate {
private readonly logger = new Logger(ServiceAuthGuard.name);
private readonly serviceKey: string;
constructor(private readonly configService: ConfigService) {
this.serviceKey = this.configService.get<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const providedKey = request.headers['x-service-key'] as string;
if (!providedKey) {
this.logger.warn('Missing X-Service-Key header');
throw new UnauthorizedException('Missing service key');
}
if (providedKey !== this.serviceKey) {
this.logger.warn('Invalid service key provided');
throw new UnauthorizedException('Invalid service key');
}
return true;
}
}

View file

@ -1,40 +0,0 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { DatabaseModule } from './db/database.module';
import { DeckModule } from './deck/deck.module';
import { SlideModule } from './slide/slide.module';
import { ThemeModule } from './theme/theme.module';
import { ShareModule } from './share/share.module';
import { AdminModule } from './admin/admin.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
DatabaseModule,
DeckModule,
SlideModule,
ThemeModule,
ShareModule,
AdminModule,
HealthModule.forRoot({ serviceName: 'presi-backend' }),
MetricsModule.register({
prefix: 'presi_',
excludePaths: ['/health'],
}),
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View file

@ -1,39 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: PostgresJsDatabase<typeof schema> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string): PostgresJsDatabase<typeof schema> {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = PostgresJsDatabase<typeof schema>;

View file

@ -1,28 +0,0 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -1,20 +0,0 @@
-- Migration: Change user_id from UUID to TEXT
-- This allows compatibility with Better Auth nanoid-based user IDs
-- Step 1: Add a temporary column with the new type
ALTER TABLE decks ADD COLUMN user_id_new TEXT;
-- Step 2: Copy and convert existing data (UUID to TEXT)
UPDATE decks SET user_id_new = user_id::text WHERE user_id IS NOT NULL;
-- Step 3: Drop the old column
ALTER TABLE decks DROP COLUMN user_id;
-- Step 4: Rename the new column
ALTER TABLE decks RENAME COLUMN user_id_new TO user_id;
-- Step 5: Add NOT NULL constraint
ALTER TABLE decks ALTER COLUMN user_id SET NOT NULL;
-- Step 6: Add index for performance
CREATE INDEX IF NOT EXISTS decks_user_id_idx ON decks(user_id);

View file

@ -1,33 +0,0 @@
import { pgTable, uuid, text, boolean, timestamp, index } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { slides } from './slides.schema';
import { themes } from './themes.schema';
import { sharedDecks } from './shared-decks.schema';
export const decks = pgTable(
'decks',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(), // TEXT for Better Auth nanoid user IDs
title: text('title').notNull(),
description: text('description'),
themeId: uuid('theme_id').references(() => themes.id),
isPublic: boolean('is_public').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('decks_user_id_idx').on(table.userId),
index('decks_user_updated_idx').on(table.userId, table.updatedAt),
index('decks_theme_id_idx').on(table.themeId),
]
);
export const decksRelations = relations(decks, ({ many, one }) => ({
slides: many(slides),
theme: one(themes, {
fields: [decks.themeId],
references: [themes.id],
}),
sharedDecks: many(sharedDecks),
}));

View file

@ -1,4 +0,0 @@
export * from './decks.schema';
export * from './slides.schema';
export * from './themes.schema';
export * from './shared-decks.schema';

View file

@ -1,24 +0,0 @@
import { pgTable, uuid, text, timestamp, index } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { decks } from './decks.schema';
export const sharedDecks = pgTable(
'shared_decks',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
shareCode: text('share_code').notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index('shared_decks_deck_id_idx').on(table.deckId)]
);
export const sharedDecksRelations = relations(sharedDecks, ({ one }) => ({
deck: one(decks, {
fields: [sharedDecks.deckId],
references: [decks.id],
}),
}));

View file

@ -1,34 +0,0 @@
import { pgTable, uuid, integer, jsonb, timestamp, index } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { decks } from './decks.schema';
export const slides = pgTable(
'slides',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
order: integer('order').notNull(),
content: jsonb('content').$type<{
type: 'title' | 'content' | 'image' | 'split';
title?: string;
subtitle?: string;
body?: string;
imageUrl?: string;
bulletPoints?: string[];
}>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('slides_deck_id_idx').on(table.deckId),
index('slides_deck_order_idx').on(table.deckId, table.order),
]
);
export const slidesRelations = relations(slides, ({ one }) => ({
deck: one(decks, {
fields: [slides.deckId],
references: [decks.id],
}),
}));

View file

@ -1,24 +0,0 @@
import { pgTable, uuid, text, jsonb, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { decks } from './decks.schema';
export const themes = pgTable('themes', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
colors: jsonb('colors').$type<{
primary: string;
secondary: string;
background: string;
text: string;
accent: string;
}>(),
fonts: jsonb('fonts').$type<{
heading: string;
body: string;
}>(),
isDefault: boolean('is_default').default(false).notNull(),
});
export const themesRelations = relations(themes, ({ many }) => ({
decks: many(decks),
}));

View file

@ -1,99 +0,0 @@
import { DeckController } from '../deck.controller';
const TEST_USER_ID = 'test-user-123';
const mockUser = { userId: TEST_USER_ID, email: 'test@example.com' };
function createMockDeck(overrides: Record<string, unknown> = {}) {
return {
id: 'deck-1',
userId: TEST_USER_ID,
title: 'Test Deck',
description: 'A test deck',
themeId: null,
isPublic: false,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe('DeckController', () => {
let controller: DeckController;
let service: any;
beforeEach(() => {
service = {
findByUser: jest.fn(),
findOneWithSlides: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
controller = new DeckController(service);
});
afterEach(() => jest.clearAllMocks());
describe('findAll', () => {
it('should return user decks', async () => {
const decks = [createMockDeck()];
service.findByUser.mockResolvedValue(decks);
const result = await controller.findAll(mockUser as any);
expect(result).toEqual(decks);
expect(service.findByUser).toHaveBeenCalledWith(TEST_USER_ID);
});
});
describe('findOne', () => {
it('should return deck with slides', async () => {
const deck = createMockDeck({ slides: [] });
service.findOneWithSlides.mockResolvedValue(deck);
const result = await controller.findOne('deck-1', mockUser as any);
expect(result).toEqual(deck);
expect(service.findOneWithSlides).toHaveBeenCalledWith('deck-1', TEST_USER_ID);
});
});
describe('create', () => {
it('should create and return deck', async () => {
const deck = createMockDeck();
service.create.mockResolvedValue(deck);
const result = await controller.create({ title: 'Test Deck' } as any, mockUser as any);
expect(result).toEqual(deck);
expect(service.create).toHaveBeenCalledWith(TEST_USER_ID, { title: 'Test Deck' });
});
});
describe('update', () => {
it('should update and return deck', async () => {
const deck = createMockDeck({ title: 'Updated' });
service.update.mockResolvedValue(deck);
const result = await controller.update(
'deck-1',
{ title: 'Updated' } as any,
mockUser as any
);
expect(result).toEqual(deck);
expect(service.update).toHaveBeenCalledWith('deck-1', TEST_USER_ID, { title: 'Updated' });
});
});
describe('remove', () => {
it('should delete and return success', async () => {
service.remove.mockResolvedValue({ success: true });
const result = await controller.remove('deck-1', mockUser as any);
expect(result).toEqual({ success: true });
expect(service.remove).toHaveBeenCalledWith('deck-1', TEST_USER_ID);
});
});
});

View file

@ -1,231 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { DeckService } from '../deck.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
const TEST_USER_ID = 'user-1';
const TEST_DECK_ID = 'deck-1';
function createMockDeck(overrides: Record<string, unknown> = {}) {
return {
id: TEST_DECK_ID,
userId: TEST_USER_ID,
title: 'Test Deck',
description: 'A test deck',
themeId: null,
isPublic: false,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
theme: null,
slides: [],
...overrides,
};
}
describe('DeckService', () => {
let service: DeckService;
let mockDb: any;
beforeEach(async () => {
mockDb = {
query: {
decks: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
},
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
DeckService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<DeckService>(DeckService);
});
describe('findByUser', () => {
it('should return all decks for a user', async () => {
const decks = [createMockDeck(), createMockDeck({ id: 'deck-2', title: 'Second Deck' })];
mockDb.query.decks.findMany.mockResolvedValue(decks);
const result = await service.findByUser(TEST_USER_ID);
expect(result).toEqual(decks);
expect(mockDb.query.decks.findMany).toHaveBeenCalledWith(
expect.objectContaining({
with: { theme: true },
})
);
});
it('should return empty array when user has no decks', async () => {
mockDb.query.decks.findMany.mockResolvedValue([]);
const result = await service.findByUser(TEST_USER_ID);
expect(result).toEqual([]);
});
});
describe('findOneWithSlides', () => {
it('should return deck with slides when found', async () => {
const deck = createMockDeck({
slides: [{ id: 'slide-1', order: 0, content: { type: 'title' } }],
});
mockDb.query.decks.findFirst.mockResolvedValue(deck);
const result = await service.findOneWithSlides(TEST_DECK_ID, TEST_USER_ID);
expect(result).toEqual(deck);
expect(result.slides).toHaveLength(1);
});
it('should throw NotFoundException when deck not found', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(null);
await expect(service.findOneWithSlides('nonexistent', TEST_USER_ID)).rejects.toThrow(
NotFoundException
);
});
it('should throw NotFoundException when user does not own deck', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(null);
await expect(service.findOneWithSlides(TEST_DECK_ID, 'other-user')).rejects.toThrow(
NotFoundException
);
});
});
describe('findOne', () => {
it('should return deck without ownership check', async () => {
const deck = createMockDeck();
mockDb.query.decks.findFirst.mockResolvedValue(deck);
const result = await service.findOne(TEST_DECK_ID);
expect(result).toEqual(deck);
});
it('should return undefined when deck not found', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(undefined);
const result = await service.findOne('nonexistent');
expect(result).toBeUndefined();
});
});
describe('create', () => {
it('should create and return a new deck', async () => {
const newDeck = createMockDeck();
mockDb.returning.mockResolvedValue([newDeck]);
const result = await service.create(TEST_USER_ID, {
title: 'Test Deck',
description: 'A test deck',
});
expect(result).toEqual(newDeck);
expect(mockDb.insert).toHaveBeenCalled();
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: TEST_USER_ID,
title: 'Test Deck',
description: 'A test deck',
})
);
});
it('should create deck with themeId', async () => {
const deck = createMockDeck({ themeId: 'theme-1' });
mockDb.returning.mockResolvedValue([deck]);
const result = await service.create(TEST_USER_ID, {
title: 'Themed Deck',
themeId: 'theme-1',
});
expect(result.themeId).toBe('theme-1');
});
});
describe('update', () => {
it('should update and return the deck', async () => {
const existing = createMockDeck();
const updated = createMockDeck({ title: 'Updated Title' });
mockDb.query.decks.findFirst.mockResolvedValue(existing);
mockDb.returning.mockResolvedValue([updated]);
const result = await service.update(TEST_DECK_ID, TEST_USER_ID, { title: 'Updated Title' });
expect(result).toEqual(updated);
expect(mockDb.update).toHaveBeenCalled();
});
it('should throw NotFoundException when deck not found', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(null);
await expect(
service.update('nonexistent', TEST_USER_ID, { title: 'Updated' })
).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException when user does not own deck', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(null);
await expect(
service.update(TEST_DECK_ID, 'other-user', { title: 'Updated' })
).rejects.toThrow(NotFoundException);
});
});
describe('remove', () => {
it('should delete deck and return success', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(createMockDeck());
mockDb.where.mockResolvedValue(undefined);
const result = await service.remove(TEST_DECK_ID, TEST_USER_ID);
expect(result).toEqual({ success: true });
expect(mockDb.delete).toHaveBeenCalled();
});
it('should throw NotFoundException when deck not found', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(null);
await expect(service.remove('nonexistent', TEST_USER_ID)).rejects.toThrow(NotFoundException);
});
});
describe('verifyOwnership', () => {
it('should return true when user owns deck', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(createMockDeck());
const result = await service.verifyOwnership(TEST_DECK_ID, TEST_USER_ID);
expect(result).toBe(true);
});
it('should return false when user does not own deck', async () => {
mockDb.query.decks.findFirst.mockResolvedValue(null);
const result = await service.verifyOwnership(TEST_DECK_ID, 'other-user');
expect(result).toBe(false);
});
});
});

View file

@ -1,54 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { DeckService } from './deck.service';
import { CreateDeckDto } from './deck.dto';
import type { UpdateDeckDto } from './deck.dto';
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
@ApiTags('Decks')
@ApiBearerAuth()
@Controller('decks')
@UseGuards(JwtAuthGuard)
export class DeckController {
constructor(private readonly deckService: DeckService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.deckService.findByUser(user.userId);
}
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) {
return this.deckService.findOneWithSlides(id, user.userId);
}
@Post()
async create(@Body() createDeckDto: CreateDeckDto, @CurrentUser() user: CurrentUserData) {
return this.deckService.create(user.userId, createDeckDto);
}
@Put(':id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDeckDto: UpdateDeckDto,
@CurrentUser() user: CurrentUserData
) {
return this.deckService.update(id, user.userId, updateDeckDto);
}
@Delete(':id')
async remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) {
return this.deckService.remove(id, user.userId);
}
}

View file

@ -1,47 +0,0 @@
import {
IsString,
IsOptional,
IsBoolean,
IsUUID,
MinLength,
MaxLength,
IsNotEmpty,
} from 'class-validator';
export class CreateDeckDto {
@IsString()
@IsNotEmpty()
@MinLength(1)
@MaxLength(200)
title: string;
@IsString()
@IsOptional()
@MaxLength(2000)
description?: string;
@IsUUID()
@IsOptional()
themeId?: string;
}
export class UpdateDeckDto {
@IsString()
@IsOptional()
@MinLength(1)
@MaxLength(200)
title?: string;
@IsString()
@IsOptional()
@MaxLength(2000)
description?: string;
@IsUUID()
@IsOptional()
themeId?: string;
@IsBoolean()
@IsOptional()
isPublic?: boolean;
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { DeckController } from './deck.controller';
import { DeckService } from './deck.service';
@Module({
controllers: [DeckController],
providers: [DeckService],
exports: [DeckService],
})
export class DeckModule {}

View file

@ -1,113 +0,0 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { decks, slides } from '../db/schema';
import { CreateDeckDto } from './deck.dto';
import type { UpdateDeckDto } from './deck.dto';
@Injectable()
export class DeckService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: Database
) {}
async findByUser(userId: string) {
return this.db.query.decks.findMany({
where: eq(decks.userId, userId),
orderBy: [desc(decks.updatedAt)],
with: {
theme: true,
},
});
}
async findOneWithSlides(id: string, userId: string) {
const deck = await this.db.query.decks.findFirst({
where: and(eq(decks.id, id), eq(decks.userId, userId)),
with: {
slides: {
orderBy: [slides.order],
},
theme: true,
},
});
if (!deck) {
throw new NotFoundException('Deck not found');
}
return deck;
}
async findOne(id: string) {
return this.db.query.decks.findFirst({
where: eq(decks.id, id),
with: {
slides: {
orderBy: [slides.order],
},
theme: true,
},
});
}
async create(userId: string, dto: CreateDeckDto) {
const [deck] = await this.db
.insert(decks)
.values({
userId,
title: dto.title,
description: dto.description,
themeId: dto.themeId,
})
.returning();
return deck;
}
async update(id: string, userId: string, dto: UpdateDeckDto) {
// Verify ownership
const existing = await this.db.query.decks.findFirst({
where: and(eq(decks.id, id), eq(decks.userId, userId)),
});
if (!existing) {
throw new NotFoundException('Deck not found');
}
const [updated] = await this.db
.update(decks)
.set({
...dto,
updatedAt: new Date(),
})
.where(eq(decks.id, id))
.returning();
return updated;
}
async remove(id: string, userId: string) {
// Verify ownership
const existing = await this.db.query.decks.findFirst({
where: and(eq(decks.id, id), eq(decks.userId, userId)),
});
if (!existing) {
throw new NotFoundException('Deck not found');
}
await this.db.delete(decks).where(eq(decks.id, id));
return { success: true };
}
async verifyOwnership(id: string, userId: string): Promise<boolean> {
const deck = await this.db.query.decks.findFirst({
where: and(eq(decks.id, id), eq(decks.userId, userId)),
});
return !!deck;
}
}

View file

@ -1,8 +0,0 @@
import { initErrorTracking } from '@manacore/shared-error-tracking';
initErrorTracking({
serviceName: 'presi-backend',
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
debug: process.env.NODE_ENV === 'development',
});

View file

@ -1,11 +0,0 @@
import './instrument';
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
import { AppModule } from './app.module';
bootstrapApp(AppModule, {
defaultPort: 3008,
serviceName: 'Presi',
additionalCorsOrigins: ['http://localhost:5177', 'http://localhost:5178'],
excludeFromPrefix: [],
swagger: true,
});

View file

@ -1,81 +0,0 @@
import { ShareController } from '../share.controller';
const TEST_USER_ID = 'test-user-123';
const mockUser = { userId: TEST_USER_ID, email: 'test@example.com' };
describe('ShareController', () => {
let controller: ShareController;
let service: any;
beforeEach(() => {
service = {
findByShareCode: jest.fn(),
createShare: jest.fn(),
getSharesForDeck: jest.fn(),
deleteShare: jest.fn(),
};
controller = new ShareController(service);
});
afterEach(() => jest.clearAllMocks());
describe('getSharedDeck', () => {
it('should return shared deck by code', async () => {
const deck = { id: 'deck-1', title: 'Shared', slides: [] };
service.findByShareCode.mockResolvedValue(deck);
const result = await controller.getSharedDeck('abc123');
expect(result).toEqual(deck);
expect(service.findByShareCode).toHaveBeenCalledWith('abc123');
});
});
describe('createShare', () => {
it('should create share link', async () => {
const share = { id: 'share-1', shareCode: 'abc123', deckId: 'deck-1' };
service.createShare.mockResolvedValue(share);
const result = await controller.createShare('deck-1', {} as any, mockUser as any);
expect(result).toEqual(share);
expect(service.createShare).toHaveBeenCalledWith('deck-1', TEST_USER_ID, undefined);
});
it('should create share link with expiration', async () => {
const share = { id: 'share-1', shareCode: 'abc123', expiresAt: '2026-12-31' };
service.createShare.mockResolvedValue(share);
const result = await controller.createShare(
'deck-1',
{ expiresAt: '2026-12-31T00:00:00.000Z' } as any,
mockUser as any
);
expect(result).toEqual(share);
});
});
describe('getSharesForDeck', () => {
it('should return shares for deck', async () => {
const shares = [{ id: 'share-1', shareCode: 'abc123' }];
service.getSharesForDeck.mockResolvedValue(shares);
const result = await controller.getSharesForDeck('deck-1', mockUser as any);
expect(result).toEqual(shares);
expect(service.getSharesForDeck).toHaveBeenCalledWith('deck-1', TEST_USER_ID);
});
});
describe('deleteShare', () => {
it('should delete share and return success', async () => {
service.deleteShare.mockResolvedValue({ success: true });
const result = await controller.deleteShare('share-1', mockUser as any);
expect(result).toEqual({ success: true });
expect(service.deleteShare).toHaveBeenCalledWith('share-1', TEST_USER_ID);
});
});
});

View file

@ -1,193 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { ShareService } from '../share.service';
import { DeckService } from '../../deck/deck.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
const TEST_USER_ID = 'user-1';
const TEST_DECK_ID = 'deck-1';
const TEST_SHARE_ID = 'share-1';
const TEST_SHARE_CODE = 'abc123def456';
function createMockShare(overrides: Record<string, unknown> = {}) {
return {
id: TEST_SHARE_ID,
deckId: TEST_DECK_ID,
shareCode: TEST_SHARE_CODE,
expiresAt: null,
createdAt: new Date('2025-01-01'),
...overrides,
};
}
describe('ShareService', () => {
let service: ShareService;
let mockDb: any;
let mockDeckService: any;
beforeEach(async () => {
mockDb = {
query: {
sharedDecks: {
findFirst: jest.fn(),
findMany: jest.fn(),
},
},
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn(),
delete: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
};
mockDeckService = {
verifyOwnership: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ShareService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
{
provide: DeckService,
useValue: mockDeckService,
},
],
}).compile();
service = module.get<ShareService>(ShareService);
});
describe('createShare', () => {
it('should create a new share link when user owns deck', async () => {
const share = createMockShare();
mockDeckService.verifyOwnership.mockResolvedValue(true);
mockDb.query.sharedDecks.findFirst.mockResolvedValue(null); // no existing share
mockDb.returning.mockResolvedValue([share]);
const result = await service.createShare(TEST_DECK_ID, TEST_USER_ID);
expect(result).toEqual(share);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should return existing valid share instead of creating new one', async () => {
const existingShare = createMockShare();
mockDeckService.verifyOwnership.mockResolvedValue(true);
mockDb.query.sharedDecks.findFirst.mockResolvedValue(existingShare);
const result = await service.createShare(TEST_DECK_ID, TEST_USER_ID);
expect(result).toEqual(existingShare);
expect(mockDb.insert).not.toHaveBeenCalled();
});
it('should throw ForbiddenException when user does not own deck', async () => {
mockDeckService.verifyOwnership.mockResolvedValue(false);
await expect(service.createShare(TEST_DECK_ID, 'other-user')).rejects.toThrow(
ForbiddenException
);
});
it('should create share with expiration date', async () => {
const expiresAt = new Date('2026-12-31');
const share = createMockShare({ expiresAt });
mockDeckService.verifyOwnership.mockResolvedValue(true);
mockDb.query.sharedDecks.findFirst.mockResolvedValue(null);
mockDb.returning.mockResolvedValue([share]);
const result = await service.createShare(TEST_DECK_ID, TEST_USER_ID, expiresAt);
expect(result.expiresAt).toEqual(expiresAt);
});
});
describe('findByShareCode', () => {
it('should return deck when share code is valid', async () => {
const deck = {
id: TEST_DECK_ID,
title: 'Shared Deck',
slides: [],
theme: null,
};
mockDb.query.sharedDecks.findFirst.mockResolvedValue({
...createMockShare(),
deck,
});
const result = await service.findByShareCode(TEST_SHARE_CODE);
expect(result).toEqual(deck);
});
it('should throw NotFoundException when share code not found', async () => {
mockDb.query.sharedDecks.findFirst.mockResolvedValue(null);
await expect(service.findByShareCode('invalid-code')).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException when share has expired', async () => {
mockDb.query.sharedDecks.findFirst.mockResolvedValue(null); // expired shares are filtered in query
await expect(service.findByShareCode(TEST_SHARE_CODE)).rejects.toThrow(NotFoundException);
});
});
describe('getSharesForDeck', () => {
it('should return shares when user owns deck', async () => {
const shares = [createMockShare(), createMockShare({ id: 'share-2', shareCode: 'xyz789' })];
mockDeckService.verifyOwnership.mockResolvedValue(true);
mockDb.query.sharedDecks.findMany.mockResolvedValue(shares);
const result = await service.getSharesForDeck(TEST_DECK_ID, TEST_USER_ID);
expect(result).toEqual(shares);
expect(result).toHaveLength(2);
});
it('should throw ForbiddenException when user does not own deck', async () => {
mockDeckService.verifyOwnership.mockResolvedValue(false);
await expect(service.getSharesForDeck(TEST_DECK_ID, 'other-user')).rejects.toThrow(
ForbiddenException
);
});
});
describe('deleteShare', () => {
it('should delete share when user owns deck', async () => {
mockDb.query.sharedDecks.findFirst.mockResolvedValue({
...createMockShare(),
deck: { userId: TEST_USER_ID },
});
mockDb.where.mockResolvedValue(undefined);
const result = await service.deleteShare(TEST_SHARE_ID, TEST_USER_ID);
expect(result).toEqual({ success: true });
});
it('should throw NotFoundException when share not found', async () => {
mockDb.query.sharedDecks.findFirst.mockResolvedValue(null);
await expect(service.deleteShare('nonexistent', TEST_USER_ID)).rejects.toThrow(
NotFoundException
);
});
it('should throw ForbiddenException when user does not own deck', async () => {
mockDb.query.sharedDecks.findFirst.mockResolvedValue({
...createMockShare(),
deck: { userId: 'other-user' },
});
await expect(service.deleteShare(TEST_SHARE_ID, TEST_USER_ID)).rejects.toThrow(
ForbiddenException
);
});
});
});

View file

@ -1,58 +0,0 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ShareService } from './share.service';
import { CreateShareDto } from './share.dto';
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
@ApiTags('Share')
@Controller('share')
export class ShareController {
constructor(private readonly shareService: ShareService) {}
@Get(':code')
async getSharedDeck(@Param('code') code: string) {
return this.shareService.findByShareCode(code);
}
@ApiBearerAuth()
@Post('deck/:deckId')
@UseGuards(JwtAuthGuard)
async createShare(
@Param('deckId', ParseUUIDPipe) deckId: string,
@Body() createShareDto: CreateShareDto,
@CurrentUser() user: CurrentUserData
) {
const expiresAt = createShareDto.expiresAt ? new Date(createShareDto.expiresAt) : undefined;
return this.shareService.createShare(deckId, user.userId, expiresAt);
}
@ApiBearerAuth()
@Get('deck/:deckId/links')
@UseGuards(JwtAuthGuard)
async getSharesForDeck(
@Param('deckId', ParseUUIDPipe) deckId: string,
@CurrentUser() user: CurrentUserData
) {
return this.shareService.getSharesForDeck(deckId, user.userId);
}
@ApiBearerAuth()
@Delete(':shareId')
@UseGuards(JwtAuthGuard)
async deleteShare(
@Param('shareId', ParseUUIDPipe) shareId: string,
@CurrentUser() user: CurrentUserData
) {
return this.shareService.deleteShare(shareId, user.userId);
}
}

View file

@ -1,7 +0,0 @@
import { IsOptional, IsDateString } from 'class-validator';
export class CreateShareDto {
@IsOptional()
@IsDateString()
expiresAt?: string;
}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { DeckModule } from '../deck/deck.module';
@Module({
imports: [DeckModule],
controllers: [ShareController],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}

View file

@ -1,111 +0,0 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, and, gt, or, isNull } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { sharedDecks, slides } from '../db/schema';
import { DeckService } from '../deck/deck.service';
import { randomBytes } from 'crypto';
@Injectable()
export class ShareService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: Database,
private readonly deckService: DeckService
) {}
private generateShareCode(): string {
return randomBytes(6).toString('hex'); // 12 character code
}
async createShare(deckId: string, userId: string, expiresAt?: Date) {
// Verify ownership
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
if (!isOwner) {
throw new ForbiddenException('You do not own this deck');
}
// Check if there's already a valid share
const existingShare = await this.db.query.sharedDecks.findFirst({
where: and(
eq(sharedDecks.deckId, deckId),
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date()))
),
});
if (existingShare) {
return existingShare;
}
// Create new share
const [share] = await this.db
.insert(sharedDecks)
.values({
deckId,
shareCode: this.generateShareCode(),
expiresAt: expiresAt || null,
})
.returning();
return share;
}
async findByShareCode(shareCode: string) {
const share = await this.db.query.sharedDecks.findFirst({
where: and(
eq(sharedDecks.shareCode, shareCode),
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date()))
),
with: {
deck: {
with: {
slides: {
orderBy: [slides.order],
},
theme: true,
},
},
},
});
if (!share) {
throw new NotFoundException('Shared deck not found or link has expired');
}
return share.deck;
}
async getSharesForDeck(deckId: string, userId: string) {
// Verify ownership
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
if (!isOwner) {
throw new ForbiddenException('You do not own this deck');
}
return this.db.query.sharedDecks.findMany({
where: eq(sharedDecks.deckId, deckId),
});
}
async deleteShare(shareId: string, userId: string) {
const share = await this.db.query.sharedDecks.findFirst({
where: eq(sharedDecks.id, shareId),
with: {
deck: true,
},
});
if (!share) {
throw new NotFoundException('Share not found');
}
// Verify ownership of the deck
if (share.deck.userId !== userId) {
throw new ForbiddenException('You do not own this deck');
}
await this.db.delete(sharedDecks).where(eq(sharedDecks.id, shareId));
return { success: true };
}
}

View file

@ -1,85 +0,0 @@
import { SlideController } from '../slide.controller';
const TEST_USER_ID = 'test-user-123';
const mockUser = { userId: TEST_USER_ID, email: 'test@example.com' };
describe('SlideController', () => {
let controller: SlideController;
let service: any;
beforeEach(() => {
service = {
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
reorder: jest.fn(),
};
controller = new SlideController(service);
});
afterEach(() => jest.clearAllMocks());
describe('create', () => {
it('should create slide for deck', async () => {
const slide = { id: 'slide-1', deckId: 'deck-1', order: 0 };
service.create.mockResolvedValue(slide);
const result = await controller.create(
'deck-1',
{ content: { type: 'title' as const, title: 'Hello' } },
mockUser as any
);
expect(result).toEqual(slide);
expect(service.create).toHaveBeenCalledWith('deck-1', TEST_USER_ID, {
content: { type: 'title', title: 'Hello' },
});
});
});
describe('update', () => {
it('should update slide', async () => {
const slide = { id: 'slide-1', content: { type: 'content', body: 'Updated' } };
service.update.mockResolvedValue(slide);
const result = await controller.update(
'slide-1',
{ content: { type: 'content' as const, body: 'Updated' } },
mockUser as any
);
expect(result).toEqual(slide);
expect(service.update).toHaveBeenCalledWith('slide-1', TEST_USER_ID, {
content: { type: 'content', body: 'Updated' },
});
});
});
describe('remove', () => {
it('should delete slide', async () => {
service.remove.mockResolvedValue({ success: true });
const result = await controller.remove('slide-1', mockUser as any);
expect(result).toEqual({ success: true });
expect(service.remove).toHaveBeenCalledWith('slide-1', TEST_USER_ID);
});
});
describe('reorder', () => {
it('should reorder slides', async () => {
service.reorder.mockResolvedValue({ success: true });
const dto = {
slides: [
{ id: 'slide-1', order: 1 },
{ id: 'slide-2', order: 0 },
],
};
const result = await controller.reorder(dto as any, mockUser as any);
expect(result).toEqual({ success: true });
expect(service.reorder).toHaveBeenCalledWith(TEST_USER_ID, dto);
});
});
});

View file

@ -1,206 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { SlideService } from '../slide.service';
import { DeckService } from '../../deck/deck.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
const TEST_USER_ID = 'user-1';
const TEST_DECK_ID = 'deck-1';
const TEST_SLIDE_ID = 'slide-1';
function createMockSlide(overrides: Record<string, unknown> = {}) {
return {
id: TEST_SLIDE_ID,
deckId: TEST_DECK_ID,
order: 0,
content: { type: 'title', title: 'Hello World' },
createdAt: new Date('2025-01-01'),
deck: { userId: TEST_USER_ID },
...overrides,
};
}
describe('SlideService', () => {
let service: SlideService;
let mockDb: any;
let mockDeckService: any;
beforeEach(async () => {
mockDb = {
query: {
slides: {
findFirst: jest.fn(),
},
},
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
};
mockDeckService = {
verifyOwnership: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SlideService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
{
provide: DeckService,
useValue: mockDeckService,
},
],
}).compile();
service = module.get<SlideService>(SlideService);
});
describe('create', () => {
it('should create a slide when user owns deck', async () => {
const slide = createMockSlide();
mockDeckService.verifyOwnership.mockResolvedValue(true);
mockDb.where.mockResolvedValueOnce([{ maxOrder: 2 }]); // max order query
mockDb.returning.mockResolvedValue([slide]);
const result = await service.create(TEST_DECK_ID, TEST_USER_ID, {
content: { type: 'title' as const, title: 'Hello World' },
});
expect(result).toEqual(slide);
expect(mockDeckService.verifyOwnership).toHaveBeenCalledWith(TEST_DECK_ID, TEST_USER_ID);
});
it('should auto-increment order when not provided', async () => {
mockDeckService.verifyOwnership.mockResolvedValue(true);
mockDb.where.mockResolvedValueOnce([{ maxOrder: 5 }]);
mockDb.returning.mockResolvedValue([createMockSlide({ order: 6 })]);
const result = await service.create(TEST_DECK_ID, TEST_USER_ID, {
content: { type: 'content' as const },
});
expect(result.order).toBe(6);
});
it('should throw ForbiddenException when user does not own deck', async () => {
mockDeckService.verifyOwnership.mockResolvedValue(false);
await expect(
service.create(TEST_DECK_ID, 'other-user', {
content: { type: 'title' as const },
})
).rejects.toThrow(ForbiddenException);
});
});
describe('update', () => {
it('should update slide when user owns deck', async () => {
const slide = createMockSlide();
const updated = createMockSlide({ content: { type: 'content', body: 'Updated' } });
mockDb.query.slides.findFirst.mockResolvedValue(slide);
mockDb.returning.mockResolvedValue([updated]);
const result = await service.update(TEST_SLIDE_ID, TEST_USER_ID, {
content: { type: 'content' as const, body: 'Updated' },
});
expect(result).toEqual(updated);
});
it('should throw NotFoundException when slide not found', async () => {
mockDb.query.slides.findFirst.mockResolvedValue(null);
await expect(
service.update('nonexistent', TEST_USER_ID, {
content: { type: 'title' as const },
})
).rejects.toThrow(NotFoundException);
});
it('should throw ForbiddenException when user does not own slide', async () => {
mockDb.query.slides.findFirst.mockResolvedValue(
createMockSlide({ deck: { userId: 'other-user' } })
);
await expect(
service.update(TEST_SLIDE_ID, TEST_USER_ID, {
content: { type: 'title' as const },
})
).rejects.toThrow(ForbiddenException);
});
});
describe('remove', () => {
it('should delete slide and return success', async () => {
mockDb.query.slides.findFirst.mockResolvedValue(createMockSlide());
mockDb.where.mockResolvedValue(undefined);
const result = await service.remove(TEST_SLIDE_ID, TEST_USER_ID);
expect(result).toEqual({ success: true });
});
it('should throw NotFoundException when slide not found', async () => {
mockDb.query.slides.findFirst.mockResolvedValue(null);
await expect(service.remove('nonexistent', TEST_USER_ID)).rejects.toThrow(NotFoundException);
});
it('should throw ForbiddenException when user does not own slide', async () => {
mockDb.query.slides.findFirst.mockResolvedValue(
createMockSlide({ deck: { userId: 'other-user' } })
);
await expect(service.remove(TEST_SLIDE_ID, TEST_USER_ID)).rejects.toThrow(ForbiddenException);
});
});
describe('reorder', () => {
it('should reorder slides when user owns all', async () => {
mockDb.query.slides.findFirst
.mockResolvedValueOnce(createMockSlide({ id: 'slide-1' }))
.mockResolvedValueOnce(createMockSlide({ id: 'slide-2' }));
mockDb.where.mockResolvedValue(undefined);
const result = await service.reorder(TEST_USER_ID, {
slides: [
{ id: 'slide-1', order: 1 },
{ id: 'slide-2', order: 0 },
],
});
expect(result).toEqual({ success: true });
});
it('should throw NotFoundException when slide not found during reorder', async () => {
mockDb.query.slides.findFirst.mockResolvedValue(null);
await expect(
service.reorder(TEST_USER_ID, {
slides: [{ id: 'nonexistent', order: 0 }],
})
).rejects.toThrow(NotFoundException);
});
it('should throw ForbiddenException when user does not own a slide', async () => {
mockDb.query.slides.findFirst.mockResolvedValue(
createMockSlide({ deck: { userId: 'other-user' } })
);
await expect(
service.reorder(TEST_USER_ID, {
slides: [{ id: 'slide-1', order: 0 }],
})
).rejects.toThrow(ForbiddenException);
});
});
});

View file

@ -1,52 +0,0 @@
import {
Controller,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { SlideService } from './slide.service';
import { CreateSlideDto } from './slide.dto';
import type { UpdateSlideDto, ReorderSlidesDto } from './slide.dto';
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
@ApiTags('Slides')
@ApiBearerAuth()
@Controller()
@UseGuards(JwtAuthGuard)
export class SlideController {
constructor(private readonly slideService: SlideService) {}
@Post('decks/:deckId/slides')
async create(
@Param('deckId', ParseUUIDPipe) deckId: string,
@Body() createSlideDto: CreateSlideDto,
@CurrentUser() user: CurrentUserData
) {
return this.slideService.create(deckId, user.userId, createSlideDto);
}
@Put('slides/:id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateSlideDto: UpdateSlideDto,
@CurrentUser() user: CurrentUserData
) {
return this.slideService.update(id, user.userId, updateSlideDto);
}
@Delete('slides/:id')
async remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) {
return this.slideService.remove(id, user.userId);
}
@Put('slides/reorder')
async reorder(@Body() reorderDto: ReorderSlidesDto, @CurrentUser() user: CurrentUserData) {
return this.slideService.reorder(user.userId, reorderDto);
}
}

View file

@ -1,85 +0,0 @@
import {
IsOptional,
IsNumber,
IsArray,
ValidateNested,
IsUUID,
IsIn,
IsString,
IsUrl,
MaxLength,
Min,
IsInt,
ArrayMaxSize,
} from 'class-validator';
import { Type } from 'class-transformer';
class SlideContent {
@IsIn(['title', 'content', 'image', 'split'])
type: 'title' | 'content' | 'image' | 'split';
@IsString()
@IsOptional()
@MaxLength(500)
title?: string;
@IsString()
@IsOptional()
@MaxLength(500)
subtitle?: string;
@IsString()
@IsOptional()
@MaxLength(5000)
body?: string;
@IsUrl()
@IsOptional()
imageUrl?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
@ArrayMaxSize(50)
bulletPoints?: string[];
}
export class CreateSlideDto {
@ValidateNested()
@Type(() => SlideContent)
content: SlideContent;
@IsInt()
@Min(0)
@IsOptional()
order?: number;
}
export class UpdateSlideDto {
@ValidateNested()
@Type(() => SlideContent)
@IsOptional()
content?: SlideContent;
@IsInt()
@Min(0)
@IsOptional()
order?: number;
}
class SlideOrderItem {
@IsUUID()
id: string;
@IsInt()
@Min(0)
order: number;
}
export class ReorderSlidesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => SlideOrderItem)
@ArrayMaxSize(200)
slides: SlideOrderItem[];
}

View file

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { SlideController } from './slide.controller';
import { SlideService } from './slide.service';
import { DeckModule } from '../deck/deck.module';
@Module({
imports: [DeckModule],
controllers: [SlideController],
providers: [SlideService],
})
export class SlideModule {}

View file

@ -1,125 +0,0 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, max } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { slides, decks } from '../db/schema';
import { DeckService } from '../deck/deck.service';
import { CreateSlideDto } from './slide.dto';
import type { UpdateSlideDto, ReorderSlidesDto } from './slide.dto';
@Injectable()
export class SlideService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: Database,
private readonly deckService: DeckService
) {}
async create(deckId: string, userId: string, dto: CreateSlideDto) {
// Verify deck ownership
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
if (!isOwner) {
throw new ForbiddenException('Not authorized to modify this deck');
}
// Get next order number
const result = await this.db
.select({ maxOrder: max(slides.order) })
.from(slides)
.where(eq(slides.deckId, deckId));
const nextOrder = dto.order ?? (result[0]?.maxOrder ?? -1) + 1;
const [slide] = await this.db
.insert(slides)
.values({
deckId,
order: nextOrder,
content: dto.content,
})
.returning();
// Update deck's updatedAt
await this.db.update(decks).set({ updatedAt: new Date() }).where(eq(decks.id, deckId));
return slide;
}
async update(id: string, userId: string, dto: UpdateSlideDto) {
// Get slide and verify ownership
const slide = await this.db.query.slides.findFirst({
where: eq(slides.id, id),
with: { deck: true },
});
if (!slide) {
throw new NotFoundException('Slide not found');
}
if (slide.deck.userId !== userId) {
throw new ForbiddenException('Not authorized to modify this slide');
}
const [updated] = await this.db
.update(slides)
.set({
content: dto.content ?? slide.content,
order: dto.order ?? slide.order,
})
.where(eq(slides.id, id))
.returning();
// Update deck's updatedAt
await this.db.update(decks).set({ updatedAt: new Date() }).where(eq(decks.id, slide.deckId));
return updated;
}
async remove(id: string, userId: string) {
// Get slide and verify ownership
const slide = await this.db.query.slides.findFirst({
where: eq(slides.id, id),
with: { deck: true },
});
if (!slide) {
throw new NotFoundException('Slide not found');
}
if (slide.deck.userId !== userId) {
throw new ForbiddenException('Not authorized to delete this slide');
}
await this.db.delete(slides).where(eq(slides.id, id));
// Update deck's updatedAt
await this.db.update(decks).set({ updatedAt: new Date() }).where(eq(decks.id, slide.deckId));
return { success: true };
}
async reorder(userId: string, dto: ReorderSlidesDto) {
// Verify ownership of all slides
for (const item of dto.slides) {
const slide = await this.db.query.slides.findFirst({
where: eq(slides.id, item.id),
with: { deck: true },
});
if (!slide) {
throw new NotFoundException(`Slide ${item.id} not found`);
}
if (slide.deck.userId !== userId) {
throw new ForbiddenException('Not authorized to reorder these slides');
}
}
// Update orders
for (const item of dto.slides) {
await this.db.update(slides).set({ order: item.order }).where(eq(slides.id, item.id));
}
return { success: true };
}
}

View file

@ -1,75 +0,0 @@
import { ThemeController } from '../theme.controller';
const mockTheme = {
id: 'theme-1',
name: 'Dark',
colors: { primary: '#000', secondary: '#333', background: '#111', text: '#fff', accent: '#f00' },
fonts: { heading: 'Arial', body: 'Helvetica' },
isDefault: false,
};
describe('ThemeController', () => {
let controller: ThemeController;
let service: any;
beforeEach(() => {
service = {
findAll: jest.fn(),
findDefault: jest.fn(),
findOne: jest.fn(),
};
controller = new ThemeController(service);
});
afterEach(() => jest.clearAllMocks());
describe('findAll', () => {
it('should return all themes', async () => {
const themes = [mockTheme];
service.findAll.mockResolvedValue(themes);
const result = await controller.findAll();
expect(result).toEqual(themes);
});
});
describe('findDefault', () => {
it('should return default theme', async () => {
const defaultTheme = { ...mockTheme, isDefault: true };
service.findDefault.mockResolvedValue(defaultTheme);
const result = await controller.findDefault();
expect(result).toEqual(defaultTheme);
expect(result.isDefault).toBe(true);
});
it('should return null when no default theme', async () => {
service.findDefault.mockResolvedValue(null);
const result = await controller.findDefault();
expect(result).toBeNull();
});
});
describe('findOne', () => {
it('should return theme by id', async () => {
service.findOne.mockResolvedValue(mockTheme);
const result = await controller.findOne('theme-1');
expect(result).toEqual(mockTheme);
expect(service.findOne).toHaveBeenCalledWith('theme-1');
});
it('should return null when not found', async () => {
service.findOne.mockResolvedValue(null);
const result = await controller.findOne('nonexistent');
expect(result).toBeNull();
});
});
});

View file

@ -1,100 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ThemeService } from '../theme.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
const mockTheme = {
id: 'theme-1',
name: 'Dark',
colors: { primary: '#000', secondary: '#333', background: '#111', text: '#fff', accent: '#f00' },
fonts: { heading: 'Arial', body: 'Helvetica' },
isDefault: false,
};
const defaultTheme = {
...mockTheme,
id: 'theme-default',
name: 'Default',
isDefault: true,
};
describe('ThemeService', () => {
let service: ThemeService;
let mockDb: any;
beforeEach(async () => {
mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ThemeService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<ThemeService>(ThemeService);
});
describe('findAll', () => {
it('should return all themes', async () => {
const themes = [mockTheme, defaultTheme];
mockDb.from.mockResolvedValue(themes);
const result = await service.findAll();
expect(result).toEqual(themes);
expect(result).toHaveLength(2);
});
it('should return empty array when no themes exist', async () => {
mockDb.from.mockResolvedValue([]);
const result = await service.findAll();
expect(result).toEqual([]);
});
});
describe('findOne', () => {
it('should return theme when found', async () => {
mockDb.where.mockResolvedValue([mockTheme]);
const result = await service.findOne('theme-1');
expect(result).toEqual(mockTheme);
});
it('should return null when theme not found', async () => {
mockDb.where.mockResolvedValue([]);
const result = await service.findOne('nonexistent');
expect(result).toBeNull();
});
});
describe('findDefault', () => {
it('should return default theme', async () => {
mockDb.where.mockResolvedValue([defaultTheme]);
const result = await service.findDefault();
expect(result).toEqual(defaultTheme);
expect(result.isDefault).toBe(true);
});
it('should return null when no default theme exists', async () => {
mockDb.where.mockResolvedValue([]);
const result = await service.findDefault();
expect(result).toBeNull();
});
});
});

View file

@ -1,24 +0,0 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ThemeService } from './theme.service';
@ApiTags('Themes')
@Controller('themes')
export class ThemeController {
constructor(private readonly themeService: ThemeService) {}
@Get()
async findAll() {
return this.themeService.findAll();
}
@Get('default')
async findDefault() {
return this.themeService.findDefault();
}
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.themeService.findOne(id);
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { ThemeController } from './theme.controller';
import { ThemeService } from './theme.service';
@Module({
controllers: [ThemeController],
providers: [ThemeService],
exports: [ThemeService],
})
export class ThemeModule {}

View file

@ -1,27 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { themes } from '../db/schema';
@Injectable()
export class ThemeService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: Database
) {}
async findAll() {
return this.db.select().from(themes);
}
async findOne(id: string) {
const result = await this.db.select().from(themes).where(eq(themes.id, id));
return result[0] || null;
}
async findDefault() {
const result = await this.db.select().from(themes).where(eq(themes.isDefault, true));
return result[0] || null;
}
}

View file

@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,22 @@
{
"name": "@presi/server",
"version": "0.1.0",
"private": true,
"description": "Presi server-side compute (Hono + Bun) — share links, admin/GDPR",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,98 @@
/**
* Database schema only tables needed by server-side share endpoints.
* Deck/slide CRUD is handled client-side via local-first + mana-sync.
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import {
pgTable,
uuid,
text,
boolean,
timestamp,
integer,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/presi';
const connection = postgres(DATABASE_URL, {
max: 5,
idle_timeout: 20,
});
// ─── Schema (read-only for share lookups) ────────────────
export const decks = pgTable('decks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
title: text('title').notNull(),
description: text('description'),
themeId: uuid('theme_id'),
isPublic: boolean('is_public').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const slides = pgTable(
'slides',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id').notNull(),
order: integer('order').default(0).notNull(),
content: jsonb('content'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index('slides_deck_order_idx').on(table.deckId, table.order)]
);
export const themes = pgTable('themes', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
colors: jsonb('colors'),
fonts: jsonb('fonts'),
isDefault: boolean('is_default').default(false),
});
export const sharedDecks = pgTable(
'shared_decks',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id').notNull(),
shareCode: text('share_code').notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index('shared_decks_deck_id_idx').on(table.deckId)]
);
export const decksRelations = relations(decks, ({ many }) => ({
slides: many(slides),
sharedDecks: many(sharedDecks),
}));
export const slidesRelations = relations(slides, ({ one }) => ({
deck: one(decks, { fields: [slides.deckId], references: [decks.id] }),
}));
export const sharedDecksRelations = relations(sharedDecks, ({ one }) => ({
deck: one(decks, { fields: [sharedDecks.deckId], references: [decks.id] }),
}));
export const db = drizzle(connection, {
schema: {
decks,
slides,
themes,
sharedDecks,
decksRelations,
slidesRelations,
sharedDecksRelations,
},
});
export type Database = typeof db;

View file

@ -0,0 +1,58 @@
/**
* Presi Server Hono + Bun
*
* Lightweight server for compute-only endpoints:
* - Share links (public deck viewing + link management)
* - Admin (GDPR compliance)
*
* All CRUD (decks, slides, themes) is handled client-side via local-first + sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { errorHandler, notFoundHandler } from '@manacore/shared-hono/error';
import { healthRoute } from '@manacore/shared-hono/health';
import { adminRoutes } from '@manacore/shared-hono/admin';
import { shareRoutes } from './routes/share';
import { db, decks, slides, sharedDecks } from './db';
const app = new Hono();
// Error handling
app.onError(errorHandler);
app.notFound(notFoundHandler);
// Middleware
app.use('*', logger());
app.use(
'*',
cors({
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:5178,http://localhost:5173').split(','),
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Authorization', 'Content-Type', 'X-Service-Key'],
credentials: true,
})
);
// Routes
app.route('/health', healthRoute('presi-server'));
app.route('/api/share', shareRoutes);
app.route(
'/api/v1/admin',
adminRoutes(db, [
{ table: sharedDecks, name: 'sharedDecks', userIdColumn: sharedDecks.deckId },
{ table: slides, name: 'slides', userIdColumn: slides.deckId },
{ table: decks, name: 'decks', userIdColumn: decks.userId },
])
);
// Start
const port = Number(process.env.PORT ?? 3008);
console.log(`Presi server (Hono + Bun) starting on port ${port}`);
export default {
port,
fetch: app.fetch,
};

View file

@ -0,0 +1,161 @@
/**
* Share routes public and authenticated share link management.
*
* Public: GET /share/:code view shared deck (no auth)
* Auth: POST /share/deck/:deckId create share link
* GET /share/deck/:deckId/links list share links
* DELETE /share/:shareId delete share link
*/
import { Hono } from 'hono';
import { eq, and, gt, or, isNull, asc } from 'drizzle-orm';
import { HTTPException } from 'hono/http-exception';
import { authMiddleware } from '@manacore/shared-hono/auth';
import { db, sharedDecks, decks, slides, themes } from '../db';
const shareRoutes = new Hono();
/** Generate a 12-character share code. */
function generateShareCode(): string {
const bytes = new Uint8Array(6);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ─── Public endpoint (no auth) ──────────────────────────
/** Get a shared deck by share code. */
shareRoutes.get('/: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 ────────────────────────────
shareRoutes.use('/deck/*', authMiddleware());
/** Create a share link for a deck. */
shareRoutes.post('/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(() => ({}));
const [share] = await db
.insert(sharedDecks)
.values({
deckId,
shareCode: generateShareCode(),
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
})
.returning();
return c.json(share, 201);
});
/** List share links for a deck. */
shareRoutes.get('/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);
});
/** Delete a share link. */
shareRoutes.delete('/:shareId', authMiddleware(), async (c) => {
const userId = c.get('userId');
const shareId = c.req.param('shareId');
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 { shareRoutes };

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src/**/*.ts"]
}

View file

@ -1,25 +1,25 @@
/**
* Presi API Client Share endpoints only.
*
* All CRUD (decks, slides, themes) is handled via local-first + mana-sync.
* This client only handles share links which require server-side state.
*/
import { browser } from '$app/environment';
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
import type {
Deck,
Slide,
CreateDeckDto,
UpdateDeckDto,
CreateSlideDto,
UpdateSlideDto,
ReorderSlidesDto,
} from '@presi/shared';
const BASE_URL = PUBLIC_BACKEND_URL || 'http://localhost:3008';
const API_URL = `${BASE_URL}/api`;
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Storage keys must match @manacore/shared-auth
const STORAGE_KEYS = {
APP_TOKEN: '@auth/appToken',
REFRESH_TOKEN: '@auth/refreshToken',
};
function getServerUrl(): string {
if (browser) {
const injected = (window as unknown as { __PUBLIC_PRESI_SERVER_URL__?: string })
.__PUBLIC_PRESI_SERVER_URL__;
if (injected) return injected;
}
return import.meta.env.PUBLIC_PRESI_SERVER_URL || 'http://localhost:3008';
}
function getToken(): string | null {
if (!browser) return null;
return localStorage.getItem(STORAGE_KEYS.APP_TOKEN);
@ -27,202 +27,16 @@ function getToken(): string | null {
async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
// Token expired - try to refresh
const refreshed = await refreshToken();
if (refreshed) {
// Retry the request with new token
const newToken = getToken();
if (newToken) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
}
return fetch(url, { ...options, headers });
}
// Clear tokens and redirect to login
if (browser) {
localStorage.removeItem(STORAGE_KEYS.APP_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
window.location.href = '/login';
}
}
return response;
return fetch(url, { ...options, headers });
}
async function refreshToken(): Promise<boolean> {
if (!browser) return false;
const storedRefreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
if (!storedRefreshToken) return false;
try {
const response = await fetch(`${AUTH_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: storedRefreshToken }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, data.accessToken);
if (data.refreshToken) {
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken);
}
return true;
}
} catch (e) {
console.error('Failed to refresh token:', e);
}
return false;
}
// Auth API (legacy - prefer using @manacore/shared-auth via auth store)
export const authApi = {
async login(email: string, password: string) {
const response = await fetch(`${AUTH_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
const data = await response.json();
if (browser) {
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, data.accessToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken);
}
return data;
},
async register(email: string, password: string) {
const response = await fetch(`${AUTH_URL}/api/v1/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Registration failed');
}
const data = await response.json();
if (browser) {
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, data.accessToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken);
}
return data;
},
logout() {
if (browser) {
localStorage.removeItem(STORAGE_KEYS.APP_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
}
},
isAuthenticated(): boolean {
if (!browser) return false;
return !!localStorage.getItem(STORAGE_KEYS.APP_TOKEN);
},
};
// Decks API
export const decksApi = {
async getAll(): Promise<Deck[]> {
const response = await fetchWithAuth(`${API_URL}/decks`);
if (!response.ok) throw new Error('Failed to fetch decks');
return response.json();
},
async getOne(id: string): Promise<{ deck: Deck; slides: Slide[] }> {
const response = await fetchWithAuth(`${API_URL}/decks/${id}`);
if (!response.ok) throw new Error('Failed to fetch deck');
return response.json();
},
async create(dto: CreateDeckDto): Promise<Deck> {
const response = await fetchWithAuth(`${API_URL}/decks`, {
method: 'POST',
body: JSON.stringify(dto),
});
if (!response.ok) throw new Error('Failed to create deck');
return response.json();
},
async update(id: string, dto: UpdateDeckDto): Promise<Deck> {
const response = await fetchWithAuth(`${API_URL}/decks/${id}`, {
method: 'PUT',
body: JSON.stringify(dto),
});
if (!response.ok) throw new Error('Failed to update deck');
return response.json();
},
async delete(id: string): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/decks/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete deck');
},
};
// Slides API
export const slidesApi = {
async create(deckId: string, dto: CreateSlideDto): Promise<Slide> {
const response = await fetchWithAuth(`${API_URL}/decks/${deckId}/slides`, {
method: 'POST',
body: JSON.stringify(dto),
});
if (!response.ok) throw new Error('Failed to create slide');
return response.json();
},
async update(id: string, dto: UpdateSlideDto): Promise<Slide> {
const response = await fetchWithAuth(`${API_URL}/slides/${id}`, {
method: 'PUT',
body: JSON.stringify(dto),
});
if (!response.ok) throw new Error('Failed to update slide');
return response.json();
},
async delete(id: string): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/slides/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete slide');
},
async reorder(dto: ReorderSlidesDto): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/slides/reorder`, {
method: 'PUT',
body: JSON.stringify(dto),
});
if (!response.ok) throw new Error('Failed to reorder slides');
},
};
// Share API
export interface ShareLink {
id: string;
@ -233,9 +47,9 @@ export interface ShareLink {
}
export const shareApi = {
// Public - no auth required
/** Public — view a shared deck by share code (no auth required). */
async getByCode(code: string): Promise<{ deck: any; slides: any[] }> {
const response = await fetch(`${API_URL}/share/${code}`);
const response = await fetch(`${getServerUrl()}/api/share/${code}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Shared deck not found or link has expired');
@ -245,9 +59,9 @@ export const shareApi = {
return response.json();
},
// Authenticated endpoints
/** Create a share link for a deck. */
async createShare(deckId: string, expiresAt?: string): Promise<ShareLink> {
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}`, {
const response = await fetchWithAuth(`${getServerUrl()}/api/share/deck/${deckId}`, {
method: 'POST',
body: JSON.stringify({ expiresAt }),
});
@ -255,14 +69,16 @@ export const shareApi = {
return response.json();
},
/** List share links for a deck. */
async getSharesForDeck(deckId: string): Promise<ShareLink[]> {
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}/links`);
const response = await fetchWithAuth(`${getServerUrl()}/api/share/deck/${deckId}/links`);
if (!response.ok) throw new Error('Failed to get share links');
return response.json();
},
/** Delete a share link. */
async deleteShare(shareId: string): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/share/${shareId}`, {
const response = await fetchWithAuth(`${getServerUrl()}/api/share/${shareId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete share link');