From 409c773acb02c2a87a1326a2a4b3609743956f0c Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Sun, 28 Sep 2025 16:51:43 +0545 Subject: [PATCH 1/9] feat: setup period table and period queries --- PRD.md | 359 ++++++++++++++++ .../migrations/0001_grey_impossible_man.sql | 8 + .../migrations/meta/0001_snapshot.json | 395 ++++++++++++++++++ apps/backend/migrations/meta/_journal.json | 7 + apps/backend/src/lib/db/queries.ts | 75 ++++ apps/backend/src/lib/db/schema.ts | 16 + task.md | 113 +++++ 7 files changed, 973 insertions(+) create mode 100644 PRD.md create mode 100644 apps/backend/migrations/0001_grey_impossible_man.sql create mode 100644 apps/backend/migrations/meta/0001_snapshot.json create mode 100644 apps/backend/src/lib/db/queries.ts create mode 100644 task.md diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..ca04874 --- /dev/null +++ b/PRD.md @@ -0,0 +1,359 @@ +# Period Tracker App - Product Requirements Document + +## 1. Product Overview + +### Vision +Create a fast, intuitive, and offline-capable period tracking app that helps young women understand their reproductive health while providing a delightful user experience across devices. + +### Target Users +- **Primary:** Teenagers and young adults (13-25 years) +- **Experience Level:** Mostly beginners to period tracking +- **Current Solutions:** Basic health watch tracking, spreadsheets, or no tracking +- **Primary Use Cases:** General health awareness, avoiding pregnancy, reproductive health education + +### Core Value Proposition +- Lightning-fast logging experience (< 30 seconds) +- Works seamlessly offline with poor internet connectivity +- Simple, beautiful interface designed for young women +- Basic predictions and health insights +- Educational resources for reproductive health + +## 2. Technical Architecture + +### Tech Stack +- **Frontend:** Vite + React SPA + TypeScript +- **Backend:** Hono + TypeScript with Hono RPC +- **Database:** Turso (serverless SQLite) +- **ORM:** Drizzle ORM +- **Styling:** Tailwind CSS + DaisyUI (Valentine theme) +- **Components:** Radix UI (headless components for complex logic) +- **Monorepo:** pnpm workspaces +- **Data Fetching:** React Query (TanStack Query) + Hono RPC client +- **Validation:** Zod schemas (shared between frontend and backend) +- **Architecture:** Monorepo with shared types and validation schemas +- **Authentication:** Better Auth with Google OAuth +- **Logging:** Winston (backend) + Console overrides (frontend) +- **Linting:** ESLint + ESLint Stylistic (formatting and linting) +- **CI/CD:** GitHub Actions +- **Deployment:** Vercel/Cloudflare (frontend), Fly.io/Railway (backend) +- **PWA:** Service Worker + Web App Manifest + +### Data Strategy +- **Approach:** Cloud-first with aggressive local caching via React Query +- **Type Safety:** End-to-end type safety with Hono RPC + Zod validation +- **Offline Strategy:** React Query cache + service worker for offline reads +- **Cross-device:** Full sync across devices via React Query synchronization +- **Performance Target:** < 1 second load times with optimistic updates + +### Data Model (Drizzle Schema + Zod Validation) +- use drizzle schema with Zod validation and make sure the types flow through the app using hono RPC +- use tanstack query hooks for fetching data and making mutations and managing cache + +## 3. MVP Features +### 3.1 Essential Features (Phase 1) +1. **User Authentication** + - Google OAuth login + - Basic profile setup + - Account deletion capability + +2. **Period Tracking** + - Start/stop period logging + - Flow intensity tracking (1-5 scale) + - Calendar view of cycles + +3. **Symptom Logging** + - Quick 1-5 scale for: Flow, Mood, Pain, Energy, Bloating, Cravings + - Optional notes field + - Medication tracking (free text) + +4. **Basic Predictions** + - Next period start date + - Cycle length averages + - Simple confidence indicators + +5. **Offline Capability** + - Service worker for offline functionality + - Local data caching + - Sync queue for when online + +6. **Static FAQ** + - Common period questions + - Health education content + - Search functionality + +### 3.2 Nice-to-Have Features (Phase 2) +1. **Partner Sharing** + - Read-only access for partners + - Shareable cycle calendar + - Privacy controls + +2. **Enhanced Analytics** + - Cycle pattern analysis + - Symptom correlation insights + - Historical comparisons + +3. **Fertility Tracking** + - Ovulation predictions + - Fertile window indicators + - Conception probability + +## 4. User Experience Requirements + +### Design Principles +- **Minimalist:** Clean, uncluttered interface using DaisyUI Valentine theme +- **Fast:** Quick interactions, instant feedback with Tailwind utilities +- **Empathetic:** Supportive tone, educational, warm pink/red color palette +- **Accessible:** Radix UI primitives ensure proper accessibility +- **Modern:** Contemporary design trends for young women, cohesive Valentine theme + +### Key User Flows + +#### Quick Logging Flow +1. Open app → Auto-loads to today's view +2. One-tap period start/stop +3. Optional: Quick symptom sliders (< 15 seconds) +4. Auto-save with visual confirmation + +#### Analysis Flow +1. Navigate to calendar view +2. See cycle history and patterns +3. View predictions for next period +4. Access detailed cycle insights + +### Performance Requirements +- **Initial Load:** < 1 second +- **Offline Functionality:** Full logging capability +- **Sync Time:** < 3 seconds when back online +- **PWA Score:** > 90 on Lighthouse + +## 5. Development Roadmap + +### Priority 1: Core Foundation +**Essential infrastructure and basic functionality** + +**Project Setup & Infrastructure** +- [ ] Initialize pnpm workspace monorepo (packages: frontend, backend, shared) +- [ ] Set up TypeScript configurations across all packages +- [ ] Configure Tailwind CSS + DaisyUI (Valentine theme) in frontend +- [ ] Set up development environment with hot reload and workspace linking +- [ ] Configure ESLint + ESLint Stylistic for all packages +- [ ] Set up GitHub Actions workflow for CI/CD +- [ ] Configure Winston logger for backend with structured logging +- [ ] Set up console logging overrides for frontend development +- [ ] Create shared Zod validation schemas package + +**Database & Authentication** +- [ ] Set up Turso database with Drizzle ORM and migrations +- [ ] Create Zod schemas for all data models in shared package +- [ ] Implement Better Auth with Google OAuth and validation +- [ ] Set up Hono RPC routes with zValidator middleware +- [ ] Create user management APIs with comprehensive error handling +- [ ] Configure middleware (auth, CORS, request logging, Zod validation) +- [ ] Environment configuration and secrets management across workspaces + +**Core Period Tracking** +- [ ] Design Drizzle schemas with Zod validation integration +- [ ] Create period start/stop functionality with DaisyUI components +- [ ] Set up React Query client with Hono RPC integration +- [ ] Build calendar view component using Radix UI Calendar +- [ ] Implement cycle calculation logic with Zod input validation +- [ ] Create type-safe CRUD operations using Hono RPC + React Query +- [ ] Add comprehensive error handling and loading states + +### Priority 2: User Experience Essentials +**Core features users need for basic functionality** + +**Symptom Logging Interface** +- [ ] Create symptom logging interface with DaisyUI sliders and Zod validation +- [ ] Implement 1-5 scale components with optimistic updates via React Query +- [ ] Build daily log view with Valentine theme colors and error boundaries +- [ ] Connect symptom data to Hono RPC endpoints with type safety +- [ ] Add notes and medication fields using DaisyUI forms + Zod schemas +- [ ] Implement quick-entry shortcuts with keyboard navigation + +**Predictions & Basic Analytics** +- [ ] Implement cycle prediction algorithm with Zod input/output validation +- [ ] Calculate average cycle length with confidence intervals and error handling +- [ ] Build prediction display components using DaisyUI cards and React Query +- [ ] Add confidence indicators with visual feedback and loading states +- [ ] Create basic analytics dashboard with Tailwind grid and type-safe data +- [ ] Implement error handling for insufficient data scenarios with user guidance + +**PWA & Offline Capability** +- [ ] Implement service worker with comprehensive caching strategy +- [ ] Add web app manifest with Valentine theme assets and proper icons +- [ ] Configure React Query for offline-first data management +- [ ] Build offline indicators using DaisyUI alerts and network status +- [ ] Test offline scenarios and implement retry logic with exponential backoff +- [ ] Add conflict resolution for offline/online data synchronization + +### Priority 3: Polish & Enhancement +**Features that improve the experience but aren't essential for launch** + +**UI/UX Polish & Accessibility** +- [ ] Implement design system with DaisyUI Valentine theme +- [ ] Responsive design optimization using Tailwind breakpoints +- [ ] Accessibility improvements with Radix UI primitives +- [ ] Loading states and error handling with DaisyUI components +- [ ] User feedback animations and micro-interactions +- [ ] Focus management and keyboard navigation +- [ ] Screen reader testing and ARIA labels + +**Content & Education** +- [ ] Create static FAQ content with search functionality +- [ ] Implement educational content with DaisyUI collapse components +- [ ] Help and support pages using Valentine theme +- [ ] Onboarding flow with DaisyUI steps component +- [ ] Error messages with helpful guidance +- [ ] Success states and positive reinforcement + +### Priority 4: Advanced Features +**Nice-to-have features for enhanced functionality** + +**Partner Sharing (Optional)** +- [ ] Partner invitation system with email integration +- [ ] Read-only sharing interface using DaisyUI badges +- [ ] Privacy controls with Radix UI switches +- [ ] Shared calendar view with restricted permissions +- [ ] Notification preferences using DaisyUI toggles + +**Advanced Analytics & Insights** +- [ ] Enhanced analytics with pattern recognition +- [ ] Fertility window calculations and visualizations +- [ ] Cycle pattern recognition with data analysis +- [ ] Historical comparisons using DaisyUI timeline +- [ ] Data export functionality (CSV/JSON) +- [ ] Advanced settings with organized DaisyUI tabs + +### Priority 5: Production & Deployment +**Getting the app ready for real users** + +**Testing & Quality Assurance** +- [ ] Unit tests for shared Zod schemas and utility functions +- [ ] Integration tests for Hono RPC endpoints with proper mocking +- [ ] End-to-end testing for critical user flows using Playwright +- [ ] React Query testing with MSW for API mocking +- [ ] Performance optimization and bundle analysis across workspaces +- [ ] Lighthouse score improvements with PWA compliance (target > 90) +- [ ] ESLint Stylistic compliance and code quality checks + +**Deployment & Monitoring** +- [ ] GitHub Actions CI/CD pipeline for pnpm workspace deployment +- [ ] Production deployment setup (Vercel for frontend, Fly.io for backend) +- [ ] Database migration scripts and backup strategy with Drizzle +- [ ] Comprehensive logging and monitoring with structured Winston logs +- [ ] Error tracking and alerting systems with proper Zod error handling +- [ ] Performance monitoring and React Query devtools integration +- [ ] Type safety verification in CI/CD pipeline + +**Launch Preparation** +- [ ] Documentation and API reference +- [ ] Privacy policy and terms of service +- [ ] Backup and disaster recovery procedures +- [ ] Security audit and penetration testing +- [ ] User acceptance testing with target audience +- [ ] Go-live checklist and rollback procedures + +## 6. Success Criteria + +### Technical Success +- [ ] App loads in < 1 second +- [ ] PWA score > 90 +- [ ] Works offline seamlessly +- [ ] Cross-browser compatibility +- [ ] Mobile-responsive design + +### User Experience Success +- [ ] Girlfriend can log period in < 30 seconds +- [ ] Predictions are reasonably accurate (within 2-3 days) +- [ ] App feels fast and responsive +- [ ] No data loss during offline usage +- [ ] Intuitive interface requiring no training + +### Learning Objectives Success +- [ ] Solid understanding of React hooks and patterns +- [ ] Experience with offline-first PWA development +- [ ] Knowledge of modern full-stack architecture +- [ ] Understanding of health app considerations +- [ ] Clean, maintainable codebase + +## 7. Technical Considerations + +### Monorepo Structure (pnpm workspaces) +``` +period-tracker/ +├── packages/ +│ ├── frontend/ # Vite + React SPA +│ │ ├── src/ +│ │ ├── package.json +│ │ └── vite.config.ts +│ ├── backend/ # Hono API server +│ │ ├── src/ +│ │ ├── package.json +│ │ └── tsconfig.json +│ └── shared/ # Shared types & Zod schemas +│ ├── src/ +│ │ ├── schemas.ts +│ │ └── types.ts +│ ├── package.json +│ └── tsconfig.json +├── apps/ # Future mobile apps +├── .github/ +│ └── workflows/ +│ └── ci-cd.yml +├── package.json # Root workspace config +├── pnpm-workspace.yaml +├── eslint.config.js # ESLint + Stylistic config +└── tsconfig.json # Root TypeScript config +``` + +### Security & Privacy +- HTTPS everywhere +- Secure authentication flow with Better Auth +- Data validation with Drizzle ORM type safety +- Rate limiting on APIs with proper logging +- GDPR-friendly data handling +- Input sanitization and SQL injection prevention via Drizzle + +### Scalability +- Stateless backend design with Hono +- Efficient database queries optimized with Drizzle +- CDN for static assets +- Turso's edge replication for global performance +- Horizontal scaling capability +- Comprehensive monitoring with Winston logging + +### Maintenance +- Clear code documentation and type safety +- Comprehensive error handling with structured logging +- Automated testing pipeline +- Easy deployment process +- Performance monitoring and alerting +- Database migration management with Drizzle + +## 8. Future Considerations + +### Phase 2+ Features +- Advanced fertility tracking +- Community features +- Wearable device integration +- AI-powered insights +- Telehealth integration + +### Monetization Options +- Premium analytics features +- Partner sharing subscriptions +- Educational content tiers +- Health coaching services +- Anonymous data insights (opt-in) + +### Technical Debt Management +- Regular dependency updates +- Code refactoring cycles +- Performance audits +- Security reviews +- User feedback integration + +--- + +*This PRD serves as the foundation for building a delightful, fast, and reliable period tracking application that prioritizes user experience while providing valuable health insights.* diff --git a/apps/backend/migrations/0001_grey_impossible_man.sql b/apps/backend/migrations/0001_grey_impossible_man.sql new file mode 100644 index 0000000..e6deb67 --- /dev/null +++ b/apps/backend/migrations/0001_grey_impossible_man.sql @@ -0,0 +1,8 @@ +CREATE TABLE `periods` ( + `id` integer PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `start_date` integer NOT NULL, + `end_date` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); diff --git a/apps/backend/migrations/meta/0001_snapshot.json b/apps/backend/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..0d859c7 --- /dev/null +++ b/apps/backend/migrations/meta/0001_snapshot.json @@ -0,0 +1,395 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f851c99f-74c9-431d-943d-f3e83cd94c99", + "prevId": "26c76fc9-f54f-441f-91bf-694f3cbc01d7", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "periods": { + "name": "periods", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/backend/migrations/meta/_journal.json b/apps/backend/migrations/meta/_journal.json index fa428b5..3e327db 100644 --- a/apps/backend/migrations/meta/_journal.json +++ b/apps/backend/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1759034863656, "tag": "0000_skinny_lady_ursula", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1759056420297, + "tag": "0001_grey_impossible_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/lib/db/queries.ts b/apps/backend/src/lib/db/queries.ts new file mode 100644 index 0000000..2858f75 --- /dev/null +++ b/apps/backend/src/lib/db/queries.ts @@ -0,0 +1,75 @@ +import { eq, and, desc, lt, gte } from 'drizzle-orm' +import { period } from './schema' +import type { LibSQLDatabase } from 'drizzle-orm/libsql' + +export interface Period { + id: string + userId: string + startDate: Date + endDate: Date | null + createdAt: Date + updatedAt: Date +} + +export const createPeriod = async (db: LibSQLDatabase, data: { userId: string; startDate: Date }) => { + const result = await db.insert(period).values({ + id: crypto.randomUUID(), + userId: data.userId, + startDate: data.startDate, + createdAt: new Date(), + updatedAt: new Date(), + }).returning() + + return result[0] +} + +export const updatePeriod = async (db: LibSQLDatabase, id: string, data: { endDate: Date }) => { + const result = await db.update(period) + .set({ + endDate: data.endDate, + updatedAt: new Date(), + }) + .where(eq(period.id, id)) + .returning() + + return result[0] +} + +export const getActivePeriod = async (db: LibSQLDatabase, userId: string) => { + const result = await db.select() + .from(period) + .where(and( + eq(period.userId, userId), + eq(period.endDate, null) + )) + .limit(1) + + return result[0] || null +} + +export const getPeriods = async (db: LibSQLDatabase, userId: string) => { + return await db.select() + .from(period) + .where(eq(period.userId, userId)) + .orderBy(desc(period.startDate)) +} + +export const getPeriodById = async (db: LibSQLDatabase, id: string) => { + const result = await db.select() + .from(period) + .where(eq(period.id, id)) + .limit(1) + + return result[0] || null +} + +export const getPastPeriods = async (db: LibSQLDatabase, userId: string, limit: number = 10) => { + return await db.select() + .from(period) + .where(and( + eq(period.userId, userId), + period.endDate.isNotNull() + )) + .orderBy(desc(period.startDate)) + .limit(limit) +} diff --git a/apps/backend/src/lib/db/schema.ts b/apps/backend/src/lib/db/schema.ts index b946d7f..948e57c 100644 --- a/apps/backend/src/lib/db/schema.ts +++ b/apps/backend/src/lib/db/schema.ts @@ -74,9 +74,25 @@ export const verification = sqliteTable('verification', { .notNull(), }) +export const period = sqliteTable('period', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + startDate: integer('start_date', { mode: 'timestamp' }).notNull(), + endDate: integer('end_date', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }) + .$defaultFn(() => new Date()) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }) + .$defaultFn(() => new Date()) + .notNull(), +}) + export const schema = { user, account, session, verification, + period, } diff --git a/task.md b/task.md new file mode 100644 index 0000000..f7d3323 --- /dev/null +++ b/task.md @@ -0,0 +1,113 @@ +# Core Period Tracking Feature - Implementation Task + +## Requirements + +The core period tracking feature allows users to log their menstrual periods with start/stop functionality and view their period history. This is a simplified version focusing on the essential logging experience. + +### Functional Requirements +1. Users can start a new period with a single tap +2. Users can stop an ongoing period with a single tap +3. Users can view a list of their past periods (start date, end date, duration) +4. The system calculates and displays basic cycle information + +### Non-functional Requirements +1. Fast interaction - period logging should take < 30 seconds +2. Type-safe API communication between frontend and backend +3. Responsive design that works on mobile and desktop +4. Accessible UI components + +## User Flow + +1. User opens the app and lands on the period tracking page +2. User sees a prominent "Start Period" button if no period is currently active +3. User taps "Start Period" to log today as the start of their period +4. On subsequent visits, if a period is active, user sees "End Period" button +5. User taps "End Period" to mark today as the end of their current period +6. User can view a simple list of past periods with start date, end date, and duration + +## Tech Choices + +### Frontend +- **Framework:** React with TypeScript +- **State Management:** React Query (TanStack Query) for data fetching and mutations +- **UI Components:** DaisyUI with Valentine theme for styling +- **API Communication:** Hono RPC client + +### Backend +- **Framework:** Hono with TypeScript +- **Database:** Turso (SQLite) +- **ORM:** Drizzle ORM +- **API Structure:** Hono RPC routes + +## Code Organization + +### Frontend Structure +``` +packages/frontend/src/ +├── components/ +│ └── PeriodTracker/ +│ ├── PeriodTracker.tsx (main component) +│ └── PeriodButton.tsx (start/stop button) +├── hooks/ +│ └── usePeriodTracking.ts (custom hook for period tracking logic) +└── lib/ + └── hono-client.ts (Hono RPC client setup) +``` + +### Backend Structure +``` +packages/backend/src/ +├── routes/ +│ └── period-tracker.ts (Hono RPC routes for period tracking) +└── lib/ + └── period-calculations.ts (cycle calculation logic) +├── db/ +│ ├── schema.ts (Drizzle schema for period tracking) +│ └── queries.ts (database queries for period data) +``` + +## Business Rules + +1. A user can only have one active period at a time +2. Periods must have a start date and can optionally have an end date +3. Cycle length is calculated from the start date of one period to the start date of the next +4. Users can only start a new period if they don't currently have an active one +5. Users can only end a period if they currently have an active one +6. Period duration is calculated as the number of days between start and end dates (inclusive) + +## Implementation Notes + +1. **Type Safety:** Use Hono RPC's generated types for API communication +2. **Error Handling:** Implement comprehensive error handling for validation errors and database issues +3. **Loading States:** Show appropriate loading indicators during data fetching and mutations +4. **Accessibility:** Ensure the start/stop button is accessible with proper ARIA attributes +5. **Visual Design:** Follow the Valentine theme with warm pink/red colors for period-related UI elements +6. **Data Display:** Show past periods in a simple list format with clear date information + +## Step-by-Step Implementation Todo List + +### Phase 1: Database Schema and Queries +1. Create Drizzle schema for period tracking in `packages/backend/src/lib/db/schema.ts` + - Define period table with id, userId, startDate, endDate, createdAt, updatedAt +2. Create database migration files for the new schema +3. Implement database queries in `packages/backend/src/lib/db/queries.ts` + - CRUD operations for periods + - Query for user's period history ordered by start date + +### Phase 2: API Routes +1. Implement Hono RPC routes in `packages/backend/src/routes/period-tracker.ts` + - createPeriod (starts a new period) + - updatePeriod (ends current period) + - getPeriods (fetches user's period history) + +### Phase 3: Frontend Implementation +1. Set up Hono RPC client in `packages/frontend/src/lib/hono-client.ts` +2. Create custom hook `packages/frontend/src/hooks/usePeriodTracking.ts` + - Implement React Query hooks for period data fetching + - Implement mutations for starting/stopping periods +3. Create PeriodTracker component in `packages/frontend/src/components/PeriodTracker/PeriodTracker.tsx` + - Fetch and display list of past periods + - Determine if user has active period +4. Create PeriodButton component in `packages/frontend/src/components/PeriodTracker/PeriodButton.tsx` + - Show "Start Period" or "End Period" based on current state + - Handle button click with appropriate mutation From 30a10e52e60c4d0cd7dcbccd7790c9134008a883 Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Sun, 28 Sep 2025 17:36:39 +0545 Subject: [PATCH 2/9] feat: create endpoint to manage period logs --- apps/backend/src/lib/db/index.ts | 5 +- apps/backend/src/lib/db/queries.ts | 27 ++-- apps/backend/src/lib/period-calculations.ts | 87 ++++++++++++ apps/backend/src/routes/index.ts | 17 ++- apps/backend/src/routes/period-tracker.ts | 143 ++++++++++++++++++++ apps/backend/src/types/hono.types.ts | 6 + task.md | 8 +- 7 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 apps/backend/src/lib/period-calculations.ts create mode 100644 apps/backend/src/routes/period-tracker.ts create mode 100644 apps/backend/src/types/hono.types.ts diff --git a/apps/backend/src/lib/db/index.ts b/apps/backend/src/lib/db/index.ts index 3d7300e..611a37d 100644 --- a/apps/backend/src/lib/db/index.ts +++ b/apps/backend/src/lib/db/index.ts @@ -2,5 +2,8 @@ import 'dotenv/config' import { drizzle } from 'drizzle-orm/libsql' import type { Context } from 'hono' import { getEnv } from '../../env.js' +import { schema } from './schema.js' -export const getDB = (c: Context) => drizzle(getEnv(c).DB_FILE_NAME) +export const getDB = (c: Context) => drizzle(getEnv(c).DB_FILE_NAME, { + schema: schema, +}) diff --git a/apps/backend/src/lib/db/queries.ts b/apps/backend/src/lib/db/queries.ts index 2858f75..7be0cce 100644 --- a/apps/backend/src/lib/db/queries.ts +++ b/apps/backend/src/lib/db/queries.ts @@ -1,6 +1,7 @@ -import { eq, and, desc, lt, gte } from 'drizzle-orm' -import { period } from './schema' +import { eq, and, desc, isNotNull, isNull } from 'drizzle-orm' import type { LibSQLDatabase } from 'drizzle-orm/libsql' +import type { schema } from './schema.js' +import { period } from './schema.js' export interface Period { id: string @@ -11,7 +12,7 @@ export interface Period { updatedAt: Date } -export const createPeriod = async (db: LibSQLDatabase, data: { userId: string; startDate: Date }) => { +export const createPeriod = async (db: LibSQLDatabase, data: { userId: string, startDate: Date }) => { const result = await db.insert(period).values({ id: crypto.randomUUID(), userId: data.userId, @@ -23,7 +24,7 @@ export const createPeriod = async (db: LibSQLDatabase, data: { userId: stri return result[0] } -export const updatePeriod = async (db: LibSQLDatabase, id: string, data: { endDate: Date }) => { +export const updatePeriod = async (db: LibSQLDatabase, id: string, data: { endDate: Date }) => { const result = await db.update(period) .set({ endDate: data.endDate, @@ -35,26 +36,30 @@ export const updatePeriod = async (db: LibSQLDatabase, id: string, data: { return result[0] } -export const getActivePeriod = async (db: LibSQLDatabase, userId: string) => { +export const getActivePeriod = async (db: LibSQLDatabase, userId: string) => { const result = await db.select() .from(period) .where(and( eq(period.userId, userId), - eq(period.endDate, null) + isNull(period.endDate), )) .limit(1) - return result[0] || null + if (result.length === 0) { + return null + } + + return result[0] } -export const getPeriods = async (db: LibSQLDatabase, userId: string) => { +export const getPeriods = async (db: LibSQLDatabase, userId: string) => { return await db.select() .from(period) .where(eq(period.userId, userId)) .orderBy(desc(period.startDate)) } -export const getPeriodById = async (db: LibSQLDatabase, id: string) => { +export const getPeriodById = async (db: LibSQLDatabase, id: string) => { const result = await db.select() .from(period) .where(eq(period.id, id)) @@ -63,12 +68,12 @@ export const getPeriodById = async (db: LibSQLDatabase, id: string) => { return result[0] || null } -export const getPastPeriods = async (db: LibSQLDatabase, userId: string, limit: number = 10) => { +export const getPastPeriods = async (db: LibSQLDatabase, userId: string, limit: number = 10) => { return await db.select() .from(period) .where(and( eq(period.userId, userId), - period.endDate.isNotNull() + isNotNull(period.endDate), )) .orderBy(desc(period.startDate)) .limit(limit) diff --git a/apps/backend/src/lib/period-calculations.ts b/apps/backend/src/lib/period-calculations.ts new file mode 100644 index 0000000..ba1e8fd --- /dev/null +++ b/apps/backend/src/lib/period-calculations.ts @@ -0,0 +1,87 @@ +// Utility functions for period calculations +export interface CycleInfo { + cycleLength: number | null // Days between start of this period and start of previous period + periodDuration: number | null // Days from start to end of period (inclusive) +} + +/** + * Calculate cycle information for a period + * @param period The period to calculate information for + * @param previousPeriod The previous period (if available) + * @returns Cycle information including cycle length and period duration + */ +export function calculateCycleInfo( + period: { startDate: Date, endDate: Date | null }, + previousPeriod?: { startDate: Date }, +): CycleInfo { + // Calculate period duration if endDate exists + let periodDuration: number | null = null + if (period.endDate) { + // Calculate inclusive days between start and end dates + const start = new Date(period.startDate) + const end = new Date(period.endDate) + // Reset time part to compare only dates + start.setHours(0, 0, 0, 0) + end.setHours(0, 0, 0, 0) + // Calculate difference in days + const diffTime = Math.abs(end.getTime() - start.getTime()) + periodDuration = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1 // +1 for inclusive + } + + // Calculate cycle length if previous period exists + let cycleLength: number | null = null + if (previousPeriod) { + const currentStart = new Date(period.startDate) + const previousStart = new Date(previousPeriod.startDate) + // Reset time part to compare only dates + currentStart.setHours(0, 0, 0, 0) + previousStart.setHours(0, 0, 0, 0) + // Calculate difference in days + const diffTime = Math.abs(currentStart.getTime() - previousStart.getTime()) + cycleLength = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + } + + return { + cycleLength, + periodDuration, + } +} + +/** + * Calculate average cycle length from a list of periods + * @param periods Array of periods ordered by startDate (newest first) + * @returns Average cycle length or null if not enough data + */ +export function calculateAverageCycleLength(periods: { startDate: Date }[]): number | null { + if (periods.length < 2) { + return null + } + + const cycleLengths: number[] = [] + + // Calculate cycle lengths between consecutive periods + for (let i = 0; i < periods.length - 1; i++) { + const currentPeriod = periods[i] + const previousPeriod = periods[i + 1] + + const currentStart = new Date(currentPeriod.startDate) + const previousStart = new Date(previousPeriod.startDate) + + // Reset time part to compare only dates + currentStart.setHours(0, 0, 0, 0) + previousStart.setHours(0, 0, 0, 0) + + // Calculate difference in days + const diffTime = Math.abs(currentStart.getTime() - previousStart.getTime()) + const cycleLength = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + cycleLengths.push(cycleLength) + } + + if (cycleLengths.length === 0) { + return null + } + + // Calculate average + const sum = cycleLengths.reduce((acc, val) => acc + val, 0) + return Math.round(sum / cycleLengths.length) +} diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index a3fda8a..fcf23d3 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -2,8 +2,10 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' import { getEnv } from '../env.js' import { auth } from '../lib/auth.js' +import type { AppVariables } from '../types/hono.types.js' +import periodTrackerRoute from './period-tracker.js' import usersRoute from './users.js' -const app = new Hono().use('*', cors({ +const app = new Hono<{ Variables: AppVariables }>().use('*', cors({ origin: (_origin, c) => { const { FRONTEND_URL } = getEnv(c) return FRONTEND_URL @@ -14,9 +16,22 @@ const app = new Hono().use('*', cors({ maxAge: 600, credentials: true, })) + .use('*', async (c, next) => { + const session = await auth(c).api.getSession({ headers: c.req.raw.headers }) + if (!session) { + c.set('user', null) + c.set('session', null) + return next() + } + c.set('user', session.user) + c.set('session', session.session) + return next() + }) + .on(['POST', 'GET'], '/api/auth/*', c => auth(c).handler(c.req.raw)) .basePath('/api/v1') .route('/users', usersRoute) + .route('/periods', periodTrackerRoute) export type AppType = typeof app export default app diff --git a/apps/backend/src/routes/period-tracker.ts b/apps/backend/src/routes/period-tracker.ts new file mode 100644 index 0000000..6afa628 --- /dev/null +++ b/apps/backend/src/routes/period-tracker.ts @@ -0,0 +1,143 @@ +import { Hono } from 'hono' +import { auth } from '../lib/auth.js' +import { getDB } from '../lib/db/index.js' +import { + createPeriod, + updatePeriod, + getActivePeriod, + getPeriods, +} from '../lib/db/queries.js' +import { calculateCycleInfo, calculateAverageCycleLength } from '../lib/period-calculations.js' +import type { AppVariables } from '../types/hono.types.js' + +const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', auth, async (c) => { + const authUser = c.get('user') + if (!authUser?.id) { + return c.json({ error: 'Unauthorized' }, 401) + } + + try { + const db = getDB(c) + + // Check if user already has an active period + const activePeriod = await getActivePeriod(db, authUser.id) + if (activePeriod) { + return c.json({ error: 'User already has an active period' }, 400) + } + + // Create new period + const newPeriod = await createPeriod(db, { + userId: authUser.id, + startDate: new Date(), + }) + + return c.json({ + success: true, + period: newPeriod, + }, 201) + } + catch (error) { + console.error('Error creating period:', error) + return c.json({ error: 'Failed to create period' }, 500) + } +}).put('/end', auth, async (c) => { + const authUser = c.get('user') + if (!authUser?.id) { + return c.json({ error: 'Unauthorized' }, 401) + } + + try { + const db = getDB(c) + + // Get active period + const activePeriod = await getActivePeriod(db, authUser.id) + if (!activePeriod) { + return c.json({ error: 'No active period found' }, 400) + } + + // End the period + const updatedPeriod = await updatePeriod(db, activePeriod.id, { + endDate: new Date(), + }) + + return c.json({ + success: true, + period: updatedPeriod, + }, 200) + } + catch (error) { + console.error('Error ending period:', error) + return c.json({ error: 'Failed to end period' }, 500) + } +}).get('/', auth, async (c) => { + const authUser = c.get('user') + if (!authUser?.id) { + return c.json({ error: 'Unauthorized' }, 401) + } + + try { + const db = getDB(c) + const periods = await getPeriods(db, authUser.id) + + // Calculate cycle information for each period + const periodsWithInfo = periods.map((period, index) => { + const previousPeriod = index < periods.length - 1 ? periods[index + 1] : undefined + const cycleInfo = calculateCycleInfo(period, previousPeriod) + return { + ...period, + cycleInfo, + } + }) + + // Calculate average cycle length + const averageCycleLength = calculateAverageCycleLength(periods) + + return c.json({ + success: true, + periods: periodsWithInfo, + averageCycleLength, + }, 200) + } + catch (error) { + console.error('Error fetching periods:', error) + return c.json({ error: 'Failed to fetch periods' }, 500) + } +}).get('/active', auth, async (c) => { + const authUser = c.get('user') + if (!authUser?.id) { + return c.json({ error: 'Unauthorized' }, 401) + } + + try { + const db = getDB(c) + const activePeriod = await getActivePeriod(db, authUser.id) + + // Calculate cycle information for active period + let periodWithInfo = null + if (activePeriod) { + // Get the most recent previous period to calculate cycle info + const periods = await getPeriods(db, authUser.id) + const previousPeriod = periods.find(p => + p.id !== activePeriod.id + && new Date(p.startDate) < new Date(activePeriod.startDate), + ) + + const cycleInfo = calculateCycleInfo(activePeriod, previousPeriod) + periodWithInfo = { + ...activePeriod, + cycleInfo, + } + } + + return c.json({ + success: true, + period: periodWithInfo, + }, 200) + } + catch (error) { + console.error('Error fetching active period:', error) + return c.json({ error: 'Failed to fetch active period' }, 500) + } +}) + +export default periodTrackerRoute diff --git a/apps/backend/src/types/hono.types.ts b/apps/backend/src/types/hono.types.ts new file mode 100644 index 0000000..eee4e9a --- /dev/null +++ b/apps/backend/src/types/hono.types.ts @@ -0,0 +1,6 @@ +import type { Session, User } from 'better-auth' + +export interface AppVariables { + user: User | null + session: Session | null +} diff --git a/task.md b/task.md index f7d3323..89f0c06 100644 --- a/task.md +++ b/task.md @@ -60,10 +60,10 @@ packages/backend/src/ ├── routes/ │ └── period-tracker.ts (Hono RPC routes for period tracking) └── lib/ - └── period-calculations.ts (cycle calculation logic) -├── db/ -│ ├── schema.ts (Drizzle schema for period tracking) -│ └── queries.ts (database queries for period data) + ├── period-calculations.ts (cycle calculation logic) + └── db/ + ├── schema.ts (Drizzle schema for period tracking) + └── queries.ts (database queries for period data) ``` ## Business Rules From d683cb3d80bf7fdc34d7b928033d42b606e5bac6 Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Sun, 28 Sep 2025 18:13:33 +0545 Subject: [PATCH 3/9] feat: create basic UI for period tracking --- .../migrations/0002_public_ozymandias.sql | 16 + .../migrations/meta/0002_snapshot.json | 409 ++++++++++++++++++ apps/backend/migrations/meta/_journal.json | 7 + apps/backend/src/lib/db/index.ts | 1 + apps/backend/src/routes/period-tracker.ts | 8 +- .../components/PeriodTracker/PeriodButton.tsx | 84 ++++ .../PeriodTracker/PeriodTracker.tsx | 165 +++++++ apps/frontend/src/hooks/usePeriodTracking.ts | 104 +++++ apps/frontend/src/lib/hono-client.ts | 19 + apps/frontend/src/routes/index.tsx | 8 +- apps/frontend/src/types/hono.types.ts | 5 + 11 files changed, 817 insertions(+), 9 deletions(-) create mode 100644 apps/backend/migrations/0002_public_ozymandias.sql create mode 100644 apps/backend/migrations/meta/0002_snapshot.json create mode 100644 apps/frontend/src/components/PeriodTracker/PeriodButton.tsx create mode 100644 apps/frontend/src/components/PeriodTracker/PeriodTracker.tsx create mode 100644 apps/frontend/src/hooks/usePeriodTracking.ts create mode 100644 apps/frontend/src/lib/hono-client.ts create mode 100644 apps/frontend/src/types/hono.types.ts diff --git a/apps/backend/migrations/0002_public_ozymandias.sql b/apps/backend/migrations/0002_public_ozymandias.sql new file mode 100644 index 0000000..3ae33e4 --- /dev/null +++ b/apps/backend/migrations/0002_public_ozymandias.sql @@ -0,0 +1,16 @@ +ALTER TABLE `periods` RENAME TO `period`;--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_period` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `start_date` integer NOT NULL, + `end_date` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_period`("id", "user_id", "start_date", "end_date", "created_at", "updated_at") SELECT "id", "user_id", "start_date", "end_date", "created_at", "updated_at" FROM `period`;--> statement-breakpoint +DROP TABLE `period`;--> statement-breakpoint +ALTER TABLE `__new_period` RENAME TO `period`;--> statement-breakpoint +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/apps/backend/migrations/meta/0002_snapshot.json b/apps/backend/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..b17e199 --- /dev/null +++ b/apps/backend/migrations/meta/0002_snapshot.json @@ -0,0 +1,409 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3369da44-09cb-42e9-b80d-05b9e9deeec4", + "prevId": "f851c99f-74c9-431d-943d-f3e83cd94c99", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "period": { + "name": "period", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "period_user_id_user_id_fk": { + "name": "period_user_id_user_id_fk", + "tableFrom": "period", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": { + "\"periods\"": "\"period\"" + }, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/backend/migrations/meta/_journal.json b/apps/backend/migrations/meta/_journal.json index 3e327db..e4829b4 100644 --- a/apps/backend/migrations/meta/_journal.json +++ b/apps/backend/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1759056420297, "tag": "0001_grey_impossible_man", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1759062307937, + "tag": "0002_public_ozymandias", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/lib/db/index.ts b/apps/backend/src/lib/db/index.ts index 611a37d..dcd41e0 100644 --- a/apps/backend/src/lib/db/index.ts +++ b/apps/backend/src/lib/db/index.ts @@ -6,4 +6,5 @@ import { schema } from './schema.js' export const getDB = (c: Context) => drizzle(getEnv(c).DB_FILE_NAME, { schema: schema, + logger: true, }) diff --git a/apps/backend/src/routes/period-tracker.ts b/apps/backend/src/routes/period-tracker.ts index 6afa628..08f4313 100644 --- a/apps/backend/src/routes/period-tracker.ts +++ b/apps/backend/src/routes/period-tracker.ts @@ -10,7 +10,7 @@ import { import { calculateCycleInfo, calculateAverageCycleLength } from '../lib/period-calculations.js' import type { AppVariables } from '../types/hono.types.js' -const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', auth, async (c) => { +const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', async (c) => { const authUser = c.get('user') if (!authUser?.id) { return c.json({ error: 'Unauthorized' }, 401) @@ -40,7 +40,7 @@ const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', au console.error('Error creating period:', error) return c.json({ error: 'Failed to create period' }, 500) } -}).put('/end', auth, async (c) => { +}).put('/end', async (c) => { const authUser = c.get('user') if (!authUser?.id) { return c.json({ error: 'Unauthorized' }, 401) @@ -69,7 +69,7 @@ const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', au console.error('Error ending period:', error) return c.json({ error: 'Failed to end period' }, 500) } -}).get('/', auth, async (c) => { +}).get('/', async (c) => { const authUser = c.get('user') if (!authUser?.id) { return c.json({ error: 'Unauthorized' }, 401) @@ -102,7 +102,7 @@ const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', au console.error('Error fetching periods:', error) return c.json({ error: 'Failed to fetch periods' }, 500) } -}).get('/active', auth, async (c) => { +}).get('/active', async (c) => { const authUser = c.get('user') if (!authUser?.id) { return c.json({ error: 'Unauthorized' }, 401) diff --git a/apps/frontend/src/components/PeriodTracker/PeriodButton.tsx b/apps/frontend/src/components/PeriodTracker/PeriodButton.tsx new file mode 100644 index 0000000..457f722 --- /dev/null +++ b/apps/frontend/src/components/PeriodTracker/PeriodButton.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react' + +interface PeriodButtonProps { + isActivePeriod: boolean + onStartPeriod: () => void + onEndPeriod: () => void + isStarting: boolean + isEnding: boolean +} + +export const PeriodButton: React.FC = ({ + isActivePeriod, + onStartPeriod, + onEndPeriod, + isStarting, + isEnding, +}) => { + const [showConfirm, setShowConfirm] = useState(false) + + const handleStart = () => { + onStartPeriod() + } + + const handleEnd = () => { + setShowConfirm(true) + } + + const confirmEnd = () => { + onEndPeriod() + setShowConfirm(false) + } + + const cancelEnd = () => { + setShowConfirm(false) + } + + if (showConfirm) { + return ( +
+

Are you sure you want to end your period?

+
+ + +
+
+ ) + } + + return ( + + ) +} diff --git a/apps/frontend/src/components/PeriodTracker/PeriodTracker.tsx b/apps/frontend/src/components/PeriodTracker/PeriodTracker.tsx new file mode 100644 index 0000000..6d16bde --- /dev/null +++ b/apps/frontend/src/components/PeriodTracker/PeriodTracker.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react' +import { usePeriodTracking } from '../../hooks/usePeriodTracking' +import { PeriodButton } from './PeriodButton' + +export const PeriodTracker = () => { + const { + periods, + activePeriod, + averageCycleLength, + isLoading, + isError, + error, + startPeriod, + endPeriod, + isStartingPeriod, + isEndingPeriod, + } = usePeriodTracking() + + const [showAllPeriods, setShowAllPeriods] = useState(false) + + // Show only the last 5 periods by default + const displayedPeriods = showAllPeriods ? periods : periods.slice(0, 5) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (isError) { + return ( +
+
+ + Error: + {error?.message || 'An error occurred'} + +
+
+ ) + } + + return ( +
+
+

Period Tracker

+

+ Track your menstrual cycle with ease +

+
+ + {/* Period Button */} +
+ +
+ + {/* Cycle Information */} + {averageCycleLength && ( +
+

Cycle Information

+
+
+
Average Cycle Length
+
+ {averageCycleLength} + {' '} + days +
+
Based on your period history
+
+ {/* @ts-ignore: cycleInfo might not exist on the type */} + {activePeriod && activePeriod.cycleInfo?.cycleLength && ( +
+
Current Cycle
+
+ {/* @ts-ignore: cycleInfo might not exist on the type */} + {activePeriod.cycleInfo.cycleLength} + {' '} + days +
+
Since last period
+
+ )} +
+
+ )} + + {/* Period History */} +
+
+

Period History

+ {periods.length > 5 && ( + + )} +
+ + {periods.length === 0 ? ( +
+

No period history yet. Start tracking your periods!

+
+ ) : ( +
+ + + + + + + + + + + {displayedPeriods.map((period) => { + const startDate = new Date(period.startDate) + const endDate = period.endDate ? new Date(period.endDate) : null + + // Calculate duration + let duration = 'Ongoing' + if (endDate) { + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1 + duration = `${diffDays} day${diffDays !== 1 ? 's' : ''}` + } + + return ( + + + + + + + ) + })} + +
Start DateEnd DateDurationCycle Length
{startDate.toLocaleDateString()} + {endDate + ? endDate.toLocaleDateString() + : ( + Active + )} + {duration} + {/* @ts-ignore: cycleInfo might not exist on the type */} + {period.cycleInfo?.cycleLength + ? `${period.cycleInfo.cycleLength} days` + : 'N/A'} +
+
+ )} +
+
+ ) +} diff --git a/apps/frontend/src/hooks/usePeriodTracking.ts b/apps/frontend/src/hooks/usePeriodTracking.ts new file mode 100644 index 0000000..2264887 --- /dev/null +++ b/apps/frontend/src/hooks/usePeriodTracking.ts @@ -0,0 +1,104 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { periodsClient } from '../lib/hono-client' +import type { InferResponseType } from 'hono/client' + +// Define types based on Hono client return types +type PeriodsResponse = InferResponseType +type PeriodResponse = InferResponseType +type StartPeriodResponse = InferResponseType +type EndPeriodResponse = InferResponseType + +// Custom hook for period tracking +export const usePeriodTracking = () => { + const queryClient = useQueryClient() + + // Fetch user's period history + const { + data: periodsData, + isLoading: isLoadingPeriods, + isError: isErrorPeriods, + error: periodsError, + } = useQuery({ + queryKey: ['periods'], + queryFn: async () => { + const res = await periodsClient.$get() + if (!res.ok) { + throw new Error('Failed to fetch periods') + } + return res.json() + }, + }) + + // Fetch active period + const { + data: activePeriodData, + isLoading: isLoadingActivePeriod, + isError: isErrorActivePeriod, + error: activePeriodError, + } = useQuery({ + queryKey: ['activePeriod'], + queryFn: async () => { + const res = await periodsClient.active.$get() + if (!res.ok) { + throw new Error('Failed to fetch active period') + } + return res.json() + }, + }) + + // Start a new period + const startPeriodMutation = useMutation({ + mutationFn: async () => { + const res = await periodsClient.$post() + if (!res.ok) { + throw new Error('Failed to start period') + } + return res.json() + }, + onSuccess: () => { + // Invalidate and refetch queries + queryClient.invalidateQueries({ queryKey: ['periods'] }) + queryClient.invalidateQueries({ queryKey: ['activePeriod'] }) + }, + }) + + // End current period + const endPeriodMutation = useMutation({ + mutationFn: async () => { + const res = await periodsClient.end.$put() + if (!res.ok) { + throw new Error('Failed to end period') + } + return res.json() + }, + onSuccess: () => { + // Invalidate and refetch queries + queryClient.invalidateQueries({ queryKey: ['periods'] }) + queryClient.invalidateQueries({ queryKey: ['activePeriod'] }) + }, + }) + + return { + // Period data + periods: periodsData?.periods || [], + activePeriod: activePeriodData?.period, + averageCycleLength: periodsData?.averageCycleLength || null, + + // Loading states + isLoadingPeriods, + isLoadingActivePeriod, + isLoading: isLoadingPeriods || isLoadingActivePeriod || startPeriodMutation.isPending || endPeriodMutation.isPending, + + // Error states + isErrorPeriods, + isErrorActivePeriod, + isError: isErrorPeriods || isErrorActivePeriod || startPeriodMutation.isError || endPeriodMutation.isError, + error: periodsError || activePeriodError || startPeriodMutation.error || endPeriodMutation.error, + + // Mutations + startPeriod: startPeriodMutation.mutate, + endPeriod: endPeriodMutation.mutate, + isStartingPeriod: startPeriodMutation.isPending, + isEndingPeriod: endPeriodMutation.isPending, + } +} diff --git a/apps/frontend/src/lib/hono-client.ts b/apps/frontend/src/lib/hono-client.ts new file mode 100644 index 0000000..9d55bff --- /dev/null +++ b/apps/frontend/src/lib/hono-client.ts @@ -0,0 +1,19 @@ +import { hc } from 'hono/client' +import type { AppType } from '@periodos/backend/routes' +import { env } from '@/env' + +// Get the backend URL from environment variables +const BACKEND_URL = env.VITE_API_BASE || 'http://localhost:3000' + +// Create the Hono client +export const client = hc(BACKEND_URL, { + init: { + credentials: 'include', + }, +}) + +// Export individual API clients for easier usage +export const usersClient = client.api.v1.users +export const periodsClient = client.api.v1.periods + +export type { AppType } diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx index 6b520c3..0600fdf 100644 --- a/apps/frontend/src/routes/index.tsx +++ b/apps/frontend/src/routes/index.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import React from 'react' import type { User } from 'better-auth' import { authClient } from '@/lib/auth-client' +import { PeriodTracker } from '@/components/PeriodTracker/PeriodTracker' export const Route = createFileRoute('/')({ component: App, @@ -59,11 +60,8 @@ function App() { {user && ( -
-
-
Logged in as
-
{ user.name}
-
+
+
) } diff --git a/apps/frontend/src/types/hono.types.ts b/apps/frontend/src/types/hono.types.ts new file mode 100644 index 0000000..815827a --- /dev/null +++ b/apps/frontend/src/types/hono.types.ts @@ -0,0 +1,5 @@ +import type { User } from 'better-auth/types' + +export interface AppVariables { + user: User & { id: string } +} From 5e6a3b52df663f50aaf449721bd50bae8e638a99 Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Thu, 2 Oct 2025 19:51:12 +0545 Subject: [PATCH 4/9] refactor: create auth guard, modules for business logic and use import path alias --- apps/backend/src/index.ts | 4 +- apps/backend/src/lib/auth.ts | 6 +- apps/backend/src/lib/db/index.ts | 5 +- apps/backend/src/middleware/auth-guard.ts | 12 + .../db/period-tracker-queries.ts} | 4 +- .../period-tracker/routes.ts} | 24 +- apps/backend/src/routes/index.ts | 11 +- apps/backend/src/routes/users.ts | 4 +- apps/backend/src/types/hono.types.ts | 5 + apps/backend/tsconfig.json | 5 +- .../PeriodTracker/PeriodTracker.tsx | 165 ------------ .../components}/PeriodButton.tsx | 0 .../components/PeriodTracker.tsx | 236 ++++++++++++++++++ .../hooks/usePeriodTracking.ts | 2 +- .../src/modules/period-tracker/index.ts | 5 + .../period-tracker/types/period.types.ts | 4 + apps/frontend/src/routes/__root.tsx | 14 +- apps/frontend/src/routes/index.tsx | 25 +- 18 files changed, 317 insertions(+), 214 deletions(-) create mode 100644 apps/backend/src/middleware/auth-guard.ts rename apps/backend/src/{lib/db/queries.ts => modules/period-tracker/db/period-tracker-queries.ts} (95%) rename apps/backend/src/{routes/period-tracker.ts => modules/period-tracker/routes.ts} (84%) delete mode 100644 apps/frontend/src/components/PeriodTracker/PeriodTracker.tsx rename apps/frontend/src/{components/PeriodTracker => modules/period-tracker/components}/PeriodButton.tsx (100%) create mode 100644 apps/frontend/src/modules/period-tracker/components/PeriodTracker.tsx rename apps/frontend/src/{ => modules/period-tracker}/hooks/usePeriodTracking.ts (98%) create mode 100644 apps/frontend/src/modules/period-tracker/index.ts create mode 100644 apps/frontend/src/modules/period-tracker/types/period.types.ts diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 09cd9b4..97ec837 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,7 +1,7 @@ import 'dotenv/config' import { serve } from '@hono/node-server' -import { runMigrations } from './lib/db/migrate.js' -import app from './routes/index.js' +import { runMigrations } from '@/lib/db/migrate.js' +import app from '@/routes/index.js' try { await runMigrations() console.log('Migrations completed') diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts index ff55319..5c7a423 100644 --- a/apps/backend/src/lib/auth.ts +++ b/apps/backend/src/lib/auth.ts @@ -1,9 +1,9 @@ import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import type { Context } from 'hono' -import { getEnv } from '../env.js' -import { getDB } from './db/index.js' -import { schema } from './db/schema.js' +import { getEnv } from '@/env.js' +import { getDB } from '@/lib/db/index.js' +import { schema } from '@/lib/db/schema.js' export const auth = (c: Context) => betterAuth({ database: drizzleAdapter(getDB(c), { diff --git a/apps/backend/src/lib/db/index.ts b/apps/backend/src/lib/db/index.ts index dcd41e0..2211b79 100644 --- a/apps/backend/src/lib/db/index.ts +++ b/apps/backend/src/lib/db/index.ts @@ -1,10 +1,9 @@ import 'dotenv/config' import { drizzle } from 'drizzle-orm/libsql' import type { Context } from 'hono' -import { getEnv } from '../../env.js' -import { schema } from './schema.js' +import { getEnv } from '@/env.js' +import { schema } from '@/lib/db/schema.js' export const getDB = (c: Context) => drizzle(getEnv(c).DB_FILE_NAME, { schema: schema, - logger: true, }) diff --git a/apps/backend/src/middleware/auth-guard.ts b/apps/backend/src/middleware/auth-guard.ts new file mode 100644 index 0000000..e240c68 --- /dev/null +++ b/apps/backend/src/middleware/auth-guard.ts @@ -0,0 +1,12 @@ +import { createMiddleware } from 'hono/factory' +import type { AppVariables } from '@/types/hono.types.js' + +export const authGuard = createMiddleware<{ + Variables: AppVariables +}>(async (c, next) => { + const authUser = c.get('user') + if (!authUser?.id) { + return c.json({ error: 'Unauthorized' }, 401) + } + await next() +}) diff --git a/apps/backend/src/lib/db/queries.ts b/apps/backend/src/modules/period-tracker/db/period-tracker-queries.ts similarity index 95% rename from apps/backend/src/lib/db/queries.ts rename to apps/backend/src/modules/period-tracker/db/period-tracker-queries.ts index 7be0cce..ff71488 100644 --- a/apps/backend/src/lib/db/queries.ts +++ b/apps/backend/src/modules/period-tracker/db/period-tracker-queries.ts @@ -1,7 +1,7 @@ import { eq, and, desc, isNotNull, isNull } from 'drizzle-orm' import type { LibSQLDatabase } from 'drizzle-orm/libsql' -import type { schema } from './schema.js' -import { period } from './schema.js' +import type { schema } from '@/lib/db/schema.js' +import { period } from '@/lib/db/schema.js' export interface Period { id: string diff --git a/apps/backend/src/routes/period-tracker.ts b/apps/backend/src/modules/period-tracker/routes.ts similarity index 84% rename from apps/backend/src/routes/period-tracker.ts rename to apps/backend/src/modules/period-tracker/routes.ts index 08f4313..5e9bbe6 100644 --- a/apps/backend/src/routes/period-tracker.ts +++ b/apps/backend/src/modules/period-tracker/routes.ts @@ -1,20 +1,17 @@ import { Hono } from 'hono' -import { auth } from '../lib/auth.js' -import { getDB } from '../lib/db/index.js' import { createPeriod, updatePeriod, getActivePeriod, getPeriods, -} from '../lib/db/queries.js' -import { calculateCycleInfo, calculateAverageCycleLength } from '../lib/period-calculations.js' -import type { AppVariables } from '../types/hono.types.js' +} from './db/period-tracker-queries.js' +import { getDB } from '@/lib/db/index.js' +import { calculateCycleInfo, calculateAverageCycleLength } from '@/lib/period-calculations.js' +import { authGuard } from '@/middleware/auth-guard.js' +import type { AuthGuardAppVariables } from '@/types/hono.types.js' -const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', async (c) => { +const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use('*', authGuard).post('/', async (c) => { const authUser = c.get('user') - if (!authUser?.id) { - return c.json({ error: 'Unauthorized' }, 401) - } try { const db = getDB(c) @@ -42,9 +39,6 @@ const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', as } }).put('/end', async (c) => { const authUser = c.get('user') - if (!authUser?.id) { - return c.json({ error: 'Unauthorized' }, 401) - } try { const db = getDB(c) @@ -71,9 +65,6 @@ const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', as } }).get('/', async (c) => { const authUser = c.get('user') - if (!authUser?.id) { - return c.json({ error: 'Unauthorized' }, 401) - } try { const db = getDB(c) @@ -104,9 +95,6 @@ const periodTrackerRoute = new Hono <{ Variables: AppVariables }>().post('/', as } }).get('/active', async (c) => { const authUser = c.get('user') - if (!authUser?.id) { - return c.json({ error: 'Unauthorized' }, 401) - } try { const db = getDB(c) diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index fcf23d3..6381df0 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -1,10 +1,11 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' -import { getEnv } from '../env.js' -import { auth } from '../lib/auth.js' -import type { AppVariables } from '../types/hono.types.js' -import periodTrackerRoute from './period-tracker.js' -import usersRoute from './users.js' +import { getEnv } from '@/env.js' +import { auth } from '@/lib/auth.js' +import periodTrackerRoute from '@/modules/period-tracker/routes.js' +import usersRoute from '@/routes/users.js' +import type { AppVariables } from '@/types/hono.types.js' + const app = new Hono<{ Variables: AppVariables }>().use('*', cors({ origin: (_origin, c) => { const { FRONTEND_URL } = getEnv(c) diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index b8b2458..04aa5bd 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -1,7 +1,7 @@ import { eq } from 'drizzle-orm' import { Hono } from 'hono' -import { getDB } from '../lib/db/index.js' -import { user as usersTable } from '../lib/db/schema.js' +import { getDB } from '@/lib/db/index.js' +import { user as usersTable } from '@/lib/db/schema.js' const usersRoute = new Hono().get('/', async (c) => { const db = getDB(c) diff --git a/apps/backend/src/types/hono.types.ts b/apps/backend/src/types/hono.types.ts index eee4e9a..6c92db5 100644 --- a/apps/backend/src/types/hono.types.ts +++ b/apps/backend/src/types/hono.types.ts @@ -4,3 +4,8 @@ export interface AppVariables { user: User | null session: Session | null } + +export interface AuthGuardAppVariables extends AppVariables +{ + user: User +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 052c895..820dd61 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -9,7 +9,10 @@ "jsx": "react-jsx", "jsxImportSource": "hono/jsx", "outDir": "./dist", - "declaration": true + "declaration": true, + "paths": { + "@/*": ["./src/*"] + } }, "exclude": ["node_modules", "dist", "drizzle.config.ts"] } diff --git a/apps/frontend/src/components/PeriodTracker/PeriodTracker.tsx b/apps/frontend/src/components/PeriodTracker/PeriodTracker.tsx deleted file mode 100644 index 6d16bde..0000000 --- a/apps/frontend/src/components/PeriodTracker/PeriodTracker.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useState } from 'react' -import { usePeriodTracking } from '../../hooks/usePeriodTracking' -import { PeriodButton } from './PeriodButton' - -export const PeriodTracker = () => { - const { - periods, - activePeriod, - averageCycleLength, - isLoading, - isError, - error, - startPeriod, - endPeriod, - isStartingPeriod, - isEndingPeriod, - } = usePeriodTracking() - - const [showAllPeriods, setShowAllPeriods] = useState(false) - - // Show only the last 5 periods by default - const displayedPeriods = showAllPeriods ? periods : periods.slice(0, 5) - - if (isLoading) { - return ( -
-
-
- ) - } - - if (isError) { - return ( -
-
- - Error: - {error?.message || 'An error occurred'} - -
-
- ) - } - - return ( -
-
-

Period Tracker

-

- Track your menstrual cycle with ease -

-
- - {/* Period Button */} -
- -
- - {/* Cycle Information */} - {averageCycleLength && ( -
-

Cycle Information

-
-
-
Average Cycle Length
-
- {averageCycleLength} - {' '} - days -
-
Based on your period history
-
- {/* @ts-ignore: cycleInfo might not exist on the type */} - {activePeriod && activePeriod.cycleInfo?.cycleLength && ( -
-
Current Cycle
-
- {/* @ts-ignore: cycleInfo might not exist on the type */} - {activePeriod.cycleInfo.cycleLength} - {' '} - days -
-
Since last period
-
- )} -
-
- )} - - {/* Period History */} -
-
-

Period History

- {periods.length > 5 && ( - - )} -
- - {periods.length === 0 ? ( -
-

No period history yet. Start tracking your periods!

-
- ) : ( -
- - - - - - - - - - - {displayedPeriods.map((period) => { - const startDate = new Date(period.startDate) - const endDate = period.endDate ? new Date(period.endDate) : null - - // Calculate duration - let duration = 'Ongoing' - if (endDate) { - const diffTime = Math.abs(endDate.getTime() - startDate.getTime()) - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1 - duration = `${diffDays} day${diffDays !== 1 ? 's' : ''}` - } - - return ( - - - - - - - ) - })} - -
Start DateEnd DateDurationCycle Length
{startDate.toLocaleDateString()} - {endDate - ? endDate.toLocaleDateString() - : ( - Active - )} - {duration} - {/* @ts-ignore: cycleInfo might not exist on the type */} - {period.cycleInfo?.cycleLength - ? `${period.cycleInfo.cycleLength} days` - : 'N/A'} -
-
- )} -
-
- ) -} diff --git a/apps/frontend/src/components/PeriodTracker/PeriodButton.tsx b/apps/frontend/src/modules/period-tracker/components/PeriodButton.tsx similarity index 100% rename from apps/frontend/src/components/PeriodTracker/PeriodButton.tsx rename to apps/frontend/src/modules/period-tracker/components/PeriodButton.tsx diff --git a/apps/frontend/src/modules/period-tracker/components/PeriodTracker.tsx b/apps/frontend/src/modules/period-tracker/components/PeriodTracker.tsx new file mode 100644 index 0000000..f40b5d7 --- /dev/null +++ b/apps/frontend/src/modules/period-tracker/components/PeriodTracker.tsx @@ -0,0 +1,236 @@ +import { useState } from 'react' +import type { ActivePeriod, Period } from '@/modules/period-tracker/types/period.types' +import { usePeriodTracking } from '@/modules/period-tracker/hooks/usePeriodTracking' +import { PeriodButton } from '@/modules/period-tracker/components/PeriodButton' + +export const PeriodTracker = () => { + const { + periods, + activePeriod, + averageCycleLength, + isLoading, + isError, + error, + startPeriod, + endPeriod, + isStartingPeriod, + isEndingPeriod, + } = usePeriodTracking() + + const [showAllPeriods, setShowAllPeriods] = useState(false) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (isError) { + return ( +
+
+ + Error: + {error?.message || 'An error occurred'} + +
+
+ ) + } + + return ( +
+
+

Period Tracker

+

+ Track your menstrual cycle with ease +

+
+ + {/* Period Button */} +
+ +
+ + {/* Cycle Information */} + {averageCycleLength && ( + + )} + + {/* Period History */} + +
+ ) +} + +const PeriodHistory = ({ periods, showAllPeriods, setShowAllPeriods }: PeriodHistoryProps) => { + const displayedPeriods = showAllPeriods ? periods : periods.slice(0, 5) + + return ( +
+
+

Period History

+ {periods.length > 5 && ( + + )} +
+ + {periods.length === 0 + ? ( +
+

No period history yet. Start tracking your periods!

+
+ ) + : ( +
+
+ + + + + + + + + + + {displayedPeriods.map((period: Period) => { + const startDate = new Date(period.startDate) + const endDate = period.endDate ? new Date(period.endDate) : null + + let duration = 'Ongoing' + if (endDate) { + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1 + duration = `${diffDays} day${diffDays !== 1 ? 's' : ''}` + } + + return ( + + + + + + + ) + })} + +
Start DateEnd DateDurationCycle Length
{startDate.toLocaleDateString()} + {endDate + ? endDate.toLocaleDateString() + : Active} + {duration} + {period.cycleInfo.cycleLength + ? `${period.cycleInfo.cycleLength} days` + : 'N/A'} +
+
+ +
+ {displayedPeriods.map((period: Period) => { + const startDate = new Date(period.startDate) + const endDate = period.endDate ? new Date(period.endDate) : null + + let duration = 'Ongoing' + if (endDate) { + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1 + duration = `${diffDays} day${diffDays !== 1 ? 's' : ''}` + } + + return ( +
+
+ Start Date: + {startDate.toLocaleDateString()} +
+
+ End Date: + + {endDate + ? endDate.toLocaleDateString() + : Active} + +
+
+ Duration: + {duration} +
+
+ Cycle Length: + + {period.cycleInfo.cycleLength + ? `${period.cycleInfo.cycleLength} days` + : 'N/A'} + +
+
+ ) + })} +
+
+ )} +
+ ) +} + +const CycleOverview = ({ averageCycleLength, activePeriod }: CycleOverviewProps) => { + return ( +
+

Cycle Information

+
+
+
Average Cycle Length
+
+ {averageCycleLength} + {' '} + days +
+
Based on your period history
+
+ {activePeriod && activePeriod.cycleInfo.cycleLength && ( +
+
Current Cycle
+
+ {activePeriod.cycleInfo.cycleLength} + {' '} + days +
+
Since last period
+
+ )} +
+
+ ) +} + +interface PeriodHistoryProps { + periods: Array + showAllPeriods: boolean + setShowAllPeriods: (show: boolean) => void +} + +interface CycleOverviewProps { + averageCycleLength: number | null + activePeriod: ActivePeriod +} diff --git a/apps/frontend/src/hooks/usePeriodTracking.ts b/apps/frontend/src/modules/period-tracker/hooks/usePeriodTracking.ts similarity index 98% rename from apps/frontend/src/hooks/usePeriodTracking.ts rename to apps/frontend/src/modules/period-tracker/hooks/usePeriodTracking.ts index 2264887..31aca32 100644 --- a/apps/frontend/src/hooks/usePeriodTracking.ts +++ b/apps/frontend/src/modules/period-tracker/hooks/usePeriodTracking.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { periodsClient } from '../lib/hono-client' import type { InferResponseType } from 'hono/client' +import { periodsClient } from '@/lib/hono-client' // Define types based on Hono client return types type PeriodsResponse = InferResponseType diff --git a/apps/frontend/src/modules/period-tracker/index.ts b/apps/frontend/src/modules/period-tracker/index.ts new file mode 100644 index 0000000..54f0c6b --- /dev/null +++ b/apps/frontend/src/modules/period-tracker/index.ts @@ -0,0 +1,5 @@ +import { PeriodTracker } from './components/PeriodTracker' + +export { + PeriodTracker, +} diff --git a/apps/frontend/src/modules/period-tracker/types/period.types.ts b/apps/frontend/src/modules/period-tracker/types/period.types.ts new file mode 100644 index 0000000..f779f69 --- /dev/null +++ b/apps/frontend/src/modules/period-tracker/types/period.types.ts @@ -0,0 +1,4 @@ +import type { usePeriodTracking } from '@/modules/period-tracker/hooks/usePeriodTracking' + +export type Period = (ReturnType)['periods'][0] +export type ActivePeriod = (ReturnType)['activePeriod'] diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx index 5ccd472..8db2bcd 100644 --- a/apps/frontend/src/routes/__root.tsx +++ b/apps/frontend/src/routes/__root.tsx @@ -1,10 +1,10 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' -import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' -import { TanstackDevtools } from '@tanstack/react-devtools' +// import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' +// import { TanstackDevtools } from '@tanstack/react-devtools' -import Header from '../components/Header' +// import Header from '../components/Header' -import TanStackQueryDevtools from '../integrations/tanstack-query/devtools' +// import TanStackQueryDevtools from '../integrations/tanstack-query/devtools' import type { QueryClient } from '@tanstack/react-query' @@ -15,9 +15,9 @@ interface MyRouterContext { export const Route = createRootRouteWithContext()({ component: () => ( <> -
+ {/*
*/} - ()({ }, TanStackQueryDevtools, ]} - /> + /> */} ), }) diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx index 0600fdf..486e78a 100644 --- a/apps/frontend/src/routes/index.tsx +++ b/apps/frontend/src/routes/index.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import React from 'react' import type { User } from 'better-auth' import { authClient } from '@/lib/auth-client' -import { PeriodTracker } from '@/components/PeriodTracker/PeriodTracker' +import { PeriodTracker } from '@/modules/period-tracker' export const Route = createFileRoute('/')({ component: App, @@ -10,9 +10,13 @@ export const Route = createFileRoute('/')({ function App() { const [user, setUser] = React.useState(null) + const [isLoading, setIsLoading] = React.useState(true) React.useEffect(() => { - authClient.getSession().then(session => setUser(session.data?.user ?? null)) + authClient.getSession().then((session) => { + setUser(session.data?.user ?? null) + setIsLoading(false) + }) }, []) async function handleSignIn() { @@ -48,6 +52,19 @@ function App() { ) + if (isLoading) { + return ( +
+

+ Periodos app +

+
+
+
+
+ ) + } + return (

@@ -55,7 +72,6 @@ function App() {

- {buttonToShow}
@@ -63,8 +79,7 @@ function App() {
- ) } - + )}
) } From 8f53a1728f9c8eaf23b825e4e576e56feb7c9d1c Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Thu, 2 Oct 2025 20:22:38 +0545 Subject: [PATCH 5/9] chore: setup backend request logging --- apps/backend/.env.example | 1 + apps/backend/package.json | 2 + apps/backend/src/env.ts | 1 + apps/backend/src/middleware/logger.ts | 18 +++++ apps/backend/src/routes/index.ts | 25 +++--- apps/backend/src/types/hono.types.ts | 2 + pnpm-lock.yaml | 108 ++++++++++++++++++++++++++ 7 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/middleware/logger.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index f0be216..3f05938 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -5,3 +5,4 @@ GOOGLE_CLIENT_SECRET= BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3000 # Base URL of your app COOKIE_DOMAIN=localhost +LOG_LEVEL=debug diff --git a/apps/backend/package.json b/apps/backend/package.json index 0283120..e49f4b8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -20,6 +20,8 @@ "dotenv": "^17.2.2", "drizzle-orm": "^0.44.5", "hono": "^4.9.8", + "hono-pino": "^0.10.2", + "pino": "^9.13.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 9561e5f..5bc05e1 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -11,6 +11,7 @@ export const getEnv = (c: Context) => createEnv({ BETTER_AUTH_URL: z.string().url(), GOOGLE_CLIENT_ID: z.string(), GOOGLE_CLIENT_SECRET: z.string(), + LOG_LEVEL: z.string().optional(), }, /** diff --git a/apps/backend/src/middleware/logger.ts b/apps/backend/src/middleware/logger.ts new file mode 100644 index 0000000..678f1e4 --- /dev/null +++ b/apps/backend/src/middleware/logger.ts @@ -0,0 +1,18 @@ +import { pinoLogger } from 'hono-pino' +import { pino } from 'pino' + +export function logger() { + return pinoLogger({ + pino: pino({ + level: process.env.LOG_LEVEL ?? 'info', + transport: process.env.NODE_ENV === 'production' + ? undefined + : { + target: 'hono-pino/debug-log', + }, + }), + http: { + reqId: () => crypto.randomUUID(), + }, + }) +} diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index 6381df0..7242421 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -2,21 +2,24 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' import { getEnv } from '@/env.js' import { auth } from '@/lib/auth.js' +import { logger } from '@/middleware/logger.js' import periodTrackerRoute from '@/modules/period-tracker/routes.js' import usersRoute from '@/routes/users.js' import type { AppVariables } from '@/types/hono.types.js' -const app = new Hono<{ Variables: AppVariables }>().use('*', cors({ - origin: (_origin, c) => { - const { FRONTEND_URL } = getEnv(c) - return FRONTEND_URL - }, - allowHeaders: ['Content-Type', 'Authorization'], - allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - exposeHeaders: ['Content-Length'], - maxAge: 600, - credentials: true, -})) +const app = new Hono<{ Variables: AppVariables }>() + .use(logger()) + .use('*', cors({ + origin: (_origin, c) => { + const { FRONTEND_URL } = getEnv(c) + return FRONTEND_URL + }, + allowHeaders: ['Content-Type', 'Authorization'], + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + exposeHeaders: ['Content-Length'], + maxAge: 600, + credentials: true, + })) .use('*', async (c, next) => { const session = await auth(c).api.getSession({ headers: c.req.raw.headers }) if (!session) { diff --git a/apps/backend/src/types/hono.types.ts b/apps/backend/src/types/hono.types.ts index 6c92db5..2714074 100644 --- a/apps/backend/src/types/hono.types.ts +++ b/apps/backend/src/types/hono.types.ts @@ -1,8 +1,10 @@ import type { Session, User } from 'better-auth' +import type { PinoLogger } from 'hono-pino' export interface AppVariables { user: User | null session: Session | null + logger: PinoLogger } export interface AuthGuardAppVariables extends AppVariables diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 532aee4..d707f6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,12 @@ importers: hono: specifier: ^4.9.8 version: 4.9.8 + hono-pino: + specifier: ^0.10.2 + version: 0.10.2(hono@4.9.8)(pino@9.13.0) + pino: + specifier: ^9.13.0 + version: 9.13.0 zod: specifier: ^3.24.2 version: 3.25.76 @@ -2022,6 +2028,10 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + babel-dead-code-elimination@1.0.10: resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} @@ -2622,6 +2632,13 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hono-pino@0.10.2: + resolution: {integrity: sha512-h8h4jODUoWdY7n7J1GsOO5Szpf0KH0+bdh8inJVTFrud/R/cxNjKty/pEaJIYSY9+enP0dfAiY+gjXTEFkARYg==} + engines: {node: '>=18'} + peerDependencies: + hono: '>=4.0.0' + pino: '>=7.1.0' + hono@4.9.8: resolution: {integrity: sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg==} engines: {node: '>=16.9.0'} @@ -2954,6 +2971,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3002,6 +3023,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.13.0: + resolution: {integrity: sha512-SpTXQhkQXekIKEe7c887S3lk3v90Q+/HVRZVyNAhe98PQc++6I5ec/R0pciH8/CciXjCoVZIZfRNicbC6KZgnw==} + hasBin: true + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -3019,6 +3050,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + promise-limit@2.7.0: resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} @@ -3036,6 +3070,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + react-dom@19.1.1: resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} peerDependencies: @@ -3056,6 +3093,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -3099,6 +3140,10 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3157,9 +3202,15 @@ packages: simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + slow-redact@0.3.0: + resolution: {integrity: sha512-cf723wn9JeRIYP9tdtd86GuqoR5937u64Io+CYjlm2i7jvu7g0H+Cp0l0ShAf/4ZL+ISUTVT+8Qzz7RZmp9FjA==} + solid-js@1.9.9: resolution: {integrity: sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==} + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3175,6 +3226,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -3226,6 +3281,9 @@ packages: resolution: {integrity: sha512-O1z7ajPkjTgEgmTGz0v9X4eqeEXTDREPTO77pVC1Nbs86feBU1Zhdg+edzavPmYW1olxkwsqA2v4uOw6E8LeDg==} engines: {node: '>=18'} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -5298,6 +5356,8 @@ snapshots: async@3.2.6: {} + atomic-sleep@1.0.0: {} + babel-dead-code-elimination@1.0.10: dependencies: '@babel/core': 7.28.4 @@ -5859,6 +5919,12 @@ snapshots: has-flag@4.0.0: {} + hono-pino@0.10.2(hono@4.9.8)(pino@9.13.0): + dependencies: + defu: 6.1.4 + hono: 4.9.8 + pino: 9.13.0 + hono@4.9.8: {} html-encoding-sniffer@4.0.0: @@ -6163,6 +6229,8 @@ snapshots: ohash@2.0.11: {} + on-exit-leak-free@2.1.2: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6204,6 +6272,26 @@ snapshots: picomatch@4.0.3: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@9.13.0: + dependencies: + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + slow-redact: 0.3.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -6220,6 +6308,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + process-warning@5.0.0: {} + promise-limit@2.7.0: {} punycode@2.3.1: {} @@ -6232,6 +6322,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + react-dom@19.1.1(react@19.1.1): dependencies: react: 19.1.1 @@ -6247,6 +6339,8 @@ snapshots: dependencies: picomatch: 2.3.1 + real-require@0.2.0: {} + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -6307,6 +6401,8 @@ snapshots: dependencies: tslib: 2.8.1 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -6369,12 +6465,18 @@ snapshots: dependencies: is-arrayish: 0.3.4 + slow-redact@0.3.0: {} + solid-js@1.9.9: dependencies: csstype: 3.1.3 seroval: 1.3.2 seroval-plugins: 1.3.3(seroval@1.3.2) + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6386,6 +6488,8 @@ snapshots: source-map@0.7.6: {} + split2@4.2.0: {} + stable-hash-x@0.2.0: {} stackback@0.0.2: {} @@ -6430,6 +6534,10 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} From 7bb3ea3fae80c13d9b9271c456b6ef0e9b609ed7 Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Sun, 5 Oct 2025 16:21:58 +0545 Subject: [PATCH 6/9] feat: Add Vitest testing infrastructure for Hono backend --- add-hono-tests.md | 134 +++++++ apps/backend/.env.test | 1 + apps/backend/package.json | 12 +- apps/backend/src/env.ts | 2 +- apps/backend/src/index.spec.ts | 9 + apps/backend/src/index.ts | 28 +- apps/backend/src/lib/db/index.ts | 12 +- apps/backend/src/lib/db/seed.ts | 38 ++ apps/backend/test.db | 0 apps/backend/vitest.config.ts | 20 + apps/backend/vitest.setup.ts | 4 + pnpm-lock.yaml | 618 ++++++++++++++++++++++++++++++- 12 files changed, 859 insertions(+), 19 deletions(-) create mode 100644 add-hono-tests.md create mode 100644 apps/backend/.env.test create mode 100644 apps/backend/src/index.spec.ts create mode 100644 apps/backend/src/lib/db/seed.ts create mode 100644 apps/backend/test.db create mode 100644 apps/backend/vitest.config.ts create mode 100644 apps/backend/vitest.setup.ts diff --git a/add-hono-tests.md b/add-hono-tests.md new file mode 100644 index 0000000..98045ef --- /dev/null +++ b/add-hono-tests.md @@ -0,0 +1,134 @@ +# Task: Add Vitest Testing to Hono Backend + +This document outlines the tasks and sub-tasks required to implement a robust testing suite for the Hono backend using Vitest. The primary goals are to set up the testing infrastructure and then write comprehensive tests for the `period-tracker` module. + +## Backend Project Structure + +The backend project is structured as a typical Node.js/TypeScript application. Understanding this structure is key to placing tests and mocks correctly. + +- `src/index.ts`: The main application entry point where the Hono server is initialized. +- `src/lib/`: Contains shared libraries and utilities. + - `src/lib/db/index.js`: Manages database connections. This will be a key file to handle for switching to a test database. + - `src/lib/period-calculations.js`: Contains business logic for period calculations. +- `src/middleware/`: Contains Hono middleware. + - `src/middleware/auth-guard.js`: Protects routes and provides the authenticated user. This will need to be mocked during tests. +- `src/modules/`: Contains the core feature modules of the application. + - `src/modules/period-tracker/`: The first module to be tested. + - `routes.ts`: Defines the API routes for period tracking. + - `db/period-tracker-queries.js`: Contains the database queries for the module. +- `src/types/`: Contains shared TypeScript types and interfaces. +Note: the backend is in the apps/backend folder from the root. It's using pnpm workspaces to manage the backend and the frontend in a monorepo. When running installation or script commands please make sure it works for the pnpm monorepo and use appropriate pnpm --filter commands to run it for the backend project. +--- + +## Phase 1: Test Infrastructure Setup + +This phase focuses on installing and configuring all the necessary tools and utilities to enable testing. + +### Sub-task 1: Install Dependencies + +Add Vitest and any necessary helper libraries for HTTP testing and mocking. + +```bash +npm install -D vitest @vitest/coverage-v8 supertest +``` + +- `vitest`: The testing framework. +- `@vitest/coverage-v8`: For generating code coverage reports. +- `supertest`: A library for making HTTP requests to the Hono app in a test environment. + +### Sub-task 2: Configure Vitest + +Create a `vitest.config.ts` file in the root of the `apps/backend` project to configure the test environment. + +```typescript +// vitest.config.ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) +``` + +Also, add a `test` script to `package.json`. + +```json +// package.json +"scripts": { + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage" +} +``` + +### Sub-task 3: Test Database Management + +We need a separate database for running tests to isolate them from development and production data. + +1. **Environment Configuration**: Use environment variables to specify the test database connection string. Create a `.env.test` file. + + ``` + # .env.test + DATABASE_URL="file:./test.db" + ``` + +2. **Update Database Logic**: Modify the `getDB` function in `src/lib/db/index.js` to use the test database when the environment is `test`. Vitest automatically sets `process.env.NODE_ENV` to `test`. + +3. **Database Migrations**: Ensure the migration works properly with the existing setup (the server runs the migration on startup). If that doesn't work then we might need a script to apply the database schema to the test database before tests run. We can add a script to `package.json`. + + ```json + // package.json + "scripts": { + "test:migrate": "dotenv -e .env.test -- drizzle-kit push:sqlite" + } + ``` + This can be called in a global setup file for Vitest if needed. + +### Sub-task 4: Database Seeding for Tests + +Tests require a consistent starting state. We need to create utilities to seed the test database. + +1. **Create a Seeding Utility**: Create a file, e.g., `src/lib/db/seed.ts`, that can clear the database and insert test data, like a specific test user. + +2. **Integrate with Tests**: Use Vitest's `beforeEach` or `beforeAll` hooks within test files to call the seeding utility, ensuring each test or test suite runs against a clean, predictable database state. + +--- + +## Phase 2: Writing Tests + +With the infrastructure in place, we can now write the actual tests. Tests should be located next to the file they are testing, using a `.spec.ts` or `.test.ts` suffix (e.g., `routes.spec.ts` alongside `routes.ts`). + +### Sub-task 1: Test `period-tracker/routes.ts` + +Create `src/modules/period-tracker/routes.spec.ts`. The main challenge here will be to properly instantiate the Hono app and mock the `authGuard` middleware to provide a test user. + +**Testing Strategy:** + +- Import the `periodTrackerRoute` Hono instance. +- In the test setup, create a new main Hono app and mount the `periodTrackerRoute` onto it. +- Create a mock `authGuard` that directly calls the next middleware and sets a mock user in the context (`c.set('user', testUser)`). +- Use `supertest` to send requests to the app instance and assert the responses. + +#### Test Cases to Implement: + +- **`POST /` - Create a new period** + - `[SUCCESS]` Should create a new period for the authenticated user and return a 201 status code. + - `[FAILURE]` Should return a 400 error if the user already has an active period. + +- **`PUT /end` - End the current period** + - `[SUCCESS]` Should update the `endDate` of the active period and return a 200 status code. + - `[FAILURE]` Should return a 400 error if there is no active period to end. + +- **`GET /` - Get all periods** + - `[SUCCESS]` Should return a list of all periods for the authenticated user, including calculated cycle info and average cycle length. + - `[SUCCESS]` Should return an empty array if the user has no periods. + +- **`GET /active` - Get the active period** + - `[SUCCESS]` Should return the currently active period with its cycle info. + - `[SUCCESS]` Should return a `period` property of `null` if no active period is found. diff --git a/apps/backend/.env.test b/apps/backend/.env.test new file mode 100644 index 0000000..efc03de --- /dev/null +++ b/apps/backend/.env.test @@ -0,0 +1 @@ +DATABASE_URL="file:./test.db" diff --git a/apps/backend/package.json b/apps/backend/package.json index e49f4b8..6df6f25 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -3,6 +3,10 @@ "type": "module", "scripts": { "dev": "rm -rf dist && tsx watch src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "test:migrate": "dotenv -e .env.test -- drizzle-kit push", "build": "rm -rf dist && tsc", "start": "node dist/index.js", "lint": "eslint", @@ -28,11 +32,15 @@ "@flydotio/dockerfile": "^0.7.10", "@hono/eslint-config": "^2.0.3", "@stylistic/eslint-plugin": "^5.4.0", - "@types/node": "^20.11.17", + "@types/node": "^20.19.17", + "@vitest/coverage-v8": "^3.2.4", + "dotenv-cli": "^10.0.0", "drizzle-kit": "^0.31.4", "eslint": "^9.36.0", + "supertest": "^7.1.4", "tsx": "^4.7.1", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^3.2.4" }, "exports": { "./routes": "./src/routes/index.ts" diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 5bc05e1..183d036 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -5,7 +5,7 @@ import { z } from 'zod' export const getEnv = (c: Context) => createEnv({ server: { FRONTEND_URL: z.string().url(), - DB_FILE_NAME: z.string().url(), + DATABASE_URL: z.string(), COOKIE_DOMAIN: z.string(), BETTER_AUTH_SECRET: z.string(), BETTER_AUTH_URL: z.string().url(), diff --git a/apps/backend/src/index.spec.ts b/apps/backend/src/index.spec.ts new file mode 100644 index 0000000..4efa82a --- /dev/null +++ b/apps/backend/src/index.spec.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest' +import app from '@/routes/index.js' + +describe('Basic Server Test', () => { + it('should return 404 for a non-existent route', async () => { + const res = await app.request('/non-existent-route') + expect(res.status).toBe(404) + }) +}) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 97ec837..9989da1 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,17 +2,21 @@ import 'dotenv/config' import { serve } from '@hono/node-server' import { runMigrations } from '@/lib/db/migrate.js' import app from '@/routes/index.js' -try { - await runMigrations() - console.log('Migrations completed') -} -catch (error) { - console.error('Error running migrations:', error) +if (process.env.NODE_ENV !== 'test') { + try { + await runMigrations() + console.log('Migrations completed') + } + catch (error) { + console.error('Error running migrations:', error) + } + + serve({ + fetch: app.fetch, + port: 3000, + }, (info) => { + console.log(`Server is running on http://localhost:${String(info.port)}`) + }) } -serve({ - fetch: app.fetch, - port: 3000, -}, (info) => { - console.log(`Server is running on http://localhost:${String(info.port)}`) -}) +export default app diff --git a/apps/backend/src/lib/db/index.ts b/apps/backend/src/lib/db/index.ts index 2211b79..87c3930 100644 --- a/apps/backend/src/lib/db/index.ts +++ b/apps/backend/src/lib/db/index.ts @@ -4,6 +4,12 @@ import type { Context } from 'hono' import { getEnv } from '@/env.js' import { schema } from '@/lib/db/schema.js' -export const getDB = (c: Context) => drizzle(getEnv(c).DB_FILE_NAME, { - schema: schema, -}) +export const getDB = (c?: Context) => { + const url = c ? getEnv(c).DATABASE_URL : process.env.DATABASE_URL + if (!url) { + throw new Error('DATABASE_URL is not set') + } + return drizzle(url, { + schema, + }) +} diff --git a/apps/backend/src/lib/db/seed.ts b/apps/backend/src/lib/db/seed.ts new file mode 100644 index 0000000..610c5e8 --- /dev/null +++ b/apps/backend/src/lib/db/seed.ts @@ -0,0 +1,38 @@ +import { getDB } from '@/lib/db/index.js' +import { schema } from '@/lib/db/schema.js' + +/** + * A consistent test user for running tests. + */ +export const testUser = { + id: 'usr_test_xxxxxxxx', + email: 'test.user@example.com', + name: 'Test User', +} + +/** + * Clears all data from the test database tables. + * This function is designed to be called before seeding to ensure a clean state. + * It deletes data in an order that respects foreign key constraints. + */ +export async function clearDatabase() { + const db = getDB() + + // Delete from child tables first to avoid foreign key violations + await db.delete(schema.period) + + // Delete from parent tables last + await db.delete(schema.user) +} + +/** + * Seeds the database with a clean set of test data. + * It first clears the database and then inserts the test user. + * This is intended to be used in `beforeAll` or `beforeEach` hooks in test files. + */ +export async function seedTestDatabase() { + await clearDatabase() + + const db = getDB() + await db.insert(schema.user).values(testUser) +} diff --git a/apps/backend/test.db b/apps/backend/test.db new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts new file mode 100644 index 0000000..86931d3 --- /dev/null +++ b/apps/backend/vitest.config.ts @@ -0,0 +1,20 @@ +// vitest.config.ts +import path from 'path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + setupFiles: ['./vitest.setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) diff --git a/apps/backend/vitest.setup.ts b/apps/backend/vitest.setup.ts new file mode 100644 index 0000000..424ce7f --- /dev/null +++ b/apps/backend/vitest.setup.ts @@ -0,0 +1,4 @@ +import { config } from 'dotenv' +import path from 'path' + +config({ path: path.resolve(__dirname, '.env.test') }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d707f6a..8dce35e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,20 +55,32 @@ importers: specifier: ^5.4.0 version: 5.4.0(eslint@9.36.0(jiti@2.5.1)) '@types/node': - specifier: ^20.11.17 + specifier: ^20.19.17 version: 20.19.17 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@20.19.17)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.5)) + dotenv-cli: + specifier: ^10.0.0 + version: 10.0.0 drizzle-kit: specifier: ^0.31.4 version: 0.31.4 eslint: specifier: ^9.36.0 version: 9.36.0(jiti@2.5.1) + supertest: + specifier: ^7.1.4 + version: 7.1.4 tsx: specifier: ^4.7.1 version: 4.20.5 typescript: specifier: ^5.8.3 version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@20.19.17)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.5) apps/frontend: dependencies: @@ -163,6 +175,10 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -311,6 +327,10 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} @@ -1171,10 +1191,18 @@ packages: resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1268,6 +1296,10 @@ packages: resolution: {integrity: sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==} engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.1': resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} @@ -1284,6 +1316,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@paralleldrive/cuid2@2.2.2': + resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} + '@peculiar/asn1-android@2.5.0': resolution: {integrity: sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==} @@ -1320,6 +1355,10 @@ packages: '@peculiar/x509@1.14.0': resolution: {integrity: sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@poppinss/colors@4.1.5': resolution: {integrity: sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==} @@ -1932,6 +1971,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1991,6 +2039,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1999,6 +2051,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.1.0: resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} engines: {node: '>=14'} @@ -2013,6 +2069,9 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1js@3.0.6: resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} engines: {node: '>=12.0.0'} @@ -2025,9 +2084,15 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.5: + resolution: {integrity: sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2103,6 +2168,14 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2163,10 +2236,17 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2180,6 +2260,9 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2224,6 +2307,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2236,6 +2323,9 @@ packages: resolution: {integrity: sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==} engines: {node: '>=8'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@7.0.0: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} @@ -2247,6 +2337,18 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dotenv-cli@10.0.0: + resolution: {integrity: sha512-lnOnttzfrzkRx2echxJHQRB6vOAMSCzzZg79IxpC00tU42wZPuZkQxNNrrwVAxaQZIIh001l4PxVlCrBxngBzA==} + hasBin: true + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dotenv@17.2.2: resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==} engines: {node: '>=12'} @@ -2347,6 +2449,13 @@ packages: sqlite3: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -2358,6 +2467,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -2369,9 +2481,25 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -2533,6 +2661,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -2571,15 +2702,30 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2588,6 +2734,14 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} @@ -2602,6 +2756,10 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2622,6 +2780,10 @@ packages: peerDependencies: csstype: ^3.0.10 + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2632,6 +2794,18 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + hono-pino@0.10.2: resolution: {integrity: sha512-h8h4jODUoWdY7n7J1GsOO5Szpf0KH0+bdh8inJVTFrud/R/cxNjKty/pEaJIYSY9+enP0dfAiY+gjXTEFkARYg==} engines: {node: '>=18'} @@ -2647,6 +2821,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -2724,6 +2901,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -2884,14 +3080,42 @@ packages: magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -2917,6 +3141,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2968,6 +3195,10 @@ packages: nwsapi@2.2.22: resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -2975,6 +3206,9 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2987,6 +3221,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3002,6 +3239,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3067,6 +3308,10 @@ packages: resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} engines: {node: '>=6.0.0'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3192,6 +3437,22 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3248,10 +3509,18 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3259,6 +3528,14 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + superagent@10.2.3: + resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==} + engines: {node: '>=14.18.0'} + + supertest@7.1.4: + resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} + engines: {node: '>=14.18.0'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -3281,6 +3558,10 @@ packages: resolution: {integrity: sha512-O1z7ajPkjTgEgmTGz0v9X4eqeEXTDREPTO77pVC1Nbs86feBU1Zhdg+edzavPmYW1olxkwsqA2v4uOw6E8LeDg==} engines: {node: '>=18'} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -3556,6 +3837,13 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -3631,6 +3919,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -3837,6 +4130,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/utils@0.3.0': {} '@better-fetch/fetch@1.1.18': {} @@ -4431,10 +4726,21 @@ snapshots: dependencies: '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4534,6 +4840,8 @@ snapshots: '@noble/ciphers@2.0.1': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} '@nodelib/fs.scandir@2.1.5': @@ -4548,6 +4856,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@paralleldrive/cuid2@2.2.2': + dependencies: + '@noble/hashes': 1.8.0 + '@peculiar/asn1-android@2.5.0': dependencies: '@peculiar/asn1-schema': 2.5.0 @@ -4644,6 +4956,9 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@pkgjs/parseargs@0.11.0': + optional: true + '@poppinss/colors@4.1.5': dependencies: kleur: 4.1.5 @@ -5260,6 +5575,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@20.19.17)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.5))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.5 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.19 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@20.19.17)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.5) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -5323,12 +5657,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + ansis@4.1.0: {} anymatch@3.1.3: @@ -5342,6 +5680,8 @@ snapshots: dependencies: dequal: 2.0.3 + asap@2.0.6: {} + asn1js@3.0.6: dependencies: pvtsutils: 1.3.6 @@ -5354,8 +5694,16 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@0.3.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + async@3.2.6: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} babel-dead-code-elimination@1.0.10: @@ -5427,6 +5775,16 @@ snapshots: cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} caniuse-lite@1.0.30001743: {} @@ -5490,8 +5848,14 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comment-parser@1.4.1: {} + component-emitter@1.3.1: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -5500,6 +5864,8 @@ snapshots: cookie@1.0.2: {} + cookiejar@2.1.4: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5534,18 +5900,38 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.0.2: {} detect-libc@2.1.0: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + diff@7.0.0: {} diff@8.0.2: {} dom-accessibility-api@0.5.16: {} + dotenv-cli@10.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 17.2.2 + dotenv-expand: 11.0.7 + minimist: 1.2.8 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + dotenv@17.2.2: {} drizzle-kit@0.31.4: @@ -5562,6 +5948,14 @@ snapshots: '@libsql/client': 0.15.15 kysely: 0.28.7 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -5570,6 +5964,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -5579,8 +5975,23 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild-register@3.6.0(esbuild@0.25.10): dependencies: debug: 4.4.3 @@ -5839,6 +6250,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -5876,17 +6289,56 @@ snapshots: flatted@3.3.3: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.2.2 + dezalgo: 1.0.4 + once: 1.4.0 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.10.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -5901,6 +6353,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@15.15.0: {} @@ -5913,12 +6374,24 @@ snapshots: dependencies: csstype: 3.1.3 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} graphemer@1.4.0: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + hono-pino@0.10.2(hono@4.9.8)(pino@9.13.0): dependencies: defu: 6.1.4 @@ -5931,6 +6404,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -6002,6 +6477,33 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jake@10.9.4: dependencies: async: 3.2.6 @@ -6152,13 +6654,35 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + + math-intrinsics@1.1.0: {} + merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + mime@3.0.0: {} miniflare@4.20250917.0: @@ -6195,6 +6719,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.2: {} minizlib@3.1.0: @@ -6227,10 +6753,16 @@ snapshots: nwsapi@2.2.22: {} + object-inspect@1.13.4: {} + ohash@2.0.11: {} on-exit-leak-free@2.1.2: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6248,6 +6780,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6260,6 +6794,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -6320,6 +6859,10 @@ snapshots: pvutils@1.1.3: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -6457,6 +7000,34 @@ snapshots: shell-quote@1.8.3: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -6504,16 +7075,47 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@3.1.1: {} strip-literal@3.0.0: dependencies: js-tokens: 9.0.1 + superagent@10.2.3: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.4 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.0 + transitivePeerDependencies: + - supports-color + + supertest@7.1.4: + dependencies: + methods: 1.1.2 + superagent: 10.2.3 + transitivePeerDependencies: + - supports-color + supports-color@10.2.2: {} supports-color@7.2.0: @@ -6534,6 +7136,12 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -6837,6 +7445,14 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + ws@8.18.0: {} ws@8.18.3: {} From 87b9158512dd261ac0ffc8a9373cb22b56effa54 Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Sun, 5 Oct 2025 16:45:50 +0545 Subject: [PATCH 7/9] refactor: turn period tracker queries functions into a service class instead --- apps/backend/.env.example | 2 +- apps/backend/src/lib/db/migrate.ts | 2 +- .../db/period-tracker-queries.ts | 80 ------------------ .../period-tracker/period-tracker.service.ts | 83 +++++++++++++++++++ .../src/modules/period-tracker/routes.ts | 22 ++--- 5 files changed, 93 insertions(+), 96 deletions(-) delete mode 100644 apps/backend/src/modules/period-tracker/db/period-tracker-queries.ts create mode 100644 apps/backend/src/modules/period-tracker/period-tracker.service.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 3f05938..9d64193 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,5 +1,5 @@ FRONTEND_URL='http://localhost:5173' -DB_FILE_NAME=file:data/database.db +DATABASE_URL=file:data/database.db GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= BETTER_AUTH_SECRET= diff --git a/apps/backend/src/lib/db/migrate.ts b/apps/backend/src/lib/db/migrate.ts index 2e3b0d9..816a192 100644 --- a/apps/backend/src/lib/db/migrate.ts +++ b/apps/backend/src/lib/db/migrate.ts @@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/libsql' import { migrate } from 'drizzle-orm/libsql/migrator' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -const db = drizzle(process.env.DB_FILE_NAME!) +const db = drizzle(process.env.DATABASE_URL!) export const runMigrations = () => migrate(db, { migrationsFolder: './migrations', diff --git a/apps/backend/src/modules/period-tracker/db/period-tracker-queries.ts b/apps/backend/src/modules/period-tracker/db/period-tracker-queries.ts deleted file mode 100644 index ff71488..0000000 --- a/apps/backend/src/modules/period-tracker/db/period-tracker-queries.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { eq, and, desc, isNotNull, isNull } from 'drizzle-orm' -import type { LibSQLDatabase } from 'drizzle-orm/libsql' -import type { schema } from '@/lib/db/schema.js' -import { period } from '@/lib/db/schema.js' - -export interface Period { - id: string - userId: string - startDate: Date - endDate: Date | null - createdAt: Date - updatedAt: Date -} - -export const createPeriod = async (db: LibSQLDatabase, data: { userId: string, startDate: Date }) => { - const result = await db.insert(period).values({ - id: crypto.randomUUID(), - userId: data.userId, - startDate: data.startDate, - createdAt: new Date(), - updatedAt: new Date(), - }).returning() - - return result[0] -} - -export const updatePeriod = async (db: LibSQLDatabase, id: string, data: { endDate: Date }) => { - const result = await db.update(period) - .set({ - endDate: data.endDate, - updatedAt: new Date(), - }) - .where(eq(period.id, id)) - .returning() - - return result[0] -} - -export const getActivePeriod = async (db: LibSQLDatabase, userId: string) => { - const result = await db.select() - .from(period) - .where(and( - eq(period.userId, userId), - isNull(period.endDate), - )) - .limit(1) - - if (result.length === 0) { - return null - } - - return result[0] -} - -export const getPeriods = async (db: LibSQLDatabase, userId: string) => { - return await db.select() - .from(period) - .where(eq(period.userId, userId)) - .orderBy(desc(period.startDate)) -} - -export const getPeriodById = async (db: LibSQLDatabase, id: string) => { - const result = await db.select() - .from(period) - .where(eq(period.id, id)) - .limit(1) - - return result[0] || null -} - -export const getPastPeriods = async (db: LibSQLDatabase, userId: string, limit: number = 10) => { - return await db.select() - .from(period) - .where(and( - eq(period.userId, userId), - isNotNull(period.endDate), - )) - .orderBy(desc(period.startDate)) - .limit(limit) -} diff --git a/apps/backend/src/modules/period-tracker/period-tracker.service.ts b/apps/backend/src/modules/period-tracker/period-tracker.service.ts new file mode 100644 index 0000000..c176cf5 --- /dev/null +++ b/apps/backend/src/modules/period-tracker/period-tracker.service.ts @@ -0,0 +1,83 @@ +import { eq, and, desc, isNotNull, isNull } from 'drizzle-orm' +import type { LibSQLDatabase } from 'drizzle-orm/libsql' +import type { schema } from '@/lib/db/schema.js' +import { period } from '@/lib/db/schema.js' + +export interface Period { + id: string + userId: string + startDate: Date + endDate: Date | null + createdAt: Date + updatedAt: Date +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class PeriodTrackerService { + static async createPeriod(db: LibSQLDatabase, data: { userId: string, startDate: Date }) { + const result = await db.insert(period).values({ + id: crypto.randomUUID(), + userId: data.userId, + startDate: data.startDate, + createdAt: new Date(), + updatedAt: new Date(), + }).returning() + + return result[0] + } + + static async updatePeriod(db: LibSQLDatabase, id: string, data: { endDate: Date }) { + const result = await db.update(period) + .set({ + endDate: data.endDate, + updatedAt: new Date(), + }) + .where(eq(period.id, id)) + .returning() + + return result[0] + } + + static async getActivePeriod(db: LibSQLDatabase, userId: string) { + const result = await db.select() + .from(period) + .where(and( + eq(period.userId, userId), + isNull(period.endDate), + )) + .limit(1) + + if (result.length === 0) { + return null + } + + return result[0] + } + + static async getPeriods(db: LibSQLDatabase, userId: string) { + return await db.select() + .from(period) + .where(eq(period.userId, userId)) + .orderBy(desc(period.startDate)) + } + + static async getPeriodById(db: LibSQLDatabase, id: string) { + const result = await db.select() + .from(period) + .where(eq(period.id, id)) + .limit(1) + + return result[0] || null + } + + static async getPastPeriods(db: LibSQLDatabase, userId: string, limit: number = 10) { + return await db.select() + .from(period) + .where(and( + eq(period.userId, userId), + isNotNull(period.endDate), + )) + .orderBy(desc(period.startDate)) + .limit(limit) + } +} diff --git a/apps/backend/src/modules/period-tracker/routes.ts b/apps/backend/src/modules/period-tracker/routes.ts index 5e9bbe6..2d23a83 100644 --- a/apps/backend/src/modules/period-tracker/routes.ts +++ b/apps/backend/src/modules/period-tracker/routes.ts @@ -1,29 +1,23 @@ import { Hono } from 'hono' -import { - createPeriod, - updatePeriod, - getActivePeriod, - getPeriods, -} from './db/period-tracker-queries.js' import { getDB } from '@/lib/db/index.js' import { calculateCycleInfo, calculateAverageCycleLength } from '@/lib/period-calculations.js' import { authGuard } from '@/middleware/auth-guard.js' +import { PeriodTrackerService } from '@/modules/period-tracker/period-tracker.service.js' import type { AuthGuardAppVariables } from '@/types/hono.types.js' const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use('*', authGuard).post('/', async (c) => { const authUser = c.get('user') - try { const db = getDB(c) // Check if user already has an active period - const activePeriod = await getActivePeriod(db, authUser.id) + const activePeriod = await PeriodTrackerService.getActivePeriod(db, authUser.id) if (activePeriod) { return c.json({ error: 'User already has an active period' }, 400) } // Create new period - const newPeriod = await createPeriod(db, { + const newPeriod = await PeriodTrackerService.createPeriod(db, { userId: authUser.id, startDate: new Date(), }) @@ -44,13 +38,13 @@ const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use const db = getDB(c) // Get active period - const activePeriod = await getActivePeriod(db, authUser.id) + const activePeriod = await PeriodTrackerService.getActivePeriod(db, authUser.id) if (!activePeriod) { return c.json({ error: 'No active period found' }, 400) } // End the period - const updatedPeriod = await updatePeriod(db, activePeriod.id, { + const updatedPeriod = await PeriodTrackerService.updatePeriod(db, activePeriod.id, { endDate: new Date(), }) @@ -68,7 +62,7 @@ const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use try { const db = getDB(c) - const periods = await getPeriods(db, authUser.id) + const periods = await PeriodTrackerService.getPeriods(db, authUser.id) // Calculate cycle information for each period const periodsWithInfo = periods.map((period, index) => { @@ -98,13 +92,13 @@ const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use try { const db = getDB(c) - const activePeriod = await getActivePeriod(db, authUser.id) + const activePeriod = await PeriodTrackerService.getActivePeriod(db, authUser.id) // Calculate cycle information for active period let periodWithInfo = null if (activePeriod) { // Get the most recent previous period to calculate cycle info - const periods = await getPeriods(db, authUser.id) + const periods = await PeriodTrackerService.getPeriods(db, authUser.id) const previousPeriod = periods.find(p => p.id !== activePeriod.id && new Date(p.startDate) < new Date(activePeriod.startDate), From 58220313f915e6533a117705947e3873f42178a1 Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Mon, 6 Oct 2025 19:38:34 +0545 Subject: [PATCH 8/9] feat: add period tracker route tests Adds comprehensive unit tests for the period tracker routes, covering success, invalid input, and failure scenarios. These tests ensure the correct behavior of the API endpoints. --- .../src/modules/period-tracker/routes.spec.ts | 337 ++++++++++++++++++ apps/backend/vitest.config.ts | 1 + 2 files changed, 338 insertions(+) create mode 100644 apps/backend/src/modules/period-tracker/routes.spec.ts diff --git a/apps/backend/src/modules/period-tracker/routes.spec.ts b/apps/backend/src/modules/period-tracker/routes.spec.ts new file mode 100644 index 0000000..cf78803 --- /dev/null +++ b/apps/backend/src/modules/period-tracker/routes.spec.ts @@ -0,0 +1,337 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import type { User } from 'better-auth' +import type { Context, Next } from 'hono' +import { testClient } from 'hono/testing' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import app from '@/index.js' +import { PeriodTrackerService } from '@/modules/period-tracker/period-tracker.service.js' +import type { Period } from '@/modules/period-tracker/period-tracker.service.js' + +// Mock the PeriodTrackerService to isolate route handlers from the database. +vi.mock('@/modules/period-tracker/period-tracker.service.js', () => ({ + PeriodTrackerService: { + getActivePeriod: vi.fn(), + createPeriod: vi.fn(), + updatePeriod: vi.fn(), + getPeriods: vi.fn(), + }, +})) + +const testUser: User = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), +} +// Mock the authGuard middleware to simulate an authenticated user. +vi.mock('@/middleware/auth-guard.js', () => ({ + authGuard: vi.fn(async (c: Context, next: Next) => { + c.set('user', testUser) + await next() + }), +})) + +// Mock the database dependency to prevent any actual DB calls. +vi.mock('@/lib/db/index.js', () => ({ + getDB: vi.fn().mockReturnValue({}), // Return a dummy DB object +})) + +describe('Period Tracker Routes', () => { + const client = testClient(app) + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('POST / - Create a new period', () => { + it('[SUCCESS] Should create a new period for the authenticated user and return a 201 status code', async () => { + // Arrange + vi.mocked(PeriodTrackerService.getActivePeriod).mockResolvedValue(null) + const newPeriod: Period = { + id: 'period-1', + userId: testUser.id, + startDate: new Date(), + endDate: null, + createdAt: new Date(), + updatedAt: new Date(), + } + vi.mocked(PeriodTrackerService.createPeriod).mockResolvedValue(newPeriod) + + // Act + const res = await client.api.v1.periods.$post() + const json = await res.json() + + // Assert + expect(res.status).toBe(201) + if ('success' in json) { + expect(json.period.id).toBe('period-1') + } + else { + expect.fail('Expected a success response but received an error.') + } + }) + + it('[INVALID] Should return a 400 status code if the user already has an active period', async () => { + // Arrange + const activePeriod: Period = { + id: 'active-period-1', + userId: testUser.id, + startDate: new Date(), + endDate: null, + createdAt: new Date(), + updatedAt: new Date(), + } + vi.mocked(PeriodTrackerService.getActivePeriod).mockResolvedValue(activePeriod) + + // Act + const res = await client.api.v1.periods.$post() + const json = await res.json() + + // Assert + expect(res.status).toBe(400) + if ('error' in json) { + expect(json.error).toBe('User already has an active period') + } + else { + expect.fail('Expected an error response but received a success response.') + } + }) + + it('[FAILURE] Should return a 500 status code if period creation fails', async () => { + // Arrange + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(PeriodTrackerService.getActivePeriod).mockResolvedValue(null) + vi.mocked(PeriodTrackerService.createPeriod).mockRejectedValue(new Error('DB error')) + + // Act + const res = await client.api.v1.periods.$post() + const json = await res.json() + + // Assert + expect(res.status).toBe(500) + if ('error' in json) { + expect(json.error).toBe('Failed to create period') + } + else { + expect.fail('Expected an error response but received a success response.') + } + consoleErrorSpy.mockRestore() + }) + }) + + describe('PUT /end - End an active period', () => { + it('[SUCCESS] Should end an active period and return a 200 status code', async () => { + // Arrange + const activePeriod: Period = { + id: 'period-1', + userId: testUser.id, + startDate: new Date(), + endDate: null, + createdAt: new Date(), + updatedAt: new Date(), + } + const endedPeriod: Period = { ...activePeriod, endDate: new Date() } + vi.mocked(PeriodTrackerService.getActivePeriod).mockResolvedValue(activePeriod) + vi.mocked(PeriodTrackerService.updatePeriod).mockResolvedValue(endedPeriod) + + // Act + const res = await client.api.v1.periods.end.$put() + const json = await res.json() + + // Assert + expect(res.status).toBe(200) + if ('success' in json) { + expect(json.period.id).toBe('period-1') + expect(json.period.endDate).not.toBe(null) + } + else { + expect.fail('Expected a success response but received an error.') + } + }) + + it('[INVALID] Should return a 400 status code if there is no active period', async () => { + // Arrange + vi.mocked(PeriodTrackerService.getActivePeriod).mockResolvedValue(null) + + // Act + const res = await client.api.v1.periods.end.$put() + const json = await res.json() + + // Assert + expect(res.status).toBe(400) + if ('error' in json) { + expect(json.error).toBe('No active period found') + } + else { + expect.fail('Expected an error response but received a success response.') + } + }) + + it('[FAILURE] Should return a 500 status code if ending period fails', async () => { + // Arrange + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const activePeriod: Period = { + id: 'period-1', + userId: testUser.id, + startDate: new Date(), + endDate: null, + createdAt: new Date(), + updatedAt: new Date(), + } + vi.mocked(PeriodTrackerService.getActivePeriod).mockResolvedValue(activePeriod) + vi.mocked(PeriodTrackerService.updatePeriod).mockRejectedValue(new Error('DB error')) + + // Act + const res = await client.api.v1.periods.end.$put() + const json = await res.json() + + // Assert + expect(res.status).toBe(500) + if ('error' in json) { + expect(json.error).toBe('Failed to end period') + } + else { + expect.fail('Expected an error response but received a success response.') + } + consoleErrorSpy.mockRestore() + }) + }) + + describe('GET / - Get all periods', () => { + it('[SUCCESS] Should return all periods for the user with cycle info', async () => { + // Arrange + const periods: Period[] = [ + { id: 'period-2', userId: testUser.id, startDate: new Date('2023-02-01'), endDate: new Date('2023-02-05'), createdAt: new Date(), updatedAt: new Date() }, + { id: 'period-1', userId: testUser.id, startDate: new Date('2023-01-01'), endDate: new Date('2023-01-05'), createdAt: new Date(), updatedAt: new Date() }, + ] + vi.mocked(PeriodTrackerService.getPeriods).mockResolvedValue(periods) + + // Act + const res = await client.api.v1.periods.$get() + const json = await res.json() + + // Assert + expect(res.status).toBe(200) + if ('success' in json) { + expect(json.periods).toHaveLength(2) + expect(json.periods[0].id).toBe('period-2') + expect(json.periods[0]).toHaveProperty('cycleInfo') + expect(json.periods[0].cycleInfo.cycleLength).toBe(31) // Feb 1 - Jan 1 + expect(json.averageCycleLength).toBe(31) + } + else { + expect.fail('Expected a success response but received an error.') + } + }) + + it('[SUCCESS] Should return an empty array if the user has no periods', async () => { + // Arrange + vi.mocked(PeriodTrackerService.getPeriods).mockResolvedValue([]) + + // Act + const res = await client.api.v1.periods.$get() + const json = await res.json() + + // Assert + expect(res.status).toBe(200) + if ('success' in json) { + expect(json.periods).toHaveLength(0) + } + else { + expect.fail('Expected a success response but received an error.') + } + }) + + it('[FAILURE] Should return a 500 status code if fetching periods fails', async () => { + // Arrange + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(PeriodTrackerService.getPeriods).mockRejectedValue(new Error('DB error')) + + // Act + const res = await client.api.v1.periods.$get() + const json = await res.json() + + // Assert + expect(res.status).toBe(500) + if ('error' in json) { + expect(json.error).toBe('Failed to fetch periods') + } + else { + expect.fail('Expected an error response but received a success response.') + } + consoleErrorSpy.mockRestore() + }) + }) + + describe('GET /active - Get active period', () => { + it('[SUCCESS] Should return the active period with cycle info', async () => { + // Arrange + const activePeriod: Period = { id: 'period-2', userId: testUser.id, startDate: new Date('2023-02-01'), endDate: null, createdAt: new Date(), updatedAt: new Date() } + const allPeriods: Period[] = [ + activePeriod, + { id: 'period-1', userId: testUser.id, startDate: new Date('2023-01-01'), endDate: new Date('2023-01-05'), createdAt: new Date(), updatedAt: new Date() }, + ] + vi.mocked(PeriodTrackerService.getActivePeriod).mockResolvedValue(activePeriod) + vi.mocked(PeriodTrackerService.getPeriods).mockResolvedValue(allPeriods) + + // Act + const res = await client.api.v1.periods.active.$get() + const json = await res.json() + + // Assert + expect(res.status).toBe(200) + if ('success' in json) { + expect(json.period?.id).toBe('period-2') + expect(json.period).toHaveProperty('cycleInfo') + expect(json.period?.cycleInfo.cycleLength).toBe(31) // Cycle length from previous period + } + else { + expect.fail('Expected a success response but received an error.') + } + }) + + it('[SUCCESS] Should return null if there is no active period', async () => { + // Arrange + vi.mocked(PeriodTrackerService.getActivePeriod).mockResolvedValue(null) + + // Act + const res = await client.api.v1.periods.active.$get() + const json = await res.json() + + // Assert + expect(res.status).toBe(200) + if ('success' in json) { + expect(json.period).toBe(null) + } + else { + expect.fail('Expected a success response but received an error.') + } + }) + + it('[FAILURE] Should return a 500 status code if fetching active period fails', async () => { + // Arrange + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(PeriodTrackerService.getActivePeriod).mockRejectedValue(new Error('DB error')) + + // Act + const res = await client.api.v1.periods.active.$get() + const json = await res.json() + + // Assert + expect(res.status).toBe(500) + if ('error' in json) { + expect(json.error).toBe('Failed to fetch active period') + } + else { + expect.fail('Expected an error response but received a success response.') + } + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 86931d3..ed44ca7 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'json', 'html'], }, + mockReset: true }, resolve: { alias: { From 420a4f30db30d68d7e853f6e885be8f054c05653 Mon Sep 17 00:00:00 2001 From: Nirjan Khadka Date: Tue, 7 Oct 2025 13:19:47 +0545 Subject: [PATCH 9/9] Refactor database and auth initialization - Initialize the database connection outside of the request handlers. - Pass the database instance to the services and routes. - Initialize `betterAuth` outside of routes. - Remove context from `getEnv` and initialize it once. - Add timing middleware. --- apps/backend/drizzle.config.ts | 3 ++- apps/backend/src/env.ts | 4 ++-- apps/backend/src/lib/auth.ts | 21 ++++++++++++------- apps/backend/src/lib/db/index.ts | 21 ++++++++++--------- apps/backend/src/lib/db/migrate.ts | 10 +++++++-- .../src/modules/period-tracker/routes.ts | 8 +------ apps/backend/src/routes/index.ts | 6 ++++-- apps/backend/src/routes/users.ts | 4 +--- .../components/PeriodTracker.tsx | 2 +- 9 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/backend/drizzle.config.ts b/apps/backend/drizzle.config.ts index 5f96f4a..81d72f1 100644 --- a/apps/backend/drizzle.config.ts +++ b/apps/backend/drizzle.config.ts @@ -1,11 +1,12 @@ import 'dotenv/config'; import { defineConfig } from 'drizzle-kit'; +const url = process.env.DATABASE_URL!; export default defineConfig({ out: './migrations', schema: './src/lib/db/schema.ts', dialect: 'sqlite', dbCredentials: { - url: process.env.DB_FILE_NAME!, + url, }, }); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 183d036..c4c743e 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -2,7 +2,7 @@ import { createEnv } from '@t3-oss/env-core' import type { Context } from 'hono' import { env } from 'hono/adapter' import { z } from 'zod' -export const getEnv = (c: Context) => createEnv({ +export const getEnv = (c?: Context) => createEnv({ server: { FRONTEND_URL: z.string().url(), DATABASE_URL: z.string(), @@ -19,7 +19,7 @@ export const getEnv = (c: Context) => createEnv({ * `process.env` or `import.meta.env`. */ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - runtimeEnv: env(c), + runtimeEnv: c ? env(c) : process.env, /** * By default, this library will feed the environment variables directly to diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts index 5c7a423..0f402e3 100644 --- a/apps/backend/src/lib/auth.ts +++ b/apps/backend/src/lib/auth.ts @@ -1,28 +1,33 @@ import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import type { Context } from 'hono' import { getEnv } from '@/env.js' -import { getDB } from '@/lib/db/index.js' +import { db } from '@/lib/db/index.js' import { schema } from '@/lib/db/schema.js' -export const auth = (c: Context) => betterAuth({ - database: drizzleAdapter(getDB(c), { +export const auth = betterAuth({ + database: drizzleAdapter(db, { provider: 'sqlite', schema, }), + session: { + cookieCache: { + enabled: true, + maxAge: 10 * 60, + }, + }, socialProviders: { google: { - clientId: getEnv(c).GOOGLE_CLIENT_ID, - clientSecret: getEnv(c).GOOGLE_CLIENT_SECRET, + clientId: getEnv().GOOGLE_CLIENT_ID, + clientSecret: getEnv().GOOGLE_CLIENT_SECRET, }, }, trustedOrigins: [ - getEnv(c).FRONTEND_URL, + getEnv().FRONTEND_URL, ], advanced: { crossSubDomainCookies: { enabled: true, - domain: getEnv(c).COOKIE_DOMAIN, + domain: getEnv().COOKIE_DOMAIN, }, }, }) diff --git a/apps/backend/src/lib/db/index.ts b/apps/backend/src/lib/db/index.ts index 87c3930..cc55109 100644 --- a/apps/backend/src/lib/db/index.ts +++ b/apps/backend/src/lib/db/index.ts @@ -1,15 +1,16 @@ import 'dotenv/config' import { drizzle } from 'drizzle-orm/libsql' -import type { Context } from 'hono' import { getEnv } from '@/env.js' import { schema } from '@/lib/db/schema.js' - -export const getDB = (c?: Context) => { - const url = c ? getEnv(c).DATABASE_URL : process.env.DATABASE_URL - if (!url) { - throw new Error('DATABASE_URL is not set') - } - return drizzle(url, { - schema, - }) +const url = getEnv().DATABASE_URL +if (!url) { + throw new Error('DATABASE_URL is not set') } + +export const db = drizzle({ + connection: { + url, + }, + schema, + logger: true, +}) diff --git a/apps/backend/src/lib/db/migrate.ts b/apps/backend/src/lib/db/migrate.ts index 816a192..2fc09b4 100644 --- a/apps/backend/src/lib/db/migrate.ts +++ b/apps/backend/src/lib/db/migrate.ts @@ -1,9 +1,15 @@ import 'dotenv/config' import { drizzle } from 'drizzle-orm/libsql' import { migrate } from 'drizzle-orm/libsql/migrator' +import { getEnv } from '@/env.js' -// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -const db = drizzle(process.env.DATABASE_URL!) +const url = getEnv().DATABASE_URL + +const db = drizzle({ + connection: { + url, + }, +}) export const runMigrations = () => migrate(db, { migrationsFolder: './migrations', diff --git a/apps/backend/src/modules/period-tracker/routes.ts b/apps/backend/src/modules/period-tracker/routes.ts index 2d23a83..80563b9 100644 --- a/apps/backend/src/modules/period-tracker/routes.ts +++ b/apps/backend/src/modules/period-tracker/routes.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import { getDB } from '@/lib/db/index.js' +import { db } from '@/lib/db/index.js' import { calculateCycleInfo, calculateAverageCycleLength } from '@/lib/period-calculations.js' import { authGuard } from '@/middleware/auth-guard.js' import { PeriodTrackerService } from '@/modules/period-tracker/period-tracker.service.js' @@ -8,8 +8,6 @@ import type { AuthGuardAppVariables } from '@/types/hono.types.js' const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use('*', authGuard).post('/', async (c) => { const authUser = c.get('user') try { - const db = getDB(c) - // Check if user already has an active period const activePeriod = await PeriodTrackerService.getActivePeriod(db, authUser.id) if (activePeriod) { @@ -35,8 +33,6 @@ const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use const authUser = c.get('user') try { - const db = getDB(c) - // Get active period const activePeriod = await PeriodTrackerService.getActivePeriod(db, authUser.id) if (!activePeriod) { @@ -61,7 +57,6 @@ const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use const authUser = c.get('user') try { - const db = getDB(c) const periods = await PeriodTrackerService.getPeriods(db, authUser.id) // Calculate cycle information for each period @@ -91,7 +86,6 @@ const periodTrackerRoute = new Hono <{ Variables: AuthGuardAppVariables }>().use const authUser = c.get('user') try { - const db = getDB(c) const activePeriod = await PeriodTrackerService.getActivePeriod(db, authUser.id) // Calculate cycle information for active period diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index 7242421..7d4a473 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' +import { timing } from 'hono/timing' import { getEnv } from '@/env.js' import { auth } from '@/lib/auth.js' import { logger } from '@/middleware/logger.js' @@ -8,6 +9,7 @@ import usersRoute from '@/routes/users.js' import type { AppVariables } from '@/types/hono.types.js' const app = new Hono<{ Variables: AppVariables }>() + .use(timing()) .use(logger()) .use('*', cors({ origin: (_origin, c) => { @@ -21,7 +23,7 @@ const app = new Hono<{ Variables: AppVariables }>() credentials: true, })) .use('*', async (c, next) => { - const session = await auth(c).api.getSession({ headers: c.req.raw.headers }) + const session = await auth.api.getSession({ headers: c.req.raw.headers }) if (!session) { c.set('user', null) c.set('session', null) @@ -32,7 +34,7 @@ const app = new Hono<{ Variables: AppVariables }>() return next() }) - .on(['POST', 'GET'], '/api/auth/*', c => auth(c).handler(c.req.raw)) + .on(['POST', 'GET'], '/api/auth/*', c => auth.handler(c.req.raw)) .basePath('/api/v1') .route('/users', usersRoute) .route('/periods', periodTrackerRoute) diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index 04aa5bd..8ab5df9 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -1,17 +1,15 @@ import { eq } from 'drizzle-orm' import { Hono } from 'hono' -import { getDB } from '@/lib/db/index.js' +import { db } from '@/lib/db/index.js' import { user as usersTable } from '@/lib/db/schema.js' const usersRoute = new Hono().get('/', async (c) => { - const db = getDB(c) const users = await db.select().from(usersTable).all() return c.json({ users, }, 200) }).get('/:id', async (c) => { const id = c.req.param('id') - const db = getDB(c) const user = await db.select().from(usersTable).where(eq(usersTable.id, id)) return c.json({ user, diff --git a/apps/frontend/src/modules/period-tracker/components/PeriodTracker.tsx b/apps/frontend/src/modules/period-tracker/components/PeriodTracker.tsx index f40b5d7..34be8fa 100644 --- a/apps/frontend/src/modules/period-tracker/components/PeriodTracker.tsx +++ b/apps/frontend/src/modules/period-tracker/components/PeriodTracker.tsx @@ -198,7 +198,7 @@ const CycleOverview = ({ averageCycleLength, activePeriod }: CycleOverviewProps) return (

Cycle Information

-
+
Average Cycle Length