feat(feedback): add centralized feedback system with AI-generated titles

- Add shared-feedback-types package with TypeScript types
- Add shared-feedback-service package with factory function
- Add shared-feedback-ui package with Svelte 5 components
- Add feedback module to mana-core-auth backend
- Add AI service using Gemini 2.0 Flash for title/category generation
- Add database schema and migration for feedback tables
- Integrate feedback page into Chat web app
- Add CORS support for X-App-Id header
- Add COMMANDS.md documentation for all dev commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 22:46:37 +01:00
parent 05fe8ca5b6
commit 819e4c9a2f
41 changed files with 4290 additions and 338 deletions

343
COMMANDS.md Normal file
View file

@ -0,0 +1,343 @@
# Manacore Monorepo - Befehle
# Alles starten (PostgreSQL, Redis, Auth, Chat)
pnpm docker:up:all
pnpm docker:down
pnpm dev:chat:app
pnpm dev:picture:app
Übersicht aller wichtigen Befehle zum Starten, Stoppen und Verwalten der Apps.
## Inhaltsverzeichnis
- [Voraussetzungen](#voraussetzungen)
- [Docker & Infrastruktur](#docker--infrastruktur)
- [Mana Core Auth](#mana-core-auth)
- [Chat](#chat)
- [Picture](#picture)
- [Manadeck](#manadeck)
- [Zitare](#zitare)
- [Presi](#presi)
- [Manacore](#manacore)
- [Mana Games](#mana-games)
- [Allgemeine Befehle](#allgemeine-befehle)
- [Stoppen & Aufräumen](#stoppen--aufräumen)
---
## Voraussetzungen
```bash
# Dependencies installieren (generiert auch .env Dateien)
pnpm install
# Nur .env Dateien neu generieren
pnpm setup:env
# Shared Packages bauen
pnpm build:packages
```
---
## Docker & Infrastruktur
### Starten
```bash
# Nur PostgreSQL + Redis (Basis-Infrastruktur)
pnpm docker:up
# Mit Mana Core Auth Service
pnpm docker:up:auth
# Mit Chat Backend
pnpm docker:up:chat
# Alles starten (PostgreSQL, Redis, Auth, Chat)
pnpm docker:up:all
```
### Status & Logs
```bash
# Container-Status anzeigen
pnpm docker:ps
# Alle Logs anzeigen (live)
pnpm docker:logs
# Nur Auth-Logs
pnpm docker:logs:auth
# Nur Chat-Logs
pnpm docker:logs:chat
```
### Stoppen
```bash
# Alle Container stoppen
pnpm docker:down
# Stoppen + Volumes löschen (Datenbank-Reset!)
pnpm docker:clean
```
---
## Mana Core Auth
Der zentrale Authentifizierungs-Service (Port 3001).
```bash
# Im Docker starten (empfohlen)
pnpm docker:up:auth
# Oder lokal starten
cd services/mana-core-auth
pnpm start:dev
# Datenbank-Migration
cd services/mana-core-auth
pnpm migration:generate # Migration erstellen
pnpm migration:run # Migration ausführen
pnpm db:push # Schema direkt pushen (dev)
pnpm db:studio # Drizzle Studio öffnen
```
---
## Chat
| App | Port | Befehl |
|-----|------|--------|
| Web | 5174 | `pnpm dev:chat:web` |
| Backend | 3002 | `pnpm dev:chat:backend` |
| Mobile | 8081 | `pnpm dev:chat:mobile` |
| Landing | - | `pnpm dev:chat:landing` |
```bash
# Web + Backend zusammen starten
pnpm dev:chat:app
# Alles (Web, Backend, Mobile, Landing)
pnpm chat:dev
```
---
## Picture
| App | Port | Befehl |
|-----|------|--------|
| Web | 5173 | `pnpm dev:picture:web` |
| Backend | - | `pnpm dev:picture:backend` |
| Mobile | 8081 | `pnpm dev:picture:mobile` |
| Landing | - | `pnpm dev:picture:landing` |
```bash
# Web + Backend zusammen starten
pnpm dev:picture:app
# Alles
pnpm picture:dev
```
---
## Manadeck
| App | Port | Befehl |
|-----|------|--------|
| Web | - | `pnpm dev:manadeck:web` |
| Backend | - | `pnpm dev:manadeck:backend` |
| Mobile | 8081 | `pnpm dev:manadeck:mobile` |
| Landing | - | `pnpm dev:manadeck:landing` |
```bash
# Web + Backend zusammen starten
pnpm dev:manadeck:app
# Alles
pnpm manadeck:dev
```
---
## Zitare
| App | Port | Befehl |
|-----|------|--------|
| Web | - | `pnpm dev:zitare:web` |
| Backend | - | `pnpm dev:zitare:backend` |
| Mobile | 8081 | `pnpm dev:zitare:mobile` |
| Landing | - | `pnpm dev:zitare:landing` |
```bash
# Web + Backend zusammen starten
pnpm dev:zitare:app
# Alles
pnpm zitare:dev
```
---
## Presi
| App | Port | Befehl |
|-----|------|--------|
| Web | - | `pnpm dev:presi:web` |
| Backend | - | `pnpm dev:presi:backend` |
| Mobile | 8081 | `pnpm dev:presi:mobile` |
```bash
# Web + Backend zusammen starten
pnpm dev:presi:app
# Alles
pnpm presi:dev
# Datenbank
pnpm presi:db:push # Schema pushen
pnpm presi:db:studio # Drizzle Studio
pnpm presi:db:seed # Seed-Daten
```
---
## Manacore
| App | Port | Befehl |
|-----|------|--------|
| Web | - | `pnpm dev:manacore:web` |
| Mobile | 8081 | `pnpm dev:manacore:mobile` |
| Landing | - | `pnpm dev:manacore:landing` |
```bash
# Alles
pnpm manacore:dev
```
---
## Mana Games
```bash
# Web + Backend zusammen starten
pnpm dev:mana-games:app
# Einzeln
pnpm dev:mana-games:web
pnpm dev:mana-games:backend
# Alles
pnpm mana-games:dev
```
---
## Allgemeine Befehle
```bash
# Alles bauen
pnpm build
# Type-Check
pnpm type-check
# Linting
pnpm lint
# Formatierung
pnpm format # Formatieren
pnpm format:check # Nur prüfen
# Tests
pnpm test
# Cache leeren
pnpm clean
```
---
## Stoppen & Aufräumen
### Prozesse stoppen
```bash
# Alle Node-Prozesse beenden (macOS/Linux)
pkill -f "node"
# Spezifischen Port freigeben
lsof -ti:3001 | xargs kill -9 # Port 3001 (Auth)
lsof -ti:3002 | xargs kill -9 # Port 3002 (Chat Backend)
lsof -ti:5174 | xargs kill -9 # Port 5174 (Chat Web)
# Turbo Cache leeren
pnpm clean
```
### Docker stoppen
```bash
# Container stoppen (Daten bleiben)
pnpm docker:down
# Container + Volumes löschen (Datenbank-Reset!)
pnpm docker:clean
```
### Komplettes Cleanup
```bash
# Alles stoppen und aufräumen
pnpm docker:down
pkill -f "node"
pnpm clean
rm -rf node_modules/.cache
```
---
## Typische Workflows
### Chat-Entwicklung starten
```bash
# 1. Infrastruktur starten
pnpm docker:up
# 2. Auth-Service starten (neues Terminal)
cd services/mana-core-auth && pnpm start:dev
# 3. Chat App starten (neues Terminal)
pnpm dev:chat:app
```
### Alles mit Docker
```bash
# Alles in Docker starten
pnpm docker:up:all
# Nur Frontend lokal
pnpm dev:chat:web
```
### Nach Code-Änderungen in Shared Packages
```bash
# Packages neu bauen
pnpm build:packages
# Oder spezifisches Package
pnpm --filter @manacore/shared-ui build
```

View file

@ -0,0 +1,15 @@
/**
* Feedback Service Instance for Chat Web App
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
appId: 'chat',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { feedbackService } from '$lib/services/feedback';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<FeedbackPage
{feedbackService}
appName="ManaChat"
currentUserId={authStore.user?.id}
/>

View file

@ -0,0 +1,24 @@
{
"name": "@manacore/shared-feedback-service",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": ["src"],
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-feedback-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,150 @@
/**
* Feedback Service Factory
*
* Creates a feedback service instance configured for a specific app.
* Handles feedback submission, retrieval, and voting.
*
* @example
* ```ts
* import { createFeedbackService } from '@manacore/shared-feedback-service';
* import { authStore } from '$lib/stores/auth.svelte';
*
* export const feedbackService = createFeedbackService({
* apiUrl: 'https://auth.manacore.app',
* appId: 'chat',
* getAuthToken: async () => authStore.getToken(),
* });
* ```
*/
import type {
Feedback,
CreateFeedbackInput,
FeedbackQueryParams,
FeedbackResponse,
FeedbackListResponse,
VoteResponse,
} from '@manacore/shared-feedback-types';
import type { FeedbackServiceConfig } from './types';
/**
* Create a feedback service instance
*/
export function createFeedbackService(config: FeedbackServiceConfig) {
const { apiUrl, appId, getAuthToken, feedbackEndpoint = '/api/v1/feedback' } = config;
// Normalize API URL (remove trailing slash)
const baseUrl = apiUrl.replace(/\/$/, '');
/**
* Helper to make authenticated requests
*/
async function fetchWithAuth<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = await getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${baseUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-App-Id': appId,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
/**
* Submit new feedback
*/
async function createFeedback(input: CreateFeedbackInput): Promise<FeedbackResponse> {
return fetchWithAuth<FeedbackResponse>(feedbackEndpoint, {
method: 'POST',
body: JSON.stringify(input),
});
}
/**
* Get public community feedback
*/
async function getPublicFeedback(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
const params = new URLSearchParams();
// Always filter by current app
params.set('appId', appId);
if (query?.status) params.set('status', query.status);
if (query?.category) params.set('category', query.category);
if (query?.sort) params.set('sort', query.sort);
if (query?.limit) params.set('limit', String(query.limit));
if (query?.offset) params.set('offset', String(query.offset));
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/public?${params}`);
}
/**
* Get user's own feedback
*/
async function getMyFeedback(): Promise<FeedbackListResponse> {
const params = new URLSearchParams();
params.set('appId', appId);
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/my?${params}`);
}
/**
* Vote on a feedback item
*/
async function vote(feedbackId: string): Promise<VoteResponse> {
return fetchWithAuth<VoteResponse>(`${feedbackEndpoint}/${feedbackId}/vote`, {
method: 'POST',
});
}
/**
* Remove vote from a feedback item
*/
async function unvote(feedbackId: string): Promise<VoteResponse> {
return fetchWithAuth<VoteResponse>(`${feedbackEndpoint}/${feedbackId}/vote`, {
method: 'DELETE',
});
}
/**
* Toggle vote on a feedback item
*/
async function toggleVote(feedbackId: string, currentlyVoted: boolean): Promise<VoteResponse> {
if (currentlyVoted) {
return unvote(feedbackId);
} else {
return vote(feedbackId);
}
}
return {
createFeedback,
getPublicFeedback,
getMyFeedback,
vote,
unvote,
toggleVote,
};
}
/**
* Type for the feedback service instance
*/
export type FeedbackService = ReturnType<typeof createFeedbackService>;

View file

@ -0,0 +1,24 @@
/**
* Shared feedback service for Manacore monorepo
*
* This package provides a factory function to create feedback service
* instances that can be used across all web and mobile apps.
*/
export { createFeedbackService, type FeedbackService } from './createFeedbackService';
export type { FeedbackServiceConfig } from './types';
// Re-export types from shared-feedback-types for convenience
export type {
Feedback,
FeedbackCategory,
FeedbackStatus,
FeedbackVote,
CreateFeedbackInput,
FeedbackQueryParams,
FeedbackResponse,
FeedbackListResponse,
VoteResponse,
FEEDBACK_CATEGORY_LABELS,
FEEDBACK_STATUS_CONFIG,
} from '@manacore/shared-feedback-types';

View file

@ -0,0 +1,13 @@
/**
* Configuration for creating a feedback service instance
*/
export interface FeedbackServiceConfig {
/** Base API URL for the feedback endpoints */
apiUrl: string;
/** App identifier for multi-app support */
appId: string;
/** Function to get the current auth token */
getAuthToken: () => Promise<string | null>;
/** Optional custom endpoint prefix (default: '/api/v1/feedback') */
feedbackEndpoint?: string;
}

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,19 @@
{
"name": "@manacore/shared-feedback-types",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./feedback": "./src/feedback.ts",
"./api": "./src/api.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,41 @@
/**
* API request/response types for feedback
*/
import type { Feedback, FeedbackCategory, FeedbackStatus } from './feedback';
export interface CreateFeedbackInput {
title?: string;
feedbackText: string;
category?: FeedbackCategory;
deviceInfo?: Record<string, unknown>;
}
export interface FeedbackQueryParams {
appId?: string;
status?: FeedbackStatus;
category?: FeedbackCategory;
sort?: 'votes' | 'recent';
limit?: number;
offset?: number;
}
export interface FeedbackResponse {
success: boolean;
feedback?: Feedback;
error?: string;
}
export interface FeedbackListResponse {
success: boolean;
items: Feedback[];
total: number;
error?: string;
}
export interface VoteResponse {
success: boolean;
newVoteCount: number;
userHasVoted: boolean;
error?: string;
}

View file

@ -0,0 +1,59 @@
/**
* Core feedback types
*/
export type FeedbackCategory = 'bug' | 'feature' | 'improvement' | 'question' | 'other';
export type FeedbackStatus =
| 'submitted'
| 'under_review'
| 'planned'
| 'in_progress'
| 'completed'
| 'declined';
export interface Feedback {
id: string;
userId: string;
appId: string;
title?: string;
feedbackText: string;
category: FeedbackCategory;
status: FeedbackStatus;
isPublic: boolean;
adminResponse?: string;
voteCount: number;
userHasVoted: boolean;
deviceInfo?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
publishedAt?: string;
completedAt?: string;
}
export interface FeedbackVote {
id: string;
feedbackId: string;
userId: string;
createdAt: string;
}
export const FEEDBACK_CATEGORY_LABELS: Record<FeedbackCategory, string> = {
bug: 'Bug',
feature: 'Feature',
improvement: 'Verbesserung',
question: 'Frage',
other: 'Sonstiges',
};
export const FEEDBACK_STATUS_CONFIG: Record<
FeedbackStatus,
{ label: string; color: string; icon: string }
> = {
submitted: { label: 'Eingereicht', color: '#999', icon: 'clock' },
under_review: { label: 'Wird geprüft', color: '#3498DB', icon: 'eye' },
planned: { label: 'Geplant', color: '#9B59B6', icon: 'calendar' },
in_progress: { label: 'In Arbeit', color: '#F39C12', icon: 'loader' },
completed: { label: 'Umgesetzt', color: '#27AE60', icon: 'check-circle' },
declined: { label: 'Abgelehnt', color: '#E74C3C', icon: 'x-circle' },
};

View file

@ -0,0 +1,25 @@
/**
* Shared feedback types for Manacore monorepo
*
* This package contains TypeScript types for feedback submissions,
* voting, and API contracts.
*/
// Feedback types
export {
type FeedbackCategory,
type FeedbackStatus,
type Feedback,
type FeedbackVote,
FEEDBACK_CATEGORY_LABELS,
FEEDBACK_STATUS_CONFIG,
} from './feedback';
// API types
export {
type CreateFeedbackInput,
type FeedbackQueryParams,
type FeedbackResponse,
type FeedbackListResponse,
type VoteResponse,
} from './api';

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,55 @@
{
"name": "@manacore/shared-feedback-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./FeedbackPage.svelte": {
"svelte": "./src/FeedbackPage.svelte",
"default": "./src/FeedbackPage.svelte"
},
"./FeedbackForm.svelte": {
"svelte": "./src/FeedbackForm.svelte",
"default": "./src/FeedbackForm.svelte"
},
"./FeedbackList.svelte": {
"svelte": "./src/FeedbackList.svelte",
"default": "./src/FeedbackList.svelte"
},
"./FeedbackCard.svelte": {
"svelte": "./src/FeedbackCard.svelte",
"default": "./src/FeedbackCard.svelte"
},
"./VoteButton.svelte": {
"svelte": "./src/VoteButton.svelte",
"default": "./src/VoteButton.svelte"
},
"./StatusBadge.svelte": {
"svelte": "./src/StatusBadge.svelte",
"default": "./src/StatusBadge.svelte"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@manacore/shared-feedback-types": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}

View file

@ -0,0 +1,165 @@
<script lang="ts">
import type { Feedback } from '@manacore/shared-feedback-types';
import StatusBadge from './StatusBadge.svelte';
import VoteButton from './VoteButton.svelte';
interface Props {
feedback: Feedback;
onVote: (feedbackId: string, hasVoted: boolean) => void;
showStatus?: boolean;
isOwner?: boolean;
votingDisabled?: boolean;
}
let {
feedback,
onVote,
showStatus = true,
isOwner = false,
votingDisabled = false,
}: Props = $props();
function handleVote() {
onVote(feedback.id, feedback.userHasVoted);
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<div class="feedback-card" class:feedback-card--owner={isOwner}>
<div class="feedback-card__vote">
<VoteButton
count={feedback.voteCount}
hasVoted={feedback.userHasVoted}
onToggle={handleVote}
disabled={votingDisabled || !feedback.isPublic}
/>
</div>
<div class="feedback-card__content">
<div class="feedback-card__header">
{#if feedback.title}
<h3 class="feedback-card__title">{feedback.title}</h3>
{/if}
{#if showStatus}
<StatusBadge status={feedback.status} />
{/if}
</div>
<p class="feedback-card__text">{feedback.feedbackText}</p>
{#if feedback.adminResponse}
<div class="feedback-card__response">
<span class="feedback-card__response-label">Admin-Antwort:</span>
<p class="feedback-card__response-text">{feedback.adminResponse}</p>
</div>
{/if}
<div class="feedback-card__footer">
<span class="feedback-card__date">{formatDate(feedback.createdAt)}</span>
{#if isOwner}
<span class="feedback-card__owner-badge">Dein Feedback</span>
{/if}
</div>
</div>
</div>
<style>
.feedback-card {
display: flex;
gap: 1rem;
padding: 1rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface, 0 0% 100%));
border: 1px solid hsl(var(--color-border, 0 0% 90%));
transition: all 0.2s ease;
}
.feedback-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.feedback-card--owner {
border-left: 3px solid hsl(var(--color-primary, 47 95% 58%));
}
.feedback-card__vote {
flex-shrink: 0;
}
.feedback-card__content {
flex: 1;
min-width: 0;
}
.feedback-card__header {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.feedback-card__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-card__text {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
color: hsl(var(--color-foreground, 0 0% 17%));
line-height: 1.5;
}
.feedback-card__response {
padding: 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary, 47 95% 58%) / 0.1);
margin-bottom: 0.75rem;
}
.feedback-card__response-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-primary, 47 95% 58%));
margin-bottom: 0.25rem;
}
.feedback-card__response-text {
margin: 0;
font-size: 0.875rem;
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-card__footer {
display: flex;
align-items: center;
gap: 0.5rem;
}
.feedback-card__date {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground, 0 0% 40%));
}
.feedback-card__owner-badge {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.625rem;
font-weight: 600;
background: hsl(var(--color-primary, 47 95% 58%) / 0.1);
color: hsl(var(--color-primary, 47 95% 58%));
}
</style>

View file

@ -0,0 +1,195 @@
<script lang="ts">
import type { CreateFeedbackInput } from '@manacore/shared-feedback-types';
interface Props {
onSubmit: (input: CreateFeedbackInput) => Promise<void>;
onCancel?: () => void;
isSubmitting?: boolean;
feedbackLabel?: string;
submitLabel?: string;
cancelLabel?: string;
feedbackPlaceholder?: string;
}
let {
onSubmit,
onCancel,
isSubmitting = false,
feedbackLabel = 'Dein Feedback',
submitLabel = 'Feedback senden',
cancelLabel = 'Abbrechen',
feedbackPlaceholder = 'Was gefällt dir? Was können wir verbessern?',
}: Props = $props();
let feedbackText = $state('');
let error = $state('');
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
if (feedbackText.trim().length < 10) {
error = 'Bitte gib mindestens 10 Zeichen ein.';
return;
}
try {
await onSubmit({
feedbackText: feedbackText.trim(),
});
// Reset form on success
feedbackText = '';
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten.';
}
}
</script>
<form class="feedback-form" onsubmit={handleSubmit}>
<div class="feedback-form__field">
<label for="feedback-text" class="feedback-form__label">{feedbackLabel}</label>
<textarea
id="feedback-text"
class="feedback-form__textarea"
placeholder={feedbackPlaceholder}
bind:value={feedbackText}
rows="5"
maxlength="2000"
disabled={isSubmitting}
required
></textarea>
<span class="feedback-form__counter">{feedbackText.length}/2000</span>
</div>
{#if error}
<div class="feedback-form__error">{error}</div>
{/if}
<div class="feedback-form__actions">
{#if onCancel}
<button
type="button"
class="feedback-form__button feedback-form__button--secondary"
onclick={onCancel}
disabled={isSubmitting}
>
{cancelLabel}
</button>
{/if}
<button
type="submit"
class="feedback-form__button feedback-form__button--primary"
disabled={isSubmitting || feedbackText.trim().length < 10}
>
{#if isSubmitting}
Wird gesendet...
{:else}
{submitLabel}
{/if}
</button>
</div>
</form>
<style>
.feedback-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.feedback-form__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.feedback-form__label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-form__input,
.feedback-form__select,
.feedback-form__textarea {
padding: 0.75rem;
border: 1px solid hsl(var(--color-border, 0 0% 90%));
border-radius: 0.5rem;
font-size: 0.875rem;
color: hsl(var(--color-foreground, 0 0% 17%));
background: hsl(var(--color-input, 0 0% 100%));
transition: border-color 0.2s ease;
}
.feedback-form__textarea::placeholder {
color: hsl(var(--color-muted-foreground, 0 0% 40%));
}
.feedback-form__input:focus,
.feedback-form__select:focus,
.feedback-form__textarea:focus {
outline: none;
border-color: hsl(var(--color-primary, 47 95% 58%));
}
.feedback-form__textarea {
resize: vertical;
min-height: 120px;
}
.feedback-form__counter {
align-self: flex-end;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground, 0 0% 40%));
}
.feedback-form__error {
padding: 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-error, 6 78% 57%) / 0.1);
color: hsl(var(--color-error, 6 78% 57%));
font-size: 0.875rem;
}
.feedback-form__actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.5rem;
}
.feedback-form__button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.feedback-form__button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.feedback-form__button--primary {
background: hsl(var(--color-primary, 47 95% 58%));
color: hsl(var(--color-primary-foreground, 0 0% 0%));
}
.feedback-form__button--primary:hover:not(:disabled) {
background: hsl(var(--color-primary, 47 95% 58%) / 0.9);
}
.feedback-form__button--secondary {
background: transparent;
border: 1px solid hsl(var(--color-border, 0 0% 90%));
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-form__button--secondary:hover:not(:disabled) {
background: hsl(var(--color-muted, 0 0% 90%));
}
</style>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import type { Feedback } from '@manacore/shared-feedback-types';
import FeedbackCard from './FeedbackCard.svelte';
interface Props {
items: Feedback[];
currentUserId?: string;
onVote: (feedbackId: string, hasVoted: boolean) => void;
votingDisabled?: boolean;
emptyMessage?: string;
}
let {
items,
currentUserId,
onVote,
votingDisabled = false,
emptyMessage = 'Noch kein Feedback vorhanden',
}: Props = $props();
</script>
<div class="feedback-list">
{#if items.length === 0}
<div class="feedback-list__empty">
<p>{emptyMessage}</p>
</div>
{:else}
{#each items as feedback (feedback.id)}
<FeedbackCard
{feedback}
{onVote}
{votingDisabled}
isOwner={currentUserId === feedback.userId}
/>
{/each}
{/if}
</div>
<style>
.feedback-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.feedback-list__empty {
padding: 2rem;
text-align: center;
border-radius: 0.75rem;
background: hsl(var(--color-surface, 0 0% 100%) / 0.5);
border: 1px dashed hsl(var(--color-border, 0 0% 90%));
}
.feedback-list__empty p {
margin: 0;
color: hsl(var(--color-muted-foreground, 0 0% 40%));
}
</style>

View file

@ -0,0 +1,384 @@
<script lang="ts">
import type { FeedbackService, Feedback } from '@manacore/shared-feedback-service';
import FeedbackForm from './FeedbackForm.svelte';
import FeedbackList from './FeedbackList.svelte';
interface Props {
/** Pre-configured feedback service instance */
feedbackService: FeedbackService;
/** App name for display */
appName: string;
/** Current user ID for highlighting own feedback */
currentUserId?: string;
/** Page title */
pageTitle?: string;
/** Page subtitle */
pageSubtitle?: string;
/** Tab label for own feedback */
myFeedbackLabel?: string;
/** Tab label for community feedback */
communityLabel?: string;
/** Empty state message for own feedback */
myFeedbackEmptyMessage?: string;
/** Empty state message for community feedback */
communityEmptyMessage?: string;
}
let {
feedbackService,
appName,
currentUserId,
pageTitle = 'Feedback & Vorschläge',
pageSubtitle = 'Teile deine Ideen und stimme für Feature-Wünsche ab',
myFeedbackLabel = 'Mein Feedback',
communityLabel = 'Community',
myFeedbackEmptyMessage = 'Du hast noch kein Feedback eingereicht',
communityEmptyMessage = 'Noch keine öffentlichen Vorschläge',
}: Props = $props();
// State
let activeTab = $state<'my' | 'community'>('community');
let myFeedback = $state<Feedback[]>([]);
let publicFeedback = $state<Feedback[]>([]);
let isLoading = $state(true);
let isSubmitting = $state(false);
let showForm = $state(false);
let successMessage = $state('');
// Load data on mount
$effect(() => {
loadFeedback();
});
async function loadFeedback() {
isLoading = true;
try {
const [myResult, publicResult] = await Promise.all([
feedbackService.getMyFeedback(),
feedbackService.getPublicFeedback({ sort: 'votes' }),
]);
myFeedback = myResult.items;
publicFeedback = publicResult.items;
} catch (error) {
console.error('[FeedbackPage] Error loading feedback:', error);
} finally {
isLoading = false;
}
}
async function handleSubmit(input: { title?: string; feedbackText: string; category?: string }) {
isSubmitting = true;
try {
await feedbackService.createFeedback(input);
showForm = false;
successMessage = 'Feedback erfolgreich gesendet!';
setTimeout(() => {
successMessage = '';
}, 3000);
await loadFeedback();
} finally {
isSubmitting = false;
}
}
async function handleVote(feedbackId: string, hasVoted: boolean) {
try {
await feedbackService.toggleVote(feedbackId, hasVoted);
await loadFeedback();
} catch (error) {
console.error('[FeedbackPage] Error voting:', error);
}
}
function setActiveTab(tab: 'my' | 'community') {
activeTab = tab;
}
</script>
<svelte:head>
<title>{pageTitle} - {appName}</title>
</svelte:head>
<div class="feedback-page">
<div class="feedback-page__container">
<!-- Header -->
<div class="feedback-page__header">
<div class="feedback-page__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h1 class="feedback-page__title">{pageTitle}</h1>
<p class="feedback-page__subtitle">{pageSubtitle}</p>
</div>
<!-- Success Message -->
{#if successMessage}
<div class="feedback-page__success">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
{successMessage}
</div>
{/if}
<!-- New Feedback Button / Form -->
<div class="feedback-page__form-section">
{#if showForm}
<div class="feedback-page__form-card">
<h2 class="feedback-page__form-title">Neues Feedback</h2>
<FeedbackForm
onSubmit={handleSubmit}
onCancel={() => (showForm = false)}
{isSubmitting}
/>
</div>
{:else}
<button class="feedback-page__new-button" onclick={() => (showForm = true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Feedback geben
</button>
{/if}
</div>
<!-- Tabs -->
<div class="feedback-page__tabs">
<button
class="feedback-page__tab"
class:feedback-page__tab--active={activeTab === 'community'}
onclick={() => setActiveTab('community')}
>
{communityLabel}
<span class="feedback-page__tab-count">{publicFeedback.length}</span>
</button>
<button
class="feedback-page__tab"
class:feedback-page__tab--active={activeTab === 'my'}
onclick={() => setActiveTab('my')}
>
{myFeedbackLabel}
<span class="feedback-page__tab-count">{myFeedback.length}</span>
</button>
</div>
<!-- Content -->
<div class="feedback-page__content">
{#if isLoading}
<div class="feedback-page__loading">
<div class="feedback-page__spinner"></div>
<p>Lade Feedback...</p>
</div>
{:else if activeTab === 'community'}
<FeedbackList
items={publicFeedback}
{currentUserId}
onVote={handleVote}
emptyMessage={communityEmptyMessage}
/>
{:else}
<FeedbackList
items={myFeedback}
{currentUserId}
onVote={handleVote}
votingDisabled={true}
emptyMessage={myFeedbackEmptyMessage}
/>
{/if}
</div>
</div>
</div>
<style>
.feedback-page {
min-height: 100%;
padding: 1rem;
}
.feedback-page__container {
max-width: 48rem;
margin: 0 auto;
}
.feedback-page__header {
text-align: center;
margin-bottom: 2rem;
}
.feedback-page__icon {
width: 4rem;
height: 4rem;
margin: 0 auto 1rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 1rem;
background: hsl(var(--color-surface, 0 0% 100%));
border: 1px solid hsl(var(--color-border, 0 0% 90%));
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
color: hsl(var(--color-primary, 47 95% 58%));
}
.feedback-page__icon svg {
width: 2rem;
height: 2rem;
}
.feedback-page__title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-page__subtitle {
margin: 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground, 0 0% 40%));
}
.feedback-page__success {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-success, 145 63% 42%) / 0.1);
color: hsl(var(--color-success, 145 63% 42%));
font-size: 0.875rem;
font-weight: 500;
}
.feedback-page__success svg {
width: 1.25rem;
height: 1.25rem;
}
.feedback-page__form-section {
margin-bottom: 1.5rem;
}
.feedback-page__new-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 1rem;
border: 2px dashed hsl(var(--color-border, 0 0% 90%));
border-radius: 0.75rem;
background: transparent;
color: hsl(var(--color-muted-foreground, 0 0% 40%));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.feedback-page__new-button:hover {
border-color: hsl(var(--color-primary, 47 95% 58%));
color: hsl(var(--color-primary, 47 95% 58%));
background: hsl(var(--color-primary, 47 95% 58%) / 0.05);
}
.feedback-page__new-button svg {
width: 1.25rem;
height: 1.25rem;
}
.feedback-page__form-card {
padding: 1.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface, 0 0% 100%));
border: 1px solid hsl(var(--color-border, 0 0% 90%));
}
.feedback-page__form-title {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-page__tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 0.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted, 0 0% 90%));
}
.feedback-page__tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: none;
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground, 0 0% 40%));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.feedback-page__tab:hover {
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-page__tab--active {
background: hsl(var(--color-surface, 0 0% 100%));
color: hsl(var(--color-foreground, 0 0% 17%));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.feedback-page__tab-count {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
background: hsl(var(--color-muted, 0 0% 90%));
}
.feedback-page__tab--active .feedback-page__tab-count {
background: hsl(var(--color-primary, 47 95% 58%) / 0.1);
color: hsl(var(--color-primary, 47 95% 58%));
}
.feedback-page__content {
min-height: 200px;
}
.feedback-page__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem;
color: hsl(var(--color-muted-foreground, 0 0% 40%));
}
.feedback-page__spinner {
width: 2rem;
height: 2rem;
border: 2px solid hsl(var(--color-border, 0 0% 90%));
border-top-color: hsl(var(--color-primary, 47 95% 58%));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import type { FeedbackStatus } from '@manacore/shared-feedback-types';
import { FEEDBACK_STATUS_CONFIG } from '@manacore/shared-feedback-types';
interface Props {
status: FeedbackStatus;
}
let { status }: Props = $props();
const config = $derived(FEEDBACK_STATUS_CONFIG[status]);
</script>
<span class="status-badge" style="--badge-color: {config.color};">
{config.label}
</span>
<style>
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
color: var(--badge-color);
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,91 @@
<script lang="ts">
interface Props {
count: number;
hasVoted: boolean;
onToggle: () => void;
disabled?: boolean;
}
let { count, hasVoted, onToggle, disabled = false }: Props = $props();
let isAnimating = $state(false);
function handleClick() {
if (disabled) return;
isAnimating = true;
onToggle();
setTimeout(() => {
isAnimating = false;
}, 300);
}
</script>
<button
class="vote-button"
class:vote-button--voted={hasVoted}
class:vote-button--animating={isAnimating}
onclick={handleClick}
{disabled}
type="button"
aria-label={hasVoted ? 'Stimme entfernen' : 'Abstimmen'}
>
<svg
class="vote-button__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 15l-6-6-6 6" />
</svg>
<span class="vote-button__count">{count}</span>
</button>
<style>
.vote-button {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
border: 1px solid hsl(var(--color-border, 0 0% 90%));
border-radius: 0.5rem;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
color: hsl(var(--color-muted-foreground, 0 0% 40%));
min-width: 3rem;
}
.vote-button:hover:not(:disabled) {
border-color: hsl(var(--color-primary, 47 95% 58%));
color: hsl(var(--color-primary, 47 95% 58%));
}
.vote-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.vote-button--voted {
background: hsl(var(--color-primary, 47 95% 58%) / 0.1);
border-color: hsl(var(--color-primary, 47 95% 58%));
color: hsl(var(--color-primary, 47 95% 58%));
}
.vote-button--animating {
transform: scale(1.1);
}
.vote-button__icon {
width: 1.25rem;
height: 1.25rem;
}
.vote-button__count {
font-size: 0.875rem;
font-weight: 600;
}
</style>

View file

@ -0,0 +1,20 @@
/**
* Shared feedback UI components for Manacore monorepo
*
* This package provides Svelte 5 components for feedback functionality
* that can be used across all web apps.
*/
// Page components
export { default as FeedbackPage } from './FeedbackPage.svelte';
// Form components
export { default as FeedbackForm } from './FeedbackForm.svelte';
// List components
export { default as FeedbackList } from './FeedbackList.svelte';
export { default as FeedbackCard } from './FeedbackCard.svelte';
// Utility components
export { default as VoteButton } from './VoteButton.svelte';
export { default as StatusBadge } from './StatusBadge.svelte';

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

568
pnpm-lock.yaml generated
View file

@ -76,7 +76,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^10.4.9
version: 10.4.9(esbuild@0.19.12)
version: 10.4.9(esbuild@0.27.0)
'@nestjs/schematics':
specifier: ^10.2.3
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
@ -109,7 +109,7 @@ importers:
version: 0.5.21
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(esbuild@0.27.0))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -1897,7 +1897,7 @@ importers:
version: 0.5.21
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0))
version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.0))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -2841,7 +2841,7 @@ importers:
version: 1.57.0
jest:
specifier: ^29.0.0
version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
version: 29.7.0(@types/node@24.10.1)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
@ -2880,6 +2880,9 @@ importers:
services/mana-core-auth:
dependencies:
'@google/generative-ai':
specifier: ^0.24.1
version: 0.24.1
'@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)
@ -2952,7 +2955,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)
@ -3006,10 +3009,10 @@ importers:
version: 7.1.4
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))(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)
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)
@ -4863,7 +4866,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==}
@ -19093,7 +19096,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.18.3
optionalDependencies:
expo-router: 6.0.15(5e7ih2rh6mb55wruwvjljgzihq)
expo-router: 6.0.15(jiucxy5ca3jdtbnulaxuc46jdq)
react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -19170,7 +19173,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.18.3
optionalDependencies:
expo-router: 6.0.15(nttrd3tw67nnyhowcwgdzipb5e)
expo-router: 6.0.15(ohit2up6tuxb3x34brxduivol4)
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -20410,6 +20413,41 @@ snapshots:
jest-util: 30.2.0
slash: 3.0.0
'@jest/core@29.7.0':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.19.1
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@22.19.1)
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.8
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
@ -20445,78 +20483,6 @@ snapshots:
- supports-color
- ts-node
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.19.1
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.8
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
'@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.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.19.12))(ts-node@10.9.2(@types/node@22.19.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/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))':
dependencies:
'@jest/console': 30.2.0
@ -20943,32 +20909,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)
@ -20995,7 +20935,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)
@ -21006,14 +20946,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'
@ -23447,7 +23387,7 @@ snapshots:
react-test-renderer: 19.1.0(react@19.1.0)
redent: 3.0.0
'@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.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)':
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.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
@ -23457,7 +23397,20 @@ snapshots:
react-test-renderer: 19.1.0(react@19.1.0)
redent: 3.0.0
optionalDependencies:
jest: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
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)':
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@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))
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)))(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)':
@ -24419,11 +24372,11 @@ snapshots:
- vite
optional: true
'@vitest/browser@3.2.4(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)':
'@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
'@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
'@vitest/utils': 3.2.4
magic-string: 0.30.21
sirv: 3.0.2
@ -24463,15 +24416,6 @@ snapshots:
optionalDependencies:
vite: 6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
'@vitest/mocker@3.2.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
optional: true
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
@ -24501,7 +24445,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
'@vitest/utils@3.2.4':
dependencies:
@ -26183,13 +26127,13 @@ snapshots:
- supports-color
- ts-node
create-jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
create-jest@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@24.10.1)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@ -27054,9 +26998,9 @@ snapshots:
'@typescript-eslint/eslint-plugin': 8.48.0(@typescript-eslint/parser@8.48.0(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.0(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.0(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.0(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.0(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
@ -27071,9 +27015,9 @@ snapshots:
'@typescript-eslint/eslint-plugin': 8.48.0(@typescript-eslint/parser@8.48.0(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.0(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.0(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.0(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.0(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
@ -27143,7 +27087,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(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
@ -27154,22 +27098,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(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.0(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.0(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.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@ -27193,25 +27122,25 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(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.0(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.0(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.0(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.0(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.0(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.0(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.0(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.0(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.0(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))
transitivePeerDependencies:
- supports-color
@ -27311,7 +27240,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(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.0(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
@ -27322,7 +27251,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.0(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.0(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.0(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
@ -27340,7 +27269,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(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.0(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
@ -27351,7 +27280,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.0(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.0(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.0(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
@ -28492,7 +28421,54 @@ snapshots:
- '@types/react-dom'
- supports-color
expo-router@6.0.15(nttrd3tw67nnyhowcwgdzipb5e):
expo-router@6.0.15(jiucxy5ca3jdtbnulaxuc46jdq):
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
'@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0)
'@radix-ui/react-tabs': 1.1.13(@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)
'@react-navigation/bottom-tabs': 7.8.6(@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-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)
'@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-navigation/native-stack': 7.8.0(@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-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)
client-only: 0.0.1
debug: 4.4.3
escape-string-regexp: 4.0.0
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.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@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))
expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo-server: 1.0.4
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.11
query-string: 7.1.3
react: 19.1.0
react-fast-compare: 3.2.2
react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
react-native-is-edge-to-edge: 1.2.1(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)
semver: 7.6.3
server-only: 0.0.1
sf-symbols-typescript: 2.1.0
shallowequal: 1.1.0
use-latest-callback: 0.2.6(react@19.1.0)
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)
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)
react-native-web: 0.21.2(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.100.2(esbuild@0.27.0))
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
- '@types/react-dom'
- supports-color
optional: true
expo-router@6.0.15(ohit2up6tuxb3x34brxduivol4):
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
@ -28526,12 +28502,12 @@ 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(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.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)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.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)
react-native-web: 0.21.2(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'
@ -29389,23 +29365,6 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
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
@ -29440,6 +29399,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
@ -29457,23 +29433,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@3.0.4:
@ -30556,16 +30515,16 @@ snapshots:
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest-cli@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
create-jest: 29.7.0(@types/node@24.10.1)
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@24.10.1)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@ -30575,15 +30534,15 @@ snapshots:
- supports-color
- ts-node
jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
jest-cli@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)):
dependencies:
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
'@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.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest-config: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))
jest-util: 30.2.0
jest-validate: 30.2.0
yargs: 17.7.2
@ -30653,6 +30612,36 @@ snapshots:
- ts-node
optional: true
jest-config@29.7.0(@types/node@22.19.1):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.28.5)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.19.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
@ -30684,38 +30673,7 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.28.5)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.19.1
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest-config@29.7.0(@types/node@24.10.1):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
@ -30741,12 +30699,11 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 24.10.1
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
jest-config@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)):
dependencies:
'@babel/core': 7.28.5
'@jest/get-type': 30.1.0
@ -30773,9 +30730,8 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.19.1
esbuild-register: 3.6.0(esbuild@0.19.12)
ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
'@types/node': 20.19.25
esbuild-register: 3.6.0(esbuild@0.27.0)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@ -31424,24 +31380,24 @@ snapshots:
- supports-color
- ts-node
jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-cli: 29.7.0(@types/node@24.10.1)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)):
dependencies:
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
'@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.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest-cli: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@ -35055,6 +35011,16 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
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
@ -35064,16 +35030,6 @@ snapshots:
webpack: 5.100.2(esbuild@0.27.0)
webpack-sources: 3.3.3
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
@ -36296,14 +36252,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
@ -36329,15 +36285,6 @@ snapshots:
optionalDependencies:
esbuild: 0.27.0
terser-webpack-plugin@5.3.14(webpack@5.100.2):
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.100.2
terser-webpack-plugin@5.3.14(webpack@5.97.1):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@ -36493,6 +36440,27 @@ snapshots:
ts-interface-checker@0.1.13: {}
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.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):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
semver: 7.7.3
type-fest: 4.41.0
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.19.12
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))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
@ -36514,25 +36482,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))(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@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: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
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)
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:
@ -36544,7 +36502,7 @@ snapshots:
typescript: 5.9.3
webpack: 5.100.2(esbuild@0.27.0)
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2):
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.0)):
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.18.3
@ -36552,17 +36510,7 @@ snapshots:
semver: 7.7.3
source-map: 0.7.6
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)
webpack: 5.97.1(esbuild@0.27.0)
ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3):
dependencies:
@ -37322,7 +37270,7 @@ snapshots:
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.10.1
'@vitest/browser': 3.2.4(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)
'@vitest/browser': 3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 27.2.0
transitivePeerDependencies:
@ -37485,7 +37433,7 @@ snapshots:
webpack-sources@3.3.3: {}
webpack@5.100.2:
webpack@5.100.2(esbuild@0.19.12):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
@ -37509,7 +37457,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.0
terser-webpack-plugin: 5.3.14(webpack@5.100.2)
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:
@ -37579,36 +37527,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

View file

@ -21,6 +21,7 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",

View file

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { AiService } from './ai.service';
@Global()
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View file

@ -0,0 +1,103 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
export interface FeedbackAnalysis {
title: string;
category: 'bug' | 'feature' | 'improvement' | 'question' | 'other';
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private genAI: GoogleGenerativeAI | null = null;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('ai.geminiApiKey');
if (apiKey) {
this.genAI = new GoogleGenerativeAI(apiKey);
} else {
this.logger.warn('GOOGLE_GENAI_API_KEY not configured - AI features disabled');
}
}
async analyzeFeedback(feedbackText: string): Promise<FeedbackAnalysis> {
// Fallback if AI not available
if (!this.genAI) {
return this.fallbackAnalysis(feedbackText);
}
try {
const model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
const prompt = `Analysiere dieses User-Feedback und generiere:
1. Einen kurzen, prägnanten deutschen Titel (max 60 Zeichen) der den Kern des Feedbacks zusammenfasst
2. Eine passende Kategorie aus: bug, feature, improvement, question, other
Feedback: "${feedbackText}"
Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein anderer Text):
{"title": "...", "category": "..."}`;
const result = await model.generateContent(prompt);
const response = result.response.text().trim();
// Parse JSON response - handle potential markdown code blocks
let jsonStr = response;
if (response.includes('```')) {
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
jsonStr = match[1].trim();
}
}
const parsed = JSON.parse(jsonStr) as FeedbackAnalysis;
// Validate category
const validCategories = ['bug', 'feature', 'improvement', 'question', 'other'];
if (!validCategories.includes(parsed.category)) {
parsed.category = 'other';
}
// Ensure title is not too long
if (parsed.title.length > 60) {
parsed.title = parsed.title.substring(0, 57) + '...';
}
this.logger.debug(`AI analyzed feedback: ${JSON.stringify(parsed)}`);
return parsed;
} catch (error) {
this.logger.error(`AI analysis failed: ${error}`);
return this.fallbackAnalysis(feedbackText);
}
}
private fallbackAnalysis(feedbackText: string): FeedbackAnalysis {
// Simple fallback: use first 60 chars as title, default category
const title =
feedbackText.length > 60 ? feedbackText.substring(0, 57) + '...' : feedbackText;
// Simple keyword-based category detection
const lowerText = feedbackText.toLowerCase();
let category: FeedbackAnalysis['category'] = 'feature';
if (
lowerText.includes('bug') ||
lowerText.includes('fehler') ||
lowerText.includes('kaputt') ||
lowerText.includes('funktioniert nicht')
) {
category = 'bug';
} else if (lowerText.includes('?') || lowerText.includes('frage') || lowerText.includes('wie')) {
category = 'question';
} else if (
lowerText.includes('besser') ||
lowerText.includes('verbessern') ||
lowerText.includes('optimieren')
) {
category = 'improvement';
}
return { title, category };
}
}

View file

@ -0,0 +1,2 @@
export * from './ai.module';
export * from './ai.service';

View file

@ -5,6 +5,8 @@ import { APP_FILTER } from '@nestjs/core';
import configuration from './config/configuration';
import { AuthModule } from './auth/auth.module';
import { CreditsModule } from './credits/credits.module';
import { FeedbackModule } from './feedback/feedback.module';
import { AiModule } from './ai/ai.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
@Module({
@ -19,8 +21,10 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
limit: 100, // 100 requests per minute
},
]),
AiModule,
AuthModule,
CreditsModule,
FeedbackModule,
],
providers: [
{

View file

@ -44,4 +44,8 @@ export default () => ({
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
},
ai: {
geminiApiKey: process.env.GOOGLE_GENAI_API_KEY || '',
},
});

View file

@ -0,0 +1,39 @@
CREATE SCHEMA "feedback";
--> statement-breakpoint
CREATE TYPE "public"."feedback_category" AS ENUM('bug', 'feature', 'improvement', 'question', 'other');--> statement-breakpoint
CREATE TYPE "public"."feedback_status" AS ENUM('submitted', 'under_review', 'planned', 'in_progress', 'completed', 'declined');--> statement-breakpoint
CREATE TABLE "feedback"."feedback_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"feedback_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feedback"."user_feedback" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"app_id" text NOT NULL,
"title" text,
"feedback_text" text NOT NULL,
"category" "feedback_category" DEFAULT 'feature' NOT NULL,
"status" "feedback_status" DEFAULT 'submitted' NOT NULL,
"is_public" boolean DEFAULT false NOT NULL,
"admin_response" text,
"vote_count" integer DEFAULT 0 NOT NULL,
"device_info" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"published_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "feedback"."feedback_votes" ADD CONSTRAINT "feedback_votes_feedback_id_user_feedback_id_fk" FOREIGN KEY ("feedback_id") REFERENCES "feedback"."user_feedback"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback"."feedback_votes" ADD CONSTRAINT "feedback_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback"."user_feedback" ADD CONSTRAINT "user_feedback_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "feedback_vote_unique" ON "feedback"."feedback_votes" USING btree ("feedback_id","user_id");--> statement-breakpoint
CREATE INDEX "feedback_votes_feedback_idx" ON "feedback"."feedback_votes" USING btree ("feedback_id");--> statement-breakpoint
CREATE INDEX "feedback_user_idx" ON "feedback"."user_feedback" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "feedback_app_idx" ON "feedback"."user_feedback" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "feedback_public_idx" ON "feedback"."user_feedback" USING btree ("is_public");--> statement-breakpoint
CREATE INDEX "feedback_status_idx" ON "feedback"."user_feedback" USING btree ("status");--> statement-breakpoint
CREATE INDEX "feedback_created_at_idx" ON "feedback"."user_feedback" USING btree ("created_at");

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1764448681401,
"tag": "0001_zippy_ma_gnuci",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,94 @@
import {
pgSchema,
uuid,
text,
timestamp,
boolean,
jsonb,
integer,
index,
pgEnum,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
export const feedbackSchema = pgSchema('feedback');
// Category enum
export const feedbackCategoryEnum = pgEnum('feedback_category', [
'bug',
'feature',
'improvement',
'question',
'other',
]);
// Status enum
export const feedbackStatusEnum = pgEnum('feedback_status', [
'submitted',
'under_review',
'planned',
'in_progress',
'completed',
'declined',
]);
// User feedback table
export const userFeedback = feedbackSchema.table(
'user_feedback',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
appId: text('app_id').notNull(), // 'chat', 'picture', 'zitare', etc.
// Content
title: text('title'),
feedbackText: text('feedback_text').notNull(),
category: feedbackCategoryEnum('category').default('feature').notNull(),
// Status & Publishing
status: feedbackStatusEnum('status').default('submitted').notNull(),
isPublic: boolean('is_public').default(false).notNull(),
adminResponse: text('admin_response'),
// Voting (denormalized for performance)
voteCount: integer('vote_count').default(0).notNull(),
// Metadata
deviceInfo: jsonb('device_info'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
publishedAt: timestamp('published_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(table) => ({
userIdx: index('feedback_user_idx').on(table.userId),
appIdx: index('feedback_app_idx').on(table.appId),
publicIdx: index('feedback_public_idx').on(table.isPublic),
statusIdx: index('feedback_status_idx').on(table.status),
createdAtIdx: index('feedback_created_at_idx').on(table.createdAt),
})
);
// Feedback votes table
export const feedbackVotes = feedbackSchema.table(
'feedback_votes',
{
id: uuid('id').primaryKey().defaultRandom(),
feedbackId: uuid('feedback_id')
.references(() => userFeedback.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
uniqueVote: uniqueIndex('feedback_vote_unique').on(table.feedbackId, table.userId),
feedbackIdx: index('feedback_votes_feedback_idx').on(table.feedbackId),
})
);

View file

@ -1,2 +1,3 @@
export * from './auth.schema';
export * from './credits.schema';
export * from './feedback.schema';

View file

@ -0,0 +1,21 @@
import { IsString, IsOptional, MaxLength, MinLength, IsEnum, IsObject } from 'class-validator';
export class CreateFeedbackDto {
@IsString()
@IsOptional()
@MaxLength(100)
title?: string;
@IsString()
@MinLength(10, { message: 'Feedback must be at least 10 characters long' })
@MaxLength(2000, { message: 'Feedback must be at most 2000 characters long' })
feedbackText: string;
@IsEnum(['bug', 'feature', 'improvement', 'question', 'other'])
@IsOptional()
category?: 'bug' | 'feature' | 'improvement' | 'question' | 'other';
@IsObject()
@IsOptional()
deviceInfo?: Record<string, unknown>;
}

View file

@ -0,0 +1,33 @@
import { IsString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
export class FeedbackQueryDto {
@IsString()
@IsOptional()
appId?: string;
@IsEnum(['submitted', 'under_review', 'planned', 'in_progress', 'completed', 'declined'])
@IsOptional()
status?: string;
@IsEnum(['bug', 'feature', 'improvement', 'question', 'other'])
@IsOptional()
category?: string;
@IsEnum(['votes', 'recent'])
@IsOptional()
sort?: 'votes' | 'recent' = 'votes';
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
@Max(50)
@IsOptional()
limit?: number = 20;
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(0)
@IsOptional()
offset?: number = 0;
}

View file

@ -0,0 +1,2 @@
export { CreateFeedbackDto } from './create-feedback.dto';
export { FeedbackQueryDto } from './feedback-query.dto';

View file

@ -0,0 +1,54 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Headers,
} from '@nestjs/common';
import { FeedbackService } from './feedback.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { CreateFeedbackDto, FeedbackQueryDto } from './dto';
@Controller('feedback')
@UseGuards(JwtAuthGuard)
export class FeedbackController {
constructor(private readonly feedbackService: FeedbackService) {}
@Post()
async createFeedback(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateFeedbackDto,
@Headers('x-app-id') appIdHeader?: string
) {
const appId = appIdHeader || 'unknown';
return this.feedbackService.createFeedback(user.userId, appId, dto);
}
@Get('public')
async getPublicFeedback(@CurrentUser() user: CurrentUserData, @Query() query: FeedbackQueryDto) {
return this.feedbackService.getPublicFeedback(user.userId, query);
}
@Get('my')
async getMyFeedback(
@CurrentUser() user: CurrentUserData,
@Query('appId') appId?: string
) {
return this.feedbackService.getMyFeedback(user.userId, appId);
}
@Post(':id/vote')
async vote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) {
return this.feedbackService.vote(user.userId, feedbackId);
}
@Delete(':id/vote')
async unvote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) {
return this.feedbackService.unvote(user.userId, feedbackId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FeedbackController } from './feedback.controller';
import { FeedbackService } from './feedback.service';
@Module({
controllers: [FeedbackController],
providers: [FeedbackService],
exports: [FeedbackService],
})
export class FeedbackModule {}

View file

@ -0,0 +1,277 @@
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, desc, sql, count } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { userFeedback, feedbackVotes } from '../db/schema';
import { CreateFeedbackDto, FeedbackQueryDto } from './dto';
import { AiService } from '../ai/ai.service';
@Injectable()
export class FeedbackService {
private readonly logger = new Logger(FeedbackService.name);
constructor(
private configService: ConfigService,
private aiService: AiService
) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
async createFeedback(userId: string, appId: string, dto: CreateFeedbackDto) {
const db = this.getDb();
// Use AI to generate title and category if not provided
let title = dto.title;
let category = dto.category;
if (!title || !category) {
this.logger.debug('Analyzing feedback with AI...');
const analysis = await this.aiService.analyzeFeedback(dto.feedbackText);
if (!title) {
title = analysis.title;
}
if (!category) {
category = analysis.category;
}
this.logger.debug(`AI generated: title="${title}", category="${category}"`);
}
const [feedback] = await db
.insert(userFeedback)
.values({
userId,
appId,
title,
feedbackText: dto.feedbackText,
category: category || 'feature',
deviceInfo: dto.deviceInfo,
})
.returning();
return {
success: true,
feedback: this.mapFeedback(feedback, false),
};
}
async getPublicFeedback(userId: string, query: FeedbackQueryDto) {
const db = this.getDb();
const { appId, status, category, sort = 'votes', limit = 20, offset = 0 } = query;
// Build conditions
const conditions = [eq(userFeedback.isPublic, true)];
if (appId) {
conditions.push(eq(userFeedback.appId, appId));
}
if (status) {
conditions.push(eq(userFeedback.status, status as any));
}
if (category) {
conditions.push(eq(userFeedback.category, category as any));
}
// Get feedback items
const feedbackItems = await db
.select()
.from(userFeedback)
.where(and(...conditions))
.orderBy(sort === 'votes' ? desc(userFeedback.voteCount) : desc(userFeedback.createdAt))
.limit(limit)
.offset(offset);
// Get total count
const [{ total }] = await db
.select({ total: count() })
.from(userFeedback)
.where(and(...conditions));
// Get user's votes
const feedbackIds = feedbackItems.map((f) => f.id);
const userVotes =
feedbackIds.length > 0
? await db
.select({ feedbackId: feedbackVotes.feedbackId })
.from(feedbackVotes)
.where(
and(
eq(feedbackVotes.userId, userId),
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
)
)
: [];
const votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
return {
success: true,
items: feedbackItems.map((f) => this.mapFeedback(f, votedFeedbackIds.has(f.id))),
total,
};
}
async getMyFeedback(userId: string, appId?: string) {
const db = this.getDb();
const conditions = [eq(userFeedback.userId, userId)];
if (appId) {
conditions.push(eq(userFeedback.appId, appId));
}
const feedbackItems = await db
.select()
.from(userFeedback)
.where(and(...conditions))
.orderBy(desc(userFeedback.createdAt));
// Get user's votes on their own feedback (for consistency)
const feedbackIds = feedbackItems.map((f) => f.id);
const userVotes =
feedbackIds.length > 0
? await db
.select({ feedbackId: feedbackVotes.feedbackId })
.from(feedbackVotes)
.where(
and(
eq(feedbackVotes.userId, userId),
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
)
)
: [];
const votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
return {
success: true,
items: feedbackItems.map((f) => this.mapFeedback(f, votedFeedbackIds.has(f.id))),
total: feedbackItems.length,
};
}
async vote(userId: string, feedbackId: string) {
const db = this.getDb();
// Check if feedback exists and is public
const [feedback] = await db
.select()
.from(userFeedback)
.where(eq(userFeedback.id, feedbackId))
.limit(1);
if (!feedback) {
throw new NotFoundException('Feedback not found');
}
if (!feedback.isPublic) {
throw new NotFoundException('Feedback not found or not public');
}
// Check if user already voted
const [existingVote] = await db
.select()
.from(feedbackVotes)
.where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId)))
.limit(1);
if (existingVote) {
throw new ConflictException('Already voted');
}
// Add vote
await db.insert(feedbackVotes).values({
feedbackId,
userId,
});
// Increment vote count
const [updated] = await db
.update(userFeedback)
.set({
voteCount: sql`${userFeedback.voteCount} + 1`,
updatedAt: new Date(),
})
.where(eq(userFeedback.id, feedbackId))
.returning();
return {
success: true,
newVoteCount: updated.voteCount,
userHasVoted: true,
};
}
async unvote(userId: string, feedbackId: string) {
const db = this.getDb();
// Check if feedback exists
const [feedback] = await db
.select()
.from(userFeedback)
.where(eq(userFeedback.id, feedbackId))
.limit(1);
if (!feedback) {
throw new NotFoundException('Feedback not found');
}
// Check if user has voted
const [existingVote] = await db
.select()
.from(feedbackVotes)
.where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId)))
.limit(1);
if (!existingVote) {
throw new NotFoundException('Vote not found');
}
// Remove vote
await db
.delete(feedbackVotes)
.where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId)));
// Decrement vote count
const [updated] = await db
.update(userFeedback)
.set({
voteCount: sql`GREATEST(${userFeedback.voteCount} - 1, 0)`,
updatedAt: new Date(),
})
.where(eq(userFeedback.id, feedbackId))
.returning();
return {
success: true,
newVoteCount: updated.voteCount,
userHasVoted: false,
};
}
private mapFeedback(
feedback: typeof userFeedback.$inferSelect,
userHasVoted: boolean
): Record<string, unknown> {
return {
id: feedback.id,
userId: feedback.userId,
appId: feedback.appId,
title: feedback.title,
feedbackText: feedback.feedbackText,
category: feedback.category,
status: feedback.status,
isPublic: feedback.isPublic,
adminResponse: feedback.adminResponse,
voteCount: feedback.voteCount,
userHasVoted,
deviceInfo: feedback.deviceInfo,
createdAt: feedback.createdAt.toISOString(),
updatedAt: feedback.updatedAt.toISOString(),
publishedAt: feedback.publishedAt?.toISOString(),
completedAt: feedback.completedAt?.toISOString(),
};
}
}

View file

@ -20,7 +20,7 @@ async function bootstrap() {
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-App-Id'],
});
// Global validation pipe