mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-29 17:49:32 +02:00
refactor(infra): remove zitare + clock NestJS backends, add shared-hono package
Both apps are fully local-first via Dexie.js + mana-sync. Their NestJS backends were pure CRUD wrappers (20 + 31 source files) that are no longer needed. Changes: - Add packages/shared-hono: JWT auth via JWKS (jose), Drizzle DB factory, health route, generic GDPR admin handler, error middleware - Migrate zitare lists page from fetch() to listsStore (local-first) - Rewrite clock timers store from API-based to timerCollection (Dexie) - Update clock +layout.svelte CommandBar search to use local collections - Remove zitare-backend + clock-backend from docker-compose, CI/CD, Prometheus, env generation, setup scripts - Add docs/TECHNOLOGY_AUDIT_2026_03.md with full repo analysis Net result: -2 Docker containers, -2 ports, -2728 lines of code Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
82de69476f
commit
32939fbfb5
81 changed files with 1236 additions and 2727 deletions
|
|
@ -1,99 +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 (required dependencies)
|
||||
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
|
||||
COPY packages/shared-errors ./packages/shared-errors
|
||||
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
|
||||
COPY packages/shared-nestjs-health ./packages/shared-nestjs-health
|
||||
COPY packages/shared-nestjs-metrics ./packages/shared-nestjs-metrics
|
||||
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
|
||||
COPY packages/shared-tsconfig ./packages/shared-tsconfig
|
||||
COPY packages/shared-error-tracking ./packages/shared-error-tracking
|
||||
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
|
||||
|
||||
# Copy zitare content package
|
||||
COPY apps/zitare/packages/content ./apps/zitare/packages/content
|
||||
|
||||
# Copy zitare backend
|
||||
COPY apps/zitare/apps/backend ./apps/zitare/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 zitare content package
|
||||
WORKDIR /app/apps/zitare/packages/content
|
||||
RUN pnpm build
|
||||
|
||||
# Build the backend
|
||||
|
||||
WORKDIR /app/packages/shared-nestjs-setup
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/packages/shared-error-tracking
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/apps/zitare/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/zitare ./apps/zitare
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY apps/zitare/apps/backend/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
WORKDIR /app/apps/zitare/apps/backend
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3007
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3007/health || exit 1
|
||||
|
||||
# Run entrypoint script
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "=== Zitare Backend Entrypoint ==="
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
echo "Waiting for PostgreSQL..."
|
||||
until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-zitare} 2>/dev/null; do
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
echo "PostgreSQL is up!"
|
||||
|
||||
cd /app/apps/zitare/apps/backend
|
||||
|
||||
# Run schema push (for development) or migrations (for production)
|
||||
if [ "$NODE_ENV" = "production" ] && [ -d "src/db/migrations/meta" ]; then
|
||||
echo "Running database migrations..."
|
||||
npx tsx src/db/migrate.ts
|
||||
echo "Migrations completed!"
|
||||
else
|
||||
echo "Pushing database schema (development mode)..."
|
||||
npx drizzle-kit push --force
|
||||
echo "Schema push completed!"
|
||||
fi
|
||||
|
||||
# Run seed if seed file exists
|
||||
if [ -f "src/db/seed.ts" ]; then
|
||||
echo "Running database seed..."
|
||||
npx tsx src/db/seed.ts
|
||||
echo "Seed completed!"
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'zitare',
|
||||
additionalEnvVars: ['ZITARE_DATABASE_URL'],
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
{
|
||||
"name": "@zitare/backend",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-error-tracking": "workspace:*",
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@manacore/shared-nestjs-health": "workspace:*",
|
||||
"@manacore/shared-nestjs-metrics": "workspace:*",
|
||||
"@manacore/shared-nestjs-setup": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +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';
|
||||
|
||||
@Controller('admin')
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
export class AdminController {
|
||||
private readonly logger = new Logger(AdminController.name);
|
||||
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@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('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,87 +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>
|
||||
) {}
|
||||
|
||||
async getUserData(userId: string): Promise<UserDataResponse> {
|
||||
this.logger.log(`Getting user data for userId: ${userId}`);
|
||||
|
||||
// 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 user lists
|
||||
const userListsResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.userLists)
|
||||
.where(eq(schema.userLists.userId, userId));
|
||||
const userListsCount = userListsResult[0]?.count ?? 0;
|
||||
|
||||
// Get last activity
|
||||
const lastFavorite = await this.db
|
||||
.select({ createdAt: schema.favorites.createdAt })
|
||||
.from(schema.favorites)
|
||||
.where(eq(schema.favorites.userId, userId))
|
||||
.orderBy(desc(schema.favorites.createdAt))
|
||||
.limit(1);
|
||||
const lastActivityAt = lastFavorite[0]?.createdAt?.toISOString();
|
||||
|
||||
const entities: EntityCount[] = [
|
||||
{ entity: 'favorites', count: favoritesCount, label: 'Favorites' },
|
||||
{ entity: 'user_lists', count: userListsCount, label: 'User Lists' },
|
||||
];
|
||||
|
||||
const totalCount = favoritesCount + userListsCount;
|
||||
|
||||
return { entities, totalCount, lastActivityAt };
|
||||
}
|
||||
|
||||
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
|
||||
this.logger.log(`Deleting user data for userId: ${userId}`);
|
||||
|
||||
const deletedCounts: EntityCount[] = [];
|
||||
let totalDeleted = 0;
|
||||
|
||||
// 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: 'Favorites' });
|
||||
totalDeleted += deletedFavorites.length;
|
||||
|
||||
// Delete user lists
|
||||
const deletedUserLists = await this.db
|
||||
.delete(schema.userLists)
|
||||
.where(eq(schema.userLists.userId, userId))
|
||||
.returning();
|
||||
deletedCounts.push({
|
||||
entity: 'user_lists',
|
||||
count: deletedUserLists.length,
|
||||
label: 'User Lists',
|
||||
});
|
||||
totalDeleted += deletedUserLists.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,36 +0,0 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
@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,27 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { ListModule } from './list/list.module';
|
||||
import { HealthModule } from '@manacore/shared-nestjs-health';
|
||||
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
FavoriteModule,
|
||||
ListModule,
|
||||
HealthModule.forRoot({ serviceName: 'quote-backend' }),
|
||||
MetricsModule.register({
|
||||
prefix: 'zitare_',
|
||||
excludePaths: ['/health'],
|
||||
}),
|
||||
AdminModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection } from './connection';
|
||||
import type { Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function runMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
console.log('Running migrations...');
|
||||
|
||||
const sql = postgres(databaseUrl, { max: 1 });
|
||||
const db = drizzle(sql);
|
||||
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
|
||||
await sql.end();
|
||||
|
||||
console.log('Migrations completed successfully!');
|
||||
}
|
||||
|
||||
runMigrations().catch(console.error);
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { pgTable, uuid, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const favorites = pgTable(
|
||||
'favorites',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
quoteId: varchar('quote_id', { length: 100 }).notNull(), // References static quote ID from shared package
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
uniqueUserQuote: unique().on(table.userId, table.quoteId),
|
||||
})
|
||||
);
|
||||
|
||||
export type Favorite = typeof favorites.$inferSelect;
|
||||
export type NewFavorite = typeof favorites.$inferInsert;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './favorites.schema';
|
||||
export * from './user-lists.schema';
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const userLists = pgTable('user_lists', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
quoteIds: jsonb('quote_ids').$type<string[]>().default([]), // References static quote IDs from shared package
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export type UserList = typeof userLists.$inferSelect;
|
||||
export type NewUserList = typeof userLists.$inferInsert;
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { FavoriteService } from './favorite.service';
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
class CreateFavoriteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
quoteId!: string;
|
||||
}
|
||||
|
||||
@Controller('favorites')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FavoriteController {
|
||||
constructor(private readonly favoriteService: FavoriteService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
const favorites = await this.favoriteService.findByUserId(user.userId);
|
||||
return { favorites };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFavoriteDto) {
|
||||
// Check if already favorited
|
||||
const exists = await this.favoriteService.exists(user.userId, dto.quoteId);
|
||||
if (exists) {
|
||||
throw new ConflictException('Quote already in favorites');
|
||||
}
|
||||
|
||||
const favorite = await this.favoriteService.create({
|
||||
userId: user.userId,
|
||||
quoteId: dto.quoteId,
|
||||
});
|
||||
return { favorite };
|
||||
}
|
||||
|
||||
@Delete(':quoteId')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('quoteId') quoteId: string) {
|
||||
await this.favoriteService.delete(user.userId, quoteId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,34 +0,0 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { favorites } from '../db/schema';
|
||||
import type { Favorite, NewFavorite } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<Favorite[]> {
|
||||
return this.db.select().from(favorites).where(eq(favorites.userId, userId));
|
||||
}
|
||||
|
||||
async create(data: NewFavorite): Promise<Favorite> {
|
||||
const [favorite] = await this.db.insert(favorites).values(data).returning();
|
||||
return favorite;
|
||||
}
|
||||
|
||||
async delete(userId: string, quoteId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.quoteId, quoteId)));
|
||||
}
|
||||
|
||||
async exists(userId: string, quoteId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.quoteId, quoteId)));
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { initErrorTracking } from '@manacore/shared-error-tracking';
|
||||
|
||||
initErrorTracking({
|
||||
serviceName: 'zitare-backend',
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.APP_VERSION,
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ListService } from './list.service';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator';
|
||||
|
||||
class CreateListDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
class UpdateListDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
quoteIds?: string[];
|
||||
}
|
||||
|
||||
class AddQuoteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
quoteId!: string;
|
||||
}
|
||||
|
||||
@Controller('lists')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ListController {
|
||||
constructor(private readonly listService: ListService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
const lists = await this.listService.findByUserId(user.userId);
|
||||
return { lists };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const list = await this.listService.findById(user.userId, id);
|
||||
return { list };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateListDto) {
|
||||
const list = await this.listService.create({
|
||||
userId: user.userId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
});
|
||||
return { list };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateListDto
|
||||
) {
|
||||
const list = await this.listService.update(user.userId, id, dto);
|
||||
return { list };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.listService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/quotes')
|
||||
async addQuote(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddQuoteDto
|
||||
) {
|
||||
const list = await this.listService.addQuoteToList(user.userId, id, dto.quoteId);
|
||||
return { list };
|
||||
}
|
||||
|
||||
@Delete(':id/quotes/:quoteId')
|
||||
async removeQuote(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Param('quoteId') quoteId: string
|
||||
) {
|
||||
const list = await this.listService.removeQuoteFromList(user.userId, id, quoteId);
|
||||
return { list };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ListController } from './list.controller';
|
||||
import { ListService } from './list.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ListController],
|
||||
providers: [ListService],
|
||||
exports: [ListService],
|
||||
})
|
||||
export class ListModule {}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { userLists } from '../db/schema';
|
||||
import type { UserList, NewUserList } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class ListService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserList[]> {
|
||||
return this.db.select().from(userLists).where(eq(userLists.userId, userId));
|
||||
}
|
||||
|
||||
async findById(userId: string, listId: string): Promise<UserList> {
|
||||
const [list] = await this.db
|
||||
.select()
|
||||
.from(userLists)
|
||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)));
|
||||
|
||||
if (!list) {
|
||||
throw new NotFoundException('List not found');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async create(data: NewUserList): Promise<UserList> {
|
||||
const [list] = await this.db.insert(userLists).values(data).returning();
|
||||
return list;
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
listId: string,
|
||||
data: Partial<Pick<UserList, 'name' | 'description' | 'quoteIds'>>
|
||||
): Promise<UserList> {
|
||||
const [list] = await this.db
|
||||
.update(userLists)
|
||||
.set(data)
|
||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!list) {
|
||||
throw new NotFoundException('List not found');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async delete(userId: string, listId: string): Promise<void> {
|
||||
const result = await this.db
|
||||
.delete(userLists)
|
||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)));
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException('List not found');
|
||||
}
|
||||
}
|
||||
|
||||
async addQuoteToList(userId: string, listId: string, quoteId: string): Promise<UserList> {
|
||||
const list = await this.findById(userId, listId);
|
||||
const quoteIds = list.quoteIds || [];
|
||||
|
||||
if (!quoteIds.includes(quoteId)) {
|
||||
quoteIds.push(quoteId);
|
||||
}
|
||||
|
||||
return this.update(userId, listId, { quoteIds });
|
||||
}
|
||||
|
||||
async removeQuoteFromList(userId: string, listId: string, quoteId: string): Promise<UserList> {
|
||||
const list = await this.findById(userId, listId);
|
||||
const quoteIds = (list.quoteIds || []).filter((id) => id !== quoteId);
|
||||
return this.update(userId, listId, { quoteIds });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import './instrument';
|
||||
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
bootstrapApp(AppModule, {
|
||||
defaultPort: 3007,
|
||||
serviceName: 'Quote',
|
||||
additionalCorsOrigins: ['http://localhost:5177'],
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -4,18 +4,9 @@
|
|||
import { _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import { listsStore } from '$lib/stores/lists.svelte';
|
||||
import { ZitareEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
interface QuoteList {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
quoteIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
let lists = $state<QuoteList[]>([]);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let deletingId = $state<string | null>(null);
|
||||
|
|
@ -23,69 +14,16 @@
|
|||
let newListName = $state('');
|
||||
let newListDescription = $state('');
|
||||
|
||||
// Get backend URL
|
||||
function getBackendUrl(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
return injectedUrl || 'http://localhost:3007';
|
||||
}
|
||||
return 'http://localhost:3007';
|
||||
}
|
||||
|
||||
async function fetchLists() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/lists`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
lists = data.lists || [];
|
||||
} else {
|
||||
toast.error($_('common.error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch lists:', error);
|
||||
toast.error($_('common.error'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createList() {
|
||||
if (!newListName.trim() || saving) return;
|
||||
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/lists`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newListName.trim(),
|
||||
description: newListDescription.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
lists = [...lists, data.list];
|
||||
const created = await listsStore.createList(
|
||||
newListName.trim(),
|
||||
newListDescription.trim() || undefined
|
||||
);
|
||||
if (created) {
|
||||
ZitareEvents.listCreated();
|
||||
showCreateModal = false;
|
||||
newListName = '';
|
||||
|
|
@ -104,18 +42,10 @@
|
|||
async function deleteList(listId: string) {
|
||||
if (deletingId || !confirm($_('lists.confirmDelete'))) return;
|
||||
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) return;
|
||||
|
||||
deletingId = listId;
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/lists/${listId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
lists = lists.filter((l) => l.id !== listId);
|
||||
const success = await listsStore.deleteList(listId);
|
||||
if (success) {
|
||||
ZitareEvents.listDeleted();
|
||||
} else {
|
||||
toast.error($_('lists.detail.toast.deleteError'));
|
||||
|
|
@ -128,8 +58,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchLists();
|
||||
onMount(async () => {
|
||||
await listsStore.loadLists();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -187,7 +118,7 @@
|
|||
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto"
|
||||
></div>
|
||||
</div>
|
||||
{:else if lists.length === 0}
|
||||
{:else if listsStore.lists.length === 0}
|
||||
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
|
||||
|
|
@ -207,7 +138,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4">
|
||||
{#each lists as list (list.id)}
|
||||
{#each listsStore.lists as list (list.id)}
|
||||
<a
|
||||
href="/lists/{list.id}"
|
||||
class="block p-6 bg-surface-elevated rounded-2xl hover:shadow-lg transition-all group"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue