diff --git a/apps/photos/apps/backend/Dockerfile b/apps/photos/apps/backend/Dockerfile deleted file mode 100644 index a7c7eac04..000000000 --- a/apps/photos/apps/backend/Dockerfile +++ /dev/null @@ -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-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-error-tracking ./packages/shared-error-tracking -COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup -COPY packages/shared-tsconfig ./packages/shared-tsconfig -COPY packages/shared-drizzle-config ./packages/shared-drizzle-config - -# Copy photos shared package -COPY apps/photos/packages/shared ./apps/photos/packages/shared - -# Copy photos backend -COPY apps/photos/apps/backend ./apps/photos/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-error-tracking -RUN pnpm build - -WORKDIR /app/apps/photos/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/photos ./apps/photos - -# Copy entrypoint script -COPY apps/photos/apps/backend/docker-entrypoint.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/docker-entrypoint.sh - -WORKDIR /app/packages/shared-error-tracking -RUN pnpm build - -WORKDIR /app/apps/photos/apps/backend - -# Expose port -EXPOSE 3039 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3039/api/v1/health || exit 1 - -# Run entrypoint script -ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["node", "dist/src/main.js"] diff --git a/apps/photos/apps/backend/docker-entrypoint.sh b/apps/photos/apps/backend/docker-entrypoint.sh deleted file mode 100644 index 180680236..000000000 --- a/apps/photos/apps/backend/docker-entrypoint.sh +++ /dev/null @@ -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 Photos Backend..." -exec "$@" diff --git a/apps/photos/apps/backend/drizzle.config.ts b/apps/photos/apps/backend/drizzle.config.ts deleted file mode 100644 index 078eb2e00..000000000 --- a/apps/photos/apps/backend/drizzle.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; - -export default createDrizzleConfig({ - dbName: 'photos', - outDir: './drizzle', -}); diff --git a/apps/photos/apps/backend/nest-cli.json b/apps/photos/apps/backend/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/apps/photos/apps/backend/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/apps/photos/apps/backend/package.json b/apps/photos/apps/backend/package.json deleted file mode 100644 index 499be31f9..000000000 --- a/apps/photos/apps/backend/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@photos/backend", - "version": "0.2.0", - "private": true, - "description": "Photos Backend API", - "scripts": { - "dev": "nest start --watch", - "build": "nest build", - "start": "nest start", - "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", - "db:generate": "drizzle-kit generate" - }, - "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", - "@manacore/shared-nestjs-auth": "workspace:*", - "@manacore/shared-nestjs-health": "workspace:*", - "@manacore/shared-nestjs-metrics": "workspace:*", - "@nestjs/common": "^10.4.9", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.9", - "@nestjs/platform-express": "^10.4.9", - "@photos/shared": "workspace:*", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "dotenv": "^16.4.7", - "drizzle-orm": "^0.38.3", - "postgres": "^3.4.5", - "prom-client": "^15.1.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "@manacore/shared-drizzle-config": "workspace:*", - "@nestjs/cli": "^10.4.9", - "@nestjs/schematics": "^10.2.3", - "@types/express": "^5.0.1", - "@types/node": "^22.15.21", - "drizzle-kit": "^0.30.2", - "tsx": "^4.19.4", - "typescript": "^5.9.3" - } -} diff --git a/apps/photos/apps/backend/src/admin/admin.controller.ts b/apps/photos/apps/backend/src/admin/admin.controller.ts deleted file mode 100644 index d57fa7127..000000000 --- a/apps/photos/apps/backend/src/admin/admin.controller.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - Controller, - Get, - Delete, - Param, - UseGuards, - Logger, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { AdminService } from './admin.service'; -import { ServiceAuthGuard } from './guards/service-auth.guard'; -import { UserDataResponse, DeleteUserDataResponse } from './dto/user-data-response.dto'; - -/** - * Admin controller for user data queries - * Used by mana-core-auth aggregation service - * Protected by X-Service-Key authentication - */ -@Controller('admin') -@UseGuards(ServiceAuthGuard) -export class AdminController { - private readonly logger = new Logger(AdminController.name); - - constructor(private readonly adminService: AdminService) {} - - /** - * Get user data counts for a specific user - * GET /api/v1/admin/user-data/:userId - */ - @Get('user-data/:userId') - async getUserData(@Param('userId') userId: string): Promise { - this.logger.log(`Admin request: getUserData for userId=${userId}`); - return this.adminService.getUserData(userId); - } - - /** - * Delete all user data (GDPR right to be forgotten) - * DELETE /api/v1/admin/user-data/:userId - */ - @Delete('user-data/:userId') - @HttpCode(HttpStatus.OK) - async deleteUserData(@Param('userId') userId: string): Promise { - this.logger.log(`Admin request: deleteUserData for userId=${userId}`); - return this.adminService.deleteUserData(userId); - } -} diff --git a/apps/photos/apps/backend/src/admin/admin.module.ts b/apps/photos/apps/backend/src/admin/admin.module.ts deleted file mode 100644 index a8f6ed50c..000000000 --- a/apps/photos/apps/backend/src/admin/admin.module.ts +++ /dev/null @@ -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 { DatabaseModule } from '../db/database.module'; - -@Module({ - imports: [ConfigModule, DatabaseModule], - controllers: [AdminController], - providers: [AdminService], -}) -export class AdminModule {} diff --git a/apps/photos/apps/backend/src/admin/admin.service.ts b/apps/photos/apps/backend/src/admin/admin.service.ts deleted file mode 100644 index 86e73be42..000000000 --- a/apps/photos/apps/backend/src/admin/admin.service.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Inject } from '@nestjs/common'; -import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import { eq, sql, desc } from 'drizzle-orm'; -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: PostgresJsDatabase - ) {} - - /** - * Get user data counts for a specific user - */ - async getUserData(userId: string): Promise { - this.logger.log(`Getting user data for userId: ${userId}`); - - // Count albums - const albumsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.albums) - .where(eq(schema.albums.userId, userId)); - const albumsCount = albumsResult[0]?.count ?? 0; - - // Count album items (through albums) - const albumItemsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.albumItems) - .innerJoin(schema.albums, eq(schema.albumItems.albumId, schema.albums.id)) - .where(eq(schema.albums.userId, userId)); - const albumItemsCount = albumItemsResult[0]?.count ?? 0; - - // Count favorites - const favoritesResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.favorites) - .where(eq(schema.favorites.userId, userId)); - const favoritesCount = favoritesResult[0]?.count ?? 0; - - // Count tags - const tagsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.tags) - .where(eq(schema.tags.userId, userId)); - const tagsCount = tagsResult[0]?.count ?? 0; - - // Count photo tags (through tags) - const photoTagsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.photoTags) - .innerJoin(schema.tags, eq(schema.photoTags.tagId, schema.tags.id)) - .where(eq(schema.tags.userId, userId)); - const photoTagsCount = photoTagsResult[0]?.count ?? 0; - - // Get last activity (most recent album update) - const lastAlbum = await this.db - .select({ updatedAt: schema.albums.updatedAt }) - .from(schema.albums) - .where(eq(schema.albums.userId, userId)) - .orderBy(desc(schema.albums.updatedAt)) - .limit(1); - const lastActivityAt = lastAlbum[0]?.updatedAt?.toISOString(); - - const entities: EntityCount[] = [ - { entity: 'albums', count: albumsCount, label: 'Alben' }, - { entity: 'album_items', count: albumItemsCount, label: 'Album-EintrΓ€ge' }, - { entity: 'favorites', count: favoritesCount, label: 'Favoriten' }, - { entity: 'tags', count: tagsCount, label: 'Tags' }, - { entity: 'photo_tags', count: photoTagsCount, label: 'Foto-Tags' }, - ]; - - const totalCount = albumsCount + albumItemsCount + favoritesCount + tagsCount + photoTagsCount; - - return { - entities, - totalCount, - lastActivityAt, - }; - } - - /** - * Delete all user data (GDPR right to be forgotten) - */ - async deleteUserData(userId: string): Promise { - this.logger.log(`Deleting user data for userId: ${userId}`); - - const deletedCounts: EntityCount[] = []; - let totalDeleted = 0; - - // Delete favorites - const deletedFavorites = await this.db - .delete(schema.favorites) - .where(eq(schema.favorites.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'favorites', - count: deletedFavorites.length, - label: 'Favoriten', - }); - totalDeleted += deletedFavorites.length; - - // Delete tags (cascades to photo_tags) - const deletedTags = await this.db - .delete(schema.tags) - .where(eq(schema.tags.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'tags', - count: deletedTags.length, - label: 'Tags', - }); - totalDeleted += deletedTags.length; - - // Delete albums (cascades to album_items) - const deletedAlbums = await this.db - .delete(schema.albums) - .where(eq(schema.albums.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'albums', - count: deletedAlbums.length, - label: 'Alben', - }); - totalDeleted += deletedAlbums.length; - - this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); - - return { - success: true, - deletedCounts, - totalDeleted, - }; - } -} diff --git a/apps/photos/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/photos/apps/backend/src/admin/dto/user-data-response.dto.ts deleted file mode 100644 index 562a2eb6d..000000000 --- a/apps/photos/apps/backend/src/admin/dto/user-data-response.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/photos/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/photos/apps/backend/src/admin/guards/service-auth.guard.ts deleted file mode 100644 index 81b60d0a4..000000000 --- a/apps/photos/apps/backend/src/admin/guards/service-auth.guard.ts +++ /dev/null @@ -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('ADMIN_SERVICE_KEY', 'dev-admin-key'); - } - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - 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; - } -} diff --git a/apps/photos/apps/backend/src/album/album.controller.ts b/apps/photos/apps/backend/src/album/album.controller.ts deleted file mode 100644 index 7556b7cf9..000000000 --- a/apps/photos/apps/backend/src/album/album.controller.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - Controller, - Get, - Post, - Patch, - Delete, - Param, - Body, - UseGuards, - NotFoundException, -} from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { AlbumService } from './album.service'; -import { CreateAlbumDto, UpdateAlbumDto, AddItemsDto } from './dto'; - -@Controller('albums') -@UseGuards(JwtAuthGuard) -export class AlbumController { - constructor(private albumService: AlbumService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - return this.albumService.findAll(user.userId); - } - - @Get(':id') - async findById(@Param('id') id: string, @CurrentUser() user: CurrentUserData) { - const album = await this.albumService.findById(id, user.userId); - if (!album) { - throw new NotFoundException('Album not found'); - } - return album; - } - - @Post() - async create(@Body() dto: CreateAlbumDto, @CurrentUser() user: CurrentUserData) { - return this.albumService.create(user.userId, dto); - } - - @Patch(':id') - async update( - @Param('id') id: string, - @Body() dto: UpdateAlbumDto, - @CurrentUser() user: CurrentUserData - ) { - const album = await this.albumService.update(id, user.userId, dto); - if (!album) { - throw new NotFoundException('Album not found'); - } - return album; - } - - @Delete(':id') - async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) { - await this.albumService.delete(id, user.userId); - return { success: true }; - } - - @Post(':id/items') - async addItems( - @Param('id') id: string, - @Body() dto: AddItemsDto, - @CurrentUser() user: CurrentUserData - ) { - await this.albumService.addItems(id, user.userId, dto.mediaIds); - return { success: true }; - } - - @Delete(':id/items/:mediaId') - async removeItem( - @Param('id') id: string, - @Param('mediaId') mediaId: string, - @CurrentUser() user: CurrentUserData - ) { - await this.albumService.removeItem(id, user.userId, mediaId); - return { success: true }; - } - - @Patch(':id/cover') - async setCover( - @Param('id') id: string, - @Body() dto: { mediaId: string }, - @CurrentUser() user: CurrentUserData - ) { - const album = await this.albumService.setCover(id, user.userId, dto.mediaId); - if (!album) { - throw new NotFoundException('Album not found'); - } - return album; - } -} diff --git a/apps/photos/apps/backend/src/album/album.module.ts b/apps/photos/apps/backend/src/album/album.module.ts deleted file mode 100644 index 3b6938856..000000000 --- a/apps/photos/apps/backend/src/album/album.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AlbumController } from './album.controller'; -import { AlbumService } from './album.service'; - -@Module({ - controllers: [AlbumController], - providers: [AlbumService], - exports: [AlbumService], -}) -export class AlbumModule {} diff --git a/apps/photos/apps/backend/src/album/album.service.ts b/apps/photos/apps/backend/src/album/album.service.ts deleted file mode 100644 index 0254a60a4..000000000 --- a/apps/photos/apps/backend/src/album/album.service.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, desc } from 'drizzle-orm'; -import { DATABASE_CONNECTION, Database } from '../db/database.module'; -import { albums, albumItems, type Album, type NewAlbum, type AlbumItem } from '../db/schema'; - -export interface AlbumWithItems extends Album { - items: AlbumItem[]; - itemCount: number; -} - -@Injectable() -export class AlbumService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findAll(userId: string): Promise { - return this.db - .select() - .from(albums) - .where(eq(albums.userId, userId)) - .orderBy(albums.sortOrder, albums.createdAt); - } - - async findById(id: string, userId: string): Promise { - const [album] = await this.db - .select() - .from(albums) - .where(and(eq(albums.id, id), eq(albums.userId, userId))) - .limit(1); - - if (!album) return null; - - const items = await this.db - .select() - .from(albumItems) - .where(eq(albumItems.albumId, id)) - .orderBy(albumItems.sortOrder, albumItems.addedAt); - - return { - ...album, - items, - itemCount: items.length, - }; - } - - async create(userId: string, data: Omit): Promise { - const [album] = await this.db - .insert(albums) - .values({ - ...data, - userId, - }) - .returning(); - return album; - } - - async update(id: string, userId: string, data: Partial): Promise { - const [updated] = await this.db - .update(albums) - .set({ - ...data, - updatedAt: new Date(), - }) - .where(and(eq(albums.id, id), eq(albums.userId, userId))) - .returning(); - return updated || null; - } - - async delete(id: string, userId: string): Promise { - await this.db.delete(albums).where(and(eq(albums.id, id), eq(albums.userId, userId))); - } - - async addItems(albumId: string, userId: string, mediaIds: string[]): Promise { - const [album] = await this.db - .select() - .from(albums) - .where(and(eq(albums.id, albumId), eq(albums.userId, userId))) - .limit(1); - - if (!album) { - throw new NotFoundException('Album not found'); - } - - const existingItems = await this.db - .select() - .from(albumItems) - .where(eq(albumItems.albumId, albumId)); - - const existingMediaIds = new Set(existingItems.map((i) => i.mediaId)); - const newMediaIds = mediaIds.filter((id) => !existingMediaIds.has(id)); - - if (newMediaIds.length > 0) { - const maxOrder = existingItems.length; - await this.db.insert(albumItems).values( - newMediaIds.map((mediaId, index) => ({ - albumId, - mediaId, - sortOrder: maxOrder + index, - })) - ); - } - } - - async removeItem(albumId: string, userId: string, mediaId: string): Promise { - const [album] = await this.db - .select() - .from(albums) - .where(and(eq(albums.id, albumId), eq(albums.userId, userId))) - .limit(1); - - if (!album) { - throw new NotFoundException('Album not found'); - } - - await this.db - .delete(albumItems) - .where(and(eq(albumItems.albumId, albumId), eq(albumItems.mediaId, mediaId))); - } - - async setCover(albumId: string, userId: string, mediaId: string): Promise { - return this.update(albumId, userId, { coverMediaId: mediaId }); - } - - async getAlbumsForMedia(userId: string, mediaId: string): Promise { - const items = await this.db - .select({ albumId: albumItems.albumId }) - .from(albumItems) - .innerJoin(albums, eq(albumItems.albumId, albums.id)) - .where(and(eq(albumItems.mediaId, mediaId), eq(albums.userId, userId))); - - if (items.length === 0) return []; - - const albumIds = items.map((i) => i.albumId); - return this.db - .select() - .from(albums) - .where(and(eq(albums.userId, userId))); - } -} diff --git a/apps/photos/apps/backend/src/album/dto/index.ts b/apps/photos/apps/backend/src/album/dto/index.ts deleted file mode 100644 index 9b9ae54f2..000000000 --- a/apps/photos/apps/backend/src/album/dto/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { IsString, IsOptional, IsArray, MaxLength, IsBoolean } from 'class-validator'; - -export class CreateAlbumDto { - @IsString() - @MaxLength(255) - name: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsString() - coverMediaId?: string; -} - -export class UpdateAlbumDto { - @IsOptional() - @IsString() - @MaxLength(255) - name?: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsString() - coverMediaId?: string; - - @IsOptional() - @IsBoolean() - isAutoGenerated?: boolean; -} - -export class AddItemsDto { - @IsArray() - @IsString({ each: true }) - mediaIds: string[]; -} diff --git a/apps/photos/apps/backend/src/app.module.ts b/apps/photos/apps/backend/src/app.module.ts deleted file mode 100644 index c8f3da6b7..000000000 --- a/apps/photos/apps/backend/src/app.module.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthModule } from '@manacore/shared-nestjs-health'; -import { MetricsModule } from '@manacore/shared-nestjs-metrics'; -import { DatabaseModule } from './db/database.module'; -import { AlbumModule } from './album/album.module'; -import { FavoriteModule } from './favorite/favorite.module'; -import { TagModule } from './tag/tag.module'; -import { PhotoModule } from './photo/photo.module'; -import { AdminModule } from './admin/admin.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: '.env', - }), - HealthModule.forRoot({ serviceName: 'photos-backend' }), - MetricsModule.register({ - prefix: 'photos_', - excludePaths: ['/health'], - }), - DatabaseModule, - AlbumModule, - FavoriteModule, - TagModule, - PhotoModule, - AdminModule, - ], -}) -export class AppModule {} diff --git a/apps/photos/apps/backend/src/db/database.module.ts b/apps/photos/apps/backend/src/db/database.module.ts deleted file mode 100644 index 8141c403e..000000000 --- a/apps/photos/apps/backend/src/db/database.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import * as schema from './schema'; - -export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; - -export type Database = PostgresJsDatabase; - -@Global() -@Module({ - providers: [ - { - provide: DATABASE_CONNECTION, - useFactory: () => { - const connectionString = process.env.DATABASE_URL; - if (!connectionString) { - throw new Error('DATABASE_URL environment variable is not set'); - } - const client = postgres(connectionString); - return drizzle(client, { schema }); - }, - }, - ], - exports: [DATABASE_CONNECTION], -}) -export class DatabaseModule {} diff --git a/apps/photos/apps/backend/src/db/schema/albums.schema.ts b/apps/photos/apps/backend/src/db/schema/albums.schema.ts deleted file mode 100644 index fde3e21dc..000000000 --- a/apps/photos/apps/backend/src/db/schema/albums.schema.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - pgTable, - uuid, - text, - varchar, - boolean, - integer, - timestamp, - index, -} from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; - -export const albums = pgTable( - 'albums', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - name: varchar('name', { length: 255 }).notNull(), - description: text('description'), - coverMediaId: text('cover_media_id'), - isAutoGenerated: boolean('is_auto_generated').default(false).notNull(), - autoGenerateType: text('auto_generate_type'), - autoGenerateValue: text('auto_generate_value'), - sortOrder: integer('sort_order').default(0).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [index('albums_user_id_idx').on(table.userId)] -); - -export const albumItems = pgTable( - 'album_items', - { - id: uuid('id').primaryKey().defaultRandom(), - albumId: uuid('album_id') - .references(() => albums.id, { onDelete: 'cascade' }) - .notNull(), - mediaId: text('media_id').notNull(), - sortOrder: integer('sort_order').default(0).notNull(), - addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - index('album_items_album_id_idx').on(table.albumId), - index('album_items_media_id_idx').on(table.mediaId), - ] -); - -export const albumsRelations = relations(albums, ({ many }) => ({ - items: many(albumItems), -})); - -export const albumItemsRelations = relations(albumItems, ({ one }) => ({ - album: one(albums, { - fields: [albumItems.albumId], - references: [albums.id], - }), -})); - -export type Album = typeof albums.$inferSelect; -export type NewAlbum = typeof albums.$inferInsert; -export type AlbumItem = typeof albumItems.$inferSelect; -export type NewAlbumItem = typeof albumItems.$inferInsert; diff --git a/apps/photos/apps/backend/src/db/schema/favorites.schema.ts b/apps/photos/apps/backend/src/db/schema/favorites.schema.ts deleted file mode 100644 index 3a5375ab6..000000000 --- a/apps/photos/apps/backend/src/db/schema/favorites.schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { pgTable, uuid, text, timestamp, index, unique } from 'drizzle-orm/pg-core'; - -export const favorites = pgTable( - 'favorites', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - mediaId: text('media_id').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - index('favorites_user_id_idx').on(table.userId), - index('favorites_media_id_idx').on(table.mediaId), - unique('favorites_user_media_unique').on(table.userId, table.mediaId), - ] -); - -export type Favorite = typeof favorites.$inferSelect; -export type NewFavorite = typeof favorites.$inferInsert; diff --git a/apps/photos/apps/backend/src/db/schema/index.ts b/apps/photos/apps/backend/src/db/schema/index.ts deleted file mode 100644 index 1a177d6b6..000000000 --- a/apps/photos/apps/backend/src/db/schema/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './albums.schema'; -export * from './favorites.schema'; -export * from './tags.schema'; diff --git a/apps/photos/apps/backend/src/db/schema/tags.schema.ts b/apps/photos/apps/backend/src/db/schema/tags.schema.ts deleted file mode 100644 index f8a7938ed..000000000 --- a/apps/photos/apps/backend/src/db/schema/tags.schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { pgTable, uuid, text, varchar, timestamp, index, primaryKey } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; - -export const tags = pgTable( - 'tags', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - name: varchar('name', { length: 50 }).notNull(), - color: varchar('color', { length: 20 }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [index('tags_user_id_idx').on(table.userId)] -); - -export const photoTags = pgTable( - 'photo_tags', - { - mediaId: text('media_id').notNull(), - tagId: uuid('tag_id') - .references(() => tags.id, { onDelete: 'cascade' }) - .notNull(), - addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - pk: primaryKey({ columns: [table.mediaId, table.tagId] }), - }) -); - -export const tagsRelations = relations(tags, ({ many }) => ({ - photoTags: many(photoTags), -})); - -export const photoTagsRelations = relations(photoTags, ({ one }) => ({ - tag: one(tags, { - fields: [photoTags.tagId], - references: [tags.id], - }), -})); - -export type Tag = typeof tags.$inferSelect; -export type NewTag = typeof tags.$inferInsert; -export type PhotoTag = typeof photoTags.$inferSelect; -export type NewPhotoTag = typeof photoTags.$inferInsert; diff --git a/apps/photos/apps/backend/src/favorite/favorite.controller.ts b/apps/photos/apps/backend/src/favorite/favorite.controller.ts deleted file mode 100644 index 688f56d9e..000000000 --- a/apps/photos/apps/backend/src/favorite/favorite.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Controller, Get, Post, Delete, Param, Query, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { FavoriteService } from './favorite.service'; - -@Controller('favorites') -@UseGuards(JwtAuthGuard) -export class FavoriteController { - constructor(private favoriteService: FavoriteService) {} - - @Get() - async findAll( - @CurrentUser() user: CurrentUserData, - @Query('limit') limit?: string, - @Query('offset') offset?: string - ) { - return this.favoriteService.findAll( - user.userId, - limit ? parseInt(limit) : 50, - offset ? parseInt(offset) : 0 - ); - } - - @Get(':mediaId/status') - async getStatus(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) { - const isFavorited = await this.favoriteService.isFavorited(user.userId, mediaId); - return { isFavorited }; - } - - @Post(':mediaId') - async add(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) { - await this.favoriteService.add(user.userId, mediaId); - return { success: true, isFavorited: true }; - } - - @Delete(':mediaId') - async remove(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) { - await this.favoriteService.remove(user.userId, mediaId); - return { success: true, isFavorited: false }; - } - - @Post(':mediaId/toggle') - async toggle(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) { - return this.favoriteService.toggle(user.userId, mediaId); - } -} diff --git a/apps/photos/apps/backend/src/favorite/favorite.module.ts b/apps/photos/apps/backend/src/favorite/favorite.module.ts deleted file mode 100644 index 8c07b7591..000000000 --- a/apps/photos/apps/backend/src/favorite/favorite.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { FavoriteController } from './favorite.controller'; -import { FavoriteService } from './favorite.service'; - -@Module({ - controllers: [FavoriteController], - providers: [FavoriteService], - exports: [FavoriteService], -}) -export class FavoriteModule {} diff --git a/apps/photos/apps/backend/src/favorite/favorite.service.ts b/apps/photos/apps/backend/src/favorite/favorite.service.ts deleted file mode 100644 index 62f05c4d4..000000000 --- a/apps/photos/apps/backend/src/favorite/favorite.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Injectable, Inject } from '@nestjs/common'; -import { eq, and, inArray, desc } from 'drizzle-orm'; -import { DATABASE_CONNECTION, Database } from '../db/database.module'; -import { favorites, type Favorite } from '../db/schema'; - -@Injectable() -export class FavoriteService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findAll(userId: string, limit = 50, offset = 0): Promise { - return this.db - .select() - .from(favorites) - .where(eq(favorites.userId, userId)) - .orderBy(desc(favorites.createdAt)) - .limit(limit) - .offset(offset); - } - - async isFavorited(userId: string, mediaId: string): Promise { - const [result] = await this.db - .select() - .from(favorites) - .where(and(eq(favorites.userId, userId), eq(favorites.mediaId, mediaId))) - .limit(1); - return !!result; - } - - async getFavoritedIds(userId: string, mediaIds: string[]): Promise> { - if (mediaIds.length === 0) return new Set(); - - const results = await this.db - .select({ mediaId: favorites.mediaId }) - .from(favorites) - .where(and(eq(favorites.userId, userId), inArray(favorites.mediaId, mediaIds))); - - return new Set(results.map((r) => r.mediaId)); - } - - async add(userId: string, mediaId: string): Promise { - const existing = await this.isFavorited(userId, mediaId); - if (existing) { - const [result] = await this.db - .select() - .from(favorites) - .where(and(eq(favorites.userId, userId), eq(favorites.mediaId, mediaId))) - .limit(1); - return result; - } - - const [favorite] = await this.db.insert(favorites).values({ userId, mediaId }).returning(); - return favorite; - } - - async remove(userId: string, mediaId: string): Promise { - await this.db - .delete(favorites) - .where(and(eq(favorites.userId, userId), eq(favorites.mediaId, mediaId))); - } - - async toggle(userId: string, mediaId: string): Promise<{ isFavorited: boolean }> { - const isFavorited = await this.isFavorited(userId, mediaId); - if (isFavorited) { - await this.remove(userId, mediaId); - return { isFavorited: false }; - } else { - await this.add(userId, mediaId); - return { isFavorited: true }; - } - } -} diff --git a/apps/photos/apps/backend/src/instrument.ts b/apps/photos/apps/backend/src/instrument.ts deleted file mode 100644 index acde32ab3..000000000 --- a/apps/photos/apps/backend/src/instrument.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { initErrorTracking } from '@manacore/shared-error-tracking'; - -initErrorTracking({ - serviceName: 'photos-backend', - environment: process.env.NODE_ENV, - release: process.env.APP_VERSION, - debug: process.env.NODE_ENV === 'development', -}); diff --git a/apps/photos/apps/backend/src/main.ts b/apps/photos/apps/backend/src/main.ts deleted file mode 100644 index 9ad2102a9..000000000 --- a/apps/photos/apps/backend/src/main.ts +++ /dev/null @@ -1,35 +0,0 @@ -import './instrument'; -import 'dotenv/config'; -import 'reflect-metadata'; -import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - - app.enableCors({ - origin: process.env.CORS_ORIGINS?.split(',') || [ - 'http://localhost:5173', - 'http://localhost:5189', - 'http://localhost:8081', - ], - credentials: true, - }); - - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - }) - ); - - app.setGlobalPrefix('api/v1'); - - const port = process.env.PORT || 3019; - await app.listen(port); - console.log(`Photos Backend listening on port ${port}`); -} - -bootstrap(); diff --git a/apps/photos/apps/backend/src/photo/photo.controller.ts b/apps/photos/apps/backend/src/photo/photo.controller.ts deleted file mode 100644 index 487acbb40..000000000 --- a/apps/photos/apps/backend/src/photo/photo.controller.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Controller, Get, Query, Param, UseGuards, NotFoundException } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { PhotoService } from './photo.service'; - -@Controller('photos') -@UseGuards(JwtAuthGuard) -export class PhotoController { - constructor(private photoService: PhotoService) {} - - @Get() - async list( - @CurrentUser() user: CurrentUserData, - @Query('apps') apps?: string, - @Query('mimeType') mimeType?: string, - @Query('dateFrom') dateFrom?: string, - @Query('dateTo') dateTo?: string, - @Query('hasLocation') hasLocation?: string, - @Query('limit') limit?: string, - @Query('offset') offset?: string, - @Query('sortBy') sortBy?: 'createdAt' | 'dateTaken' | 'size', - @Query('sortOrder') sortOrder?: 'asc' | 'desc' - ) { - return this.photoService.listPhotos(user.userId, { - apps: apps ? apps.split(',').map((a) => a.trim()) : undefined, - mimeType: mimeType || 'image/*', - dateFrom, - dateTo, - hasLocation: hasLocation === 'true', - limit: limit ? parseInt(limit) : 50, - offset: offset ? parseInt(offset) : 0, - sortBy, - sortOrder, - }); - } - - @Get('stats') - async stats(@CurrentUser() user: CurrentUserData) { - return this.photoService.getStats(user.userId); - } - - @Get(':mediaId') - async get(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) { - const photo = await this.photoService.getPhoto(user.userId, mediaId); - if (!photo) { - throw new NotFoundException('Photo not found'); - } - return photo; - } -} diff --git a/apps/photos/apps/backend/src/photo/photo.module.ts b/apps/photos/apps/backend/src/photo/photo.module.ts deleted file mode 100644 index 2ee639163..000000000 --- a/apps/photos/apps/backend/src/photo/photo.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PhotoController } from './photo.controller'; -import { PhotoService } from './photo.service'; -import { FavoriteModule } from '../favorite/favorite.module'; -import { TagModule } from '../tag/tag.module'; - -@Module({ - imports: [FavoriteModule, TagModule], - controllers: [PhotoController], - providers: [PhotoService], - exports: [PhotoService], -}) -export class PhotoModule {} diff --git a/apps/photos/apps/backend/src/photo/photo.service.ts b/apps/photos/apps/backend/src/photo/photo.service.ts deleted file mode 100644 index 328c084ac..000000000 --- a/apps/photos/apps/backend/src/photo/photo.service.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { FavoriteService } from '../favorite/favorite.service'; -import { TagService } from '../tag/tag.service'; -import type { Tag } from '../db/schema'; - -export interface MediaItem { - id: string; - status: string; - originalName: string | null; - mimeType: string; - size: number; - hash: string; - urls: { - original: string; - thumbnail?: string; - medium?: string; - large?: string; - }; - metadata?: { - width?: number; - height?: number; - format?: string; - }; - exif?: { - cameraMake?: string; - cameraModel?: string; - dateTaken?: string; - focalLength?: string; - aperture?: string; - iso?: number; - exposureTime?: string; - gpsLatitude?: string; - gpsLongitude?: string; - }; - createdAt: string; -} - -export interface EnrichedPhoto extends MediaItem { - isFavorited: boolean; - tags: Tag[]; -} - -export interface ListPhotosParams { - apps?: string[]; - mimeType?: string; - dateFrom?: string; - dateTo?: string; - hasLocation?: boolean; - limit?: number; - offset?: number; - sortBy?: 'createdAt' | 'dateTaken' | 'size'; - sortOrder?: 'asc' | 'desc'; -} - -export interface ListPhotosResult { - items: EnrichedPhoto[]; - total: number; - hasMore: boolean; -} - -export interface PhotoStats { - totalCount: number; - totalSize: number; - byApp: Record; - byYear: Record; -} - -@Injectable() -export class PhotoService { - private readonly logger = new Logger(PhotoService.name); - private readonly manaMediaUrl: string; - - constructor( - private favoriteService: FavoriteService, - private tagService: TagService - ) { - this.manaMediaUrl = process.env.MANA_MEDIA_URL || 'http://localhost:3015'; - } - - async listPhotos(userId: string, params: ListPhotosParams): Promise { - const queryParams = new URLSearchParams(); - queryParams.set('userId', userId); - - if (params.apps?.length) { - queryParams.set('apps', params.apps.join(',')); - } - if (params.mimeType) { - queryParams.set('mimeType', params.mimeType); - } - if (params.dateFrom) { - queryParams.set('dateFrom', params.dateFrom); - } - if (params.dateTo) { - queryParams.set('dateTo', params.dateTo); - } - if (params.hasLocation) { - queryParams.set('hasLocation', 'true'); - } - if (params.limit) { - queryParams.set('limit', String(params.limit)); - } - if (params.offset) { - queryParams.set('offset', String(params.offset)); - } - if (params.sortBy) { - queryParams.set('sortBy', params.sortBy); - } - if (params.sortOrder) { - queryParams.set('sortOrder', params.sortOrder); - } - - try { - const response = await fetch( - `${this.manaMediaUrl}/api/v1/media/list/all?${queryParams.toString()}` - ); - - if (!response.ok) { - this.logger.error(`Failed to fetch photos from mana-media: ${response.status}`); - return { items: [], total: 0, hasMore: false }; - } - - const data = await response.json(); - const mediaItems: MediaItem[] = data.items || []; - - // Enrich with local data - const enriched = await this.enrichPhotos(userId, mediaItems); - - return { - items: enriched, - total: data.total || 0, - hasMore: data.hasMore || false, - }; - } catch (error) { - this.logger.error('Failed to fetch photos from mana-media', error); - return { items: [], total: 0, hasMore: false }; - } - } - - async getPhoto(userId: string, mediaId: string): Promise { - try { - const response = await fetch(`${this.manaMediaUrl}/api/v1/media/${mediaId}`); - - if (!response.ok) { - return null; - } - - const mediaItem: MediaItem = await response.json(); - const [enriched] = await this.enrichPhotos(userId, [mediaItem]); - return enriched; - } catch (error) { - this.logger.error(`Failed to fetch photo ${mediaId} from mana-media`, error); - return null; - } - } - - async getStats(userId: string): Promise { - try { - const response = await fetch(`${this.manaMediaUrl}/api/v1/media/stats?userId=${userId}`); - - if (!response.ok) { - return { totalCount: 0, totalSize: 0, byApp: {}, byYear: {} }; - } - - return response.json(); - } catch (error) { - this.logger.error('Failed to fetch stats from mana-media', error); - return { totalCount: 0, totalSize: 0, byApp: {}, byYear: {} }; - } - } - - private async enrichPhotos(userId: string, items: MediaItem[]): Promise { - if (items.length === 0) return []; - - const mediaIds = items.map((i) => i.id); - - // Fetch favorites and tags in parallel - const [favoritedIds, tagsMap] = await Promise.all([ - this.favoriteService.getFavoritedIds(userId, mediaIds), - this.tagService.getTagsForPhotos(mediaIds), - ]); - - return items.map((item) => ({ - ...item, - isFavorited: favoritedIds.has(item.id), - tags: tagsMap.get(item.id) || [], - })); - } -} diff --git a/apps/photos/apps/backend/src/tag/dto/index.ts b/apps/photos/apps/backend/src/tag/dto/index.ts deleted file mode 100644 index 1d94308f3..000000000 --- a/apps/photos/apps/backend/src/tag/dto/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IsString, IsOptional, IsArray, MaxLength } from 'class-validator'; - -export class CreateTagDto { - @IsString() - @MaxLength(50) - name: string; - - @IsOptional() - @IsString() - @MaxLength(20) - color?: string; -} - -export class UpdateTagDto { - @IsOptional() - @IsString() - @MaxLength(50) - name?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - color?: string; -} - -export class SetTagsDto { - @IsArray() - @IsString({ each: true }) - tagIds: string[]; -} diff --git a/apps/photos/apps/backend/src/tag/tag.controller.ts b/apps/photos/apps/backend/src/tag/tag.controller.ts deleted file mode 100644 index b0f782244..000000000 --- a/apps/photos/apps/backend/src/tag/tag.controller.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - Controller, - Get, - Post, - Patch, - Delete, - Param, - Body, - UseGuards, - NotFoundException, -} from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { TagService } from './tag.service'; -import { CreateTagDto, UpdateTagDto, SetTagsDto } from './dto'; - -@Controller('tags') -@UseGuards(JwtAuthGuard) -export class TagController { - constructor(private tagService: TagService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - return this.tagService.findAll(user.userId); - } - - @Post() - async create(@Body() dto: CreateTagDto, @CurrentUser() user: CurrentUserData) { - return this.tagService.create(user.userId, dto); - } - - @Patch(':id') - async update( - @Param('id') id: string, - @Body() dto: UpdateTagDto, - @CurrentUser() user: CurrentUserData - ) { - const tag = await this.tagService.update(id, user.userId, dto); - if (!tag) { - throw new NotFoundException('Tag not found'); - } - return tag; - } - - @Delete(':id') - async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) { - await this.tagService.delete(id, user.userId); - return { success: true }; - } -} - -@Controller('photos') -@UseGuards(JwtAuthGuard) -export class PhotoTagController { - constructor(private tagService: TagService) {} - - @Get(':mediaId/tags') - async getPhotoTags(@Param('mediaId') mediaId: string) { - return this.tagService.getTagsForPhoto(mediaId); - } - - @Post(':mediaId/tags/:tagId') - async addTag( - @Param('mediaId') mediaId: string, - @Param('tagId') tagId: string, - @CurrentUser() user: CurrentUserData - ) { - await this.tagService.addTagToPhoto(mediaId, tagId, user.userId); - return { success: true }; - } - - @Delete(':mediaId/tags/:tagId') - async removeTag(@Param('mediaId') mediaId: string, @Param('tagId') tagId: string) { - await this.tagService.removeTagFromPhoto(mediaId, tagId); - return { success: true }; - } - - @Patch(':mediaId/tags') - async setTags( - @Param('mediaId') mediaId: string, - @Body() dto: SetTagsDto, - @CurrentUser() user: CurrentUserData - ) { - await this.tagService.setTagsForPhoto(mediaId, dto.tagIds, user.userId); - return { success: true }; - } -} diff --git a/apps/photos/apps/backend/src/tag/tag.module.ts b/apps/photos/apps/backend/src/tag/tag.module.ts deleted file mode 100644 index 9f693d409..000000000 --- a/apps/photos/apps/backend/src/tag/tag.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TagController, PhotoTagController } from './tag.controller'; -import { TagService } from './tag.service'; - -@Module({ - controllers: [TagController, PhotoTagController], - providers: [TagService], - exports: [TagService], -}) -export class TagModule {} diff --git a/apps/photos/apps/backend/src/tag/tag.service.ts b/apps/photos/apps/backend/src/tag/tag.service.ts deleted file mode 100644 index a35414125..000000000 --- a/apps/photos/apps/backend/src/tag/tag.service.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, inArray } from 'drizzle-orm'; -import { DATABASE_CONNECTION, Database } from '../db/database.module'; -import { tags, photoTags, type Tag, type NewTag } from '../db/schema'; - -@Injectable() -export class TagService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findAll(userId: string): Promise { - return this.db.select().from(tags).where(eq(tags.userId, userId)).orderBy(tags.name); - } - - async findById(id: string, userId: string): Promise { - const [tag] = await this.db - .select() - .from(tags) - .where(and(eq(tags.id, id), eq(tags.userId, userId))) - .limit(1); - return tag || null; - } - - async create(userId: string, data: Omit): Promise { - const [tag] = await this.db - .insert(tags) - .values({ ...data, userId }) - .returning(); - return tag; - } - - async update(id: string, userId: string, data: Partial): Promise { - const [updated] = await this.db - .update(tags) - .set(data) - .where(and(eq(tags.id, id), eq(tags.userId, userId))) - .returning(); - return updated || null; - } - - async delete(id: string, userId: string): Promise { - await this.db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId))); - } - - async getTagsForPhoto(mediaId: string): Promise { - const tagIds = await this.db - .select({ tagId: photoTags.tagId }) - .from(photoTags) - .where(eq(photoTags.mediaId, mediaId)); - - if (tagIds.length === 0) return []; - - return this.db - .select() - .from(tags) - .where( - inArray( - tags.id, - tagIds.map((t) => t.tagId) - ) - ); - } - - async getTagsForPhotos(mediaIds: string[]): Promise> { - if (mediaIds.length === 0) return new Map(); - - const results = await this.db - .select({ mediaId: photoTags.mediaId, tag: tags }) - .from(photoTags) - .innerJoin(tags, eq(photoTags.tagId, tags.id)) - .where(inArray(photoTags.mediaId, mediaIds)); - - const map = new Map(); - for (const { mediaId, tag } of results) { - if (!map.has(mediaId)) { - map.set(mediaId, []); - } - map.get(mediaId)!.push(tag); - } - return map; - } - - async addTagToPhoto(mediaId: string, tagId: string, userId: string): Promise { - const tag = await this.findById(tagId, userId); - if (!tag) { - throw new NotFoundException('Tag not found'); - } - - await this.db.insert(photoTags).values({ mediaId, tagId }).onConflictDoNothing(); - } - - async removeTagFromPhoto(mediaId: string, tagId: string): Promise { - await this.db - .delete(photoTags) - .where(and(eq(photoTags.mediaId, mediaId), eq(photoTags.tagId, tagId))); - } - - async setTagsForPhoto(mediaId: string, tagIds: string[], userId: string): Promise { - // Remove all existing tags - await this.db.delete(photoTags).where(eq(photoTags.mediaId, mediaId)); - - // Add new tags - if (tagIds.length > 0) { - // Verify all tags belong to user - const userTags = await this.db - .select() - .from(tags) - .where(and(eq(tags.userId, userId), inArray(tags.id, tagIds))); - - const validTagIds = userTags.map((t) => t.id); - - if (validTagIds.length > 0) { - await this.db.insert(photoTags).values(validTagIds.map((tagId) => ({ mediaId, tagId }))); - } - } - } -} diff --git a/apps/photos/apps/backend/tsconfig.json b/apps/photos/apps/backend/tsconfig.json deleted file mode 100644 index 38c2b55d7..000000000 --- a/apps/photos/apps/backend/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "resolveJsonModule": true - } -} diff --git a/apps/photos/apps/web/src/lib/stores/albums.svelte.ts b/apps/photos/apps/web/src/lib/stores/albums.svelte.ts index d2d774062..9fe358809 100644 --- a/apps/photos/apps/web/src/lib/stores/albums.svelte.ts +++ b/apps/photos/apps/web/src/lib/stores/albums.svelte.ts @@ -1,8 +1,16 @@ /** - * Albums Store - Manages album state using Svelte 5 runes + * Albums Store β€” Local-First with Dexie.js + * + * All reads and writes go to IndexedDB first. + * When authenticated, changes sync to the server in the background. */ -import { api } from '$lib/api/client'; +import { + albumCollection, + albumItemCollection, + type LocalAlbum, + type LocalAlbumItem, +} from '$lib/data/local-store'; import { PhotosEvents } from '@manacore/shared-utils/analytics'; import type { Album, Photo } from '@photos/shared'; @@ -13,8 +21,27 @@ let albumPhotos = $state([]); let loading = $state(false); let error = $state(null); +function toAlbum(local: LocalAlbum): Album { + return { + id: local.id, + userId: 'local', + name: local.name, + description: local.description ?? undefined, + coverMediaId: local.coverMediaId ?? undefined, + isAutoGenerated: local.isAutoGenerated, + autoGenerateType: local.autoGenerateType ?? undefined, + autoGenerateValue: local.autoGenerateValue ?? undefined, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + } as Album; +} + +async function refreshAlbums() { + const localAlbums = await albumCollection.getAll(); + albums = localAlbums.map(toAlbum); +} + export const albumStore = { - // Getters get albums() { return albums; }, @@ -31,22 +58,11 @@ export const albumStore = { return error; }, - /** - * Load all albums - */ async loadAlbums() { loading = true; error = null; - try { - const result = await api.get('/albums'); - if (result.error) { - error = result.error.message; - return; - } - if (result.data) { - albums = result.data; - } + await refreshAlbums(); } catch (e) { error = e instanceof Error ? e.message : 'Failed to load albums'; } finally { @@ -54,22 +70,23 @@ export const albumStore = { } }, - /** - * Load single album with items - */ async loadAlbum(id: string) { loading = true; error = null; - try { - const result = await api.get(`/albums/${id}`); - if (result.error) { - error = result.error.message; - return; - } - if (result.data) { - currentAlbum = result.data; - albumPhotos = result.data.items || []; + const local = await albumCollection.get(id); + if (local) { + currentAlbum = toAlbum(local); + // Load album items (media IDs) + const items = await albumItemCollection.getAll(); + const albumItems = items + .filter((item) => item.albumId === id) + .sort((a, b) => a.sortOrder - b.sortOrder); + // Album items reference mediaIds β€” photo data comes from mana-media + albumPhotos = albumItems.map((item) => ({ id: item.mediaId }) as Photo); + } else { + currentAlbum = null; + albumPhotos = []; } } catch (e) { error = e instanceof Error ? e.message : 'Failed to load album'; @@ -78,49 +95,42 @@ export const albumStore = { } }, - /** - * Create new album - */ async createAlbum(data: { name: string; description?: string }) { - loading = true; error = null; - try { - const result = await api.post('/albums', data); - if (result.error) { - error = result.error.message; - return null; - } - if (result.data) { - albums = [...albums, result.data]; - PhotosEvents.albumCreated(); - return result.data; - } - return null; + const newLocal: LocalAlbum = { + id: crypto.randomUUID(), + name: data.name, + description: data.description ?? null, + coverMediaId: null, + isAutoGenerated: false, + autoGenerateType: null, + autoGenerateValue: null, + }; + const inserted = await albumCollection.insert(newLocal); + const newAlbum = toAlbum(inserted); + albums = [...albums, newAlbum]; + PhotosEvents.albumCreated(); + return newAlbum; } catch (e) { error = e instanceof Error ? e.message : 'Failed to create album'; return null; - } finally { - loading = false; } }, - /** - * Update album - */ async updateAlbum(id: string, data: { name?: string; description?: string }) { + error = null; try { - const result = await api.patch(`/albums/${id}`, data); - if (result.error) { - error = result.error.message; - return null; - } - if (result.data) { - albums = albums.map((a) => (a.id === id ? result.data! : a)); - if (currentAlbum?.id === id) { - currentAlbum = result.data; - } - return result.data; + const updateData: Partial = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description ?? null; + + const updated = await albumCollection.update(id, updateData); + if (updated) { + const updatedAlbum = toAlbum(updated); + albums = albums.map((a) => (a.id === id ? updatedAlbum : a)); + if (currentAlbum?.id === id) currentAlbum = updatedAlbum; + return updatedAlbum; } return null; } catch (e) { @@ -129,16 +139,15 @@ export const albumStore = { } }, - /** - * Delete album - */ async deleteAlbum(id: string) { + error = null; try { - const result = await api.delete(`/albums/${id}`); - if (result.error) { - error = result.error.message; - return false; + // Delete album items first + const items = await albumItemCollection.getAll(); + for (const item of items.filter((i) => i.albumId === id)) { + await albumItemCollection.delete(item.id); } + await albumCollection.delete(id); albums = albums.filter((a) => a.id !== id); PhotosEvents.albumDeleted(); if (currentAlbum?.id === id) { @@ -152,18 +161,25 @@ export const albumStore = { } }, - /** - * Add photos to album - */ async addPhotosToAlbum(albumId: string, mediaIds: string[]) { + error = null; try { - const result = await api.post(`/albums/${albumId}/items`, { mediaIds }); - if (result.error) { - error = result.error.message; - return false; + const existing = await albumItemCollection.getAll(); + const existingInAlbum = existing.filter((i) => i.albumId === albumId); + let nextOrder = existingInAlbum.length; + + for (const mediaId of mediaIds) { + // Skip duplicates + if (existingInAlbum.some((i) => i.mediaId === mediaId)) continue; + + await albumItemCollection.insert({ + id: crypto.randomUUID(), + albumId, + mediaId, + sortOrder: nextOrder++, + }); } PhotosEvents.photosAddedToAlbum(mediaIds.length); - // Reload album to get updated items if (currentAlbum?.id === albumId) { await this.loadAlbum(albumId); } @@ -174,18 +190,16 @@ export const albumStore = { } }, - /** - * Remove photo from album - */ async removePhotoFromAlbum(albumId: string, mediaId: string) { + error = null; try { - const result = await api.delete(`/albums/${albumId}/items/${mediaId}`); - if (result.error) { - error = result.error.message; - return false; + const items = await albumItemCollection.getAll(); + const item = items.find((i) => i.albumId === albumId && i.mediaId === mediaId); + if (item) { + await albumItemCollection.delete(item.id); + albumPhotos = albumPhotos.filter((p) => p.id !== mediaId); + PhotosEvents.photoRemovedFromAlbum(); } - albumPhotos = albumPhotos.filter((p) => p.id !== mediaId); - PhotosEvents.photoRemovedFromAlbum(); return true; } catch (e) { error = e instanceof Error ? e.message : 'Failed to remove photo from album'; @@ -193,21 +207,16 @@ export const albumStore = { } }, - /** - * Set album cover - */ async setCover(albumId: string, mediaId: string) { + error = null; try { - const result = await api.patch(`/albums/${albumId}/cover`, { mediaId }); - if (result.error) { - error = result.error.message; - return false; - } - if (result.data) { - albums = albums.map((a) => (a.id === albumId ? result.data! : a)); - if (currentAlbum?.id === albumId) { - currentAlbum = result.data; - } + const updated = await albumCollection.update(albumId, { + coverMediaId: mediaId, + } as Partial); + if (updated) { + const updatedAlbum = toAlbum(updated); + albums = albums.map((a) => (a.id === albumId ? updatedAlbum : a)); + if (currentAlbum?.id === albumId) currentAlbum = updatedAlbum; } return true; } catch (e) { @@ -216,17 +225,11 @@ export const albumStore = { } }, - /** - * Clear current album - */ clearCurrentAlbum() { currentAlbum = null; albumPhotos = []; }, - /** - * Reset store - */ reset() { albums = []; currentAlbum = null; diff --git a/apps/photos/apps/web/src/lib/stores/photos.svelte.ts b/apps/photos/apps/web/src/lib/stores/photos.svelte.ts index acbcc0b56..8887e2190 100644 --- a/apps/photos/apps/web/src/lib/stores/photos.svelte.ts +++ b/apps/photos/apps/web/src/lib/stores/photos.svelte.ts @@ -1,11 +1,37 @@ /** - * Photos Store - Manages photo gallery state using Svelte 5 runes + * Photos Store β€” Fetches from mana-media directly, favorites local-first. + * + * Photo files live on mana-media. Albums/favorites/tags are local (Dexie). + * This store calls mana-media for photo listing and enriches with local data. */ -import { api } from '$lib/api/client'; +import { favoriteCollection, type LocalFavorite } from '$lib/data/local-store'; +import { authStore } from '$lib/stores/auth.svelte'; import { PhotosEvents } from '@manacore/shared-utils/analytics'; import type { Photo, PhotoFilters, PhotoStats } from '@photos/shared'; +const MEDIA_URL = () => + (typeof window !== 'undefined' + ? (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }).__PUBLIC_MANA_MEDIA_URL__ + : null) || + import.meta.env.PUBLIC_MANA_MEDIA_URL || + 'http://localhost:3015'; + +async function mediaFetch(path: string, options: RequestInit = {}): Promise { + const token = await authStore.getValidToken(); + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${MEDIA_URL()}/api/v1${path}`, { ...options, headers }); + if (!response.ok) return null; + return response.json(); +} + // State let photos = $state([]); let loading = $state(false); @@ -20,8 +46,14 @@ let filters = $state({ let stats = $state(null); let selectedPhoto = $state(null); +/** Enrich photos with local favorite status. */ +async function enrichWithFavorites(items: Photo[]): Promise { + const favs = await favoriteCollection.getAll(); + const favMediaIds = new Set(favs.map((f) => f.mediaId)); + return items.map((p) => ({ ...p, isFavorited: favMediaIds.has(p.id) })); +} + export const photoStore = { - // Getters get photos() { return photos; }, @@ -44,9 +76,6 @@ export const photoStore = { return selectedPhoto; }, - /** - * Load photos with current filters - */ async loadPhotos(reset = false) { if (loading) return; @@ -71,19 +100,15 @@ export const photoStore = { params.set('sortBy', filters.sortBy || 'dateTaken'); params.set('sortOrder', filters.sortOrder || 'desc'); - const result = await api.get<{ items: Photo[]; total: number; hasMore: boolean }>( - `/photos?${params.toString()}` + const result = await mediaFetch<{ items: Photo[]; total: number; hasMore: boolean }>( + `/media/list/all?${params.toString()}` ); - if (result.error) { - error = result.error.message; - return; - } - - if (result.data) { - photos = reset ? result.data.items : [...photos, ...result.data.items]; - hasMore = result.data.hasMore; - filters = { ...filters, offset: (filters.offset || 0) + result.data.items.length }; + if (result) { + const enriched = await enrichWithFavorites(result.items); + photos = reset ? enriched : [...photos, ...enriched]; + hasMore = result.hasMore; + filters = { ...filters, offset: (filters.offset || 0) + result.items.length }; } } catch (e) { error = e instanceof Error ? e.message : 'Failed to load photos'; @@ -92,81 +117,74 @@ export const photoStore = { } }, - /** - * Load more photos (pagination) - */ async loadMore() { if (!hasMore || loading) return; await this.loadPhotos(false); }, - /** - * Update filters and reload - */ async setFilters(newFilters: Partial) { filters = { ...filters, ...newFilters, offset: 0 }; PhotosEvents.filtersApplied(); await this.loadPhotos(true); }, - /** - * Load photo statistics - */ async loadStats() { try { - const result = await api.get('/photos/stats'); - if (result.data) { - stats = result.data; - } + const result = await mediaFetch('/media/stats'); + if (result) stats = result; } catch (e) { console.error('Failed to load stats:', e); } }, - /** - * Select a photo for detail view - */ selectPhoto(photo: Photo | null) { selectedPhoto = photo; }, - /** - * Toggle favorite status - */ + /** Toggle favorite β€” local-first via Dexie. */ async toggleFavorite(mediaId: string) { try { - const result = await api.post<{ isFavorited: boolean }>(`/favorites/${mediaId}/toggle`); - if (result.data) { - PhotosEvents.photoFavorited(result.data.isFavorited); - // Update photo in list - photos = photos.map((p) => - p.id === mediaId ? { ...p, isFavorited: result.data!.isFavorited } : p - ); - // Update selected photo if it's the same - if (selectedPhoto?.id === mediaId) { - selectedPhoto = { ...selectedPhoto, isFavorited: result.data.isFavorited }; - } + const existing = await favoriteCollection.getAll(); + const fav = existing.find((f) => f.mediaId === mediaId); + let isFavorited: boolean; + + if (fav) { + await favoriteCollection.delete(fav.id); + isFavorited = false; + } else { + await favoriteCollection.insert({ + id: crypto.randomUUID(), + mediaId, + } as LocalFavorite); + isFavorited = true; + } + + PhotosEvents.photoFavorited(isFavorited); + photos = photos.map((p) => (p.id === mediaId ? { ...p, isFavorited } : p)); + if (selectedPhoto?.id === mediaId) { + selectedPhoto = { ...selectedPhoto, isFavorited }; } } catch (e) { console.error('Failed to toggle favorite:', e); } }, - /** - * Delete a photo - */ async deletePhoto(mediaId: string) { try { - const result = await api.delete(`/photos/${mediaId}`); - if (result.error) { - error = result.error.message; + const token = await authStore.getValidToken(); + const response = await fetch(`${MEDIA_URL()}/api/v1/media/${mediaId}`, { + method: 'DELETE', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (!response.ok) { + error = 'Failed to delete photo'; return false; } + photos = photos.filter((p) => p.id !== mediaId); PhotosEvents.photoDeleted(); - if (selectedPhoto?.id === mediaId) { - selectedPhoto = null; - } + if (selectedPhoto?.id === mediaId) selectedPhoto = null; return true; } catch (e) { error = e instanceof Error ? e.message : 'Failed to delete photo'; @@ -174,20 +192,12 @@ export const photoStore = { } }, - /** - * Clear all state - */ reset() { photos = []; loading = false; error = null; hasMore = true; - filters = { - limit: 50, - offset: 0, - sortBy: 'dateTaken', - sortOrder: 'desc', - }; + filters = { limit: 50, offset: 0, sortBy: 'dateTaken', sortOrder: 'desc' }; stats = null; selectedPhoto = null; }, diff --git a/apps/photos/apps/web/src/lib/stores/tags.svelte.ts b/apps/photos/apps/web/src/lib/stores/tags.svelte.ts index 844786801..eaa55184f 100644 --- a/apps/photos/apps/web/src/lib/stores/tags.svelte.ts +++ b/apps/photos/apps/web/src/lib/stores/tags.svelte.ts @@ -2,7 +2,7 @@ * Tag Store β€” Local-First via Shared Tag Store * * Core tag CRUD is handled by the shared local-first tag store. - * Photo-specific tag operations (junction table) go through the Photos backend. + * Photo-specific tag operations (junction table) are local-first via Dexie. */ export { @@ -14,22 +14,18 @@ export { getTagsByGroup, } from '@manacore/shared-stores'; -import { api } from '$lib/api/client'; -import type { Tag } from '@photos/shared'; +import { photoTagCollection, type LocalPhotoTag } from '$lib/data/local-store'; /** * Photo-specific tag operations (junction table: photo <-> tag). - * These go through the Photos backend, not the shared tag store. + * Local-first via Dexie β€” syncs to server in the background. */ export const photoTagOps = { /** Get tags for a photo */ - async getPhotoTags(mediaId: string): Promise { + async getPhotoTags(mediaId: string): Promise { try { - const result = await api.get(`/photos/${mediaId}/tags`); - if (result.data) { - return result.data; - } - return []; + const all = await photoTagCollection.getAll(); + return all.filter((pt) => pt.mediaId === mediaId).map((pt) => pt.tagId); } catch (e) { console.error('Failed to get photo tags:', e); return []; @@ -39,8 +35,17 @@ export const photoTagOps = { /** Add tag to photo */ async addTagToPhoto(mediaId: string, tagId: string) { try { - const result = await api.post(`/photos/${mediaId}/tags/${tagId}`); - return !result.error; + // Check if already exists + const all = await photoTagCollection.getAll(); + const exists = all.some((pt) => pt.mediaId === mediaId && pt.tagId === tagId); + if (exists) return true; + + await photoTagCollection.insert({ + id: crypto.randomUUID(), + mediaId, + tagId, + } as LocalPhotoTag); + return true; } catch (e) { console.error('Failed to add tag to photo:', e); return false; @@ -50,19 +55,37 @@ export const photoTagOps = { /** Remove tag from photo */ async removeTagFromPhoto(mediaId: string, tagId: string) { try { - const result = await api.delete(`/photos/${mediaId}/tags/${tagId}`); - return !result.error; + const all = await photoTagCollection.getAll(); + const item = all.find((pt) => pt.mediaId === mediaId && pt.tagId === tagId); + if (item) { + await photoTagCollection.delete(item.id); + } + return true; } catch (e) { console.error('Failed to remove tag from photo:', e); return false; } }, - /** Set all tags for a photo */ + /** Set all tags for a photo (replace) */ async setPhotoTags(mediaId: string, tagIds: string[]) { try { - const result = await api.patch(`/photos/${mediaId}/tags`, { tagIds }); - return !result.error; + // Remove existing tags for this photo + const all = await photoTagCollection.getAll(); + const existing = all.filter((pt) => pt.mediaId === mediaId); + for (const item of existing) { + await photoTagCollection.delete(item.id); + } + + // Add new tags + for (const tagId of tagIds) { + await photoTagCollection.insert({ + id: crypto.randomUUID(), + mediaId, + tagId, + } as LocalPhotoTag); + } + return true; } catch (e) { console.error('Failed to set photo tags:', e); return false; diff --git a/apps/photos/apps/web/src/routes/(app)/favorites/+page.svelte b/apps/photos/apps/web/src/routes/(app)/favorites/+page.svelte index 1ab2687ae..aa7555cff 100644 --- a/apps/photos/apps/web/src/routes/(app)/favorites/+page.svelte +++ b/apps/photos/apps/web/src/routes/(app)/favorites/+page.svelte @@ -1,8 +1,8 @@