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:
Till JS 2026-03-27 22:43:46 +01:00
parent 82de69476f
commit 32939fbfb5
81 changed files with 1236 additions and 2727 deletions

View file

@ -1,54 +0,0 @@
# syntax=docker/dockerfile:1
# Build stage — inherits pre-built shared packages from nestjs-base
FROM nestjs-base:local AS builder
# Copy clock-specific packages and backend
COPY apps/clock/packages ./apps/clock/packages
COPY apps/clock/apps/backend ./apps/clock/apps/backend
# Reinstall to link app-specific dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts
# Build the backend
WORKDIR /app/apps/clock/apps/backend
RUN pnpm build
# Remove devDependencies and unnecessary files from node_modules
WORKDIR /app
RUN pnpm prune --prod --no-optional 2>/dev/null || true \
&& find node_modules -name '*.ts' -not -name '*.d.ts' -delete 2>/dev/null || true \
&& find node_modules -name '*.map' -delete 2>/dev/null || true \
&& find node_modules -type d \( -name 'test' -o -name 'tests' -o -name '__tests__' -o -name 'docs' \) -prune -exec rm -rf {} + 2>/dev/null || true
# Production stage
FROM node:20-alpine AS production
# Install postgresql-client for health checks
RUN 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/clock ./apps/clock
# Copy entrypoint script
COPY apps/clock/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/apps/clock/apps/backend
# Expose port
EXPOSE 3017
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3017/api/v1/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -1,23 +0,0 @@
#!/bin/sh
set -e
echo "=== Clock 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:-postgres} 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is up!"
cd /app/apps/clock/apps/backend
# Run schema push
echo "Pushing database schema..."
npx drizzle-kit push --config drizzle.config.ts --force
echo "Schema push completed!"
# Execute the main command
echo "Starting application..."
exec "$@"

View file

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

View file

@ -1,60 +0,0 @@
{
"name": "@clock/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:*",
"@clock/shared": "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",
"@nestjs/schedule": "^4.1.2",
"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",
"prom-client": "^15.1.0",
"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"
}
}

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,145 +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 alarms
const alarmsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.alarms)
.where(eq(schema.alarms.userId, userId));
const alarmsCount = alarmsResult[0]?.count ?? 0;
// Count timers
const timersResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.timers)
.where(eq(schema.timers.userId, userId));
const timersCount = timersResult[0]?.count ?? 0;
// Count world clocks
const worldClocksResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.worldClocks)
.where(eq(schema.worldClocks.userId, userId));
const worldClocksCount = worldClocksResult[0]?.count ?? 0;
// Count presets
const presetsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.presets)
.where(eq(schema.presets.userId, userId));
const presetsCount = presetsResult[0]?.count ?? 0;
// Get last activity (most recent alarm update)
const lastAlarm = await this.db
.select({ updatedAt: schema.alarms.updatedAt })
.from(schema.alarms)
.where(eq(schema.alarms.userId, userId))
.orderBy(desc(schema.alarms.updatedAt))
.limit(1);
const lastActivityAt = lastAlarm[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'alarms', count: alarmsCount, label: 'Wecker' },
{ entity: 'timers', count: timersCount, label: 'Timer' },
{ entity: 'world_clocks', count: worldClocksCount, label: 'Weltuhren' },
{ entity: 'presets', count: presetsCount, label: 'Vorlagen' },
];
const totalCount = alarmsCount + timersCount + worldClocksCount + presetsCount;
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 alarms
const deletedAlarms = await this.db
.delete(schema.alarms)
.where(eq(schema.alarms.userId, userId))
.returning();
deletedCounts.push({
entity: 'alarms',
count: deletedAlarms.length,
label: 'Wecker',
});
totalDeleted += deletedAlarms.length;
// Delete timers
const deletedTimers = await this.db
.delete(schema.timers)
.where(eq(schema.timers.userId, userId))
.returning();
deletedCounts.push({
entity: 'timers',
count: deletedTimers.length,
label: 'Timer',
});
totalDeleted += deletedTimers.length;
// Delete world clocks
const deletedWorldClocks = await this.db
.delete(schema.worldClocks)
.where(eq(schema.worldClocks.userId, userId))
.returning();
deletedCounts.push({
entity: 'world_clocks',
count: deletedWorldClocks.length,
label: 'Weltuhren',
});
totalDeleted += deletedWorldClocks.length;
// Delete presets
const deletedPresets = await this.db
.delete(schema.presets)
.where(eq(schema.presets.userId, userId))
.returning();
deletedCounts.push({
entity: 'presets',
count: deletedPresets.length,
label: 'Vorlagen',
});
totalDeleted += deletedPresets.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,45 +0,0 @@
import { Controller, Get, Post, Put, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AlarmService } from './alarm.service';
import { CreateAlarmDto, UpdateAlarmDto } from './dto';
@Controller('alarms')
@UseGuards(JwtAuthGuard)
export class AlarmController {
constructor(private readonly alarmService: AlarmService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.alarmService.findAll(user.userId);
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.alarmService.findByIdOrThrow(id, user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAlarmDto) {
return this.alarmService.create(user.userId, dto);
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateAlarmDto
) {
return this.alarmService.update(id, user.userId, dto);
}
@Patch(':id/toggle')
async toggle(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.alarmService.toggle(id, user.userId);
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.alarmService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AlarmController } from './alarm.controller';
import { AlarmService } from './alarm.service';
@Module({
controllers: [AlarmController],
providers: [AlarmService],
exports: [AlarmService],
})
export class AlarmModule {}

View file

@ -1,82 +0,0 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { alarms, type Alarm } from '../db/schema';
import { CreateAlarmDto, UpdateAlarmDto } from './dto';
@Injectable()
export class AlarmService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Alarm[]> {
return this.db.select().from(alarms).where(eq(alarms.userId, userId));
}
async findById(id: string, userId: string): Promise<Alarm | null> {
const result = await this.db
.select()
.from(alarms)
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Alarm> {
const alarm = await this.findById(id, userId);
if (!alarm) {
throw new NotFoundException(`Alarm with id ${id} not found`);
}
return alarm;
}
async create(userId: string, dto: CreateAlarmDto): Promise<Alarm> {
const result = await this.db
.insert(alarms)
.values({
userId,
label: dto.label,
time: dto.time,
enabled: dto.enabled ?? true,
repeatDays: dto.repeatDays,
snoozeMinutes: dto.snoozeMinutes ?? 5,
sound: dto.sound ?? 'default',
vibrate: dto.vibrate ?? true,
})
.returning();
return result[0];
}
async update(id: string, userId: string, dto: UpdateAlarmDto): Promise<Alarm> {
await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(alarms)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
.returning();
return result[0];
}
async toggle(id: string, userId: string): Promise<Alarm> {
const alarm = await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(alarms)
.set({
enabled: !alarm.enabled,
updatedAt: new Date(),
})
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
.returning();
return result[0];
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(alarms).where(and(eq(alarms.id, id), eq(alarms.userId, userId)));
}
}

View file

@ -1,85 +0,0 @@
import {
IsString,
IsOptional,
IsBoolean,
IsArray,
IsNumber,
Min,
Max,
Matches,
} from 'class-validator';
export class CreateAlarmDto {
@IsOptional()
@IsString()
label?: string;
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, {
message: 'time must be in HH:MM:SS format',
})
time!: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
@Min(0, { each: true })
@Max(6, { each: true })
repeatDays?: number[];
@IsOptional()
@IsNumber()
@Min(1)
@Max(60)
snoozeMinutes?: number;
@IsOptional()
@IsString()
sound?: string;
@IsOptional()
@IsBoolean()
vibrate?: boolean;
}
export class UpdateAlarmDto {
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, {
message: 'time must be in HH:MM:SS format',
})
time?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
@Min(0, { each: true })
@Max(6, { each: true })
repeatDays?: number[];
@IsOptional()
@IsNumber()
@Min(1)
@Max(60)
snoozeMinutes?: number;
@IsOptional()
@IsString()
sound?: string;
@IsOptional()
@IsBoolean()
vibrate?: boolean;
}

View file

@ -1,33 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { AlarmModule } from './alarm/alarm.module';
import { TimerModule } from './timer/timer.module';
import { WorldClockModule } from './world-clock/world-clock.module';
import { PresetModule } from './preset/preset.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
MetricsModule.register({
prefix: 'clock_',
excludePaths: ['/health'],
}),
DatabaseModule,
HealthModule.forRoot({ serviceName: 'clock-backend' }),
AlarmModule,
TimerModule,
WorldClockModule,
PresetModule,
AdminModule,
],
})
export class AppModule {}

View file

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

View file

@ -1,28 +0,0 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, 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();
}
}

View file

@ -1,27 +0,0 @@
import {
pgTable,
uuid,
text,
varchar,
time,
boolean,
integer,
timestamp,
} from 'drizzle-orm/pg-core';
export const alarms = pgTable('alarms', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
label: varchar('label', { length: 255 }),
time: time('time').notNull(),
enabled: boolean('enabled').default(true).notNull(),
repeatDays: integer('repeat_days').array(), // [0-6] for weekdays (0=Sun)
snoozeMinutes: integer('snooze_minutes').default(5),
sound: varchar('sound', { length: 100 }).default('default'),
vibrate: boolean('vibrate').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Alarm = typeof alarms.$inferSelect;
export type NewAlarm = typeof alarms.$inferInsert;

View file

@ -1,4 +0,0 @@
export * from './alarms.schema';
export * from './timers.schema';
export * from './world-clocks.schema';
export * from './presets.schema';

View file

@ -1,24 +0,0 @@
import { pgTable, uuid, text, varchar, integer, jsonb, timestamp } from 'drizzle-orm/pg-core';
export interface PresetSettings {
// For pomodoro presets
workDuration?: number; // in seconds
breakDuration?: number; // in seconds
longBreakDuration?: number; // in seconds
sessionsBeforeLongBreak?: number;
// For timer presets
sound?: string;
}
export const presets = pgTable('presets', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
type: varchar('type', { length: 20 }).notNull(), // 'timer' | 'pomodoro'
name: varchar('name', { length: 255 }).notNull(),
durationSeconds: integer('duration_seconds').notNull(),
settings: jsonb('settings').$type<PresetSettings>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Preset = typeof presets.$inferSelect;
export type NewPreset = typeof presets.$inferInsert;

View file

@ -1,18 +0,0 @@
import { pgTable, uuid, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
export const timers = pgTable('timers', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
label: varchar('label', { length: 255 }),
durationSeconds: integer('duration_seconds').notNull(),
remainingSeconds: integer('remaining_seconds'),
status: varchar('status', { length: 20 }).default('idle').notNull(), // idle, running, paused, finished
startedAt: timestamp('started_at', { withTimezone: true }),
pausedAt: timestamp('paused_at', { withTimezone: true }),
sound: varchar('sound', { length: 100 }).default('default'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Timer = typeof timers.$inferSelect;
export type NewTimer = typeof timers.$inferInsert;

View file

@ -1,13 +0,0 @@
import { pgTable, uuid, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
export const worldClocks = pgTable('world_clocks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
timezone: varchar('timezone', { length: 100 }).notNull(), // IANA timezone e.g. 'America/New_York'
cityName: varchar('city_name', { length: 255 }).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type WorldClock = typeof worldClocks.$inferSelect;
export type NewWorldClock = typeof worldClocks.$inferInsert;

View file

@ -1,8 +0,0 @@
import { initErrorTracking } from '@manacore/shared-error-tracking';
initErrorTracking({
serviceName: 'clock-backend',
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
debug: process.env.NODE_ENV === 'development',
});

View file

@ -1,9 +0,0 @@
import './instrument';
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
import { AppModule } from './app.module';
bootstrapApp(AppModule, {
defaultPort: 3017,
serviceName: 'Clock',
additionalCorsOrigins: ['http://localhost:5186'],
});

View file

@ -1,65 +0,0 @@
import { IsString, IsOptional, IsNumber, Min, Max, IsIn, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
class PresetSettingsDto {
@IsOptional()
@IsNumber()
@Min(1)
workDuration?: number;
@IsOptional()
@IsNumber()
@Min(1)
breakDuration?: number;
@IsOptional()
@IsNumber()
@Min(1)
longBreakDuration?: number;
@IsOptional()
@IsNumber()
@Min(1)
@Max(10)
sessionsBeforeLongBreak?: number;
@IsOptional()
@IsString()
sound?: string;
}
export class CreatePresetDto {
@IsString()
@IsIn(['timer', 'pomodoro'])
type!: string;
@IsString()
name!: string;
@IsNumber()
@Min(1)
@Max(86400)
durationSeconds!: number;
@IsOptional()
@ValidateNested()
@Type(() => PresetSettingsDto)
settings?: PresetSettingsDto;
}
export class UpdatePresetDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsNumber()
@Min(1)
@Max(86400)
durationSeconds?: number;
@IsOptional()
@ValidateNested()
@Type(() => PresetSettingsDto)
settings?: PresetSettingsDto;
}

View file

@ -1,40 +0,0 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { PresetService } from './preset.service';
import { CreatePresetDto, UpdatePresetDto } from './dto';
@Controller('presets')
@UseGuards(JwtAuthGuard)
export class PresetController {
constructor(private readonly presetService: PresetService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.presetService.findAll(user.userId);
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.presetService.findByIdOrThrow(id, user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreatePresetDto) {
return this.presetService.create(user.userId, dto);
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdatePresetDto
) {
return this.presetService.update(id, user.userId, dto);
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.presetService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { PresetController } from './preset.controller';
import { PresetService } from './preset.service';
@Module({
controllers: [PresetController],
providers: [PresetService],
exports: [PresetService],
})
export class PresetModule {}

View file

@ -1,65 +0,0 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { presets, type Preset, type PresetSettings } from '../db/schema';
import { CreatePresetDto, UpdatePresetDto } from './dto';
@Injectable()
export class PresetService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Preset[]> {
return this.db.select().from(presets).where(eq(presets.userId, userId));
}
async findById(id: string, userId: string): Promise<Preset | null> {
const result = await this.db
.select()
.from(presets)
.where(and(eq(presets.id, id), eq(presets.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Preset> {
const preset = await this.findById(id, userId);
if (!preset) {
throw new NotFoundException(`Preset with id ${id} not found`);
}
return preset;
}
async create(userId: string, dto: CreatePresetDto): Promise<Preset> {
const result = await this.db
.insert(presets)
.values({
userId,
type: dto.type,
name: dto.name,
durationSeconds: dto.durationSeconds,
settings: dto.settings as PresetSettings,
})
.returning();
return result[0];
}
async update(id: string, userId: string, dto: UpdatePresetDto): Promise<Preset> {
await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(presets)
.set({
...dto,
settings: dto.settings as PresetSettings,
})
.where(and(eq(presets.id, id), eq(presets.userId, userId)))
.returning();
return result[0];
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(presets).where(and(eq(presets.id, id), eq(presets.userId, userId)));
}
}

View file

@ -1,32 +0,0 @@
import { IsString, IsOptional, IsNumber, Min, Max } from 'class-validator';
export class CreateTimerDto {
@IsOptional()
@IsString()
label?: string;
@IsNumber()
@Min(1)
@Max(86400) // Max 24 hours
durationSeconds!: number;
@IsOptional()
@IsString()
sound?: string;
}
export class UpdateTimerDto {
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsNumber()
@Min(1)
@Max(86400)
durationSeconds?: number;
@IsOptional()
@IsString()
sound?: string;
}

View file

@ -1,55 +0,0 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { TimerService } from './timer.service';
import { CreateTimerDto, UpdateTimerDto } from './dto';
@Controller('timers')
@UseGuards(JwtAuthGuard)
export class TimerController {
constructor(private readonly timerService: TimerService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.timerService.findAll(user.userId);
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.timerService.findByIdOrThrow(id, user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTimerDto) {
return this.timerService.create(user.userId, dto);
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateTimerDto
) {
return this.timerService.update(id, user.userId, dto);
}
@Post(':id/start')
async start(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.timerService.start(id, user.userId);
}
@Post(':id/pause')
async pause(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.timerService.pause(id, user.userId);
}
@Post(':id/reset')
async reset(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.timerService.reset(id, user.userId);
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.timerService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { TimerController } from './timer.controller';
import { TimerService } from './timer.service';
@Module({
controllers: [TimerController],
providers: [TimerService],
exports: [TimerService],
})
export class TimerModule {}

View file

@ -1,129 +0,0 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { timers, type Timer } from '../db/schema';
import { CreateTimerDto, UpdateTimerDto } from './dto';
@Injectable()
export class TimerService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Timer[]> {
return this.db.select().from(timers).where(eq(timers.userId, userId));
}
async findById(id: string, userId: string): Promise<Timer | null> {
const result = await this.db
.select()
.from(timers)
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Timer> {
const timer = await this.findById(id, userId);
if (!timer) {
throw new NotFoundException(`Timer with id ${id} not found`);
}
return timer;
}
async create(userId: string, dto: CreateTimerDto): Promise<Timer> {
const result = await this.db
.insert(timers)
.values({
userId,
label: dto.label,
durationSeconds: dto.durationSeconds,
remainingSeconds: dto.durationSeconds,
status: 'idle',
sound: dto.sound ?? 'default',
})
.returning();
return result[0];
}
async update(id: string, userId: string, dto: UpdateTimerDto): Promise<Timer> {
await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(timers)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.returning();
return result[0];
}
async start(id: string, userId: string): Promise<Timer> {
const timer = await this.findByIdOrThrow(id, userId);
if (timer.status === 'running') {
throw new BadRequestException('Timer is already running');
}
const result = await this.db
.update(timers)
.set({
status: 'running',
startedAt: new Date(),
pausedAt: null,
updatedAt: new Date(),
})
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.returning();
return result[0];
}
async pause(id: string, userId: string): Promise<Timer> {
const timer = await this.findByIdOrThrow(id, userId);
if (timer.status !== 'running') {
throw new BadRequestException('Timer is not running');
}
// Calculate remaining seconds
const elapsed = timer.startedAt
? Math.floor((Date.now() - timer.startedAt.getTime()) / 1000)
: 0;
const remaining = Math.max(0, (timer.remainingSeconds ?? timer.durationSeconds) - elapsed);
const result = await this.db
.update(timers)
.set({
status: 'paused',
remainingSeconds: remaining,
pausedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.returning();
return result[0];
}
async reset(id: string, userId: string): Promise<Timer> {
const timer = await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(timers)
.set({
status: 'idle',
remainingSeconds: timer.durationSeconds,
startedAt: null,
pausedAt: null,
updatedAt: new Date(),
})
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.returning();
return result[0];
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(timers).where(and(eq(timers.id, id), eq(timers.userId, userId)));
}
}

View file

@ -1,15 +0,0 @@
import { IsString, IsArray, IsUUID } from 'class-validator';
export class CreateWorldClockDto {
@IsString()
timezone!: string;
@IsString()
cityName!: string;
}
export class ReorderWorldClocksDto {
@IsArray()
@IsUUID('4', { each: true })
ids!: string[];
}

View file

@ -1,41 +0,0 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { WorldClockService } from './world-clock.service';
import { CreateWorldClockDto, ReorderWorldClocksDto } from './dto';
@Controller('world-clocks')
@UseGuards(JwtAuthGuard)
export class WorldClockController {
constructor(private readonly worldClockService: WorldClockService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.worldClockService.findAll(user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateWorldClockDto) {
return this.worldClockService.create(user.userId, dto);
}
@Put('reorder')
async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderWorldClocksDto) {
return this.worldClockService.reorder(user.userId, dto.ids);
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.worldClockService.delete(id, user.userId);
return { success: true };
}
}
@Controller('timezones')
export class TimezoneController {
constructor(private readonly worldClockService: WorldClockService) {}
@Get('search')
async search(@Query('q') query: string) {
return this.worldClockService.searchTimezones(query);
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { WorldClockController, TimezoneController } from './world-clock.controller';
import { WorldClockService } from './world-clock.service';
@Module({
controllers: [WorldClockController, TimezoneController],
providers: [WorldClockService],
exports: [WorldClockService],
})
export class WorldClockModule {}

View file

@ -1,122 +0,0 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { worldClocks, type WorldClock } from '../db/schema';
import { CreateWorldClockDto } from './dto';
// Common timezones with city names
const TIMEZONES = [
{ timezone: 'America/New_York', city: 'New York' },
{ timezone: 'America/Los_Angeles', city: 'Los Angeles' },
{ timezone: 'America/Chicago', city: 'Chicago' },
{ timezone: 'America/Denver', city: 'Denver' },
{ timezone: 'America/Toronto', city: 'Toronto' },
{ timezone: 'America/Vancouver', city: 'Vancouver' },
{ timezone: 'America/Mexico_City', city: 'Mexico City' },
{ timezone: 'America/Sao_Paulo', city: 'São Paulo' },
{ timezone: 'America/Buenos_Aires', city: 'Buenos Aires' },
{ timezone: 'Europe/London', city: 'London' },
{ timezone: 'Europe/Paris', city: 'Paris' },
{ timezone: 'Europe/Berlin', city: 'Berlin' },
{ timezone: 'Europe/Rome', city: 'Rome' },
{ timezone: 'Europe/Madrid', city: 'Madrid' },
{ timezone: 'Europe/Amsterdam', city: 'Amsterdam' },
{ timezone: 'Europe/Vienna', city: 'Vienna' },
{ timezone: 'Europe/Zurich', city: 'Zurich' },
{ timezone: 'Europe/Moscow', city: 'Moscow' },
{ timezone: 'Europe/Istanbul', city: 'Istanbul' },
{ timezone: 'Asia/Tokyo', city: 'Tokyo' },
{ timezone: 'Asia/Shanghai', city: 'Shanghai' },
{ timezone: 'Asia/Hong_Kong', city: 'Hong Kong' },
{ timezone: 'Asia/Singapore', city: 'Singapore' },
{ timezone: 'Asia/Seoul', city: 'Seoul' },
{ timezone: 'Asia/Mumbai', city: 'Mumbai' },
{ timezone: 'Asia/Dubai', city: 'Dubai' },
{ timezone: 'Asia/Bangkok', city: 'Bangkok' },
{ timezone: 'Asia/Jakarta', city: 'Jakarta' },
{ timezone: 'Australia/Sydney', city: 'Sydney' },
{ timezone: 'Australia/Melbourne', city: 'Melbourne' },
{ timezone: 'Pacific/Auckland', city: 'Auckland' },
{ timezone: 'Pacific/Honolulu', city: 'Honolulu' },
{ timezone: 'Africa/Cairo', city: 'Cairo' },
{ timezone: 'Africa/Johannesburg', city: 'Johannesburg' },
];
@Injectable()
export class WorldClockService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<WorldClock[]> {
return this.db
.select()
.from(worldClocks)
.where(eq(worldClocks.userId, userId))
.orderBy(asc(worldClocks.sortOrder));
}
async findById(id: string, userId: string): Promise<WorldClock | null> {
const result = await this.db
.select()
.from(worldClocks)
.where(and(eq(worldClocks.id, id), eq(worldClocks.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<WorldClock> {
const clock = await this.findById(id, userId);
if (!clock) {
throw new NotFoundException(`World clock with id ${id} not found`);
}
return clock;
}
async create(userId: string, dto: CreateWorldClockDto): Promise<WorldClock> {
// Get the max sort order for this user
const existing = await this.findAll(userId);
const maxOrder = existing.length > 0 ? Math.max(...existing.map((c) => c.sortOrder)) : -1;
const result = await this.db
.insert(worldClocks)
.values({
userId,
timezone: dto.timezone,
cityName: dto.cityName,
sortOrder: maxOrder + 1,
})
.returning();
return result[0];
}
async reorder(userId: string, ids: string[]): Promise<WorldClock[]> {
// Update sort order for each world clock
for (let i = 0; i < ids.length; i++) {
await this.db
.update(worldClocks)
.set({ sortOrder: i })
.where(and(eq(worldClocks.id, ids[i]), eq(worldClocks.userId, userId)));
}
return this.findAll(userId);
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db
.delete(worldClocks)
.where(and(eq(worldClocks.id, id), eq(worldClocks.userId, userId)));
}
searchTimezones(query: string): { timezone: string; city: string }[] {
if (!query || query.length < 2) {
return TIMEZONES.slice(0, 10);
}
const lowerQuery = query.toLowerCase();
return TIMEZONES.filter(
(tz) =>
tz.city.toLowerCase().includes(lowerQuery) || tz.timezone.toLowerCase().includes(lowerQuery)
).slice(0, 20);
}
}

View file

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

View file

@ -1,19 +1,43 @@
/**
* Timers Store - Manages timer state using Svelte 5 runes
* Supports both authenticated (cloud) and guest (session) modes
* Timers Store Local-First with Dexie.js
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
*/
import { api } from '$lib/api/client';
import { sessionTimersStore } from './session-timers.svelte';
import { authStore } from './auth.svelte';
import { timerCollection, type LocalTimer } from '$lib/data/local-store';
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
import { ClockEvents } from '@manacore/shared-utils/analytics';
// State
// State — populated from IndexedDB
let timers = $state<Timer[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalTimer (IndexedDB record) to the shared Timer type. */
function toTimer(local: LocalTimer): Timer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
status: local.status,
startedAt: local.startedAt,
pausedAt: local.pausedAt,
sound: local.sound,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load timers from IndexedDB into the reactive state. */
async function refreshTimers() {
const localTimers = await timerCollection.getAll();
timers = localTimers.map(toTimer);
}
export const timersStore = {
// Getters
get timers() {
@ -30,204 +54,201 @@ export const timersStore = {
},
/**
* Fetch all timers from the backend
* In guest mode, loads from session storage
* Fetch all timers reads from IndexedDB.
*/
async fetchTimers() {
loading = true;
error = null;
// Guest mode: load from session storage
if (!authStore.isAuthenticated) {
timers = sessionTimersStore.timers;
try {
await refreshTimers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch timers';
console.error('Failed to fetch timers:', e);
} finally {
loading = false;
return { success: true };
}
// Authenticated: fetch from API
const response = await api.get<Timer[]>('/timers');
if (response.error) {
error = response.error.message;
loading = false;
return { success: false, error: response.error.message };
}
timers = response.data || [];
loading = false;
return { success: true };
},
/**
* Create a new timer
* In guest mode, creates in session storage
* Create a new timer writes to IndexedDB instantly.
*/
async createTimer(input: CreateTimerInput) {
// Guest mode: create in session storage
if (!authStore.isAuthenticated) {
const timer = sessionTimersStore.createTimer(input);
timers = [...timers, timer];
return { success: true, data: timer };
}
error = null;
try {
const newLocal: LocalTimer = {
id: crypto.randomUUID(),
label: input.label ?? null,
durationSeconds: input.durationSeconds,
remainingSeconds: null,
status: 'idle',
startedAt: null,
pausedAt: null,
sound: input.sound ?? null,
};
// Authenticated: create via API
const response = await api.post<Timer>('/timers', input);
if (response.error) {
return { success: false, error: response.error.message };
const inserted = await timerCollection.insert(newLocal);
const newTimer = toTimer(inserted);
timers = [...timers, newTimer];
return { success: true, data: newTimer };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create timer';
console.error('Failed to create timer:', e);
return { success: false, error: error };
}
if (response.data) {
timers = [...timers, response.data];
}
return { success: true, data: response.data };
},
/**
* Update a timer
* In guest mode, updates in session storage
* Update a timer writes to IndexedDB instantly.
*/
async updateTimer(id: string, input: UpdateTimerInput) {
// Guest mode: update in session storage
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
const timer = sessionTimersStore.updateTimer(id, input);
if (timer) {
timers = timers.map((t) => (t.id === id ? timer : t));
return { success: true, data: timer };
error = null;
try {
const updateData: Partial<LocalTimer> = {};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.durationSeconds !== undefined) updateData.durationSeconds = input.durationSeconds;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update timer';
console.error('Failed to update timer:', e);
return { success: false, error: error };
}
// Authenticated: update via API
const response = await api.patch<Timer>(`/timers/${id}`, input);
if (response.error) {
return { success: false, error: response.error.message };
}
if (response.data) {
timers = timers.map((t) => (t.id === id ? response.data! : t));
}
return { success: true, data: response.data };
},
/**
* Start a timer
* In guest mode, starts in session storage
* Start a timer sets status to running with current timestamp.
*/
async startTimer(id: string) {
// Guest mode: start in session storage
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
const timer = sessionTimersStore.startTimer(id);
if (timer) {
timers = timers.map((t) => (t.id === id ? timer : t));
return { success: true, data: timer };
error = null;
try {
const existing = await timerCollection.get(id);
if (!existing) return { success: false, error: 'Timer not found' };
const updateData: Partial<LocalTimer> = {
status: 'running',
startedAt: new Date().toISOString(),
pausedAt: null,
};
// If resuming from pause, keep remaining seconds
if (existing.status !== 'paused') {
updateData.remainingSeconds = existing.durationSeconds;
}
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
ClockEvents.timerStarted(
(updatedTimer as Timer & { type?: string }).type as 'pomodoro' | 'stopwatch' | 'countdown'
);
return { success: true, data: updatedTimer };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to start timer';
console.error('Failed to start timer:', e);
return { success: false, error: error };
}
// Authenticated: start via API
const response = await api.post<Timer>(`/timers/${id}/start`);
if (response.error) {
return { success: false, error: response.error.message };
}
if (response.data) {
timers = timers.map((t) => (t.id === id ? response.data! : t));
ClockEvents.timerStarted(response.data.type as 'pomodoro' | 'stopwatch' | 'countdown');
}
return { success: true, data: response.data };
},
/**
* Pause a timer
* In guest mode, pauses in session storage
* Pause a timer calculates remaining seconds and saves.
*/
async pauseTimer(id: string) {
// Guest mode: pause in session storage
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
const timer = sessionTimersStore.pauseTimer(id);
if (timer) {
timers = timers.map((t) => (t.id === id ? timer : t));
return { success: true, data: timer };
error = null;
try {
const existing = await timerCollection.get(id);
if (!existing) return { success: false, error: 'Timer not found' };
// Calculate remaining seconds
let remaining = existing.remainingSeconds ?? existing.durationSeconds;
if (existing.startedAt) {
const elapsed = (Date.now() - new Date(existing.startedAt).getTime()) / 1000;
remaining = Math.max(0, remaining - elapsed);
}
const updateData: Partial<LocalTimer> = {
status: 'paused',
pausedAt: new Date().toISOString(),
remainingSeconds: Math.round(remaining),
startedAt: null,
};
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to pause timer';
console.error('Failed to pause timer:', e);
return { success: false, error: error };
}
// Authenticated: pause via API
const response = await api.post<Timer>(`/timers/${id}/pause`);
if (response.error) {
return { success: false, error: response.error.message };
}
if (response.data) {
timers = timers.map((t) => (t.id === id ? response.data! : t));
}
return { success: true, data: response.data };
},
/**
* Reset a timer
* In guest mode, resets in session storage
* Reset a timer back to idle with full duration.
*/
async resetTimer(id: string) {
// Guest mode: reset in session storage
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
const timer = sessionTimersStore.resetTimer(id);
if (timer) {
timers = timers.map((t) => (t.id === id ? timer : t));
return { success: true, data: timer };
error = null;
try {
const updateData: Partial<LocalTimer> = {
status: 'idle',
remainingSeconds: null,
startedAt: null,
pausedAt: null,
};
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reset timer';
console.error('Failed to reset timer:', e);
return { success: false, error: error };
}
// Authenticated: reset via API
const response = await api.post<Timer>(`/timers/${id}/reset`);
if (response.error) {
return { success: false, error: response.error.message };
}
if (response.data) {
timers = timers.map((t) => (t.id === id ? response.data! : t));
}
return { success: true, data: response.data };
},
/**
* Delete a timer
* In guest mode, deletes from session storage
* Delete a timer removes from IndexedDB instantly.
*/
async deleteTimer(id: string) {
// Guest mode: delete from session storage
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
sessionTimersStore.deleteTimer(id);
error = null;
try {
await timerCollection.delete(id);
timers = timers.filter((t) => t.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete timer';
console.error('Failed to delete timer:', e);
return { success: false, error: error };
}
// Authenticated: delete via API
const response = await api.delete(`/timers/${id}`);
if (response.error) {
return { success: false, error: response.error.message };
}
timers = timers.filter((t) => t.id !== id);
return { success: true };
},
/**
* Update local timer state (for countdown display)
* Update local timer state (for countdown display).
*/
updateLocalState(id: string, updates: Partial<Timer>) {
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
},
/**
* Clear all timers (local state only)
* Clear all timers (local state only).
*/
clear() {
timers = [];
@ -235,52 +256,21 @@ export const timersStore = {
},
/**
* Get session timer count (for guest mode banner)
* No longer relevant all timers are local and editable.
*/
get sessionTimerCount(): number {
return sessionTimersStore.count;
return 0;
},
/**
* Check if there are session timers
*/
get hasSessionTimers(): boolean {
return sessionTimersStore.count > 0;
return false;
},
/**
* Migrate session timers to cloud after login
*/
async migrateSessionTimers(): Promise<void> {
if (!authStore.isAuthenticated) return;
const sessionTimers = sessionTimersStore.getAllTimers();
if (sessionTimers.length === 0) return;
// Create each timer via API
for (const timer of sessionTimers) {
try {
await api.post<Timer>('/timers', {
label: timer.label,
durationSeconds: timer.durationSeconds,
sound: timer.sound,
});
} catch (e) {
console.error('Failed to migrate timer:', e);
}
}
// Clear session data after migration
sessionTimersStore.clear();
// Reload timers from server
await this.fetchTimers();
// No-op: local-first mode handles data persistence automatically.
},
/**
* Check if a timer ID is a session timer
*/
isSessionTimer(id: string): boolean {
return sessionTimersStore.isSessionTimer(id);
isSessionTimer(_id: string): boolean {
return false;
},
};

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { setContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
@ -25,14 +26,21 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { alarmsApi } from '$lib/api/alarms';
import { timersApi } from '$lib/api/timers';
import { alarmCollection, timerCollection } from '$lib/data/local-store';
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { clockStore } from '$lib/data/local-store';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
// Shared tag store (local-first)
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Guest welcome modal state
let showGuestWelcome = $state(false);
@ -81,8 +89,8 @@
const results: CommandBarItem[] = [];
try {
// Search alarms
const alarms = await alarmsApi.getAll();
// Search alarms (local-first — reads from IndexedDB)
const alarms = await alarmCollection.getAll();
const matchingAlarms = alarms
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
@ -93,8 +101,8 @@
}));
results.push(...matchingAlarms);
// Search timers
const timers = await timersApi.getAll();
// Search timers (local-first — reads from IndexedDB)
const timers = await timerCollection.getAll();
const matchingTimers = timers
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
@ -256,12 +264,14 @@
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await clockStore.initialize();
// Initialize local-first databases (opens IndexedDB, seeds guest data)
await Promise.all([clockStore.initialize(), tagLocalStore.initialize()]);
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
clockStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
clockStore.startSync(getToken);
tagMutations.startSync(getToken);
}
// Initialize collapsed state from localStorage
@ -274,9 +284,9 @@
// Show guest welcome modal on first visit
initGuestWelcome();
// Load user settings and tags (these need auth / central service)
// Load user settings (requires auth)
if (authStore.isAuthenticated) {
await Promise.all([userSettings.load(), tagStore.fetchTags()]);
await userSettings.load();
}
// Redirect to start page if on root and a custom start page is set
@ -327,7 +337,7 @@
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
tags={tagStore.tags.map((t) => ({
tags={allTags.value.map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
@ -336,7 +346,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

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

View file

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

View file

@ -1,6 +0,0 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'zitare',
additionalEnvVars: ['ZITARE_DATABASE_URL'],
});

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
export * from './favorites.schema';
export * from './user-lists.schema';

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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