mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
refactor(photos): remove NestJS backend, use local-first + direct mana-media
The Photos NestJS backend was a proxy to mana-media that enriched responses with local album/favorite/tag data. Now: - Albums store → local-first via albumCollection + albumItemCollection - Favorites → local-first via favoriteCollection (toggle in IndexedDB) - Photo tags → local-first via photoTagCollection - Photo listing/stats → direct mana-media API calls from frontend - Upload → direct mana-media upload from frontend - Delete → direct mana-media delete from frontend Removed 27 TypeScript files, 1 Docker container, 1 port (3039). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7a8567e61
commit
d7799ec95d
43 changed files with 243 additions and 1816 deletions
|
|
@ -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"]
|
|
||||||
|
|
@ -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 "$@"
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
|
||||||
|
|
||||||
export default createDrizzleConfig({
|
|
||||||
dbName: 'photos',
|
|
||||||
outDir: './drizzle',
|
|
||||||
});
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
|
||||||
"collection": "@nestjs/schematics",
|
|
||||||
"sourceRoot": "src",
|
|
||||||
"compilerOptions": {
|
|
||||||
"deleteOutDir": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<UserDataResponse> {
|
|
||||||
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<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 { DatabaseModule } from '../db/database.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [ConfigModule, DatabaseModule],
|
|
||||||
controllers: [AdminController],
|
|
||||||
providers: [AdminService],
|
|
||||||
})
|
|
||||||
export class AdminModule {}
|
|
||||||
|
|
@ -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<typeof schema>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user data counts for a specific user
|
|
||||||
*/
|
|
||||||
async getUserData(userId: string): Promise<UserDataResponse> {
|
|
||||||
this.logger.log(`Getting user data for userId: ${userId}`);
|
|
||||||
|
|
||||||
// Count albums
|
|
||||||
const albumsResult = await this.db
|
|
||||||
.select({ count: sql<number>`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<number>`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<number>`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<number>`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<number>`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<DeleteUserDataResponse> {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<Album[]> {
|
|
||||||
return this.db
|
|
||||||
.select()
|
|
||||||
.from(albums)
|
|
||||||
.where(eq(albums.userId, userId))
|
|
||||||
.orderBy(albums.sortOrder, albums.createdAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string, userId: string): Promise<AlbumWithItems | null> {
|
|
||||||
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<NewAlbum, 'userId'>): Promise<Album> {
|
|
||||||
const [album] = await this.db
|
|
||||||
.insert(albums)
|
|
||||||
.values({
|
|
||||||
...data,
|
|
||||||
userId,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
return album;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, userId: string, data: Partial<NewAlbum>): Promise<Album | null> {
|
|
||||||
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<void> {
|
|
||||||
await this.db.delete(albums).where(and(eq(albums.id, id), eq(albums.userId, userId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async addItems(albumId: string, userId: string, mediaIds: string[]): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<Album | null> {
|
|
||||||
return this.update(albumId, userId, { coverMediaId: mediaId });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAlbumsForMedia(userId: string, mediaId: string): Promise<Album[]> {
|
|
||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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[];
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<typeof schema>;
|
|
||||||
|
|
||||||
@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 {}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './albums.schema';
|
|
||||||
export * from './favorites.schema';
|
|
||||||
export * from './tags.schema';
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<Favorite[]> {
|
|
||||||
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<boolean> {
|
|
||||||
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<Set<string>> {
|
|
||||||
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<Favorite> {
|
|
||||||
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<void> {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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',
|
|
||||||
});
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<string, { count: number; size: number }>;
|
|
||||||
byYear: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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<ListPhotosResult> {
|
|
||||||
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<EnrichedPhoto | null> {
|
|
||||||
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<PhotoStats> {
|
|
||||||
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<EnrichedPhoto[]> {
|
|
||||||
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) || [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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[];
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<Tag[]> {
|
|
||||||
return this.db.select().from(tags).where(eq(tags.userId, userId)).orderBy(tags.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string, userId: string): Promise<Tag | null> {
|
|
||||||
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<NewTag, 'userId'>): Promise<Tag> {
|
|
||||||
const [tag] = await this.db
|
|
||||||
.insert(tags)
|
|
||||||
.values({ ...data, userId })
|
|
||||||
.returning();
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, userId: string, data: Partial<NewTag>): Promise<Tag | null> {
|
|
||||||
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<void> {
|
|
||||||
await this.db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTagsForPhoto(mediaId: string): Promise<Tag[]> {
|
|
||||||
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<Map<string, Tag[]>> {
|
|
||||||
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<string, Tag[]>();
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
await this.db
|
|
||||||
.delete(photoTags)
|
|
||||||
.where(and(eq(photoTags.mediaId, mediaId), eq(photoTags.tagId, tagId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async setTagsForPhoto(mediaId: string, tagIds: string[], userId: string): Promise<void> {
|
|
||||||
// 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 })));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 { PhotosEvents } from '@manacore/shared-utils/analytics';
|
||||||
import type { Album, Photo } from '@photos/shared';
|
import type { Album, Photo } from '@photos/shared';
|
||||||
|
|
||||||
|
|
@ -13,8 +21,27 @@ let albumPhotos = $state<Photo[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(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 = {
|
export const albumStore = {
|
||||||
// Getters
|
|
||||||
get albums() {
|
get albums() {
|
||||||
return albums;
|
return albums;
|
||||||
},
|
},
|
||||||
|
|
@ -31,22 +58,11 @@ export const albumStore = {
|
||||||
return error;
|
return error;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all albums
|
|
||||||
*/
|
|
||||||
async loadAlbums() {
|
async loadAlbums() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.get<Album[]>('/albums');
|
await refreshAlbums();
|
||||||
if (result.error) {
|
|
||||||
error = result.error.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.data) {
|
|
||||||
albums = result.data;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to load albums';
|
error = e instanceof Error ? e.message : 'Failed to load albums';
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -54,22 +70,23 @@ export const albumStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Load single album with items
|
|
||||||
*/
|
|
||||||
async loadAlbum(id: string) {
|
async loadAlbum(id: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.get<Album & { items: Photo[] }>(`/albums/${id}`);
|
const local = await albumCollection.get(id);
|
||||||
if (result.error) {
|
if (local) {
|
||||||
error = result.error.message;
|
currentAlbum = toAlbum(local);
|
||||||
return;
|
// Load album items (media IDs)
|
||||||
}
|
const items = await albumItemCollection.getAll();
|
||||||
if (result.data) {
|
const albumItems = items
|
||||||
currentAlbum = result.data;
|
.filter((item) => item.albumId === id)
|
||||||
albumPhotos = result.data.items || [];
|
.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) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to load album';
|
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 }) {
|
async createAlbum(data: { name: string; description?: string }) {
|
||||||
loading = true;
|
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.post<Album>('/albums', data);
|
const newLocal: LocalAlbum = {
|
||||||
if (result.error) {
|
id: crypto.randomUUID(),
|
||||||
error = result.error.message;
|
name: data.name,
|
||||||
return null;
|
description: data.description ?? null,
|
||||||
}
|
coverMediaId: null,
|
||||||
if (result.data) {
|
isAutoGenerated: false,
|
||||||
albums = [...albums, result.data];
|
autoGenerateType: null,
|
||||||
PhotosEvents.albumCreated();
|
autoGenerateValue: null,
|
||||||
return result.data;
|
};
|
||||||
}
|
const inserted = await albumCollection.insert(newLocal);
|
||||||
return null;
|
const newAlbum = toAlbum(inserted);
|
||||||
|
albums = [...albums, newAlbum];
|
||||||
|
PhotosEvents.albumCreated();
|
||||||
|
return newAlbum;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to create album';
|
error = e instanceof Error ? e.message : 'Failed to create album';
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Update album
|
|
||||||
*/
|
|
||||||
async updateAlbum(id: string, data: { name?: string; description?: string }) {
|
async updateAlbum(id: string, data: { name?: string; description?: string }) {
|
||||||
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await api.patch<Album>(`/albums/${id}`, data);
|
const updateData: Partial<LocalAlbum> = {};
|
||||||
if (result.error) {
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
error = result.error.message;
|
if (data.description !== undefined) updateData.description = data.description ?? null;
|
||||||
return null;
|
|
||||||
}
|
const updated = await albumCollection.update(id, updateData);
|
||||||
if (result.data) {
|
if (updated) {
|
||||||
albums = albums.map((a) => (a.id === id ? result.data! : a));
|
const updatedAlbum = toAlbum(updated);
|
||||||
if (currentAlbum?.id === id) {
|
albums = albums.map((a) => (a.id === id ? updatedAlbum : a));
|
||||||
currentAlbum = result.data;
|
if (currentAlbum?.id === id) currentAlbum = updatedAlbum;
|
||||||
}
|
return updatedAlbum;
|
||||||
return result.data;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -129,16 +139,15 @@ export const albumStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete album
|
|
||||||
*/
|
|
||||||
async deleteAlbum(id: string) {
|
async deleteAlbum(id: string) {
|
||||||
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await api.delete(`/albums/${id}`);
|
// Delete album items first
|
||||||
if (result.error) {
|
const items = await albumItemCollection.getAll();
|
||||||
error = result.error.message;
|
for (const item of items.filter((i) => i.albumId === id)) {
|
||||||
return false;
|
await albumItemCollection.delete(item.id);
|
||||||
}
|
}
|
||||||
|
await albumCollection.delete(id);
|
||||||
albums = albums.filter((a) => a.id !== id);
|
albums = albums.filter((a) => a.id !== id);
|
||||||
PhotosEvents.albumDeleted();
|
PhotosEvents.albumDeleted();
|
||||||
if (currentAlbum?.id === id) {
|
if (currentAlbum?.id === id) {
|
||||||
|
|
@ -152,18 +161,25 @@ export const albumStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Add photos to album
|
|
||||||
*/
|
|
||||||
async addPhotosToAlbum(albumId: string, mediaIds: string[]) {
|
async addPhotosToAlbum(albumId: string, mediaIds: string[]) {
|
||||||
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await api.post(`/albums/${albumId}/items`, { mediaIds });
|
const existing = await albumItemCollection.getAll();
|
||||||
if (result.error) {
|
const existingInAlbum = existing.filter((i) => i.albumId === albumId);
|
||||||
error = result.error.message;
|
let nextOrder = existingInAlbum.length;
|
||||||
return false;
|
|
||||||
|
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);
|
PhotosEvents.photosAddedToAlbum(mediaIds.length);
|
||||||
// Reload album to get updated items
|
|
||||||
if (currentAlbum?.id === albumId) {
|
if (currentAlbum?.id === albumId) {
|
||||||
await this.loadAlbum(albumId);
|
await this.loadAlbum(albumId);
|
||||||
}
|
}
|
||||||
|
|
@ -174,18 +190,16 @@ export const albumStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove photo from album
|
|
||||||
*/
|
|
||||||
async removePhotoFromAlbum(albumId: string, mediaId: string) {
|
async removePhotoFromAlbum(albumId: string, mediaId: string) {
|
||||||
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await api.delete(`/albums/${albumId}/items/${mediaId}`);
|
const items = await albumItemCollection.getAll();
|
||||||
if (result.error) {
|
const item = items.find((i) => i.albumId === albumId && i.mediaId === mediaId);
|
||||||
error = result.error.message;
|
if (item) {
|
||||||
return false;
|
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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to remove photo from album';
|
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) {
|
async setCover(albumId: string, mediaId: string) {
|
||||||
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await api.patch<Album>(`/albums/${albumId}/cover`, { mediaId });
|
const updated = await albumCollection.update(albumId, {
|
||||||
if (result.error) {
|
coverMediaId: mediaId,
|
||||||
error = result.error.message;
|
} as Partial<LocalAlbum>);
|
||||||
return false;
|
if (updated) {
|
||||||
}
|
const updatedAlbum = toAlbum(updated);
|
||||||
if (result.data) {
|
albums = albums.map((a) => (a.id === albumId ? updatedAlbum : a));
|
||||||
albums = albums.map((a) => (a.id === albumId ? result.data! : a));
|
if (currentAlbum?.id === albumId) currentAlbum = updatedAlbum;
|
||||||
if (currentAlbum?.id === albumId) {
|
|
||||||
currentAlbum = result.data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -216,17 +225,11 @@ export const albumStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear current album
|
|
||||||
*/
|
|
||||||
clearCurrentAlbum() {
|
clearCurrentAlbum() {
|
||||||
currentAlbum = null;
|
currentAlbum = null;
|
||||||
albumPhotos = [];
|
albumPhotos = [];
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset store
|
|
||||||
*/
|
|
||||||
reset() {
|
reset() {
|
||||||
albums = [];
|
albums = [];
|
||||||
currentAlbum = null;
|
currentAlbum = null;
|
||||||
|
|
|
||||||
|
|
@ -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 { PhotosEvents } from '@manacore/shared-utils/analytics';
|
||||||
import type { Photo, PhotoFilters, PhotoStats } from '@photos/shared';
|
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<T>(path: string, options: RequestInit = {}): Promise<T | null> {
|
||||||
|
const token = await authStore.getValidToken();
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${MEDIA_URL()}/api/v1${path}`, { ...options, headers });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let photos = $state<Photo[]>([]);
|
let photos = $state<Photo[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
@ -20,8 +46,14 @@ let filters = $state<PhotoFilters>({
|
||||||
let stats = $state<PhotoStats | null>(null);
|
let stats = $state<PhotoStats | null>(null);
|
||||||
let selectedPhoto = $state<Photo | null>(null);
|
let selectedPhoto = $state<Photo | null>(null);
|
||||||
|
|
||||||
|
/** Enrich photos with local favorite status. */
|
||||||
|
async function enrichWithFavorites(items: Photo[]): Promise<Photo[]> {
|
||||||
|
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 = {
|
export const photoStore = {
|
||||||
// Getters
|
|
||||||
get photos() {
|
get photos() {
|
||||||
return photos;
|
return photos;
|
||||||
},
|
},
|
||||||
|
|
@ -44,9 +76,6 @@ export const photoStore = {
|
||||||
return selectedPhoto;
|
return selectedPhoto;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Load photos with current filters
|
|
||||||
*/
|
|
||||||
async loadPhotos(reset = false) {
|
async loadPhotos(reset = false) {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
|
|
@ -71,19 +100,15 @@ export const photoStore = {
|
||||||
params.set('sortBy', filters.sortBy || 'dateTaken');
|
params.set('sortBy', filters.sortBy || 'dateTaken');
|
||||||
params.set('sortOrder', filters.sortOrder || 'desc');
|
params.set('sortOrder', filters.sortOrder || 'desc');
|
||||||
|
|
||||||
const result = await api.get<{ items: Photo[]; total: number; hasMore: boolean }>(
|
const result = await mediaFetch<{ items: Photo[]; total: number; hasMore: boolean }>(
|
||||||
`/photos?${params.toString()}`
|
`/media/list/all?${params.toString()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.error) {
|
if (result) {
|
||||||
error = result.error.message;
|
const enriched = await enrichWithFavorites(result.items);
|
||||||
return;
|
photos = reset ? enriched : [...photos, ...enriched];
|
||||||
}
|
hasMore = result.hasMore;
|
||||||
|
filters = { ...filters, offset: (filters.offset || 0) + result.items.length };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to load photos';
|
error = e instanceof Error ? e.message : 'Failed to load photos';
|
||||||
|
|
@ -92,81 +117,74 @@ export const photoStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Load more photos (pagination)
|
|
||||||
*/
|
|
||||||
async loadMore() {
|
async loadMore() {
|
||||||
if (!hasMore || loading) return;
|
if (!hasMore || loading) return;
|
||||||
await this.loadPhotos(false);
|
await this.loadPhotos(false);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Update filters and reload
|
|
||||||
*/
|
|
||||||
async setFilters(newFilters: Partial<PhotoFilters>) {
|
async setFilters(newFilters: Partial<PhotoFilters>) {
|
||||||
filters = { ...filters, ...newFilters, offset: 0 };
|
filters = { ...filters, ...newFilters, offset: 0 };
|
||||||
PhotosEvents.filtersApplied();
|
PhotosEvents.filtersApplied();
|
||||||
await this.loadPhotos(true);
|
await this.loadPhotos(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Load photo statistics
|
|
||||||
*/
|
|
||||||
async loadStats() {
|
async loadStats() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<PhotoStats>('/photos/stats');
|
const result = await mediaFetch<PhotoStats>('/media/stats');
|
||||||
if (result.data) {
|
if (result) stats = result;
|
||||||
stats = result.data;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load stats:', e);
|
console.error('Failed to load stats:', e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a photo for detail view
|
|
||||||
*/
|
|
||||||
selectPhoto(photo: Photo | null) {
|
selectPhoto(photo: Photo | null) {
|
||||||
selectedPhoto = photo;
|
selectedPhoto = photo;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** Toggle favorite — local-first via Dexie. */
|
||||||
* Toggle favorite status
|
|
||||||
*/
|
|
||||||
async toggleFavorite(mediaId: string) {
|
async toggleFavorite(mediaId: string) {
|
||||||
try {
|
try {
|
||||||
const result = await api.post<{ isFavorited: boolean }>(`/favorites/${mediaId}/toggle`);
|
const existing = await favoriteCollection.getAll();
|
||||||
if (result.data) {
|
const fav = existing.find((f) => f.mediaId === mediaId);
|
||||||
PhotosEvents.photoFavorited(result.data.isFavorited);
|
let isFavorited: boolean;
|
||||||
// Update photo in list
|
|
||||||
photos = photos.map((p) =>
|
if (fav) {
|
||||||
p.id === mediaId ? { ...p, isFavorited: result.data!.isFavorited } : p
|
await favoriteCollection.delete(fav.id);
|
||||||
);
|
isFavorited = false;
|
||||||
// Update selected photo if it's the same
|
} else {
|
||||||
if (selectedPhoto?.id === mediaId) {
|
await favoriteCollection.insert({
|
||||||
selectedPhoto = { ...selectedPhoto, isFavorited: result.data.isFavorited };
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to toggle favorite:', e);
|
console.error('Failed to toggle favorite:', e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a photo
|
|
||||||
*/
|
|
||||||
async deletePhoto(mediaId: string) {
|
async deletePhoto(mediaId: string) {
|
||||||
try {
|
try {
|
||||||
const result = await api.delete(`/photos/${mediaId}`);
|
const token = await authStore.getValidToken();
|
||||||
if (result.error) {
|
const response = await fetch(`${MEDIA_URL()}/api/v1/media/${mediaId}`, {
|
||||||
error = result.error.message;
|
method: 'DELETE',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
error = 'Failed to delete photo';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
photos = photos.filter((p) => p.id !== mediaId);
|
photos = photos.filter((p) => p.id !== mediaId);
|
||||||
PhotosEvents.photoDeleted();
|
PhotosEvents.photoDeleted();
|
||||||
if (selectedPhoto?.id === mediaId) {
|
if (selectedPhoto?.id === mediaId) selectedPhoto = null;
|
||||||
selectedPhoto = null;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to delete photo';
|
error = e instanceof Error ? e.message : 'Failed to delete photo';
|
||||||
|
|
@ -174,20 +192,12 @@ export const photoStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all state
|
|
||||||
*/
|
|
||||||
reset() {
|
reset() {
|
||||||
photos = [];
|
photos = [];
|
||||||
loading = false;
|
loading = false;
|
||||||
error = null;
|
error = null;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
filters = {
|
filters = { limit: 50, offset: 0, sortBy: 'dateTaken', sortOrder: 'desc' };
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
sortBy: 'dateTaken',
|
|
||||||
sortOrder: 'desc',
|
|
||||||
};
|
|
||||||
stats = null;
|
stats = null;
|
||||||
selectedPhoto = null;
|
selectedPhoto = null;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* Tag Store — Local-First via Shared Tag Store
|
* Tag Store — Local-First via Shared Tag Store
|
||||||
*
|
*
|
||||||
* Core tag CRUD is handled by the shared local-first 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 {
|
export {
|
||||||
|
|
@ -14,22 +14,18 @@ export {
|
||||||
getTagsByGroup,
|
getTagsByGroup,
|
||||||
} from '@manacore/shared-stores';
|
} from '@manacore/shared-stores';
|
||||||
|
|
||||||
import { api } from '$lib/api/client';
|
import { photoTagCollection, type LocalPhotoTag } from '$lib/data/local-store';
|
||||||
import type { Tag } from '@photos/shared';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Photo-specific tag operations (junction table: photo <-> tag).
|
* 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 = {
|
export const photoTagOps = {
|
||||||
/** Get tags for a photo */
|
/** Get tags for a photo */
|
||||||
async getPhotoTags(mediaId: string): Promise<Tag[]> {
|
async getPhotoTags(mediaId: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<Tag[]>(`/photos/${mediaId}/tags`);
|
const all = await photoTagCollection.getAll();
|
||||||
if (result.data) {
|
return all.filter((pt) => pt.mediaId === mediaId).map((pt) => pt.tagId);
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to get photo tags:', e);
|
console.error('Failed to get photo tags:', e);
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -39,8 +35,17 @@ export const photoTagOps = {
|
||||||
/** Add tag to photo */
|
/** Add tag to photo */
|
||||||
async addTagToPhoto(mediaId: string, tagId: string) {
|
async addTagToPhoto(mediaId: string, tagId: string) {
|
||||||
try {
|
try {
|
||||||
const result = await api.post(`/photos/${mediaId}/tags/${tagId}`);
|
// Check if already exists
|
||||||
return !result.error;
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to add tag to photo:', e);
|
console.error('Failed to add tag to photo:', e);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -50,19 +55,37 @@ export const photoTagOps = {
|
||||||
/** Remove tag from photo */
|
/** Remove tag from photo */
|
||||||
async removeTagFromPhoto(mediaId: string, tagId: string) {
|
async removeTagFromPhoto(mediaId: string, tagId: string) {
|
||||||
try {
|
try {
|
||||||
const result = await api.delete(`/photos/${mediaId}/tags/${tagId}`);
|
const all = await photoTagCollection.getAll();
|
||||||
return !result.error;
|
const item = all.find((pt) => pt.mediaId === mediaId && pt.tagId === tagId);
|
||||||
|
if (item) {
|
||||||
|
await photoTagCollection.delete(item.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to remove tag from photo:', e);
|
console.error('Failed to remove tag from photo:', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Set all tags for a photo */
|
/** Set all tags for a photo (replace) */
|
||||||
async setPhotoTags(mediaId: string, tagIds: string[]) {
|
async setPhotoTags(mediaId: string, tagIds: string[]) {
|
||||||
try {
|
try {
|
||||||
const result = await api.patch(`/photos/${mediaId}/tags`, { tagIds });
|
// Remove existing tags for this photo
|
||||||
return !result.error;
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to set photo tags:', e);
|
console.error('Failed to set photo tags:', e);
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { api } from '$lib/api/client';
|
|
||||||
import { photoStore } from '$lib/stores/photos.svelte';
|
import { photoStore } from '$lib/stores/photos.svelte';
|
||||||
|
import { favoriteCollection } from '$lib/data/local-store';
|
||||||
import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte';
|
||||||
import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte';
|
import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte';
|
||||||
import type { Photo } from '@photos/shared';
|
import type { Photo } from '@photos/shared';
|
||||||
|
|
@ -20,14 +20,9 @@
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ items: Photo[] }>('/favorites');
|
const localFavs = await favoriteCollection.getAll();
|
||||||
if (result.error) {
|
// Favorited media IDs — full photo data would come from mana-media
|
||||||
error = result.error.message;
|
favorites = localFavs.map((f) => ({ id: f.mediaId, isFavorited: true }) as Photo);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.data) {
|
|
||||||
favorites = result.data.items;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to load favorites';
|
error = e instanceof Error ? e.message : 'Failed to load favorites';
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { uploadWithAuth } from '$lib/api/client';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { PhotosEvents } from '@manacore/shared-utils/analytics';
|
import { PhotosEvents } from '@manacore/shared-utils/analytics';
|
||||||
|
|
||||||
|
const MEDIA_URL = import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
|
||||||
import UploadDropzone from '$lib/components/upload/UploadDropzone.svelte';
|
import UploadDropzone from '$lib/components/upload/UploadDropzone.svelte';
|
||||||
|
|
||||||
interface UploadFile {
|
interface UploadFile {
|
||||||
|
|
@ -49,7 +51,13 @@
|
||||||
formData.append('file', files[i].file);
|
formData.append('file', files[i].file);
|
||||||
formData.append('app', 'photos');
|
formData.append('app', 'photos');
|
||||||
|
|
||||||
await uploadWithAuth('/photos/upload', formData);
|
const token = await authStore.getValidToken();
|
||||||
|
const response = await fetch(`${MEDIA_URL}/api/v1/media/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Upload failed');
|
||||||
|
|
||||||
files[i].status = 'success';
|
files[i].status = 'success';
|
||||||
files[i].progress = 100;
|
files[i].progress = 100;
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ services:
|
||||||
PICTURE_BACKEND_URL: http://picture-backend:3040
|
PICTURE_BACKEND_URL: http://picture-backend:3040
|
||||||
# PRESI_BACKEND_URL: removed — replaced by Hono server
|
# PRESI_BACKEND_URL: removed — replaced by Hono server
|
||||||
# ZITARE_BACKEND_URL: removed — migrated to local-first
|
# ZITARE_BACKEND_URL: removed — migrated to local-first
|
||||||
PHOTOS_BACKEND_URL: http://photos-backend:3039
|
# PHOTOS_BACKEND_URL: removed — migrated to local-first
|
||||||
# CLOCK_BACKEND_URL: removed — migrated to local-first
|
# CLOCK_BACKEND_URL: removed — migrated to local-first
|
||||||
STORAGE_BACKEND_URL: http://storage-backend:3035
|
STORAGE_BACKEND_URL: http://storage-backend:3035
|
||||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||||
|
|
@ -781,38 +781,7 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
photos-backend:
|
# photos-backend: REMOVED — migrated to local-first (talks to mana-media directly)
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: apps/photos/apps/backend/Dockerfile
|
|
||||||
image: photos-backend:local
|
|
||||||
container_name: mana-app-photos-backend
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
mana-auth:
|
|
||||||
condition: service_healthy
|
|
||||||
mana-media:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3039
|
|
||||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/photos
|
|
||||||
DB_HOST: postgres
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_USER: postgres
|
|
||||||
MANA_CORE_AUTH_URL: http://mana-auth:3001
|
|
||||||
MANA_MEDIA_URL: http://mana-media:3015
|
|
||||||
CORS_ORIGINS: https://photos.mana.how,https://mana.how
|
|
||||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
|
||||||
GLITCHTIP_DSN: http://032aef0f1da94497b8b8f6accb0c4587@glitchtip:8020/12
|
|
||||||
ports:
|
|
||||||
- "3039:3039"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3039/api/v1/health"]
|
|
||||||
interval: 120s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 60s
|
|
||||||
|
|
||||||
# zitare-backend: REMOVED — migrated to local-first (mana-sync handles CRUD)
|
# zitare-backend: REMOVED — migrated to local-first (mana-sync handles CRUD)
|
||||||
|
|
||||||
|
|
@ -1405,24 +1374,22 @@ services:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/photos/apps/web/Dockerfile
|
dockerfile: apps/photos/apps/web/Dockerfile
|
||||||
args:
|
args:
|
||||||
PUBLIC_BACKEND_URL: http://photos-backend:3039
|
|
||||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||||
PUBLIC_MANA_MEDIA_URL: http://mana-media:3015
|
PUBLIC_MANA_MEDIA_URL: http://mana-media:3015
|
||||||
image: photos-web:local
|
image: photos-web:local
|
||||||
container_name: mana-app-photos-web
|
container_name: mana-app-photos-web
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
photos-backend:
|
mana-auth:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 5019
|
PORT: 5019
|
||||||
PUBLIC_BACKEND_URL: http://photos-backend:3039
|
|
||||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||||
PUBLIC_MANA_MEDIA_URL: http://mana-media:3015
|
PUBLIC_MANA_MEDIA_URL: http://mana-media:3015
|
||||||
PUBLIC_BACKEND_URL_CLIENT: https://photos-api.mana.how
|
|
||||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
||||||
PUBLIC_MANA_MEDIA_URL_CLIENT: https://media.mana.how
|
PUBLIC_MANA_MEDIA_URL_CLIENT: https://media.mana.how
|
||||||
|
PUBLIC_SYNC_SERVER_URL: ws://mana-sync:3050
|
||||||
ports:
|
ports:
|
||||||
- "5019:5019"
|
- "5019:5019"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,7 @@ scrape_configs:
|
||||||
metrics_path: '/metrics'
|
metrics_path: '/metrics'
|
||||||
scrape_interval: 30s
|
scrape_interval: 30s
|
||||||
|
|
||||||
# Photos Backend
|
# Photos Backend: REMOVED — migrated to local-first + direct mana-media
|
||||||
- job_name: 'photos-backend'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['photos-backend:3039']
|
|
||||||
metrics_path: '/metrics'
|
|
||||||
scrape_interval: 30s
|
|
||||||
|
|
||||||
# Zitare Backend: REMOVED — migrated to local-first
|
# Zitare Backend: REMOVED — migrated to local-first
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,11 +112,8 @@
|
||||||
"todo:db:seed": "pnpm --filter @todo/backend db:seed",
|
"todo:db:seed": "pnpm --filter @todo/backend db:seed",
|
||||||
"photos:dev": "turbo run dev --filter=photos...",
|
"photos:dev": "turbo run dev --filter=photos...",
|
||||||
"dev:photos:web": "pnpm --filter @photos/web dev",
|
"dev:photos:web": "pnpm --filter @photos/web dev",
|
||||||
"dev:photos:backend": "pnpm --filter @photos/backend dev",
|
"dev:photos:app": "pnpm dev:photos:web",
|
||||||
"dev:photos:app": "turbo run dev --filter=@photos/web --filter=@photos/backend",
|
"dev:photos:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:photos:web\"",
|
||||||
"dev:photos:full": "./scripts/setup-databases.sh photos && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:photos:backend\" \"pnpm dev:photos:web\"",
|
|
||||||
"photos:db:push": "pnpm --filter @photos/backend db:push",
|
|
||||||
"photos:db:studio": "pnpm --filter @photos/backend db:studio",
|
|
||||||
"dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
|
"dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
|
||||||
"inventar:dev": "turbo run dev --filter=inventar...",
|
"inventar:dev": "turbo run dev --filter=inventar...",
|
||||||
"dev:inventar:web": "pnpm --filter @inventar/web dev",
|
"dev:inventar:web": "pnpm --filter @inventar/web dev",
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ for container in $ALL_PROBLEM_CONTAINERS; do
|
||||||
mana-app-skilltree-web) SERVICE_NAME="skilltree-web" ;;
|
mana-app-skilltree-web) SERVICE_NAME="skilltree-web" ;;
|
||||||
mana-app-skilltree-backend) SERVICE_NAME="skilltree-backend" ;;
|
mana-app-skilltree-backend) SERVICE_NAME="skilltree-backend" ;;
|
||||||
mana-app-photos-web) SERVICE_NAME="photos-web" ;;
|
mana-app-photos-web) SERVICE_NAME="photos-web" ;;
|
||||||
mana-app-photos-backend) SERVICE_NAME="photos-backend" ;;
|
# mana-app-photos-backend: REMOVED
|
||||||
mana-app-web) SERVICE_NAME="mana-web" ;;
|
mana-app-web) SERVICE_NAME="mana-web" ;;
|
||||||
mana-core-auth) SERVICE_NAME="mana-auth" ;;
|
mana-core-auth) SERVICE_NAME="mana-auth" ;;
|
||||||
mana-core-gateway) SERVICE_NAME="api-gateway" ;;
|
mana-core-gateway) SERVICE_NAME="api-gateway" ;;
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ setup_service() {
|
||||||
;;
|
;;
|
||||||
photos)
|
photos)
|
||||||
create_db_if_not_exists "photos"
|
create_db_if_not_exists "photos"
|
||||||
push_schema "@photos/backend" "photos"
|
# Schema managed by mana-sync (backend removed)
|
||||||
;;
|
;;
|
||||||
finance)
|
finance)
|
||||||
create_db_if_not_exists "finance"
|
create_db_if_not_exists "finance"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue