mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
5c33962439
commit
dd2f814cf3
62 changed files with 393 additions and 3038 deletions
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
|
|
@ -61,7 +61,6 @@ jobs:
|
|||
clock-web: ${{ steps.changes.outputs.clock-web }}
|
||||
contacts-backend: ${{ steps.changes.outputs.contacts-backend }}
|
||||
contacts-web: ${{ steps.changes.outputs.contacts-web }}
|
||||
presi-backend: ${{ steps.changes.outputs.presi-backend }}
|
||||
presi-web: ${{ steps.changes.outputs.presi-web }}
|
||||
storage-backend: ${{ steps.changes.outputs.storage-backend }}
|
||||
storage-web: ${{ steps.changes.outputs.storage-web }}
|
||||
|
|
@ -96,7 +95,6 @@ jobs:
|
|||
echo "clock-web=true" >> $GITHUB_OUTPUT
|
||||
echo "contacts-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "contacts-web=true" >> $GITHUB_OUTPUT
|
||||
echo "presi-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "presi-web=true" >> $GITHUB_OUTPUT
|
||||
echo "storage-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "storage-web=true" >> $GITHUB_OUTPUT
|
||||
|
|
@ -135,7 +133,6 @@ jobs:
|
|||
echo "clock-web=true" >> $GITHUB_OUTPUT
|
||||
echo "contacts-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "contacts-web=true" >> $GITHUB_OUTPUT
|
||||
echo "presi-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "presi-web=true" >> $GITHUB_OUTPUT
|
||||
echo "storage-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "storage-web=true" >> $GITHUB_OUTPUT
|
||||
|
|
@ -275,13 +272,7 @@ jobs:
|
|||
echo "contacts-web=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# presi-backend
|
||||
PRESI_BACKEND_CHANGED=$(check_pattern "apps/presi/apps/backend/|apps/presi/packages/")
|
||||
if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$PRESI_BACKEND_CHANGED" == "true" ]; then
|
||||
echo "presi-backend=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "presi-backend=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
# presi-backend: REMOVED — replaced by Hono server
|
||||
|
||||
# presi-web
|
||||
PRESI_WEB_CHANGED=$(check_pattern "apps/presi/apps/web/|apps/presi/packages/")
|
||||
|
|
@ -383,7 +374,7 @@ jobs:
|
|||
echo "| clock-web | ${{ steps.changes.outputs.clock-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| contacts-backend | ${{ steps.changes.outputs.contacts-backend }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| contacts-web | ${{ steps.changes.outputs.contacts-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| presi-backend | ${{ steps.changes.outputs.presi-backend }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| presi-backend | removed |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| presi-web | ${{ steps.changes.outputs.presi-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| storage-backend | ${{ steps.changes.outputs.storage-backend }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| storage-web | ${{ steps.changes.outputs.storage-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
|
@ -808,34 +799,7 @@ jobs:
|
|||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-presi-backend:
|
||||
name: Build presi-backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.presi-backend == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/presi-backend
|
||||
tags: type=raw,value=latest
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: apps/presi/apps/backend/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# build-presi-backend: REMOVED — replaced by Hono server
|
||||
|
||||
build-presi-web:
|
||||
name: Build presi-web
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import 'dotenv/config';
|
||||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({ dbName: 'presi' });
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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),
|
||||
}));
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './decks.schema';
|
||||
export * from './slides.schema';
|
||||
export * from './themes.schema';
|
||||
export * from './shared-decks.schema';
|
||||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
@ -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),
|
||||
}));
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { IsOptional, IsDateString } from 'class-validator';
|
||||
|
||||
export class CreateShareDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
22
apps/presi/apps/server/package.json
Normal file
22
apps/presi/apps/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
98
apps/presi/apps/server/src/db/index.ts
Normal file
98
apps/presi/apps/server/src/db/index.ts
Normal 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;
|
||||
58
apps/presi/apps/server/src/index.ts
Normal file
58
apps/presi/apps/server/src/index.ts
Normal 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,
|
||||
};
|
||||
161
apps/presi/apps/server/src/routes/share.ts
Normal file
161
apps/presi/apps/server/src/routes/share.ts
Normal 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 };
|
||||
12
apps/presi/apps/server/tsconfig.json
Normal file
12
apps/presi/apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ services:
|
|||
CALENDAR_BACKEND_URL: http://calendar-backend:3032
|
||||
CONTACTS_BACKEND_URL: http://contacts-backend:3034
|
||||
PICTURE_BACKEND_URL: http://picture-backend:3040
|
||||
PRESI_BACKEND_URL: http://presi-backend:3036
|
||||
# PRESI_BACKEND_URL: removed — replaced by Hono server
|
||||
# ZITARE_BACKEND_URL: removed — migrated to local-first
|
||||
PHOTOS_BACKEND_URL: http://photos-backend:3039
|
||||
# CLOCK_BACKEND_URL: removed — migrated to local-first
|
||||
|
|
@ -688,32 +688,8 @@ services:
|
|||
retries: 3
|
||||
start_period: 50s
|
||||
|
||||
presi-backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/presi/apps/backend/Dockerfile
|
||||
image: presi-backend:local
|
||||
container_name: mana-app-presi-backend
|
||||
restart: always
|
||||
depends_on:
|
||||
mana-auth:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3036
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/presi
|
||||
MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
CORS_ORIGINS: https://presi.mana.how,https://mana.how
|
||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||
GLITCHTIP_DSN: http://24df6aad72b646ba9fb68e54b566ad3e@glitchtip:8020/14
|
||||
ports:
|
||||
- "3036:3036"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3036/api/v1/health"]
|
||||
interval: 120s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 55s
|
||||
# presi-backend: REPLACED by lightweight Hono server (apps/presi/apps/server)
|
||||
# TODO: Add presi-server container when Bun Docker image is ready
|
||||
|
||||
manadeck-backend:
|
||||
build:
|
||||
|
|
@ -1329,15 +1305,14 @@ services:
|
|||
container_name: mana-app-presi-web
|
||||
restart: always
|
||||
depends_on:
|
||||
presi-backend:
|
||||
mana-auth:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 5016
|
||||
PUBLIC_BACKEND_URL: http://presi-backend:3036
|
||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
PUBLIC_BACKEND_URL_CLIENT: https://presi-api.mana.how
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
||||
PUBLIC_SYNC_SERVER_URL: ws://mana-sync:3050
|
||||
ports:
|
||||
- "5016:5016"
|
||||
healthcheck:
|
||||
|
|
|
|||
|
|
@ -93,12 +93,7 @@ scrape_configs:
|
|||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
|
||||
# Presi Backend
|
||||
- job_name: 'presi-backend'
|
||||
static_configs:
|
||||
- targets: ['presi-backend:3036']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
# Presi Backend: REMOVED — replaced by Hono server
|
||||
|
||||
# Nutriphi Backend
|
||||
- job_name: 'nutriphi-backend'
|
||||
|
|
|
|||
20
package.json
20
package.json
|
|
@ -200,12 +200,9 @@
|
|||
"presi:dev": "turbo run dev --filter=presi...",
|
||||
"dev:presi:web": "pnpm --filter @presi/web dev",
|
||||
"dev:presi:landing": "pnpm --filter @presi/landing dev",
|
||||
"dev:presi:backend": "pnpm --filter @presi/backend dev",
|
||||
"dev:presi:app": "turbo run dev --filter=@presi/web --filter=@presi/backend",
|
||||
"dev:presi:full": "./scripts/setup-databases.sh presi && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:presi:backend\" \"pnpm dev:presi:web\"",
|
||||
"presi:db:push": "pnpm --filter @presi/backend db:push",
|
||||
"presi:db:studio": "pnpm --filter @presi/backend db:studio",
|
||||
"presi:db:seed": "pnpm --filter @presi/backend db:seed",
|
||||
"dev:presi:server": "cd apps/presi/apps/server && bun run --watch src/index.ts",
|
||||
"dev:presi:app": "pnpm dev:presi:web",
|
||||
"dev:presi:full": "concurrently -n auth,sync,server,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:presi:server\" \"pnpm dev:presi:web\"",
|
||||
"storage:dev": "turbo run dev --filter=storage...",
|
||||
"dev:storage:web": "pnpm --filter @storage/web dev",
|
||||
"dev:storage:backend": "pnpm --filter @storage/backend dev",
|
||||
|
|
@ -260,14 +257,9 @@
|
|||
"cf:login": "npx wrangler login",
|
||||
"cf:projects:list": "npx wrangler pages project list",
|
||||
"cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main",
|
||||
"dev:search": "pnpm --filter @manacore/mana-search dev",
|
||||
"dev:search:docker": "docker-compose -f services/mana-search/docker-compose.dev.yml up -d",
|
||||
"dev:search:docker:down": "docker-compose -f services/mana-search/docker-compose.dev.yml down",
|
||||
"dev:search:docker:logs": "docker-compose -f services/mana-search/docker-compose.dev.yml logs -f",
|
||||
"dev:search:full": "docker-compose -f services/mana-search/docker-compose.dev.yml up -d && pnpm --filter @manacore/mana-search dev",
|
||||
"search:docker:up": "docker-compose -f services/mana-search/docker-compose.yml up -d",
|
||||
"search:docker:down": "docker-compose -f services/mana-search/docker-compose.yml down",
|
||||
"search:docker:logs": "docker-compose -f services/mana-search/docker-compose.yml logs -f",
|
||||
"dev:search": "cd services/mana-search-go && go run ./cmd/server",
|
||||
"dev:crawler": "cd services/mana-crawler-go && go run ./cmd/server",
|
||||
"dev:notify": "cd services/mana-notify-go && go run ./cmd/server",
|
||||
"questions:dev": "turbo run dev --filter=questions...",
|
||||
"dev:questions:backend": "pnpm --filter @questions/backend dev",
|
||||
"dev:questions:web": "pnpm --filter @questions/web dev",
|
||||
|
|
|
|||
|
|
@ -270,20 +270,7 @@ const APP_CONFIGS = [
|
|||
},
|
||||
},
|
||||
|
||||
// Presi Backend (NestJS)
|
||||
{
|
||||
path: 'apps/presi/apps/backend/.env',
|
||||
vars: {
|
||||
NODE_ENV: () => 'development',
|
||||
PORT: (env) => env.PRESI_BACKEND_PORT || '3008',
|
||||
DATABASE_URL: (env) => env.PRESI_DATABASE_URL,
|
||||
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
DEV_BYPASS_AUTH: () => 'true',
|
||||
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
|
||||
JWT_PUBLIC_KEY: (env) => env.JWT_PUBLIC_KEY,
|
||||
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
|
||||
},
|
||||
},
|
||||
// Presi Backend: REMOVED — replaced by Hono server (apps/presi/apps/server)
|
||||
|
||||
// Presi Web (SvelteKit)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -159,7 +159,6 @@ for container in $ALL_PROBLEM_CONTAINERS; do
|
|||
mana-app-storage-web) SERVICE_NAME="storage-web" ;;
|
||||
mana-app-storage-backend) SERVICE_NAME="storage-backend" ;;
|
||||
mana-app-presi-web) SERVICE_NAME="presi-web" ;;
|
||||
mana-app-presi-backend) SERVICE_NAME="presi-backend" ;;
|
||||
mana-app-nutriphi-web) SERVICE_NAME="nutriphi-web" ;;
|
||||
mana-app-nutriphi-backend) SERVICE_NAME="nutriphi-backend" ;;
|
||||
mana-app-skilltree-web) SERVICE_NAME="skilltree-web" ;;
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ setup_service() {
|
|||
;;
|
||||
presi)
|
||||
create_db_if_not_exists "presi"
|
||||
push_schema "@presi/backend" "presi"
|
||||
# Schema managed by mana-sync (NestJS backend removed, Hono server for shares)
|
||||
;;
|
||||
storage)
|
||||
create_db_if_not_exists "storage"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue