diff --git a/apps/calendar/apps/backend/src/admin/admin.controller.ts b/apps/calendar/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..7908a56e8 --- /dev/null +++ b/apps/calendar/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,34 @@ +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('api/v1/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 { + 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 { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/calendar/apps/backend/src/admin/admin.module.ts b/apps/calendar/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/calendar/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +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 {} diff --git a/apps/calendar/apps/backend/src/admin/admin.service.ts b/apps/calendar/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..ee54fb466 --- /dev/null +++ b/apps/calendar/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,152 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq, sql, desc, inArray } 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: NodePgDatabase + ) {} + + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count calendars + const calendarsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.calendars) + .where(eq(schema.calendars.userId, userId)); + const calendarsCount = calendarsResult[0]?.count ?? 0; + + // Count events + const eventsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.events) + .where(eq(schema.events.userId, userId)); + const eventsCount = eventsResult[0]?.count ?? 0; + + // Count reminders + const remindersResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.reminders) + .where(eq(schema.reminders.userId, userId)); + const remindersCount = remindersResult[0]?.count ?? 0; + + // Count calendar shares (invited by user) + const sharesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.calendarShares) + .where(eq(schema.calendarShares.invitedBy, userId)); + const sharesCount = sharesResult[0]?.count ?? 0; + + // Count external calendars + const externalCalendarsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.externalCalendars) + .where(eq(schema.externalCalendars.userId, userId)); + const externalCalendarsCount = externalCalendarsResult[0]?.count ?? 0; + + // Get last activity + const lastEvent = await this.db + .select({ updatedAt: schema.events.updatedAt }) + .from(schema.events) + .where(eq(schema.events.userId, userId)) + .orderBy(desc(schema.events.updatedAt)) + .limit(1); + const lastActivityAt = lastEvent[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'calendars', count: calendarsCount, label: 'Calendars' }, + { entity: 'events', count: eventsCount, label: 'Events' }, + { entity: 'reminders', count: remindersCount, label: 'Reminders' }, + { entity: 'calendar_shares', count: sharesCount, label: 'Calendar Shares' }, + { entity: 'external_calendars', count: externalCalendarsCount, label: 'External Calendars' }, + ]; + + const totalCount = + calendarsCount + eventsCount + remindersCount + sharesCount + externalCalendarsCount; + + return { entities, totalCount, lastActivityAt }; + } + + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Delete reminders + const deletedReminders = await this.db + .delete(schema.reminders) + .where(eq(schema.reminders.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'reminders', count: deletedReminders.length, label: 'Reminders' }); + totalDeleted += deletedReminders.length; + + // Delete calendar shares (where user invited) + const deletedShares = await this.db + .delete(schema.calendarShares) + .where(eq(schema.calendarShares.invitedBy, userId)) + .returning(); + deletedCounts.push({ + entity: 'calendar_shares', + count: deletedShares.length, + label: 'Calendar Shares', + }); + totalDeleted += deletedShares.length; + + // Delete events (cascades from calendars but also direct) + const deletedEvents = await this.db + .delete(schema.events) + .where(eq(schema.events.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'events', count: deletedEvents.length, label: 'Events' }); + totalDeleted += deletedEvents.length; + + // Delete external calendars + const deletedExternalCalendars = await this.db + .delete(schema.externalCalendars) + .where(eq(schema.externalCalendars.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'external_calendars', + count: deletedExternalCalendars.length, + label: 'External Calendars', + }); + totalDeleted += deletedExternalCalendars.length; + + // Delete calendars + const deletedCalendars = await this.db + .delete(schema.calendars) + .where(eq(schema.calendars.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'calendars', count: deletedCalendars.length, label: 'Calendars' }); + totalDeleted += deletedCalendars.length; + + // Delete device tokens + const deletedDeviceTokens = await this.db + .delete(schema.deviceTokens) + .where(eq(schema.deviceTokens.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'device_tokens', + count: deletedDeviceTokens.length, + label: 'Device Tokens', + }); + totalDeleted += deletedDeviceTokens.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { success: true, deletedCounts, totalDeleted }; + } +} diff --git a/apps/calendar/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/calendar/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/calendar/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/apps/calendar/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/calendar/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..535b89f4e --- /dev/null +++ b/apps/calendar/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,36 @@ +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('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/calendar/apps/backend/src/app.module.ts b/apps/calendar/apps/backend/src/app.module.ts index 73acd3227..a8fb47cf2 100644 --- a/apps/calendar/apps/backend/src/app.module.ts +++ b/apps/calendar/apps/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { SyncModule } from './sync/sync.module'; import { NetworkModule } from './network/network.module'; import { EmailModule } from './email/email.module'; import { NotificationModule } from './notification/notification.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { NotificationModule } from './notification/notification.module'; ShareModule, SyncModule, NetworkModule, + AdminModule, ], }) export class AppModule {} diff --git a/apps/chat/apps/backend/src/admin/admin.controller.ts b/apps/chat/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..57d434424 --- /dev/null +++ b/apps/chat/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,47 @@ +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('api/v1/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 { + 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 { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/chat/apps/backend/src/admin/admin.module.ts b/apps/chat/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/chat/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +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 {} diff --git a/apps/chat/apps/backend/src/admin/admin.service.ts b/apps/chat/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..a34644430 --- /dev/null +++ b/apps/chat/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,148 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +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: NodePgDatabase + ) {} + + /** + * Get user data counts for a specific user + */ + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count conversations + const conversationsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.conversations) + .where(eq(schema.conversations.userId, userId)); + const conversationsCount = conversationsResult[0]?.count ?? 0; + + // Count messages (through conversations) + const messagesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.messages) + .innerJoin(schema.conversations, eq(schema.messages.conversationId, schema.conversations.id)) + .where(eq(schema.conversations.userId, userId)); + const messagesCount = messagesResult[0]?.count ?? 0; + + // Count spaces owned by user + const spacesOwnedResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.spaces) + .where(eq(schema.spaces.ownerId, userId)); + const spacesOwnedCount = spacesOwnedResult[0]?.count ?? 0; + + // Count space memberships + const spaceMembershipsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.spaceMembers) + .where(eq(schema.spaceMembers.userId, userId)); + const spaceMembershipsCount = spaceMembershipsResult[0]?.count ?? 0; + + // Count documents (through conversations) + const documentsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.documents) + .innerJoin(schema.conversations, eq(schema.documents.conversationId, schema.conversations.id)) + .where(eq(schema.conversations.userId, userId)); + const documentsCount = documentsResult[0]?.count ?? 0; + + // Get last activity (most recent conversation or message) + const lastConversation = await this.db + .select({ updatedAt: schema.conversations.updatedAt }) + .from(schema.conversations) + .where(eq(schema.conversations.userId, userId)) + .orderBy(desc(schema.conversations.updatedAt)) + .limit(1); + const lastActivityAt = lastConversation[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'conversations', count: conversationsCount, label: 'Conversations' }, + { entity: 'messages', count: messagesCount, label: 'Messages' }, + { entity: 'spaces_owned', count: spacesOwnedCount, label: 'Spaces (Owned)' }, + { entity: 'space_memberships', count: spaceMembershipsCount, label: 'Space Memberships' }, + { entity: 'documents', count: documentsCount, label: 'Documents' }, + ]; + + const totalCount = + conversationsCount + + messagesCount + + spacesOwnedCount + + spaceMembershipsCount + + documentsCount; + + return { + entities, + totalCount, + lastActivityAt, + }; + } + + /** + * Delete all user data (GDPR right to be forgotten) + */ + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Delete space memberships first + const deletedMemberships = await this.db + .delete(schema.spaceMembers) + .where(eq(schema.spaceMembers.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'space_memberships', + count: deletedMemberships.length, + label: 'Space Memberships', + }); + totalDeleted += deletedMemberships.length; + + // Delete spaces owned by user (cascades to members) + const deletedSpaces = await this.db + .delete(schema.spaces) + .where(eq(schema.spaces.ownerId, userId)) + .returning(); + deletedCounts.push({ + entity: 'spaces_owned', + count: deletedSpaces.length, + label: 'Spaces (Owned)', + }); + totalDeleted += deletedSpaces.length; + + // Delete conversations (cascades to messages and documents) + const deletedConversations = await this.db + .delete(schema.conversations) + .where(eq(schema.conversations.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'conversations', + count: deletedConversations.length, + label: 'Conversations', + }); + totalDeleted += deletedConversations.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { + success: true, + deletedCounts, + totalDeleted, + }; + } +} diff --git a/apps/chat/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/chat/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/chat/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/apps/chat/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/chat/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..81b60d0a4 --- /dev/null +++ b/apps/chat/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,40 @@ +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('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/chat/apps/backend/src/app.module.ts b/apps/chat/apps/backend/src/app.module.ts index d9aa1f777..d3d835e9e 100644 --- a/apps/chat/apps/backend/src/app.module.ts +++ b/apps/chat/apps/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { TemplateModule } from './template/template.module'; import { SpaceModule } from './space/space.module'; import { DocumentModule } from './document/document.module'; import { ModelModule } from './model/model.module'; +import { AdminModule } from './admin/admin.module'; import { HealthModule } from '@manacore/shared-nestjs-health'; @Module({ @@ -37,6 +38,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health'; SpaceModule, DocumentModule, ModelModule, + AdminModule, HealthModule.forRoot({ serviceName: 'chat-backend' }), ], }) diff --git a/apps/contacts/apps/backend/src/admin/admin.controller.ts b/apps/contacts/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..7908a56e8 --- /dev/null +++ b/apps/contacts/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,34 @@ +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('api/v1/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 { + 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 { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/contacts/apps/backend/src/admin/admin.module.ts b/apps/contacts/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/contacts/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +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 {} diff --git a/apps/contacts/apps/backend/src/admin/admin.service.ts b/apps/contacts/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..a241a8e0a --- /dev/null +++ b/apps/contacts/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,152 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq, sql, desc, inArray } 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: NodePgDatabase + ) {} + + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count contacts + const contactsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.contacts) + .where(eq(schema.contacts.userId, userId)); + const contactsCount = contactsResult[0]?.count ?? 0; + + // Count tags + const tagsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.contactTags) + .where(eq(schema.contactTags.userId, userId)); + const tagsCount = tagsResult[0]?.count ?? 0; + + // Count notes + const notesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.contactNotes) + .where(eq(schema.contactNotes.userId, userId)); + const notesCount = notesResult[0]?.count ?? 0; + + // Count activities + const activitiesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.contactActivities) + .where(eq(schema.contactActivities.userId, userId)); + const activitiesCount = activitiesResult[0]?.count ?? 0; + + // Get last activity + const lastContact = await this.db + .select({ updatedAt: schema.contacts.updatedAt }) + .from(schema.contacts) + .where(eq(schema.contacts.userId, userId)) + .orderBy(desc(schema.contacts.updatedAt)) + .limit(1); + const lastActivityAt = lastContact[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'contacts', count: contactsCount, label: 'Contacts' }, + { entity: 'tags', count: tagsCount, label: 'Tags' }, + { entity: 'notes', count: notesCount, label: 'Notes' }, + { entity: 'activities', count: activitiesCount, label: 'Activities' }, + ]; + + const totalCount = contactsCount + tagsCount + notesCount + activitiesCount; + + return { entities, totalCount, lastActivityAt }; + } + + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Delete activities + const deletedActivities = await this.db + .delete(schema.contactActivities) + .where(eq(schema.contactActivities.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'activities', + count: deletedActivities.length, + label: 'Activities', + }); + totalDeleted += deletedActivities.length; + + // Delete notes + const deletedNotes = await this.db + .delete(schema.contactNotes) + .where(eq(schema.contactNotes.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'notes', count: deletedNotes.length, label: 'Notes' }); + totalDeleted += deletedNotes.length; + + // Get contact IDs for tag deletion + const userContacts = await this.db + .select({ id: schema.contacts.id }) + .from(schema.contacts) + .where(eq(schema.contacts.userId, userId)); + const contactIds = userContacts.map((c) => c.id); + + // Delete contact_to_tags + if (contactIds.length > 0) { + const deletedContactToTags = await this.db + .delete(schema.contactToTags) + .where(inArray(schema.contactToTags.contactId, contactIds)) + .returning(); + deletedCounts.push({ + entity: 'contact_to_tags', + count: deletedContactToTags.length, + label: 'Contact Tags', + }); + totalDeleted += deletedContactToTags.length; + } + + // Delete tags + const deletedTags = await this.db + .delete(schema.contactTags) + .where(eq(schema.contactTags.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'tags', count: deletedTags.length, label: 'Tags' }); + totalDeleted += deletedTags.length; + + // Delete contacts + const deletedContacts = await this.db + .delete(schema.contacts) + .where(eq(schema.contacts.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'contacts', count: deletedContacts.length, label: 'Contacts' }); + totalDeleted += deletedContacts.length; + + // Delete connected accounts + const deletedConnectedAccounts = await this.db + .delete(schema.connectedAccounts) + .where(eq(schema.connectedAccounts.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'connected_accounts', + count: deletedConnectedAccounts.length, + label: 'Connected Accounts', + }); + totalDeleted += deletedConnectedAccounts.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { success: true, deletedCounts, totalDeleted }; + } +} diff --git a/apps/contacts/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/contacts/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/contacts/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/apps/contacts/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/contacts/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..535b89f4e --- /dev/null +++ b/apps/contacts/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,36 @@ +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('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/contacts/apps/backend/src/app.module.ts b/apps/contacts/apps/backend/src/app.module.ts index 5b1e7be7d..20dc82058 100644 --- a/apps/contacts/apps/backend/src/app.module.ts +++ b/apps/contacts/apps/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { DuplicatesModule } from './duplicates/duplicates.module'; import { PhotoModule } from './photo/photo.module'; import { BatchModule } from './batch/batch.module'; import { NetworkModule } from './network/network.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { NetworkModule } from './network/network.module'; PhotoModule, BatchModule, NetworkModule, + AdminModule, ], }) export class AppModule {} diff --git a/apps/manacore/apps/web/src/lib/api/services/admin.ts b/apps/manacore/apps/web/src/lib/api/services/admin.ts new file mode 100644 index 000000000..e1ef5af44 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/api/services/admin.ts @@ -0,0 +1,185 @@ +/** + * Admin API Service + * + * Provides admin functionality for managing user data across all projects. + */ + +import { browser } from '$app/environment'; +import { createApiClient, type ApiResult } from '../base-client'; + +// Get Auth API URL dynamically at runtime (admin endpoints are on auth service) +function getAuthApiUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + if (injectedUrl) { + return `${injectedUrl}/api/v1`; + } + } + return 'http://localhost:3001/api/v1'; +} + +// Lazy-initialized client +let _client: ReturnType | null = null; + +function getClient() { + if (!_client) { + _client = createApiClient(getAuthApiUrl()); + } + return _client; +} + +/** + * User list item from admin API + */ +export interface UserListItem { + id: string; + email: string; + name: string; + role: string; + createdAt: string; + lastActiveAt?: string; +} + +/** + * User list response with pagination + */ +export interface UserListResponse { + users: UserListItem[]; + total: number; + page: number; + limit: number; +} + +/** + * Entity count for a project + */ +export interface EntityCount { + entity: string; + count: number; + label: string; +} + +/** + * Project data summary + */ +export interface ProjectDataSummary { + projectId: string; + projectName: string; + icon: string; + available: boolean; + error?: string; + entities: EntityCount[]; + totalCount: number; + lastActivityAt?: string; +} + +/** + * User info + */ +export interface UserInfo { + id: string; + email: string; + name: string; + role: string; + createdAt: string; + emailVerified: boolean; +} + +/** + * Auth data summary + */ +export interface AuthDataSummary { + sessionsCount: number; + accountsCount: number; + has2FA: boolean; + lastLoginAt: string | null; +} + +/** + * Credits data summary + */ +export interface CreditsDataSummary { + balance: number; + totalEarned: number; + totalSpent: number; + transactionsCount: number; +} + +/** + * Full user data summary + */ +export interface UserDataSummary { + user: UserInfo; + auth: AuthDataSummary; + credits: CreditsDataSummary; + projects: ProjectDataSummary[]; + totals: { + totalEntities: number; + projectsWithData: number; + }; +} + +/** + * Delete result for a project + */ +export interface ProjectDeleteResult { + projectId: string; + projectName: string; + success: boolean; + deletedCount?: number; + error?: string; +} + +/** + * Full delete response + */ +export interface DeleteUserDataResponse { + success: boolean; + deletedFromProjects: ProjectDeleteResult[]; + deletedFromAuth: { + sessions: number; + accounts: number; + credits: number; + user: boolean; + }; + totalDeleted: number; +} + +/** + * Admin service for user data management + */ +export const adminService = { + /** + * Get list of users with pagination and search + */ + async getUsers( + page: number = 1, + limit: number = 20, + search?: string + ): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + if (search) { + params.set('search', search); + } + + return getClient().get(`/admin/users?${params.toString()}`); + }, + + /** + * Get aggregated user data from all projects + */ + async getUserData(userId: string): Promise> { + return getClient().get(`/admin/users/${userId}/data`); + }, + + /** + * Delete all user data (GDPR right to be forgotten) + */ + async deleteUserData(userId: string): Promise> { + return getClient().delete(`/admin/users/${userId}/data`); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/components/admin/ProjectDataCard.svelte b/apps/manacore/apps/web/src/lib/components/admin/ProjectDataCard.svelte new file mode 100644 index 000000000..c2c455314 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/admin/ProjectDataCard.svelte @@ -0,0 +1,83 @@ + + +
+
+
+ {project.icon} +
+

{project.projectName}

+ {#if project.available} +

+ {project.totalCount} Einträge +

+ {:else} +

{project.error || 'Nicht verfügbar'}

+ {/if} +
+
+ {#if project.available} +
+ {:else} +
+ {/if} +
+ + {#if project.available} +
+ {#if project.entities.length > 0} +
+ {#each project.entities as entity} +
+ {entity.label} + {entity.count} +
+ {/each} +
+ + {#if project.lastActivityAt} +
+

+ Letzte Aktivitat: {formatRelativeTime(project.lastActivityAt)} +

+
+ {/if} + {:else} +

Keine Daten vorhanden

+ {/if} +
+ {:else} +
+

Backend nicht erreichbar

+
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/admin/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/admin/+layout.svelte index 376cb9ab9..cd70b93cb 100644 --- a/apps/manacore/apps/web/src/routes/(app)/admin/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/admin/+layout.svelte @@ -7,12 +7,14 @@ const tabs = [ { href: '/admin', label: 'Overview', icon: 'home' }, { href: '/admin/users', label: 'Users', icon: 'users' }, + { href: '/admin/user-data', label: 'User Data', icon: 'database' }, { href: '/admin/system', label: 'System', icon: 'server' }, ]; const icons: Record = { home: ``, users: ``, + database: ``, server: ``, }; diff --git a/apps/manacore/apps/web/src/routes/(app)/admin/user-data/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/admin/user-data/+page.svelte new file mode 100644 index 000000000..ac620b480 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/admin/user-data/+page.svelte @@ -0,0 +1,254 @@ + + +
+
+
+

Nutzerdaten

+

Durchsuche und analysiere Nutzerdaten aller Projekte

+
+
+ + +
+
+ + + + +
+ + {total} Nutzer gefunden + +
+ + +
+
+

Nutzer

+
+ {#if loading} +
+ {#each Array(5) as _} +
+
+
+
+
+
+
+ {/each} +
+ {:else if error} +
+

{error}

+ +
+ {:else} +
+ + + + + + + + + + + + {#each users as user} + + + + + + + + {/each} + +
+ Nutzer + + Rolle + + Registriert + + Letzte Aktivitat + + Aktionen +
+
+
+ + {(user.name || user.email)[0].toUpperCase()} + +
+
+

{user.name || '-'}

+

{user.email}

+
+
+
+ + {user.role} + + + {formatDate(user.createdAt)} + + {formatRelativeTime(user.lastActiveAt)} + + +
+
+ + {#if users.length === 0} +
Keine Nutzer gefunden
+ {/if} + + + {#if totalPages > 1} +
+ + + Seite {page} von {totalPages} + + +
+ {/if} + {/if} +
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/admin/user-data/[userId]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/admin/user-data/[userId]/+page.svelte new file mode 100644 index 000000000..f42feaaf7 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/admin/user-data/[userId]/+page.svelte @@ -0,0 +1,401 @@ + + +
+ +
+ +
+

Nutzerdaten

+

+ {userData?.user.email || 'Laden...'} +

+
+ {#if userData} + + {/if} +
+ + {#if loading} +
+ {#each Array(4) as _} +
+
+
+
+ {/each} +
+ {:else if error} +
+

{error}

+ +
+ {:else if userData} + +
+
+
+ + {(userData.user.name || userData.user.email)[0].toUpperCase()} + +
+
+

{userData.user.name || 'Kein Name'}

+

{userData.user.email}

+
+ + {userData.user.role} + + {#if userData.user.emailVerified} + + + + + Email verifiziert + + {:else} + + + + + Email nicht verifiziert + + {/if} +
+

+ Registriert am {formatDate(userData.user.createdAt)} +

+
+
+
+ + +
+ + + + +
+ + +
+ +
+

Authentifizierung

+
+
+ Aktive Sessions + {userData.auth.sessionsCount} +
+
+ Verknupfte Accounts + {userData.auth.accountsCount} +
+
+ 2FA aktiviert + + {userData.auth.has2FA ? 'Ja' : 'Nein'} + +
+
+ Letzter Login + + {userData.auth.lastLoginAt ? formatDate(userData.auth.lastLoginAt) : '-'} + +
+
+
+ + +
+

Credits

+
+
+ Aktueller Stand + {userData.credits.balance} +
+
+ Gesamt verdient + +{userData.credits.totalEarned} +
+
+ Gesamt ausgegeben + -{userData.credits.totalSpent} +
+
+ Transaktionen + {userData.credits.transactionsCount} +
+
+
+
+ + +
+

Projektdaten

+
+ {#each userData.projects as project} + + {/each} +
+
+ {/if} +
+ + +{#if showDeleteDialog} +
+
+ {#if deleteResult} + +
+
+
+ + + +
+

Loschung abgeschlossen

+
+ +

+ Insgesamt wurden {deleteResult.totalDeleted} Eintrage geloscht. +

+ +
+ {#each deleteResult.deletedFromProjects as project} +
+ {project.projectName} + {#if project.success} + {project.deletedCount} geloscht + {:else} + Fehler + {/if} +
+ {/each} +
+
+ Sessions + {deleteResult.deletedFromAuth.sessions} +
+
+ Accounts + {deleteResult.deletedFromAuth.accounts} +
+
+
+ + +
+ {:else} + +
+
+
+ + + +
+

Daten unwiderruflich loschen?

+
+ +

+ Diese Aktion loscht alle Daten des Nutzers aus allen Projekten. Dies umfasst: +

+ +
    +
  • Alle Projektdaten (Chat, Todo, Calendar, etc.)
  • +
  • Alle Sessions und verknupften Accounts
  • +
  • Credits und Transaktionshistorie
  • +
  • Das Nutzerkonto selbst
  • +
+ +
+ + +
+ + {#if deleteError} +

{deleteError}

+ {/if} + +
+ + +
+
+ {/if} +
+
+{/if} diff --git a/apps/picture/apps/backend/src/admin/admin.controller.ts b/apps/picture/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..7908a56e8 --- /dev/null +++ b/apps/picture/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,34 @@ +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('api/v1/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 { + 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 { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/picture/apps/backend/src/admin/admin.module.ts b/apps/picture/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/picture/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +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 {} diff --git a/apps/picture/apps/backend/src/admin/admin.service.ts b/apps/picture/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..a7e757e68 --- /dev/null +++ b/apps/picture/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,159 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +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: NodePgDatabase + ) {} + + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count images + const imagesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.images) + .where(eq(schema.images.userId, userId)); + const imagesCount = imagesResult[0]?.count ?? 0; + + // Count image generations + const generationsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.imageGenerations) + .where(eq(schema.imageGenerations.userId, userId)); + const generationsCount = generationsResult[0]?.count ?? 0; + + // Count boards + const boardsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.boards) + .where(eq(schema.boards.userId, userId)); + const boardsCount = boardsResult[0]?.count ?? 0; + + // Count image likes + const likesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.imageLikes) + .where(eq(schema.imageLikes.userId, userId)); + const likesCount = likesResult[0]?.count ?? 0; + + // Get last activity + const lastImage = await this.db + .select({ updatedAt: schema.images.updatedAt }) + .from(schema.images) + .where(eq(schema.images.userId, userId)) + .orderBy(desc(schema.images.updatedAt)) + .limit(1); + const lastActivityAt = lastImage[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'images', count: imagesCount, label: 'Images' }, + { entity: 'image_generations', count: generationsCount, label: 'Image Generations' }, + { entity: 'boards', count: boardsCount, label: 'Boards' }, + { entity: 'image_likes', count: likesCount, label: 'Image Likes' }, + ]; + + const totalCount = imagesCount + generationsCount + boardsCount + likesCount; + + return { entities, totalCount, lastActivityAt }; + } + + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Delete image likes + const deletedLikes = await this.db + .delete(schema.imageLikes) + .where(eq(schema.imageLikes.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'image_likes', count: deletedLikes.length, label: 'Image Likes' }); + totalDeleted += deletedLikes.length; + + // Delete board items (through boards) + const userBoards = await this.db + .select({ id: schema.boards.id }) + .from(schema.boards) + .where(eq(schema.boards.userId, userId)); + + if (userBoards.length > 0) { + const boardIds = userBoards.map((b) => b.id); + const deletedBoardItems = await this.db + .delete(schema.boardItems) + .where(sql`${schema.boardItems.boardId} IN (${sql.join(boardIds, sql`, `)})`) + .returning(); + deletedCounts.push({ + entity: 'board_items', + count: deletedBoardItems.length, + label: 'Board Items', + }); + totalDeleted += deletedBoardItems.length; + } + + // Delete boards + const deletedBoards = await this.db + .delete(schema.boards) + .where(eq(schema.boards.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'boards', count: deletedBoards.length, label: 'Boards' }); + totalDeleted += deletedBoards.length; + + // Delete batch generations + const deletedBatchGenerations = await this.db + .delete(schema.batchGenerations) + .where(eq(schema.batchGenerations.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'batch_generations', + count: deletedBatchGenerations.length, + label: 'Batch Generations', + }); + totalDeleted += deletedBatchGenerations.length; + + // Delete image generations + const deletedGenerations = await this.db + .delete(schema.imageGenerations) + .where(eq(schema.imageGenerations.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'image_generations', + count: deletedGenerations.length, + label: 'Image Generations', + }); + totalDeleted += deletedGenerations.length; + + // Delete images + const deletedImages = await this.db + .delete(schema.images) + .where(eq(schema.images.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'images', count: deletedImages.length, label: 'Images' }); + totalDeleted += deletedImages.length; + + // Delete profiles (profile ID is the user ID) + const deletedProfiles = await this.db + .delete(schema.profiles) + .where(eq(schema.profiles.id, userId)) + .returning(); + deletedCounts.push({ entity: 'profiles', count: deletedProfiles.length, label: 'Profiles' }); + totalDeleted += deletedProfiles.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { success: true, deletedCounts, totalDeleted }; + } +} diff --git a/apps/picture/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/picture/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/picture/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/apps/picture/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/picture/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..535b89f4e --- /dev/null +++ b/apps/picture/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,36 @@ +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('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/picture/apps/backend/src/app.module.ts b/apps/picture/apps/backend/src/app.module.ts index 10bff2acb..132843bda 100644 --- a/apps/picture/apps/backend/src/app.module.ts +++ b/apps/picture/apps/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { GenerateModule } from './generate/generate.module'; import { ExploreModule } from './explore/explore.module'; import { ProfileModule } from './profile/profile.module'; import { BatchModule } from './batch/batch.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -42,6 +43,7 @@ import { BatchModule } from './batch/batch.module'; ExploreModule, ProfileModule, BatchModule, + AdminModule, ], }) export class AppModule {} diff --git a/apps/presi/apps/backend/src/admin/admin.controller.ts b/apps/presi/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..cb7c92aae --- /dev/null +++ b/apps/presi/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,46 @@ +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 cross-service user data management + * All endpoints require service key authentication (X-Service-Key header) + */ +@Controller('api/v1/admin') +@UseGuards(ServiceAuthGuard) +export class AdminController { + private readonly logger = new Logger(AdminController.name); + + constructor(private readonly adminService: AdminService) {} + + /** + * Get user data summary for this backend + * Called by mana-core-auth admin service to aggregate cross-project data + */ + @Get('user-data/:userId') + async getUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: getUserData for userId=${userId}`); + return this.adminService.getUserData(userId); + } + + /** + * Delete all user data from this backend (GDPR right to be forgotten) + * Called by mana-core-auth admin service during cross-project deletion + */ + @Delete('user-data/:userId') + @HttpCode(HttpStatus.OK) + async deleteUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/presi/apps/backend/src/admin/admin.module.ts b/apps/presi/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..5ceacac37 --- /dev/null +++ b/apps/presi/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { ServiceAuthGuard } from './guards/service-auth.guard'; + +@Module({ + imports: [ConfigModule], + controllers: [AdminController], + providers: [AdminService, ServiceAuthGuard], +}) +export class AdminModule {} diff --git a/apps/presi/apps/backend/src/admin/admin.service.ts b/apps/presi/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..9f10c7d54 --- /dev/null +++ b/apps/presi/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { eq, sql, desc, inArray } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/connection'; +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: Database + ) {} + + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count decks + const decksResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.decks) + .where(eq(schema.decks.userId, userId)); + const decksCount = decksResult[0]?.count ?? 0; + + // Count slides (through decks) + const userDecks = await this.db + .select({ id: schema.decks.id }) + .from(schema.decks) + .where(eq(schema.decks.userId, userId)); + + let slidesCount = 0; + if (userDecks.length > 0) { + const deckIds = userDecks.map((d) => d.id); + const slidesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.slides) + .where(inArray(schema.slides.deckId, deckIds)); + slidesCount = slidesResult[0]?.count ?? 0; + } + + // Count shared decks (through decks) + let sharedDecksCount = 0; + if (userDecks.length > 0) { + const deckIds = userDecks.map((d) => d.id); + const sharedResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.sharedDecks) + .where(inArray(schema.sharedDecks.deckId, deckIds)); + sharedDecksCount = sharedResult[0]?.count ?? 0; + } + + // Get last activity + const lastDeck = await this.db + .select({ updatedAt: schema.decks.updatedAt }) + .from(schema.decks) + .where(eq(schema.decks.userId, userId)) + .orderBy(desc(schema.decks.updatedAt)) + .limit(1); + const lastActivityAt = lastDeck[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'decks', count: decksCount, label: 'Decks' }, + { entity: 'slides', count: slidesCount, label: 'Slides' }, + { entity: 'shared_decks', count: sharedDecksCount, label: 'Shared Links' }, + ]; + + const totalCount = decksCount + slidesCount + sharedDecksCount; + + return { entities, totalCount, lastActivityAt }; + } + + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Get user's decks first + const userDecks = await this.db + .select({ id: schema.decks.id }) + .from(schema.decks) + .where(eq(schema.decks.userId, userId)); + + if (userDecks.length > 0) { + const deckIds = userDecks.map((d) => d.id); + + // Delete shared decks + const deletedShared = await this.db + .delete(schema.sharedDecks) + .where(inArray(schema.sharedDecks.deckId, deckIds)) + .returning(); + deletedCounts.push({ + entity: 'shared_decks', + count: deletedShared.length, + label: 'Shared Links', + }); + totalDeleted += deletedShared.length; + + // Delete slides (cascade should handle this, but let's be explicit) + const deletedSlides = await this.db + .delete(schema.slides) + .where(inArray(schema.slides.deckId, deckIds)) + .returning(); + deletedCounts.push({ entity: 'slides', count: deletedSlides.length, label: 'Slides' }); + totalDeleted += deletedSlides.length; + } + + // Delete decks + const deletedDecks = await this.db + .delete(schema.decks) + .where(eq(schema.decks.userId, userId)) + .returning(); + deletedCounts.push({ entity: 'decks', count: deletedDecks.length, label: 'Decks' }); + totalDeleted += deletedDecks.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { success: true, deletedCounts, totalDeleted }; + } +} diff --git a/apps/presi/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/presi/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/presi/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/apps/presi/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/presi/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..81b60d0a4 --- /dev/null +++ b/apps/presi/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,40 @@ +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('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/presi/apps/backend/src/app.module.ts b/apps/presi/apps/backend/src/app.module.ts index d85a78bcc..539af667a 100644 --- a/apps/presi/apps/backend/src/app.module.ts +++ b/apps/presi/apps/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { DeckModule } from './deck/deck.module'; import { SlideModule } from './slide/slide.module'; import { ThemeModule } from './theme/theme.module'; import { ShareModule } from './share/share.module'; +import { AdminModule } from './admin/admin.module'; import { HealthModule } from '@manacore/shared-nestjs-health'; @Module({ @@ -18,6 +19,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health'; SlideModule, ThemeModule, ShareModule, + AdminModule, HealthModule.forRoot({ serviceName: 'presi-backend' }), ], }) diff --git a/apps/presi/apps/backend/src/db/migrations/0001_user_id_to_text.sql b/apps/presi/apps/backend/src/db/migrations/0001_user_id_to_text.sql new file mode 100644 index 000000000..ad49f9045 --- /dev/null +++ b/apps/presi/apps/backend/src/db/migrations/0001_user_id_to_text.sql @@ -0,0 +1,20 @@ +-- Migration: Change user_id from UUID to TEXT +-- This allows compatibility with Better Auth nanoid-based user IDs + +-- Step 1: Add a temporary column with the new type +ALTER TABLE decks ADD COLUMN user_id_new TEXT; + +-- Step 2: Copy and convert existing data (UUID to TEXT) +UPDATE decks SET user_id_new = user_id::text WHERE user_id IS NOT NULL; + +-- Step 3: Drop the old column +ALTER TABLE decks DROP COLUMN user_id; + +-- Step 4: Rename the new column +ALTER TABLE decks RENAME COLUMN user_id_new TO user_id; + +-- Step 5: Add NOT NULL constraint +ALTER TABLE decks ALTER COLUMN user_id SET NOT NULL; + +-- Step 6: Add index for performance +CREATE INDEX IF NOT EXISTS decks_user_id_idx ON decks(user_id); diff --git a/apps/presi/apps/backend/src/db/schema/decks.schema.ts b/apps/presi/apps/backend/src/db/schema/decks.schema.ts index 8478821a0..10af61172 100644 --- a/apps/presi/apps/backend/src/db/schema/decks.schema.ts +++ b/apps/presi/apps/backend/src/db/schema/decks.schema.ts @@ -6,7 +6,7 @@ import { sharedDecks } from './shared-decks.schema'; export const decks = pgTable('decks', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), // TEXT for Better Auth nanoid user IDs title: text('title').notNull(), description: text('description'), themeId: uuid('theme_id').references(() => themes.id), diff --git a/apps/todo/apps/backend/src/admin/admin.controller.ts b/apps/todo/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..57d434424 --- /dev/null +++ b/apps/todo/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,47 @@ +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('api/v1/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 { + 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 { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/todo/apps/backend/src/admin/admin.module.ts b/apps/todo/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/todo/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +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 {} diff --git a/apps/todo/apps/backend/src/admin/admin.service.ts b/apps/todo/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..1b5f654ca --- /dev/null +++ b/apps/todo/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,206 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq, sql, desc, inArray } 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: NodePgDatabase + ) {} + + /** + * Get user data counts for a specific user + */ + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count projects + const projectsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.projects) + .where(eq(schema.projects.userId, userId)); + const projectsCount = projectsResult[0]?.count ?? 0; + + // Count tasks + const tasksResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.tasks) + .where(eq(schema.tasks.userId, userId)); + const tasksCount = tasksResult[0]?.count ?? 0; + + // Count labels + const labelsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.labels) + .where(eq(schema.labels.userId, userId)); + const labelsCount = labelsResult[0]?.count ?? 0; + + // Count reminders + const remindersResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.reminders) + .where(eq(schema.reminders.userId, userId)); + const remindersCount = remindersResult[0]?.count ?? 0; + + // Count kanban boards + const kanbanBoardsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.kanbanBoards) + .where(eq(schema.kanbanBoards.userId, userId)); + const kanbanBoardsCount = kanbanBoardsResult[0]?.count ?? 0; + + // Get last activity (most recent task update) + const lastTask = await this.db + .select({ updatedAt: schema.tasks.updatedAt }) + .from(schema.tasks) + .where(eq(schema.tasks.userId, userId)) + .orderBy(desc(schema.tasks.updatedAt)) + .limit(1); + const lastActivityAt = lastTask[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'projects', count: projectsCount, label: 'Projects' }, + { entity: 'tasks', count: tasksCount, label: 'Tasks' }, + { entity: 'labels', count: labelsCount, label: 'Labels' }, + { entity: 'reminders', count: remindersCount, label: 'Reminders' }, + { entity: 'kanban_boards', count: kanbanBoardsCount, label: 'Kanban Boards' }, + ]; + + const totalCount = + projectsCount + tasksCount + labelsCount + remindersCount + kanbanBoardsCount; + + return { + entities, + totalCount, + lastActivityAt, + }; + } + + /** + * Delete all user data (GDPR right to be forgotten) + */ + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Delete reminders first (FK to tasks) + const deletedReminders = await this.db + .delete(schema.reminders) + .where(eq(schema.reminders.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'reminders', + count: deletedReminders.length, + label: 'Reminders', + }); + totalDeleted += deletedReminders.length; + + // Delete task_labels (through tasks owned by user) + const userTasks = await this.db + .select({ id: schema.tasks.id }) + .from(schema.tasks) + .where(eq(schema.tasks.userId, userId)); + const taskIds = userTasks.map((t) => t.id); + + if (taskIds.length > 0) { + const deletedTaskLabels = await this.db + .delete(schema.taskLabels) + .where(inArray(schema.taskLabels.taskId, taskIds)) + .returning(); + deletedCounts.push({ + entity: 'task_labels', + count: deletedTaskLabels.length, + label: 'Task Labels', + }); + totalDeleted += deletedTaskLabels.length; + } + + // Delete labels + const deletedLabels = await this.db + .delete(schema.labels) + .where(eq(schema.labels.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'labels', + count: deletedLabels.length, + label: 'Labels', + }); + totalDeleted += deletedLabels.length; + + // Delete tasks + const deletedTasks = await this.db + .delete(schema.tasks) + .where(eq(schema.tasks.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'tasks', + count: deletedTasks.length, + label: 'Tasks', + }); + totalDeleted += deletedTasks.length; + + // Delete kanban columns (through boards owned by user) + const userBoards = await this.db + .select({ id: schema.kanbanBoards.id }) + .from(schema.kanbanBoards) + .where(eq(schema.kanbanBoards.userId, userId)); + const boardIds = userBoards.map((b) => b.id); + + if (boardIds.length > 0) { + const deletedColumns = await this.db + .delete(schema.kanbanColumns) + .where(inArray(schema.kanbanColumns.boardId, boardIds)) + .returning(); + deletedCounts.push({ + entity: 'kanban_columns', + count: deletedColumns.length, + label: 'Kanban Columns', + }); + totalDeleted += deletedColumns.length; + } + + // Delete kanban boards + const deletedBoards = await this.db + .delete(schema.kanbanBoards) + .where(eq(schema.kanbanBoards.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'kanban_boards', + count: deletedBoards.length, + label: 'Kanban Boards', + }); + totalDeleted += deletedBoards.length; + + // Delete projects + const deletedProjects = await this.db + .delete(schema.projects) + .where(eq(schema.projects.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'projects', + count: deletedProjects.length, + label: 'Projects', + }); + totalDeleted += deletedProjects.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { + success: true, + deletedCounts, + totalDeleted, + }; + } +} diff --git a/apps/todo/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/todo/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/todo/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/apps/todo/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/todo/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..81b60d0a4 --- /dev/null +++ b/apps/todo/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,40 @@ +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('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/todo/apps/backend/src/app.module.ts b/apps/todo/apps/backend/src/app.module.ts index fa1473b41..b5638780f 100644 --- a/apps/todo/apps/backend/src/app.module.ts +++ b/apps/todo/apps/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { LabelModule } from './label/label.module'; import { ReminderModule } from './reminder/reminder.module'; import { KanbanModule } from './kanban/kanban.module'; import { NetworkModule } from './network/network.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -40,6 +41,7 @@ import { NetworkModule } from './network/network.module'; ReminderModule, KanbanModule, NetworkModule, + AdminModule, ], }) export class AppModule {} diff --git a/apps/zitare/apps/backend/src/admin/admin.controller.ts b/apps/zitare/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..7908a56e8 --- /dev/null +++ b/apps/zitare/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,34 @@ +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('api/v1/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 { + 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 { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/zitare/apps/backend/src/admin/admin.module.ts b/apps/zitare/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/zitare/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +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 {} diff --git a/apps/zitare/apps/backend/src/admin/admin.service.ts b/apps/zitare/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..1705bc435 --- /dev/null +++ b/apps/zitare/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +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: NodePgDatabase + ) {} + + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count favorites + const favoritesResult = await this.db + .select({ count: sql`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`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 { + 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 }; + } +} diff --git a/apps/zitare/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/zitare/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/zitare/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/apps/zitare/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/zitare/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..535b89f4e --- /dev/null +++ b/apps/zitare/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,36 @@ +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('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/zitare/apps/backend/src/app.module.ts b/apps/zitare/apps/backend/src/app.module.ts index 414ab2c0f..370bbd196 100644 --- a/apps/zitare/apps/backend/src/app.module.ts +++ b/apps/zitare/apps/backend/src/app.module.ts @@ -4,6 +4,7 @@ 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 { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health'; FavoriteModule, ListModule, HealthModule.forRoot({ serviceName: 'quote-backend' }), + AdminModule, ], }) export class AppModule {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f8387abf..b0f06fda3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,7 +148,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.19.12) + version: 10.4.9 '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -199,7 +199,7 @@ importers: version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -223,14 +223,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)) @@ -239,13 +239,13 @@ importers: version: 20.19.25 eslint: specifier: ^9.0.0 - version: 9.39.1(jiti@1.21.7) + version: 9.39.1(jiti@2.6.1) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.1(jiti@1.21.7)) + version: 9.1.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.5.0(eslint@9.39.1(jiti@1.21.7)) + version: 1.5.0(eslint@9.39.1(jiti@2.6.1)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -626,19 +626,19 @@ importers: version: 18.3.27 '@typescript-eslint/eslint-plugin': specifier: ^7.7.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^7.7.0 - version: 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + version: 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) dotenv: specifier: ^16.4.7 version: 16.6.1 eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@2.6.1) + version: 9.39.1(jiti@1.21.7) eslint-config-universe: specifier: ^12.0.1 - version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3) + version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3) prettier: specifier: ^3.2.5 version: 3.6.2 @@ -1041,12 +1041,6 @@ importers: apps/contacts/apps/backend: dependencies: - '@manacore/credit-operations': - specifier: workspace:* - version: link:../../../../packages/credit-operations - '@manacore/nestjs-integration': - specifier: workspace:* - version: link:../../../../packages/mana-core-nestjs-integration '@manacore/shared-nestjs-auth': specifier: workspace:* version: link:../../../../packages/shared-nestjs-auth @@ -1557,7 +1551,7 @@ importers: version: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) expo-router: specifier: ~6.0.15 - version: 6.0.15(vmxlpuhz6xqbe2ee7fdabyqx3y) + version: 6.0.15(g2vconqrtzzmzlh6ymhbjirn5e) expo-status-bar: specifier: ~3.0.8 version: 3.0.8(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -1975,7 +1969,7 @@ importers: version: 8.0.9(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) expo-router: specifier: ~6.0.10 - version: 6.0.15(psh6y5usp77eac7hbbid4ov2mi) + version: 6.0.15(ewsdnidpxwg6dzyorlbigkbme4) expo-secure-store: specifier: ^15.0.7 version: 15.0.7(expo@54.0.13) @@ -2817,7 +2811,7 @@ importers: version: 18.2.0(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)) expo-router: specifier: ~6.0.10 - version: 6.0.15(supujcsjl47mo53hnmela4rs24) + version: 6.0.15(f6my4lgi43u5yo7kczxd3pw7ru) expo-secure-store: specifier: ~15.0.7 version: 15.0.7(expo@54.0.12) @@ -3885,10 +3879,10 @@ importers: version: 0.30.6 jest: specifier: ^30.2.0 - version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) tsx: specifier: ^4.19.4 version: 4.20.6 @@ -4272,10 +4266,10 @@ importers: version: 0.30.6 jest: specifier: ^30.2.0 - version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) tsx: specifier: ^4.19.4 version: 4.20.6 @@ -5513,6 +5507,9 @@ importers: '@google/generative-ai': specifier: ^0.24.1 version: 0.24.1 + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2) '@nestjs/common': specifier: ^10.4.15 version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5534,6 +5531,9 @@ importers: '@nestjs/throttler': specifier: ^6.2.1 version: 6.4.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2) + axios: + specifier: ^1.7.2 + version: 1.13.2 bcrypt: specifier: ^5.1.1 version: 5.1.1(encoding@0.1.13) @@ -5606,7 +5606,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^11.0.0 - version: 11.0.12(@types/node@22.19.1) + version: 11.0.12(@types/node@22.19.1)(esbuild@0.19.12) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) @@ -5669,7 +5669,7 @@ importers: version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.100.2) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -9222,7 +9222,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -17126,15 +17126,18 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@12.0.0: @@ -17148,16 +17151,17 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-dirs@0.1.1: resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} @@ -23994,16 +23998,6 @@ snapshots: transitivePeerDependencies: - ts-node - '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': - dependencies: - astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) - autoprefixer: 10.4.22(postcss@8.5.6) - postcss: 8.5.6 - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1) - transitivePeerDependencies: - - ts-node - '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': dependencies: astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) @@ -26765,7 +26759,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(supujcsjl47mo53hnmela4rs24) + expo-router: 6.0.15(f6my4lgi43u5yo7kczxd3pw7ru) react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -26842,7 +26836,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(psh6y5usp77eac7hbbid4ov2mi) + expo-router: 6.0.15(ewsdnidpxwg6dzyorlbigkbme4) react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -26919,7 +26913,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(xyagqkzos5etzn52s4may7634u) + expo-router: 6.0.15(lvmr432nn4pnebfhbi2qjoy364) react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -26996,7 +26990,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(k2muy65dii4k2uiuhg4mwyy6ki) + expo-router: 6.0.15(6hayu32hencph7rqfkncbd2qum) react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -27073,7 +27067,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(7mqaurqidri6vkknnsci36yp4e) + expo-router: 6.0.15(5ll7ovd7i5kd7vxhny3dgbs3xy) react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -28451,7 +28445,7 @@ snapshots: - supports-color - ts-node - '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))': + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': dependencies: '@jest/console': 30.2.0 '@jest/pattern': 30.0.1 @@ -28466,7 +28460,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.2.0 - jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) jest-haste-map: 30.2.0 jest-message-util: 30.2.0 jest-regex-util: 30.0.1 @@ -28486,6 +28480,7 @@ snapshots: - esbuild-register - supports-color - ts-node + optional: true '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': dependencies: @@ -28523,6 +28518,80 @@ snapshots: - supports-color - ts-node + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3))': + dependencies: + '@jest/console': 30.2.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.19.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.2.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.19.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + '@jest/create-cache-key-function@29.7.0': dependencies: '@jest/types': 29.6.3 @@ -28918,6 +28987,12 @@ snapshots: dependencies: svelte: 5.44.0 + '@nestjs/axios@4.0.1(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.13.2 + rxjs: 7.8.2 + '@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -28984,32 +29059,6 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/cli@10.4.9(esbuild@0.19.12)': - dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0) - '@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2) - chalk: 4.1.2 - chokidar: 3.6.0 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12)) - glob: 10.4.5 - inquirer: 8.2.6 - node-emoji: 1.11.0 - ora: 5.4.1 - tree-kill: 1.2.2 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.7.2 - webpack: 5.97.1(esbuild@0.19.12) - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - esbuild - - uglify-js - - webpack-cli - '@nestjs/cli@10.4.9(esbuild@0.27.0)': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -29036,7 +29085,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/cli@11.0.12(@types/node@22.19.1)': + '@nestjs/cli@11.0.12(@types/node@22.19.1)(esbuild@0.19.12)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) @@ -29047,14 +29096,14 @@ snapshots: chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)) glob: 12.0.0 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.100.2 + webpack: 5.100.2(esbuild@0.19.12) webpack-node-externals: 3.0.0 transitivePeerDependencies: - '@types/node' @@ -32706,7 +32755,7 @@ snapshots: jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) optional: true - '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 picocolors: 1.1.1 @@ -32716,10 +32765,10 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) optional: true - '@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 picocolors: 1.1.1 @@ -32729,10 +32778,10 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)) optional: true - '@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react-test-renderer@19.1.0(react@18.3.1))(react@18.3.1)': + '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react-test-renderer@19.1.0(react@18.3.1))(react@18.3.1)': dependencies: jest-matcher-utils: 30.2.0 picocolors: 1.1.1 @@ -32742,10 +32791,23 @@ snapshots: react-test-renderer: 19.1.0(react@18.3.1) redent: 3.0.0 optionalDependencies: - jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)) optional: true - '@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + jest-matcher-utils: 30.2.0 + picocolors: 1.1.1 + pretty-format: 30.2.0 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + react-test-renderer: 19.1.0(react@19.1.0) + redent: 3.0.0 + optionalDependencies: + jest: 30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + optional: true + + '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 picocolors: 1.1.1 @@ -32755,7 +32817,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) optional: true '@testing-library/svelte-core@1.0.0(svelte@5.44.0)': @@ -33305,16 +33367,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -33363,15 +33425,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -33463,14 +33525,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -33502,14 +33564,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -33635,12 +33697,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -33671,12 +33733,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -33858,15 +33920,15 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -33897,13 +33959,13 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) transitivePeerDependencies: - supports-color - typescript @@ -34817,108 +34879,6 @@ snapshots: transitivePeerDependencies: - supports-color - astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): - dependencies: - '@astrojs/compiler': 2.13.0 - '@astrojs/internal-helpers': 0.7.5 - '@astrojs/markdown-remark': 6.3.9 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 3.0.1 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.53.3) - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.3.1 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.0 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.5.0 - diff: 5.2.0 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.25.12 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.3.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.1 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.5.0 - piccolore: 0.1.3 - picomatch: 4.0.3 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.3 - shiki: 3.15.0 - smol-toml: 1.5.2 - svgo: 4.0.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.6.0 - unist-util-visit: 5.0.0 - unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.9.2) - vfile: 6.0.3 - vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 @@ -37327,11 +37287,6 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - semver: 7.7.3 - eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -37342,9 +37297,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -37359,9 +37314,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 0.1.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -37379,14 +37334,14 @@ snapshots: dependencies: eslint: 8.57.1 + eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) - eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -37411,17 +37366,17 @@ snapshots: - supports-color - typescript - eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3): + eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3): dependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2) + eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@1.21.7)) optionalDependencies: prettier: 3.6.2 transitivePeerDependencies: @@ -37459,7 +37414,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -37470,22 +37425,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) - get-tsconfig: 4.13.0 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -37499,12 +37439,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -37519,39 +37459,25 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)): - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) - '@jridgewell/sourcemap-codec': 1.5.5 - '@typescript-eslint/types': 8.48.0 - astro-eslint-parser: 1.2.2 - eslint: 9.39.1(jiti@1.21.7) - eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7)) - globals: 16.5.0 - postcss: 8.5.6 - postcss-selector-parser: 7.1.0 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -37575,6 +37501,12 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 + eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-utils: 2.1.0 + regexpp: 3.2.0 + eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -37628,7 +37560,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -37637,9 +37569,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -37651,7 +37583,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -37686,7 +37618,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -37697,7 +37629,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -37715,7 +37647,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -37726,7 +37658,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -37754,6 +37686,16 @@ snapshots: resolve: 1.22.11 semver: 6.3.1 + eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-plugin-es: 3.0.1(eslint@9.39.1(jiti@1.21.7)) + eslint-utils: 2.1.0 + ignore: 5.3.2 + minimatch: 3.1.2 + resolve: 1.22.11 + semver: 6.3.1 + eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -37784,6 +37726,16 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 8.10.2(eslint@8.57.1) + eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + prettier: 3.6.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.11 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -37808,6 +37760,10 @@ snapshots: dependencies: eslint: 8.57.1 + eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -37838,6 +37794,28 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.1 + eslint: 9.39.1(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -38982,7 +38960,7 @@ snapshots: - react-native - supports-color - expo-router@6.0.15(7mqaurqidri6vkknnsci36yp4e): + expo-router@6.0.15(5ll7ovd7i5kd7vxhny3dgbs3xy): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.7 @@ -39021,7 +38999,7 @@ snapshots: react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.97.1(esbuild@0.19.12)) + react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.19.12)) transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@types/react' @@ -39029,7 +39007,7 @@ snapshots: - supports-color optional: true - expo-router@6.0.15(k2muy65dii4k2uiuhg4mwyy6ki): + expo-router@6.0.15(6hayu32hencph7rqfkncbd2qum): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) '@expo/schema-utils': 0.1.7 @@ -39063,7 +39041,7 @@ snapshots: vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) optionalDependencies: '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) - '@testing-library/react-native': 13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react-test-renderer@19.1.0(react@18.3.1))(react@18.3.1) + '@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react-test-renderer@19.1.0(react@18.3.1))(react@18.3.1) react-dom: 19.1.0(react@18.3.1) react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) @@ -39076,7 +39054,7 @@ snapshots: - supports-color optional: true - expo-router@6.0.15(psh6y5usp77eac7hbbid4ov2mi): + expo-router@6.0.15(ewsdnidpxwg6dzyorlbigkbme4): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.13)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.7 @@ -39110,7 +39088,7 @@ snapshots: vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) optionalDependencies: '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@testing-library/react-native': 13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) react-dom: 19.1.0(react@19.1.0) react-native-gesture-handler: 2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -39122,7 +39100,7 @@ snapshots: - '@types/react-dom' - supports-color - expo-router@6.0.15(supujcsjl47mo53hnmela4rs24): + expo-router@6.0.15(f6my4lgi43u5yo7kczxd3pw7ru): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.12)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.7 @@ -39156,7 +39134,7 @@ snapshots: vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) optionalDependencies: '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@testing-library/react-native': 13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) react-dom: 19.1.0(react@19.1.0) react-native-gesture-handler: 2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -39168,7 +39146,7 @@ snapshots: - '@types/react-dom' - supports-color - expo-router@6.0.15(vmxlpuhz6xqbe2ee7fdabyqx3y): + expo-router@6.0.15(g2vconqrtzzmzlh6ymhbjirn5e): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.7 @@ -39202,7 +39180,7 @@ snapshots: vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) optionalDependencies: '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@testing-library/react-native': 13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) react-dom: 19.1.0(react@19.1.0) react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -39214,7 +39192,7 @@ snapshots: - '@types/react-dom' - supports-color - expo-router@6.0.15(xyagqkzos5etzn52s4may7634u): + expo-router@6.0.15(lvmr432nn4pnebfhbi2qjoy364): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.7 @@ -39248,7 +39226,7 @@ snapshots: vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) optionalDependencies: '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) react-dom: 19.1.0(react@19.1.0) react-native-gesture-handler: 2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -40155,23 +40133,6 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - '@babel/code-frame': 7.27.1 - chalk: 4.1.2 - chokidar: 3.6.0 - cosmiconfig: 8.3.6(typescript@5.7.2) - deepmerge: 4.3.1 - fs-extra: 10.1.0 - memfs: 3.5.3 - minimatch: 3.1.2 - node-abort-controller: 3.1.1 - schema-utils: 3.3.0 - semver: 7.7.3 - tapable: 2.3.0 - typescript: 5.7.2 - webpack: 5.97.1(esbuild@0.19.12) - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.27.0)): dependencies: '@babel/code-frame': 7.27.1 @@ -40206,6 +40167,23 @@ snapshots: typescript: 5.7.2 webpack: 5.97.1 + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)): + dependencies: + '@babel/code-frame': 7.27.1 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.2 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.7.3 + tapable: 2.3.0 + typescript: 5.9.3 + webpack: 5.100.2(esbuild@0.19.12) + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)): dependencies: '@babel/code-frame': 7.27.1 @@ -40223,23 +40201,6 @@ snapshots: typescript: 5.9.3 webpack: 5.100.2(esbuild@0.27.0) - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2): - dependencies: - '@babel/code-frame': 7.27.1 - chalk: 4.1.2 - chokidar: 4.0.3 - cosmiconfig: 8.3.6(typescript@5.9.3) - deepmerge: 4.3.1 - fs-extra: 10.1.0 - memfs: 3.5.3 - minimatch: 3.1.2 - node-abort-controller: 3.1.1 - schema-utils: 3.3.0 - semver: 7.7.3 - tapable: 2.3.0 - typescript: 5.9.3 - webpack: 5.100.2 - form-data-encoder@1.7.2: {} form-data@2.3.3: @@ -41670,15 +41631,15 @@ snapshots: - supports-color - ts-node - jest-cli@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)): + jest-cli@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@jest/test-result': 30.2.0 '@jest/types': 30.2.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) + jest-config: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) jest-util: 30.2.0 jest-validate: 30.2.0 yargs: 17.7.2 @@ -41690,25 +41651,6 @@ snapshots: - ts-node optional: true - jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): - dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) - '@jest/test-result': 30.2.0 - '@jest/types': 30.2.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) - jest-util: 30.2.0 - jest-validate: 30.2.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -41728,15 +41670,35 @@ snapshots: - supports-color - ts-node - jest-cli@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)): + jest-cli@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)): dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)) '@jest/test-result': 30.2.0 '@jest/types': 30.2.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest-config: 30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + + jest-cli@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-util: 30.2.0 jest-validate: 30.2.0 yargs: 17.7.2 @@ -41839,7 +41801,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)): + jest-config@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/get-type': 30.1.0 @@ -41868,12 +41830,13 @@ snapshots: optionalDependencies: '@types/node': 20.19.25 esbuild-register: 3.6.0(esbuild@0.27.0) + ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color optional: true - jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): + jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/get-type': 30.1.0 @@ -41902,9 +41865,11 @@ snapshots: optionalDependencies: '@types/node': 22.19.1 esbuild-register: 3.6.0(esbuild@0.27.0) + ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color + optional: true jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: @@ -41940,7 +41905,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)): + jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)): dependencies: '@babel/core': 7.28.5 '@jest/get-type': 30.1.0 @@ -41967,7 +41932,114 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: + '@types/node': 22.19.1 esbuild-register: 3.6.0(esbuild@0.27.0) + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.8.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + + jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.28.5 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.1 + esbuild-register: 3.6.0(esbuild@0.27.0) + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + + jest-config@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)): + dependencies: + '@babel/core': 7.28.5 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.10.1 + esbuild-register: 3.6.0(esbuild@0.27.0) + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.8.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + + jest-config@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.28.5 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.10.1 + esbuild-register: 3.6.0(esbuild@0.27.0) + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -42433,12 +42505,12 @@ snapshots: - supports-color - ts-node - jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)): + jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@jest/types': 30.2.0 import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) + jest-cli: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -42447,19 +42519,6 @@ snapshots: - ts-node optional: true - jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): - dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) - '@jest/types': 30.2.0 - import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -42473,12 +42532,26 @@ snapshots: - supports-color - ts-node - jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)): + jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)): dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)) '@jest/types': 30.2.0 import-local: 3.2.0 - jest-cli: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest-cli: 30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + + jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -46817,6 +46890,16 @@ snapshots: webpack-sources: 3.3.3 optional: true + react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.19.12)): + dependencies: + acorn-loose: 8.5.2 + neo-async: 2.6.2 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + webpack: 5.100.2(esbuild@0.19.12) + webpack-sources: 3.3.3 + optional: true + react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.27.0)): dependencies: acorn-loose: 8.5.2 @@ -46827,16 +46910,6 @@ snapshots: webpack-sources: 3.3.3 optional: true - react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - acorn-loose: 8.5.2 - neo-async: 2.6.2 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - webpack: 5.97.1(esbuild@0.19.12) - webpack-sources: 3.3.3 - optional: true - react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -48337,14 +48410,14 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)): + terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.97.1(esbuild@0.19.12) + webpack: 5.100.2(esbuild@0.19.12) optionalDependencies: esbuild: 0.19.12 @@ -48598,26 +48671,15 @@ snapshots: esbuild: 0.27.0 jest-util: 30.2.0 - ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3): + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)): dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 + chalk: 4.1.2 + enhanced-resolve: 5.18.3 + micromatch: 4.0.8 semver: 7.7.3 - type-fest: 4.41.0 + source-map: 0.7.6 typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.28.5 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.5) - esbuild: 0.27.0 - jest-util: 30.2.0 + webpack: 5.100.2(esbuild@0.19.12) ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)): dependencies: @@ -48639,16 +48701,6 @@ snapshots: typescript: 5.9.3 webpack: 5.100.2 - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - chalk: 4.1.2 - enhanced-resolve: 5.18.3 - micromatch: 4.0.8 - semver: 7.7.3 - source-map: 0.7.6 - typescript: 5.9.3 - webpack: 5.97.1(esbuild@0.19.12) - ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -48686,6 +48738,25 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.10.1 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.8.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -49350,23 +49421,6 @@ snapshots: lightningcss: 1.30.2 terser: 5.44.1 - vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 20.19.25 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.30.2 - terser: 5.44.1 - tsx: 4.20.6 - yaml: 2.8.1 - vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -49470,10 +49524,6 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): - optionalDependencies: - vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): optionalDependencies: vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) @@ -49874,6 +49924,38 @@ snapshots: - esbuild - uglify-js + webpack@5.100.2(esbuild@0.19.12): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.28.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.100.2(esbuild@0.27.0): dependencies: '@types/eslint-scope': 3.7.7 @@ -49936,36 +50018,6 @@ snapshots: - esbuild - uglify-js - webpack@5.97.1(esbuild@0.19.12): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - browserslist: 4.28.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 - es-module-lexer: 1.7.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)) - watchpack: 2.4.4 - webpack-sources: 3.3.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.97.1(esbuild@0.27.0): dependencies: '@types/eslint-scope': 3.7.7 diff --git a/services/mana-core-auth/package.json b/services/mana-core-auth/package.json index 625f29d8f..cd0252ae6 100644 --- a/services/mana-core-auth/package.json +++ b/services/mana-core-auth/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@google/generative-ai": "^0.24.1", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", @@ -32,6 +33,7 @@ "@nestjs/schedule": "^4.1.2", "@nestjs/swagger": "^8.1.0", "@nestjs/throttler": "^6.2.1", + "axios": "^1.7.2", "bcrypt": "^5.1.1", "better-auth": "^1.4.3", "body-parser": "^2.2.2", @@ -65,8 +67,8 @@ "@types/cookie-parser": "^1.4.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", - "@types/node": "^22.10.2", "@types/jsonwebtoken": "^9.0.9", + "@types/node": "^22.10.2", "@types/nodemailer": "^7.0.5", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^8.18.2", diff --git a/services/mana-core-auth/src/admin/admin.module.ts b/services/mana-core-auth/src/admin/admin.module.ts new file mode 100644 index 000000000..e726f6eb1 --- /dev/null +++ b/services/mana-core-auth/src/admin/admin.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { UserDataController } from './user-data.controller'; +import { UserDataService } from './user-data.service'; +import { AdminGuard } from './guards/admin.guard'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [ + ConfigModule, + HttpModule.register({ + timeout: 5000, + maxRedirects: 3, + }), + AuthModule, + ], + controllers: [UserDataController], + providers: [UserDataService, AdminGuard], +}) +export class AdminModule {} diff --git a/services/mana-core-auth/src/admin/dto/user-data.dto.ts b/services/mana-core-auth/src/admin/dto/user-data.dto.ts new file mode 100644 index 000000000..1749278eb --- /dev/null +++ b/services/mana-core-auth/src/admin/dto/user-data.dto.ts @@ -0,0 +1,78 @@ +export interface EntityCount { + entity: string; + count: number; + label: string; +} + +export interface ProjectDataSummary { + projectId: string; + projectName: string; + icon: string; + available: boolean; + error?: string; + entities: EntityCount[]; + totalCount: number; + lastActivityAt?: string; +} + +export interface UserDataSummary { + user: { + id: string; + email: string; + name: string; + role: string; + createdAt: string; + emailVerified: boolean; + }; + auth: { + sessionsCount: number; + accountsCount: number; + has2FA: boolean; + lastLoginAt: string | null; + }; + credits: { + balance: number; + totalEarned: number; + totalSpent: number; + transactionsCount: number; + }; + projects: ProjectDataSummary[]; + totals: { + totalEntities: number; + projectsWithData: number; + }; +} + +export interface DeleteUserDataResponse { + success: boolean; + deletedFromProjects: { + projectId: string; + projectName: string; + success: boolean; + error?: string; + deletedCount?: number; + }[]; + deletedFromAuth: { + sessions: number; + accounts: number; + credits: number; + user: boolean; + }; + totalDeleted: number; +} + +export interface UserListItem { + id: string; + email: string; + name: string; + role: string; + createdAt: string; + lastActiveAt?: string; +} + +export interface UserListResponse { + users: UserListItem[]; + total: number; + page: number; + limit: number; +} diff --git a/services/mana-core-auth/src/admin/guards/admin.guard.ts b/services/mana-core-auth/src/admin/guards/admin.guard.ts new file mode 100644 index 000000000..f28c13b0b --- /dev/null +++ b/services/mana-core-auth/src/admin/guards/admin.guard.ts @@ -0,0 +1,72 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { BetterAuthService } from '../../auth/services/better-auth.service'; + +/** + * Guard for admin-only endpoints + * Checks JWT token and verifies user has admin role or is in ADMIN_USER_IDS + */ +@Injectable() +export class AdminGuard implements CanActivate { + private readonly logger = new Logger(AdminGuard.name); + private readonly adminUserIds: string[]; + + constructor( + private readonly configService: ConfigService, + private readonly betterAuthService: BetterAuthService + ) { + const adminIds = this.configService.get('ADMIN_USER_IDS', ''); + this.adminUserIds = adminIds ? adminIds.split(',').map((id) => id.trim()) : []; + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + this.logger.warn('Missing or invalid Authorization header'); + throw new UnauthorizedException('Missing authorization token'); + } + + const token = authHeader.substring(7); + + try { + // Validate JWT using Better Auth + const result = await this.betterAuthService.validateToken(token); + + if (!result.valid || !result.payload) { + throw new UnauthorizedException('Invalid token'); + } + + const userId = result.payload.sub; + const userRole = result.payload.role; + + // Check if user is admin (by role or by explicit ID) + const isAdmin = userRole === 'admin' || this.adminUserIds.includes(userId); + + if (!isAdmin) { + this.logger.warn(`User ${userId} attempted admin access without permission`); + throw new ForbiddenException('Admin access required'); + } + + // Attach user info to request + (request as any).user = result.payload; + + return true; + } catch (error) { + if (error instanceof UnauthorizedException || error instanceof ForbiddenException) { + throw error; + } + this.logger.error(`Token validation error: ${error.message}`); + throw new UnauthorizedException('Token validation failed'); + } + } +} diff --git a/services/mana-core-auth/src/admin/user-data.controller.ts b/services/mana-core-auth/src/admin/user-data.controller.ts new file mode 100644 index 000000000..0a7e0cbb4 --- /dev/null +++ b/services/mana-core-auth/src/admin/user-data.controller.ts @@ -0,0 +1,66 @@ +import { + Controller, + Get, + Delete, + Param, + Query, + UseGuards, + Logger, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { UserDataService } from './user-data.service'; +import { AdminGuard } from './guards/admin.guard'; +import { UserDataSummary, DeleteUserDataResponse, UserListResponse } from './dto/user-data.dto'; + +/** + * Admin controller for cross-project user data management + * All endpoints require admin authentication (role=admin or in ADMIN_USER_IDS) + */ +@Controller('api/v1/admin') +@UseGuards(AdminGuard) +export class UserDataController { + private readonly logger = new Logger(UserDataController.name); + + constructor(private readonly userDataService: UserDataService) {} + + /** + * List all users with pagination and search + * GET /api/v1/admin/users?page=1&limit=20&search=email + */ + @Get('users') + async getUsers( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('search') search?: string + ): Promise { + const pageNum = parseInt(page || '1', 10); + const limitNum = Math.min(parseInt(limit || '20', 10), 100); + + this.logger.log( + `Admin request: getUsers page=${pageNum} limit=${limitNum} search=${search || ''}` + ); + return this.userDataService.getUsers(pageNum, limitNum, search); + } + + /** + * Get aggregated user data from all projects + * GET /api/v1/admin/users/:userId/data + */ + @Get('users/:userId/data') + async getUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: getUserData for userId=${userId}`); + return this.userDataService.getUserDataSummary(userId); + } + + /** + * Delete all user data across all projects (GDPR right to be forgotten) + * DELETE /api/v1/admin/users/:userId/data + */ + @Delete('users/:userId/data') + @HttpCode(HttpStatus.OK) + async deleteUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.userDataService.deleteUserData(userId); + } +} diff --git a/services/mana-core-auth/src/admin/user-data.service.ts b/services/mana-core-auth/src/admin/user-data.service.ts new file mode 100644 index 000000000..a5db44d9c --- /dev/null +++ b/services/mana-core-auth/src/admin/user-data.service.ts @@ -0,0 +1,357 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { eq, sql, desc, ilike, or } from 'drizzle-orm'; +import { getDb } from '../db/connection'; +import * as schema from '../db/schema'; +import { + UserDataSummary, + ProjectDataSummary, + DeleteUserDataResponse, + UserListItem, + UserListResponse, +} from './dto/user-data.dto'; + +interface ProjectConfig { + id: string; + name: string; + icon: string; + url: string; +} + +@Injectable() +export class UserDataService { + private readonly logger = new Logger(UserDataService.name); + private readonly serviceKey: string; + private readonly projectConfigs: ProjectConfig[]; + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService + ) { + this.serviceKey = this.configService.get('ADMIN_SERVICE_KEY', 'dev-admin-key'); + + // Configure backend URLs from environment or use defaults + this.projectConfigs = this.initProjectConfigs(); + } + + private getDatabase() { + const databaseUrl = this.configService.get('database.url'); + return getDb(databaseUrl!); + } + + private initProjectConfigs(): ProjectConfig[] { + return [ + { + id: 'chat', + name: 'Chat', + icon: '💬', + url: this.configService.get('CHAT_BACKEND_URL', 'http://localhost:3002'), + }, + { + id: 'todo', + name: 'Todo', + icon: '✅', + url: this.configService.get('TODO_BACKEND_URL', 'http://localhost:3018'), + }, + { + id: 'contacts', + name: 'Contacts', + icon: '👥', + url: this.configService.get('CONTACTS_BACKEND_URL', 'http://localhost:3015'), + }, + { + id: 'calendar', + name: 'Calendar', + icon: '📅', + url: this.configService.get('CALENDAR_BACKEND_URL', 'http://localhost:3014'), + }, + { + id: 'picture', + name: 'Picture', + icon: '🎨', + url: this.configService.get('PICTURE_BACKEND_URL', 'http://localhost:3006'), + }, + { + id: 'zitare', + name: 'Zitare', + icon: '💡', + url: this.configService.get('ZITARE_BACKEND_URL', 'http://localhost:3007'), + }, + { + id: 'presi', + name: 'Presi', + icon: '📊', + url: this.configService.get('PRESI_BACKEND_URL', 'http://localhost:3008'), + }, + ]; + } + + /** + * Get list of all users with pagination + */ + async getUsers(page: number = 1, limit: number = 20, search?: string): Promise { + const db = this.getDatabase(); + const offset = (page - 1) * limit; + + // Build base query + const baseConditions = search + ? or(ilike(schema.users.email, `%${search}%`), ilike(schema.users.name, `%${search}%`)) + : undefined; + + const [users, countResult] = await Promise.all([ + db + .select() + .from(schema.users) + .where(baseConditions) + .orderBy(desc(schema.users.createdAt)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`count(*)::int` }) + .from(schema.users) + .where(baseConditions), + ]); + + // Get last session for each user + const userIds = users.map((user: typeof schema.users.$inferSelect) => user.id); + const lastSessions = + userIds.length > 0 + ? await db + .select({ + odriUserId: schema.sessions.userId, + lastActivityAt: sql`MAX(${schema.sessions.lastActivityAt})`, + }) + .from(schema.sessions) + .where(sql`${schema.sessions.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(schema.sessions.userId) + : []; + + const sessionMap = new Map( + lastSessions.map((session) => [session.odriUserId, session.lastActivityAt]) + ); + + const userList: UserListItem[] = users.map((user: typeof schema.users.$inferSelect) => ({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + createdAt: user.createdAt.toISOString(), + lastActiveAt: sessionMap.get(user.id)?.toISOString(), + })); + + return { + users: userList, + total: countResult[0]?.count ?? 0, + page, + limit, + }; + } + + /** + * Get aggregated user data from all projects + */ + async getUserDataSummary(userId: string): Promise { + const db = this.getDatabase(); + this.logger.log(`Getting user data summary for userId: ${userId}`); + + // Get user data from local DB + const user = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1); + + if (!user.length) { + throw new NotFoundException(`User ${userId} not found`); + } + + // Get auth data + const [sessionsCount, accountsCount, has2FA, lastSession] = await Promise.all([ + db + .select({ count: sql`count(*)::int` }) + .from(schema.sessions) + .where(eq(schema.sessions.userId, userId)), + db + .select({ count: sql`count(*)::int` }) + .from(schema.accounts) + .where(eq(schema.accounts.userId, userId)), + db + .select() + .from(schema.twoFactorAuth) + .where(eq(schema.twoFactorAuth.userId, userId)) + .limit(1), + db + .select({ lastActivityAt: schema.sessions.lastActivityAt }) + .from(schema.sessions) + .where(eq(schema.sessions.userId, userId)) + .orderBy(desc(schema.sessions.lastActivityAt)) + .limit(1), + ]); + + // Get credits data + const creditsResult = await db + .select() + .from(schema.balances) + .where(eq(schema.balances.userId, userId)) + .limit(1); + + const transactionsCount = await db + .select({ count: sql`count(*)::int` }) + .from(schema.transactions) + .where(eq(schema.transactions.userId, userId)); + + const credits = creditsResult[0]; + + // Query all backends in parallel + const projectResults = await Promise.all( + this.projectConfigs.map((config) => this.queryBackend(config, userId)) + ); + + // Calculate totals + const totalEntities = projectResults.reduce( + (sum, p) => sum + (p.available ? p.totalCount : 0), + 0 + ); + const projectsWithData = projectResults.filter((p) => p.available && p.totalCount > 0).length; + + return { + user: { + id: user[0].id, + email: user[0].email, + name: user[0].name, + role: user[0].role, + createdAt: user[0].createdAt.toISOString(), + emailVerified: user[0].emailVerified, + }, + auth: { + sessionsCount: sessionsCount[0]?.count ?? 0, + accountsCount: accountsCount[0]?.count ?? 0, + has2FA: has2FA.length > 0, + lastLoginAt: lastSession[0]?.lastActivityAt?.toISOString() ?? null, + }, + credits: { + balance: credits?.balance ?? 0, + totalEarned: credits?.totalEarned ?? 0, + totalSpent: credits?.totalSpent ?? 0, + transactionsCount: transactionsCount[0]?.count ?? 0, + }, + projects: projectResults, + totals: { + totalEntities, + projectsWithData, + }, + }; + } + + /** + * Delete all user data across all projects (GDPR) + */ + async deleteUserData(userId: string): Promise { + const db = this.getDatabase(); + this.logger.log(`Deleting all user data for userId: ${userId}`); + + // Verify user exists + const user = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1); + + if (!user.length) { + throw new NotFoundException(`User ${userId} not found`); + } + + // Delete from all backends in parallel + const projectResults = await Promise.all( + this.projectConfigs.map(async (config) => { + try { + const response = await firstValueFrom( + this.httpService.delete(`${config.url}/api/v1/admin/user-data/${userId}`, { + headers: { 'X-Service-Key': this.serviceKey }, + timeout: 10000, + }) + ); + return { + projectId: config.id, + projectName: config.name, + success: true, + deletedCount: response.data?.totalDeleted ?? 0, + }; + } catch (error: any) { + this.logger.warn(`Failed to delete data from ${config.name}: ${error.message}`); + return { + projectId: config.id, + projectName: config.name, + success: false, + error: error.message, + }; + } + }) + ); + + // Delete from local auth tables + const [deletedSessions, deletedAccounts, deletedTransactions] = await Promise.all([ + db.delete(schema.sessions).where(eq(schema.sessions.userId, userId)).returning(), + db.delete(schema.accounts).where(eq(schema.accounts.userId, userId)).returning(), + db.delete(schema.transactions).where(eq(schema.transactions.userId, userId)).returning(), + ]); + + // Delete credits balance + await db.delete(schema.balances).where(eq(schema.balances.userId, userId)); + + // Delete 2FA + await db.delete(schema.twoFactorAuth).where(eq(schema.twoFactorAuth.userId, userId)); + + // Soft delete user (or hard delete if preferred) + await db.update(schema.users).set({ deletedAt: new Date() }).where(eq(schema.users.id, userId)); + + const totalFromProjects = projectResults + .filter((p) => p.success) + .reduce((sum, p) => sum + (p.deletedCount ?? 0), 0); + + return { + success: true, + deletedFromProjects: projectResults, + deletedFromAuth: { + sessions: deletedSessions.length, + accounts: deletedAccounts.length, + credits: deletedTransactions.length, + user: true, + }, + totalDeleted: + totalFromProjects + + deletedSessions.length + + deletedAccounts.length + + deletedTransactions.length, + }; + } + + /** + * Query a single backend for user data + */ + private async queryBackend(config: ProjectConfig, userId: string): Promise { + try { + const response = await firstValueFrom( + this.httpService.get(`${config.url}/api/v1/admin/user-data/${userId}`, { + headers: { 'X-Service-Key': this.serviceKey }, + timeout: 5000, + }) + ); + + return { + projectId: config.id, + projectName: config.name, + icon: config.icon, + available: true, + entities: response.data.entities || [], + totalCount: response.data.totalCount || 0, + lastActivityAt: response.data.lastActivityAt, + }; + } catch (error: any) { + this.logger.warn(`Backend ${config.name} unavailable: ${error.message}`); + return { + projectId: config.id, + projectName: config.name, + icon: config.icon, + available: false, + error: error.code === 'ECONNREFUSED' ? 'Backend offline' : error.message, + entities: [], + totalCount: 0, + }; + } + } +} diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index b3d0f7ccc..46490796f 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -13,6 +13,7 @@ import { AiModule } from './ai/ai.module'; import { HealthModule } from './health/health.module'; import { MetricsModule } from './metrics'; import { AnalyticsModule } from './analytics'; +import { AdminModule } from './admin/admin.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { LoggerModule } from './common/logger'; @@ -39,6 +40,7 @@ import { LoggerModule } from './common/logger'; ReferralsModule, SettingsModule, TagsModule, + AdminModule, ], providers: [ {