mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41: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 type { Album, Photo } from '@photos/shared';
|
||||
|
||||
|
|
@ -13,8 +21,27 @@ let albumPhotos = $state<Photo[]>([]);
|
|||
let loading = $state(false);
|
||||
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 = {
|
||||
// Getters
|
||||
get albums() {
|
||||
return albums;
|
||||
},
|
||||
|
|
@ -31,22 +58,11 @@ export const albumStore = {
|
|||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all albums
|
||||
*/
|
||||
async loadAlbums() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.get<Album[]>('/albums');
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
albums = result.data;
|
||||
}
|
||||
await refreshAlbums();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load albums';
|
||||
} finally {
|
||||
|
|
@ -54,22 +70,23 @@ export const albumStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load single album with items
|
||||
*/
|
||||
async loadAlbum(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.get<Album & { items: Photo[] }>(`/albums/${id}`);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
currentAlbum = result.data;
|
||||
albumPhotos = result.data.items || [];
|
||||
const local = await albumCollection.get(id);
|
||||
if (local) {
|
||||
currentAlbum = toAlbum(local);
|
||||
// Load album items (media IDs)
|
||||
const items = await albumItemCollection.getAll();
|
||||
const albumItems = items
|
||||
.filter((item) => item.albumId === id)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
// Album items reference mediaIds — photo data comes from mana-media
|
||||
albumPhotos = albumItems.map((item) => ({ id: item.mediaId }) as Photo);
|
||||
} else {
|
||||
currentAlbum = null;
|
||||
albumPhotos = [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load album';
|
||||
|
|
@ -78,49 +95,42 @@ export const albumStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new album
|
||||
*/
|
||||
async createAlbum(data: { name: string; description?: string }) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.post<Album>('/albums', data);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return null;
|
||||
}
|
||||
if (result.data) {
|
||||
albums = [...albums, result.data];
|
||||
PhotosEvents.albumCreated();
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
const newLocal: LocalAlbum = {
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
coverMediaId: null,
|
||||
isAutoGenerated: false,
|
||||
autoGenerateType: null,
|
||||
autoGenerateValue: null,
|
||||
};
|
||||
const inserted = await albumCollection.insert(newLocal);
|
||||
const newAlbum = toAlbum(inserted);
|
||||
albums = [...albums, newAlbum];
|
||||
PhotosEvents.albumCreated();
|
||||
return newAlbum;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create album';
|
||||
return null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update album
|
||||
*/
|
||||
async updateAlbum(id: string, data: { name?: string; description?: string }) {
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.patch<Album>(`/albums/${id}`, data);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return null;
|
||||
}
|
||||
if (result.data) {
|
||||
albums = albums.map((a) => (a.id === id ? result.data! : a));
|
||||
if (currentAlbum?.id === id) {
|
||||
currentAlbum = result.data;
|
||||
}
|
||||
return result.data;
|
||||
const updateData: Partial<LocalAlbum> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description ?? null;
|
||||
|
||||
const updated = await albumCollection.update(id, updateData);
|
||||
if (updated) {
|
||||
const updatedAlbum = toAlbum(updated);
|
||||
albums = albums.map((a) => (a.id === id ? updatedAlbum : a));
|
||||
if (currentAlbum?.id === id) currentAlbum = updatedAlbum;
|
||||
return updatedAlbum;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
|
|
@ -129,16 +139,15 @@ export const albumStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete album
|
||||
*/
|
||||
async deleteAlbum(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.delete(`/albums/${id}`);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
// Delete album items first
|
||||
const items = await albumItemCollection.getAll();
|
||||
for (const item of items.filter((i) => i.albumId === id)) {
|
||||
await albumItemCollection.delete(item.id);
|
||||
}
|
||||
await albumCollection.delete(id);
|
||||
albums = albums.filter((a) => a.id !== id);
|
||||
PhotosEvents.albumDeleted();
|
||||
if (currentAlbum?.id === id) {
|
||||
|
|
@ -152,18 +161,25 @@ export const albumStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add photos to album
|
||||
*/
|
||||
async addPhotosToAlbum(albumId: string, mediaIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.post(`/albums/${albumId}/items`, { mediaIds });
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
const existing = await albumItemCollection.getAll();
|
||||
const existingInAlbum = existing.filter((i) => i.albumId === albumId);
|
||||
let nextOrder = existingInAlbum.length;
|
||||
|
||||
for (const mediaId of mediaIds) {
|
||||
// Skip duplicates
|
||||
if (existingInAlbum.some((i) => i.mediaId === mediaId)) continue;
|
||||
|
||||
await albumItemCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
albumId,
|
||||
mediaId,
|
||||
sortOrder: nextOrder++,
|
||||
});
|
||||
}
|
||||
PhotosEvents.photosAddedToAlbum(mediaIds.length);
|
||||
// Reload album to get updated items
|
||||
if (currentAlbum?.id === albumId) {
|
||||
await this.loadAlbum(albumId);
|
||||
}
|
||||
|
|
@ -174,18 +190,16 @@ export const albumStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove photo from album
|
||||
*/
|
||||
async removePhotoFromAlbum(albumId: string, mediaId: string) {
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.delete(`/albums/${albumId}/items/${mediaId}`);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
const items = await albumItemCollection.getAll();
|
||||
const item = items.find((i) => i.albumId === albumId && i.mediaId === mediaId);
|
||||
if (item) {
|
||||
await albumItemCollection.delete(item.id);
|
||||
albumPhotos = albumPhotos.filter((p) => p.id !== mediaId);
|
||||
PhotosEvents.photoRemovedFromAlbum();
|
||||
}
|
||||
albumPhotos = albumPhotos.filter((p) => p.id !== mediaId);
|
||||
PhotosEvents.photoRemovedFromAlbum();
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to remove photo from album';
|
||||
|
|
@ -193,21 +207,16 @@ export const albumStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set album cover
|
||||
*/
|
||||
async setCover(albumId: string, mediaId: string) {
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.patch<Album>(`/albums/${albumId}/cover`, { mediaId });
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
}
|
||||
if (result.data) {
|
||||
albums = albums.map((a) => (a.id === albumId ? result.data! : a));
|
||||
if (currentAlbum?.id === albumId) {
|
||||
currentAlbum = result.data;
|
||||
}
|
||||
const updated = await albumCollection.update(albumId, {
|
||||
coverMediaId: mediaId,
|
||||
} as Partial<LocalAlbum>);
|
||||
if (updated) {
|
||||
const updatedAlbum = toAlbum(updated);
|
||||
albums = albums.map((a) => (a.id === albumId ? updatedAlbum : a));
|
||||
if (currentAlbum?.id === albumId) currentAlbum = updatedAlbum;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
|
@ -216,17 +225,11 @@ export const albumStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear current album
|
||||
*/
|
||||
clearCurrentAlbum() {
|
||||
currentAlbum = null;
|
||||
albumPhotos = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset store
|
||||
*/
|
||||
reset() {
|
||||
albums = [];
|
||||
currentAlbum = null;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,37 @@
|
|||
/**
|
||||
* Photos Store - Manages photo gallery state using Svelte 5 runes
|
||||
* Photos Store — Fetches from mana-media directly, favorites local-first.
|
||||
*
|
||||
* Photo files live on mana-media. Albums/favorites/tags are local (Dexie).
|
||||
* This store calls mana-media for photo listing and enriches with local data.
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import { favoriteCollection, type LocalFavorite } from '$lib/data/local-store';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { PhotosEvents } from '@manacore/shared-utils/analytics';
|
||||
import type { Photo, PhotoFilters, PhotoStats } from '@photos/shared';
|
||||
|
||||
const MEDIA_URL = () =>
|
||||
(typeof window !== 'undefined'
|
||||
? (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }).__PUBLIC_MANA_MEDIA_URL__
|
||||
: null) ||
|
||||
import.meta.env.PUBLIC_MANA_MEDIA_URL ||
|
||||
'http://localhost:3015';
|
||||
|
||||
async function mediaFetch<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
|
||||
let photos = $state<Photo[]>([]);
|
||||
let loading = $state(false);
|
||||
|
|
@ -20,8 +46,14 @@ let filters = $state<PhotoFilters>({
|
|||
let stats = $state<PhotoStats | 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 = {
|
||||
// Getters
|
||||
get photos() {
|
||||
return photos;
|
||||
},
|
||||
|
|
@ -44,9 +76,6 @@ export const photoStore = {
|
|||
return selectedPhoto;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load photos with current filters
|
||||
*/
|
||||
async loadPhotos(reset = false) {
|
||||
if (loading) return;
|
||||
|
||||
|
|
@ -71,19 +100,15 @@ export const photoStore = {
|
|||
params.set('sortBy', filters.sortBy || 'dateTaken');
|
||||
params.set('sortOrder', filters.sortOrder || 'desc');
|
||||
|
||||
const result = await api.get<{ items: Photo[]; total: number; hasMore: boolean }>(
|
||||
`/photos?${params.toString()}`
|
||||
const result = await mediaFetch<{ items: Photo[]; total: number; hasMore: boolean }>(
|
||||
`/media/list/all?${params.toString()}`
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
photos = reset ? result.data.items : [...photos, ...result.data.items];
|
||||
hasMore = result.data.hasMore;
|
||||
filters = { ...filters, offset: (filters.offset || 0) + result.data.items.length };
|
||||
if (result) {
|
||||
const enriched = await enrichWithFavorites(result.items);
|
||||
photos = reset ? enriched : [...photos, ...enriched];
|
||||
hasMore = result.hasMore;
|
||||
filters = { ...filters, offset: (filters.offset || 0) + result.items.length };
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load photos';
|
||||
|
|
@ -92,81 +117,74 @@ export const photoStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load more photos (pagination)
|
||||
*/
|
||||
async loadMore() {
|
||||
if (!hasMore || loading) return;
|
||||
await this.loadPhotos(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update filters and reload
|
||||
*/
|
||||
async setFilters(newFilters: Partial<PhotoFilters>) {
|
||||
filters = { ...filters, ...newFilters, offset: 0 };
|
||||
PhotosEvents.filtersApplied();
|
||||
await this.loadPhotos(true);
|
||||
},
|
||||
|
||||
/**
|
||||
* Load photo statistics
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const result = await api.get<PhotoStats>('/photos/stats');
|
||||
if (result.data) {
|
||||
stats = result.data;
|
||||
}
|
||||
const result = await mediaFetch<PhotoStats>('/media/stats');
|
||||
if (result) stats = result;
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a photo for detail view
|
||||
*/
|
||||
selectPhoto(photo: Photo | null) {
|
||||
selectedPhoto = photo;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
/** Toggle favorite — local-first via Dexie. */
|
||||
async toggleFavorite(mediaId: string) {
|
||||
try {
|
||||
const result = await api.post<{ isFavorited: boolean }>(`/favorites/${mediaId}/toggle`);
|
||||
if (result.data) {
|
||||
PhotosEvents.photoFavorited(result.data.isFavorited);
|
||||
// Update photo in list
|
||||
photos = photos.map((p) =>
|
||||
p.id === mediaId ? { ...p, isFavorited: result.data!.isFavorited } : p
|
||||
);
|
||||
// Update selected photo if it's the same
|
||||
if (selectedPhoto?.id === mediaId) {
|
||||
selectedPhoto = { ...selectedPhoto, isFavorited: result.data.isFavorited };
|
||||
}
|
||||
const existing = await favoriteCollection.getAll();
|
||||
const fav = existing.find((f) => f.mediaId === mediaId);
|
||||
let isFavorited: boolean;
|
||||
|
||||
if (fav) {
|
||||
await favoriteCollection.delete(fav.id);
|
||||
isFavorited = false;
|
||||
} else {
|
||||
await favoriteCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
mediaId,
|
||||
} as LocalFavorite);
|
||||
isFavorited = true;
|
||||
}
|
||||
|
||||
PhotosEvents.photoFavorited(isFavorited);
|
||||
photos = photos.map((p) => (p.id === mediaId ? { ...p, isFavorited } : p));
|
||||
if (selectedPhoto?.id === mediaId) {
|
||||
selectedPhoto = { ...selectedPhoto, isFavorited };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a photo
|
||||
*/
|
||||
async deletePhoto(mediaId: string) {
|
||||
try {
|
||||
const result = await api.delete(`/photos/${mediaId}`);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
const token = await authStore.getValidToken();
|
||||
const response = await fetch(`${MEDIA_URL()}/api/v1/media/${mediaId}`, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
error = 'Failed to delete photo';
|
||||
return false;
|
||||
}
|
||||
|
||||
photos = photos.filter((p) => p.id !== mediaId);
|
||||
PhotosEvents.photoDeleted();
|
||||
if (selectedPhoto?.id === mediaId) {
|
||||
selectedPhoto = null;
|
||||
}
|
||||
if (selectedPhoto?.id === mediaId) selectedPhoto = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete photo';
|
||||
|
|
@ -174,20 +192,12 @@ export const photoStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state
|
||||
*/
|
||||
reset() {
|
||||
photos = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
hasMore = true;
|
||||
filters = {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
sortBy: 'dateTaken',
|
||||
sortOrder: 'desc',
|
||||
};
|
||||
filters = { limit: 50, offset: 0, sortBy: 'dateTaken', sortOrder: 'desc' };
|
||||
stats = null;
|
||||
selectedPhoto = null;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Tag Store — Local-First via Shared Tag Store
|
||||
*
|
||||
* Core tag CRUD is handled by the shared local-first tag store.
|
||||
* Photo-specific tag operations (junction table) go through the Photos backend.
|
||||
* Photo-specific tag operations (junction table) are local-first via Dexie.
|
||||
*/
|
||||
|
||||
export {
|
||||
|
|
@ -14,22 +14,18 @@ export {
|
|||
getTagsByGroup,
|
||||
} from '@manacore/shared-stores';
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import type { Tag } from '@photos/shared';
|
||||
import { photoTagCollection, type LocalPhotoTag } from '$lib/data/local-store';
|
||||
|
||||
/**
|
||||
* Photo-specific tag operations (junction table: photo <-> tag).
|
||||
* These go through the Photos backend, not the shared tag store.
|
||||
* Local-first via Dexie — syncs to server in the background.
|
||||
*/
|
||||
export const photoTagOps = {
|
||||
/** Get tags for a photo */
|
||||
async getPhotoTags(mediaId: string): Promise<Tag[]> {
|
||||
async getPhotoTags(mediaId: string): Promise<string[]> {
|
||||
try {
|
||||
const result = await api.get<Tag[]>(`/photos/${mediaId}/tags`);
|
||||
if (result.data) {
|
||||
return result.data;
|
||||
}
|
||||
return [];
|
||||
const all = await photoTagCollection.getAll();
|
||||
return all.filter((pt) => pt.mediaId === mediaId).map((pt) => pt.tagId);
|
||||
} catch (e) {
|
||||
console.error('Failed to get photo tags:', e);
|
||||
return [];
|
||||
|
|
@ -39,8 +35,17 @@ export const photoTagOps = {
|
|||
/** Add tag to photo */
|
||||
async addTagToPhoto(mediaId: string, tagId: string) {
|
||||
try {
|
||||
const result = await api.post(`/photos/${mediaId}/tags/${tagId}`);
|
||||
return !result.error;
|
||||
// Check if already exists
|
||||
const all = await photoTagCollection.getAll();
|
||||
const exists = all.some((pt) => pt.mediaId === mediaId && pt.tagId === tagId);
|
||||
if (exists) return true;
|
||||
|
||||
await photoTagCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
mediaId,
|
||||
tagId,
|
||||
} as LocalPhotoTag);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to add tag to photo:', e);
|
||||
return false;
|
||||
|
|
@ -50,19 +55,37 @@ export const photoTagOps = {
|
|||
/** Remove tag from photo */
|
||||
async removeTagFromPhoto(mediaId: string, tagId: string) {
|
||||
try {
|
||||
const result = await api.delete(`/photos/${mediaId}/tags/${tagId}`);
|
||||
return !result.error;
|
||||
const all = await photoTagCollection.getAll();
|
||||
const item = all.find((pt) => pt.mediaId === mediaId && pt.tagId === tagId);
|
||||
if (item) {
|
||||
await photoTagCollection.delete(item.id);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to remove tag from photo:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Set all tags for a photo */
|
||||
/** Set all tags for a photo (replace) */
|
||||
async setPhotoTags(mediaId: string, tagIds: string[]) {
|
||||
try {
|
||||
const result = await api.patch(`/photos/${mediaId}/tags`, { tagIds });
|
||||
return !result.error;
|
||||
// Remove existing tags for this photo
|
||||
const all = await photoTagCollection.getAll();
|
||||
const existing = all.filter((pt) => pt.mediaId === mediaId);
|
||||
for (const item of existing) {
|
||||
await photoTagCollection.delete(item.id);
|
||||
}
|
||||
|
||||
// Add new tags
|
||||
for (const tagId of tagIds) {
|
||||
await photoTagCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
mediaId,
|
||||
tagId,
|
||||
} as LocalPhotoTag);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to set photo tags:', e);
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { api } from '$lib/api/client';
|
||||
import { photoStore } from '$lib/stores/photos.svelte';
|
||||
import { favoriteCollection } from '$lib/data/local-store';
|
||||
import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte';
|
||||
import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte';
|
||||
import type { Photo } from '@photos/shared';
|
||||
|
|
@ -20,14 +20,9 @@
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.get<{ items: Photo[] }>('/favorites');
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
favorites = result.data.items;
|
||||
}
|
||||
const localFavs = await favoriteCollection.getAll();
|
||||
// Favorited media IDs — full photo data would come from mana-media
|
||||
favorites = localFavs.map((f) => ({ id: f.mediaId, isFavorited: true }) as Photo);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load favorites';
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
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';
|
||||
|
||||
const MEDIA_URL = import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
|
||||
import UploadDropzone from '$lib/components/upload/UploadDropzone.svelte';
|
||||
|
||||
interface UploadFile {
|
||||
|
|
@ -49,7 +51,13 @@
|
|||
formData.append('file', files[i].file);
|
||||
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].progress = 100;
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ services:
|
|||
PICTURE_BACKEND_URL: http://picture-backend:3040
|
||||
# PRESI_BACKEND_URL: removed — replaced by Hono server
|
||||
# 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
|
||||
STORAGE_BACKEND_URL: http://storage-backend:3035
|
||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||
|
|
@ -781,38 +781,7 @@ services:
|
|||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
photos-backend:
|
||||
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
|
||||
# photos-backend: REMOVED — migrated to local-first (talks to mana-media directly)
|
||||
|
||||
# zitare-backend: REMOVED — migrated to local-first (mana-sync handles CRUD)
|
||||
|
||||
|
|
@ -1405,24 +1374,22 @@ services:
|
|||
context: .
|
||||
dockerfile: apps/photos/apps/web/Dockerfile
|
||||
args:
|
||||
PUBLIC_BACKEND_URL: http://photos-backend:3039
|
||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
PUBLIC_MANA_MEDIA_URL: http://mana-media:3015
|
||||
image: photos-web:local
|
||||
container_name: mana-app-photos-web
|
||||
restart: always
|
||||
depends_on:
|
||||
photos-backend:
|
||||
mana-auth:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 5019
|
||||
PUBLIC_BACKEND_URL: http://photos-backend:3039
|
||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
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_MEDIA_URL_CLIENT: https://media.mana.how
|
||||
PUBLIC_SYNC_SERVER_URL: ws://mana-sync:3050
|
||||
ports:
|
||||
- "5019:5019"
|
||||
healthcheck:
|
||||
|
|
|
|||
|
|
@ -109,12 +109,7 @@ scrape_configs:
|
|||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
|
||||
# Photos Backend
|
||||
- job_name: 'photos-backend'
|
||||
static_configs:
|
||||
- targets: ['photos-backend:3039']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
# Photos Backend: REMOVED — migrated to local-first + direct mana-media
|
||||
|
||||
# Zitare Backend: REMOVED — migrated to local-first
|
||||
|
||||
|
|
|
|||
|
|
@ -112,11 +112,8 @@
|
|||
"todo:db:seed": "pnpm --filter @todo/backend db:seed",
|
||||
"photos:dev": "turbo run dev --filter=photos...",
|
||||
"dev:photos:web": "pnpm --filter @photos/web dev",
|
||||
"dev:photos:backend": "pnpm --filter @photos/backend dev",
|
||||
"dev:photos:app": "turbo run dev --filter=@photos/web --filter=@photos/backend",
|
||||
"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:photos:app": "pnpm dev:photos:web",
|
||||
"dev:photos:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:photos: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...",
|
||||
"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-backend) SERVICE_NAME="skilltree-backend" ;;
|
||||
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-core-auth) SERVICE_NAME="mana-auth" ;;
|
||||
mana-core-gateway) SERVICE_NAME="api-gateway" ;;
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ setup_service() {
|
|||
;;
|
||||
photos)
|
||||
create_db_if_not_exists "photos"
|
||||
push_schema "@photos/backend" "photos"
|
||||
# Schema managed by mana-sync (backend removed)
|
||||
;;
|
||||
finance)
|
||||
create_db_if_not_exists "finance"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue