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:
Till JS 2026-03-28 02:18:03 +01:00
parent e7a8567e61
commit d7799ec95d
43 changed files with 243 additions and 1816 deletions

View file

@ -1,93 +0,0 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY patches ./patches
# Copy shared packages (all required dependencies)
COPY packages/shared-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"]

View file

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

View file

@ -1,6 +0,0 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'photos',
outDir: './drizzle',
});

View file

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

View file

@ -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"
}
}

View file

@ -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);
}
}

View file

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

View file

@ -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,
};
}
}

View file

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

View file

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

View file

@ -1,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;
}
}

View file

@ -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 {}

View file

@ -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)));
}
}

View file

@ -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[];
}

View file

@ -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 {}

View file

@ -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 {}

View file

@ -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;

View file

@ -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;

View file

@ -1,3 +0,0 @@
export * from './albums.schema';
export * from './favorites.schema';
export * from './tags.schema';

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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 };
}
}
}

View file

@ -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',
});

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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 {}

View file

@ -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) || [],
}));
}
}

View file

@ -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[];
}

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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 })));
}
}
}
}

View file

@ -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
}
}

View file

@ -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;

View file

@ -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;
},

View file

@ -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;

View file

@ -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 {

View file

@ -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;

View file

@ -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:

View file

@ -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

View file

@ -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",

View file

@ -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" ;;

View file

@ -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"