diff --git a/.cursor/handoffs/README.md b/.cursor/handoffs/README.md new file mode 100644 index 00000000..a6682b47 --- /dev/null +++ b/.cursor/handoffs/README.md @@ -0,0 +1,31 @@ +# Cross-Repo Handoffs + +Use this folder to hand work cleanly between frontend and backend. + +## Folders + +- `open/` -> active requests waiting for pickup +- `done/` -> completed requests with outcome notes + +## Naming + +Use: + +- `YYYY-MM-DD-backend-.md` for frontend -> backend +- `YYYY-MM-DD-frontend-.md` for backend -> frontend + +## Rule + +Every handoff must include: +- clear direction +- exact requested changes +- acceptance criteria +- verification steps + +## Backend Startup Requirement + +Before any new backend implementation task: +- check `open/` for frontend-originated tasks +- communicate either: + - `open_handoff_tasks_found` + file names, or + - `no_open_handoff_tasks` diff --git a/.cursor/handoffs/closed/2026-03-02-backend-doula-profile-cloudsql-bio.md b/.cursor/handoffs/closed/2026-03-02-backend-doula-profile-cloudsql-bio.md new file mode 100644 index 00000000..dd6ea71f --- /dev/null +++ b/.cursor/handoffs/closed/2026-03-02-backend-doula-profile-cloudsql-bio.md @@ -0,0 +1,112 @@ +# Handoff: Cloud SQL doula profile parity for profile tab fields + +## Metadata +- Direction: `frontend->backend` +- Priority: `P0` +- Requested By: `frontend` +- Date: `2026-03-02` +- Status: `closed` +- Related Links: + - `frontend-crm/src/features/doula-dashboard/components/ProfileTab.tsx` + - `frontend-crm/src/api/doulas/doulaService.ts` + - `backend/src/controllers/doulaController.ts` + - `backend/src/services/cloudSqlTeamService.ts` + - `backend/src/db/migrations/add_bio_to_doulas.sql` + +## Why This Is Needed +- Doula profile save can fail with `User not found` when legacy `users` row is missing. +- Frontend profile form includes `bio` and address fields that should persist reliably. +- Current source split between Supabase `users` and Cloud SQL `public.doulas` causes drift. + +## Current Behavior +- `PUT /api/doulas/profile` may fail for Cloud SQL-only doula identities in some environments. +- `public.doulas` supports `bio` in code, but migration rollout and parity checks are still required. +- Field ownership is partially ambiguous across Cloud SQL, Supabase `users`, and Supabase storage. + +## Expected Behavior +- Authenticated doula can always load and update profile through `/api/doulas/profile`. +- `bio` persists in Cloud SQL. +- Remaining profile fields have a defined source of truth and are returned consistently. + +## Requested Changes +- [x] Apply migrations in local environment: + - `src/db/migrations/add_bio_to_doulas.sql` + - `src/db/migrations/add_profile_fields_to_doulas.sql` +- [ ] Apply migrations in all target environments (staging/production rollout). +- [x] Ensure `/api/doulas/profile` update path supports Cloud SQL-only doula records. +- [x] Implement final field ownership: + - Cloud SQL `public.doulas`: `firstname/lastname` (or `full_name` mapping), `email`, `phone_number`, `bio`, `address`, `city`, `state`, `country`, `zip_code`, `account_status` + - Supabase storage: `profile_picture` file storage + - No `business` field support needed +- [x] Keep response shape compatible with frontend: `{ success, profile }`. + +## API/Contract Notes +- Endpoint(s): + - `GET /api/doulas/profile` + - `PUT /api/doulas/profile` +- Request shape: + - Current frontend sends `UpdateProfileData` from `doulaService.ts`. +- Response shape: + - `profile` object with user-facing doula fields: + - required: `firstname`, `lastname`, `email`, `bio`, `address`, `city`, `state`, `country`, `zip_code`, `account_status` + - optional passthrough: `profile_picture` (from Supabase-linked source) + - excluded: `business` +- Backward compatibility: + - Do not break existing frontend parser expectations for `profile`. + +## Data/Migration Notes +- Tables: + - `public.doulas` + - optional legacy: `public.users` +- Required migration: + - `yes` -> + - `src/db/migrations/add_bio_to_doulas.sql` + - `src/db/migrations/add_profile_fields_to_doulas.sql` + - `address TEXT` + - `city TEXT` + - `state TEXT` + - `country TEXT` + - `zip_code TEXT` + - `account_status TEXT NOT NULL DEFAULT 'approved'` + +## Backend File Touchpoints +- `src/services/cloudSqlTeamService.ts` + - map/select/update added profile fields from `public.doulas` +- `src/controllers/doulaController.ts` + - ensure GET/PUT profile returns new field set with Cloud SQL-first behavior +- `src/db/migrations/add_profile_fields_to_doulas.sql` + - add missing columns for profile tab parity + +## Acceptance Criteria +- [x] Doula can update `bio` without `User not found` error path in Cloud SQL-first profile update flow. +- [x] `GET /api/doulas/profile` returns `bio` in profile response. +- [x] Profile update succeeds for users present only in Cloud SQL `public.doulas`. +- [x] `address`, `city`, `state`, `country`, `zip_code`, `account_status` now exist in Cloud SQL and round-trip via updated GET/PUT profile flow. +- [x] `business` is not required by backend contract and is ignored safely if sent. +- [x] Contract remains compatible with existing frontend `ProfileTab` expectations (`{ success, profile }`). + +## Verification Steps +- Backend: + - Run both migrations in Cloud SQL. + - Call `PUT /api/doulas/profile` with `bio`, `address`, `city`, `state`, `country`, `zip_code`, `account_status` and confirm 200. + - Call `GET /api/doulas/profile` and confirm persisted fields. +- Frontend: + - Update bio in profile tab and refresh page. + - Confirm persisted values display and no error toast appears. + +## Implementation Notes +- Current frontend relies on `ProfileTab` + `updateDoulaProfile` and expects stable profile payload. +- Profile picture remains in Supabase storage; do not move binary/media storage into Cloud SQL. + +## Completion Summary (2026-03-02) + +- Added and applied local Cloud SQL migrations: + - `src/db/migrations/add_bio_to_doulas.sql` + - `src/db/migrations/add_profile_fields_to_doulas.sql` +- Updated Cloud SQL profile mapping and update behavior in: + - `backend/src/services/cloudSqlTeamService.ts` + - `backend/src/controllers/doulaController.ts` +- Verified local Cloud SQL `public.doulas` now includes: + - `bio`, `address`, `city`, `state`, `country`, `zip_code`, `account_status`. +- Remaining non-code rollout item: + - apply migrations in all target environments. diff --git a/.cursor/handoffs/closed/2026-03-11-backend-doula-documents-id-mismatch.md b/.cursor/handoffs/closed/2026-03-11-backend-doula-documents-id-mismatch.md new file mode 100644 index 00000000..0f8c9969 --- /dev/null +++ b/.cursor/handoffs/closed/2026-03-11-backend-doula-documents-id-mismatch.md @@ -0,0 +1,38 @@ +# Backend handoff: Doula documents ID mismatch + +## Status: closed + +## Summary + +There's an **ID mismatch**: documents are stored with the **Supabase auth user id**, but the admin view loads them using the **Cloud SQL doula id**. For some doulas (e.g. info@techluminateacademy.com), those IDs can differ, so the admin sees no documents. + +## Proposed fix + +When the admin document endpoint finds no documents by Cloud SQL doula id, it should: + +1. Look up the doula's email in Cloud SQL +2. Find the Supabase auth user id for that email +3. Fetch documents by that auth user id and return them + +## Implementation (completed) + +- [x] Added `DoulaDocumentIdResolver` service to resolve Cloud SQL doula id → Supabase auth user id via email +- [x] Updated `getDoulaDocumentsAdmin`: try Cloud SQL id first; if no docs, fallback to auth user id by email +- [x] Updated `reviewDocument` and `getDocumentUrl`: use `isDocumentOwnedByDoula` for ownership check (handles ID mismatch) +- [x] Frontend: No changes required + +## Optional quick check + +In Supabase, run: + +```sql +SELECT doula_id, file_name, document_type FROM doula_documents; +``` + +In Cloud SQL, run: + +```sql +SELECT id, email FROM public.doulas WHERE email = 'info@techluminateacademy.com'; +``` + +If the `doula_id` in `doula_documents` is different from `id` in `doulas`, that confirms the mismatch. diff --git a/.cursor/handoffs/closed/2026-05-11-backend-request-intake-referral-source-other.md b/.cursor/handoffs/closed/2026-05-11-backend-request-intake-referral-source-other.md new file mode 100644 index 00000000..3ee6846b --- /dev/null +++ b/.cursor/handoffs/closed/2026-05-11-backend-request-intake-referral-source-other.md @@ -0,0 +1,64 @@ +# Backend handoff: request intake & client referral fields (`referral_source_other`) + +## Status: closed + +## Completion summary (2026-05-11) + +- **Intake** (`POST /requestService/requestSubmission` → `RequestFormService.newForm`): `referral_source` is required and must be one of the CRM enum values; `referral_source_other` is required (trimmed non-empty) when source is `Other`; otherwise stored as null. `referral_email` validated when non-empty. Shared rules in `src/constants/referralSource.ts` (`parseIntakeReferral`). +- **Persistence**: `phi_clients` INSERT extended with `referral_source`, `referral_name`, `referral_email`, `referral_source_other`. Migration `src/db/migrations/add_phi_clients_referral_intake_fields.sql` adds columns if missing. +- **Staff PATCH** (`updateClient`): `normalizeStaffReferralOperationalPatch` runs when any referral field is present; if `referral_source` is set to a non-`Other` value, **`referral_source_other` is cleared server-side** (`null`). +- **GET /clients/:id** (PHI merge): returns `referral_source_other` with other referral fields. +- **Types / entities**: `RequestFormData`, `RequestFormResponse`, `User`, `ClientDetailDTO`, `RequestForm` entity; `cloudSqlClientRepository` map + `updateClient` + `updateClientOperational` allowlists; `phiFields` `OPERATIONAL_UPDATE_COLUMNS` + `referralSourceOther` alias; `supabaseClientRepository` parity for shadow/legacy. +- **Tests**: `src/__tests__/requestEndpoint.test.ts` updated and expanded. +- **Docs**: `docs/CLOUD_SQL_SOKANA_PRIVATE_SCHEMA.md` updated. + +--- + +## Direction (original) + +`frontend` → `backend` (Sokana CRM frontend contract) + +## Summary (original) + +The public **request form** and **admin lead/client profile** were updated in the frontend. Align the API and persistence layer so the same fields validate, save, and return consistently. + +--- + +## 1. `referral_source_other` (new) + +### Behavior (match frontend) + +- `referral_source` remains a **required** categorical value on intake (one of the known options, including **`Other`**). +- When `referral_source === "Other"`, **`referral_source_other`** is **required**: non-empty string after trim (free-text explanation of how the client heard about Sokana). +- When `referral_source !== "Other"`, **`referral_source_other`** should be **optional**; treat empty/null as “not provided” and **prefer clearing** any stored value if the client changes from `Other` to another option. + +### Allowed `referral_source` values (must include `Other`) + +`Google`, `Doula Match`, `Former client`, `Sokana Member`, `Social Media`, `Email Blast`, `Other` + +### Endpoints to update + +1. **Request submission** — `POST /requestService/requestSubmission` (`src/routes/requestRoute.ts`, `RequestFormService`, `RequestFormController`, `RequestFormRepository`). +2. **Client / lead read + update** — staff CRM (`GET`/`PATCH` client flows in `clientController`, `cloudSqlClientRepository`, `ClientDetailDTO`, `User` entity / serializers). + +### Database + +- Nullable columns on `public.phi_clients`: `referral_source`, `referral_name`, `referral_email`, `referral_source_other` (migration adds if missing). + +--- + +## 4. Acceptance criteria (backend) + +- [x] Request submission accepts and stores **`referral_source_other`** when `referral_source` is **`Other`**; rejects missing/blank **`referral_source_other`** in that case with a clear validation error. +- [x] Request submission accepts **`referral_source`** = **`Other`** as a valid enum/value. +- [x] Client/lead detail APIs return **`referral_source_other`** when set. +- [x] Staff updates can set or clear **`referral_source_other`**; changing **`referral_source`** away from **`Other`** clears **`referral_source_other`** server-side. +- [x] Migration + ORM/model/DTO updates completed; no silent drops of the new key in serializers. + +--- + +## Completion checklist + +- [x] Implementation merged +- [x] Tests green (`npm test -- --testPathPattern=requestEndpoint`) +- [x] Handoff moved to `closed/` with summary and status updated diff --git a/.cursor/handoffs/closed/2026-08-20-hipaa-13a-restrict-client-csv-export.md b/.cursor/handoffs/closed/2026-08-20-hipaa-13a-restrict-client-csv-export.md new file mode 100644 index 00000000..848d2cc2 --- /dev/null +++ b/.cursor/handoffs/closed/2026-08-20-hipaa-13a-restrict-client-csv-export.md @@ -0,0 +1,42 @@ +# Handoff: HIPAA-13A Restrict bulk client CSV exports + +## Metadata + +- Direction: `compliance->backend` +- Priority: `P0` +- Requested By: HIPAA remediation (INV-02) +- Date: `2026-08-20` +- Status: `ready_for_verification` +- Related Links: + - `docs/HIPAA_13A_CLIENT_CSV_EXPORT_STATUS.md` + - `docs/HIPAA_TECHNICAL_PHI_INVENTORY.md` (INV-02) + - `docs/HIPAA_BOARD_TECHNICAL_STATUS.md` + +## Why This Is Needed + +`GET /clients/fetchCSV` allowed the `client` role and exported all families’ +names, income, and address. Highest-clarity P0 authorization issue. + +## Requested Changes + +- [x] Remove `client` (and non-admin) role access — interim **admin-only** +- [x] Enforce server-side (route + use case) +- [x] Negative tests: client, doula, billing, unauthenticated +- [x] Log denied attempts without PHI +- [x] Stakeholder status of access + exported fields +- [ ] Production deploy confirmation +- [ ] Formal closure approval / reviewer sign-off + +## Acceptance Criteria + +- Non-admin roles receive 403; unauthenticated receives 401 +- Admin still receives CSV +- Deny logs include role/userId/event only +- Stakeholder doc lists current export columns + +## Completion Summary (2026-08-20) + +Contained in code. Automated tests: `src/__tests__/clientCsvExportAuth.test.ts` +— **10/10 passed**. Stakeholder brief: +`docs/HIPAA_13A_CLIENT_CSV_EXPORT_STATUS.md`. Remaining: deploy + formal +closure. diff --git a/.cursor/handoffs/closed/2026-08-24-hipaa-13f-intake-email-minimization.md b/.cursor/handoffs/closed/2026-08-24-hipaa-13f-intake-email-minimization.md new file mode 100644 index 00000000..acfc0a06 --- /dev/null +++ b/.cursor/handoffs/closed/2026-08-24-hipaa-13f-intake-email-minimization.md @@ -0,0 +1,41 @@ +# Handoff: HIPAA-13F — Remove clinical information from intake emails + +## Metadata + +- Direction: `compliance->backend` +- Priority: `P0` +- Requested By: HIPAA board / INV-01 +- Date: `2026-08-24` +- Status: `closed` +- Related Links: + - `docs/HIPAA_13F_INTAKE_EMAIL_STATUS.md` + - `docs/HIPAA_BOARD_TECHNICAL_STATUS.md` (INV-01) + - `docs/EMAIL_NOTIFICATION_SYSTEM.md` + +## Why This Is Needed + +Public intake emailed the full clinical + identity payload to ordinary staff +Gmail (INV-01) — direct PHI exposure. + +## Requested Changes + +- Remove clinical fields from intake emails +- Keep PHI out of subject, body, URLs, and logs +- Send staff to authenticated CRM instead +- Add tests proving clinical fields are absent +- Document approved notification template + +## Acceptance Criteria + +- [x] Staff notification contains client number + CRM link only +- [x] Clinical / identity intake fields absent from staff subject/body +- [x] Submitter confirmation has no name/clinical content in subject/body +- [x] Unit + endpoint tests cover absence of clinical payload +- [x] Approved template documented (`docs/HIPAA_13F_INTAKE_EMAIL_STATUS.md`) + +## Completion Summary + +Implemented minimal intake notification builders under +`src/features/intake/notifications/`, wired through `requestFormController`, +propagated `client_number` on the intake entity, and updated email/HIPAA docs. +Pending production deploy and formal verification sign-off. diff --git a/.cursor/handoffs/open/2026-08-10-backend-architecture-boundary-refactor.md b/.cursor/handoffs/open/2026-08-10-backend-architecture-boundary-refactor.md new file mode 100644 index 00000000..a8cb9dc0 --- /dev/null +++ b/.cursor/handoffs/open/2026-08-10-backend-architecture-boundary-refactor.md @@ -0,0 +1,528 @@ +# Handoff: Backend modular-monolith architecture boundary refactor + +## Metadata + +- Direction: `architecture-assessment->backend` +- Priority: `P0` (security + quality gates first; structural P1/P2 after) +- Requested By: architecture assessment (read-only analysis 2026-08-10) +- Date: `2026-08-10` +- Status: `in_progress` +- Related Links: + - `docs/Backend_Architecture_Boundary_Assessment.docx` + - `docs/SECURITY_P0_HARDENING_SUMMARY.md` (P0 security what-was-done + GCP + encryption guidance) + - Companion frontend handoff: + `sokana-crm-frontend/frontend-crm/.cursor/handoffs/open/2026-08-10-frontend-architecture-boundary-refactor.md` + +## Why This Is Needed + +- Pilot system has substantial architectural debt but is viable; do **not** + rewrite. +- Most urgent work is security containment, auth/webhook hardening, and test/CI + gates—not folder reorganization. +- Goal: incremental, feature-packaged modular monolith (ports/adapters) while + preserving Cloud Run services, databases, public URLs, and API behavior. + +## Current Behavior + +- Express modular monolith (`sokana-private-api`) with partial composition root; + many routes/controllers construct services/repos/vendor clients directly. +- Multiple route aliases increase public surface (`/clients`, `/client`, + `/api/clients`, `/api/client`, etc.). +- Mixed auth/cookie/token patterns; some payment/contract/signing/debug routes + lack consistent guards. +- Webhooks lack clear provider signature/replay protection; QB webhook may be + behind user auth incorrectly. +- Large controllers mix business rules and infrastructure; repository interfaces + are broad/`any`-heavy. +- ~72 paired `.js`/`.ts` sources; env access often bypasses `config/env.ts`. +- Backend: typecheck passes; **38 suites / 300 tests pass**; Jest open handle + + noisy logs; lint ~418 errors; tests not mandatory in CI deploy gate. + +## Expected Behavior + +- Stabilize → capture behavior → extract pure rules → introduce ports → adapters + → switch one endpoint → monitor → remove old path later. +- Domain code imports nothing from Express/DB/SDKs/env; use cases depend on + small interfaces; composition at the edge. +- One canonical API error envelope; Zod at body/params/query for migrated + routes; authoritative server-managed roles. +- P0 security and quality work completed before broad structural migration. + +## Principles (Essentials.dev) + +- Preserve behavior before restructuring (characterization tests). +- Functional core, imperative shell. +- Dependency inversion via small ports. +- Explicit boundary contracts; untrusted I/O validated at edges. +- Make invalid states hard to represent; typed domain errors mapped to stable + API codes. +- High cohesion / single responsibility; dependency direction over folders. +- Refactor through seams; remove legacy only after telemetry proves unused. + +--- + +## Requested Changes + +### P0 — pilot protection and security + +- [x] Remove localhost telemetry from `clientController` (`127.0.0.1:7707`). +- [x] Build explicit endpoint authorization matrix; protect payment, contract, + template, debugging, invitation routes. +- [x] Make DB/app-managed role data authoritative; never grant staff from + `user_metadata`. +- [x] Add signature verification, replay prevention, and idempotency to SignNow + and QuickBooks webhooks. +- [x] Move provider webhooks outside user-session auth while retaining provider + authentication. +- [x] Replace QuickBooks OAuth state with cryptographically random, stored, + expiring, single-use value. +- [x] Redact SignNow, email, contract-field, token, and PHI logging. +- [x] Stop returning stacks and raw provider payloads to clients. +- [x] Stabilize cookie naming and auth failure behavior. +- [x] Begin token transport migration (dual-support → measure → retire + JSON/query tokens after compatibility proven). +- [x] Add rate limiting, idempotency, and abuse protection to public request + submission. +- [x] Fix backend Jest open handles. +- [x] Make backend tests and security smoke tests mandatory before deployment. + +### P1 — structural improvements without product changes + +- [x] Define one canonical API success/error envelope with stable + machine-readable error codes. +- [x] Apply Zod validation to body, params, and query at every migrated route. +- [ ] Select TypeScript as authoritative source; remove paired JS only after + import/build audit. +- [ ] Enable strict TypeScript incrementally by migrated module. +- [ ] Extract client, assignment, contract, billing, and portal use cases from + largest controllers. +- [ ] Break repository interfaces into use-case-focused ports. +- [ ] Move all environment access behind validated configuration. +- [ ] Make backend client-status handling the sole owner of QuickBooks sync; add + idempotency/outbox before FE removal. +- [ ] Introduce migration ledger, checksums, transactions where supported, + pre/post-deploy checks. +- [ ] Add structured correlation IDs and safe audit events. +- [x] Add deprecation telemetry and headers to legacy route aliases (do not + remove yet). + +### P2 — after pilot validation + +- [ ] Retire unused API aliases and legacy API feature flag. +- [ ] Retire legacy Supabase data paths after Cloud SQL SoT verification. +- [ ] Complete remaining feature-package migrations (`clients`, `intake`, + `matching`, `portal`, `contracts`, `billing`, `auth`, `doulas`, + `documents`). +- [ ] Consolidate/baseline historical migrations only after backup/restore + proof. +- [ ] Decide SignNow vs DocuSign support scope. +- [ ] Remove obsolete repository/service/type implementations. +- [ ] Consider shared generated contracts only after backend contracts + stabilize. +- [ ] Performance work from production traces (not component size alone). +- [ ] Do **not** split Cloud Run into more services now. + +### Implementation milestones (one scoped PR each) + +Do not implement this handoff as one large PR. Create one implementation +ticket/PR per milestone and update this checklist as each is verified. + +- [x] **PR 1 — Feature-package guardrails:** add `src/features/README.md`; + document package ownership, allowed dependency direction, public + entrypoints, and the target intake package. This PR defines the structure + only and does not move production code. +- [x] **PR 2 — Baseline and CI (2–4d):** freeze the route/response inventory; + record the 300-test baseline; fix the Jest open handle; require tests and + security smoke in CI; document pilot-critical journeys and rollback + owners. +- [x] **PR 3 — Immediate containment:** remove localhost telemetry; redact + sensitive logging; stop returning stacks and raw provider payloads; + preserve current client-visible contracts unless an exposure requires a + documented fix. +- [x] **PR 4 — Endpoint authorization:** define the public/protected endpoint + and role matrix; protect payment, signing, template, invitation, + maintenance, and debugging operations; add auth/role matrix tests. +- [x] **PR 5 — Webhooks and OAuth:** verify provider signatures; add replay + prevention and idempotency; mount webhooks outside user-session auth; + implement cryptographically secure, stored, expiring, single-use OAuth + state. +- [x] **PR 6 — Authentication compatibility:** make server-managed roles + authoritative; standardize cookie handling; add dual-support for the + target auth transport; measure legacy usage before retiring JSON/query + token delivery. +- [x] **PR 7 — HTTP contracts (1–2w):** introduce canonical errors and Zod + schemas incrementally; preserve fields, aliases, and status codes; add + deprecation telemetry without removing routes. +- [x] **PR 8 — First structural slice (1–2w):** migrate **request intake** + first. Characterize the public submission behavior; extract pure + validation and normalization rules; introduce application ports/adapters + behind the existing route/controller façade; shadow-compare results; keep + the old path available for one monitored release window. +- [ ] **Later slices:** portal eligibility → client status/QuickBooks sync → + doula matching → contracts → billing → documents/PHI. + +Ordering rule: establish the feature-package convention first, then complete the +P0 security and quality gates before moving production files. This avoids mixing +path churn with security-sensitive changes while still making the intended +architecture explicit from the first PR. + +### Completion summary (PR 1) + +- Status set to `in_progress`; PR 1 marked complete. +- Added `src/features/README.md` with feature-first packaging, dependency + direction, public entrypoints, bootstrap/shared rules, and the target `intake` + package layout. +- No production packages created, no imports/routes moved, no runtime behavior + changes. +- Next milestone after PR 1 was PR 2 (baseline and CI); that work is complete. + Do not start structural file moves until P0 gates allow. + +### Completion summary (PR 2) + +- Frozen route/response inventory: `docs/ROUTE_RESPONSE_CONTRACT_INVENTORY.md`. +- Pilot journeys + rollback: `docs/PILOT_JOURNEYS_AND_ROLLBACK.md`. +- Test baseline confirmed: 38 suites / 300 tests minimum; after security-smoke + scaffold → 39 suites / 303 tests passing. +- Jest open handle fixed without `--forceExit` (DELETE `/clients/delete` suite + no longer leaves a supertest server handle). +- GitHub Actions gate: `.github/workflows/test.yml` (Node 20) runs `npm ci`, + `npm run build`, `npm test -- --runInBand`, `npm run test:security-smoke`. +- **PR 2.1 — Deployment gate alignment:** Cloud Build (`cloudbuild.yaml`) now + enforces the same gate before buildpack/push/deploy (`test-gate` → `buildpack` + → `push` → `deploy` via `waitFor`). Lint workflow updated to Node 20 + (`actions/checkout@v4`, `actions/setup-node@v4`). +- Next milestone: PR 3 (immediate containment). No security route hardening or + folder moves in this PR. + +### Completion summary (PR 3) + +- Status remains `in_progress` (epic not closed). PR 3 marked complete; endpoint + authorization remains **PR 4**. +- Baseline before PR 3: 39 suites / 303 tests. After PR 3: **40 suites / 308 + tests** (build + `npm test -- --runInBand --detectOpenHandles` + + security-smoke pass; no open-handle report). +- Removed `127.0.0.1:7707` telemetry from `clientController.assignDoula` + (IDs/roles/services were being exfiltrated). +- Redacted sensitive logging: email SMTP password previews; SignNow auth params + / field values / token prefixes / Bearer headers; contract field value dumps; + provider raw payloads. +- Removed hardcoded SignNow API token from `signNowService.js` and + `pdfContractRoutes` (runtime auth / env only). +- Sanitized unexpected 500 responses to stable non-sensitive messages via + `SAFE_INTERNAL_ERROR_MESSAGE` / `toSafeClientErrorBody` (domain 4xx messages + preserved). +- **Intentional error-response security bug fixes (status codes preserved where + possible):** + - `contractSigningRoutes`: dropped `details` containing `error.stack` / + `error.response.data`. + - `contractRoutes` send-client-invite: dropped `details: error.response?.data` + and raw `error.message` on 500. + - `signNowRoutes` send-client-partner: dropped + `details: error.response.data.errors` and provider error messages (429 + daily-limit message retained). + - `clientController` / `authController` handleError: unexpected 500 → + `Internal Server Error` (no SQL/provider text). + - `emailController`: 500 → generic failed-email / internal messages (no + stack). + - `quickbooksController`: dropped `details: err.message`; OAuth error redirect + no longer embeds raw message. + - `paymentRoutes` / `paymentMethodController` / `pdfContractRoutes`: + unexpected 500s use generic safe messages. +- Regression tests: `src/__tests__/immediateContainment.test.ts`. +- **Deferred to PR 4 (documented, auth unchanged):** unauthenticated + `/api/contract-signing/*`, `/api/signnow/*` tooling, `/api/pdf-contract/*`, + many `/api/payments/*` dashboard/maintenance routes, `/quickbooks/customers` + outside auth middleware, public request intake (intentional), QB webhook + session-auth mismatch. +- Changed files (PR 3 only): `clientController.ts`, `authController.ts`, + `emailController.ts`, `emailService.ts`, `paymentMethodController.ts`, + `quickbooksController.ts`, `contractSigningRoutes.ts`, `contractRoutes.ts`, + `signNowRoutes.ts`, `paymentRoutes.ts`, `pdfContractRoutes.ts`, + `signNowService.ts`, `signNowService.js`, `signNowContractProcessor.ts`, + `safeLogging.ts`, `sendTestEmail.ts`, `immediateContainment.test.ts`, + `requestEndpoint.test.ts` (email throw assertion), handoff + frontend-context. + +### Completion summary (PR 4) + +- Status remains `in_progress`. PR 4 marked complete; **webhook provider + authentication remains PR 5**. +- Authorization matrix: `docs/ENDPOINT_AUTHORIZATION_MATRIX.md`. +- Policies: `src/security/authorizationPolicies.ts` (`roleAllows`, + `decideOwnershipAccess`, `decideClientResourceAccess`). +- Baseline before PR 4: 40 suites / 308 tests. After PR 4: **41 suites / 334 + tests** (build + `npm test -- --runInBand --detectOpenHandles` + + security-smoke; no open-handle report). +- **Routes newly protected (anonymous access denied — security bug fixes):** + - Payments: `/dashboard`, `/overdue`, `/due-between`, `/status/:status`, + `PUT /payment/:paymentId/status`, `/maintenance/*`, plus ownership on + `/contract/:id/summary|schedule`. + - `/api/contract-signing/*`, `/api/contract/*`, `/api/pdf-contract/*` → admin. + - `/api/signnow/*` tooling + `send-client-partner` → admin (`/callback` stays + public). + - `/quickbooks/customers` + `/invoiceable` → admin|billing. + - QB CRM ops after session auth → admin|billing; `simulate-payment` → admin. + - `/email/*` → admin role (was session-only). +- **QB webhook** `POST …/webhooks/invoice-paid` moved **before** + `authMiddleware` so providers can reach it without CRM cookies + (signature/replay still PR 5). +- Tests: `src/__tests__/authorizationMatrix.test.ts` (+ smoke baseline doc + link). +- Ambiguous / deferred: billing vs doula on `GET /api/payments` list; unmounted + DocuSign/Stripe route files; debug `/session-token` env exception; PR 5 + webhook crypto; PR 6 authoritative roles. + +### Completion summary (PR 5) + +- Status remains `in_progress`. PR 5 marked complete; next milestone is **PR 6 + (Authentication compatibility)**. +- Baseline before PR 5: 41 suites / 334 tests. After PR 5: **42 suites / 345 + tests** (build + `npm test -- --runInBand --detectOpenHandles` + + security-smoke; no open-handle report). +- SignNow: `requireSignNowWebhookAuth` verifies `X-SignNow-Signature` HMAC + (`SIGNNOW_WEBHOOK_SECRET`); event ledger via `webhook_events` / memory in + tests; duplicate deliveries return `reason: 'duplicate'`. +- QuickBooks: `requireQuickBooksWebhookAuth` verifies `intuit-signature` with + verifier token + optional `intuit-created-time` freshness; ledger keyed by + `intuit-t-id` or `qbo:invoice:{id}:paid`. +- Webhooks remain outside CRM session auth (PR 4 mount order preserved). +- OAuth: `createOAuthState` / `consumeOAuthState` — `crypto.randomBytes` + base64url, stored in `oauth_states`, 10m TTL, single-use; consumed in + `handleAuthCallback` before token exchange. +- Additive migration (manual): + `src/db/migrations/add_webhook_events_and_oauth_states.sql`. +- Env: `SIGNNOW_WEBHOOK_SECRET`, `QB_WEBHOOK_VERIFIER_TOKEN` (documented in + `.env.example`); production fails closed if missing. +- Raw body capture on `express.json` for HMAC. +- Tests: `src/__tests__/webhookAndOauthSecurity.test.ts` (+ SignNow duplicate + coverage). +- Docs: auth matrix + route inventory updated for PR 5. +- No feature-folder moves; FE `{ url }` OAuth contract unchanged. + +### Completion summary (PR 6) + +- Status remains `in_progress`. PR 6 marked complete; next milestone is **PR 7 + (HTTP contracts)**. +- Baseline before PR 6: 42 suites / 345 tests. After PR 6: **43 suites / 354 + tests** (build + `npm test -- --runInBand --detectOpenHandles` + + security-smoke; no open-handle report). +- Authoritative roles: `src/security/resolveAuthoritativeRole.ts` — Cloud SQL + `admins`/`doulas` (and `phi_clients` for client hint) + app-managed + `public.users.role`; **never** grant staff from + `user_metadata`/`app_metadata`. +- Removed `/auth/me` metadata role override; login/`getMe`/`getUserFromToken` + all resolve via authoritative path. +- Cookies: canonical `sb-access-token` via `setSessionCookie` / + `clearSessionCookies`; OAuth + `POST /auth/callback` no longer set legacy + `session` (still accepted temporarily for dual-support). +- Transport priority: `X-Session-Token` → Bearer → `sb-access-token` → legacy + `session` cookie. JSON login `token` and body `access_token` retained with + telemetry counters (`authTransportTelemetry`) — not retired yet. +- Tests: `src/__tests__/authCompatibility.test.ts`. +- Docs: auth matrix updated. No feature-folder moves; FE login/`/auth/me` shapes + preserved. + +### Completion summary (PR 7) + +- Status remains `in_progress`. PR 7 marked complete; next milestone is **PR 8 + (request intake structural slice)**. +- Baseline before PR 7: 43 suites / 354 tests. After PR 7: **44 suites / 362 + tests** (build + `npm test -- --runInBand --detectOpenHandles` + + security-smoke; no open-handle report). +- Canonical helpers: `src/common/http/apiEnvelope.ts`, + `src/security/errorCodes.ts`; existing `ApiResponse` retained. +- Zod: upgraded `validateRequest` for body/params/query; pilot `loginBodySchema` + on `POST /auth/login` and alias `POST /login`. Login success shape unchanged. +- Additive `code` on auth middleware / authorizeRoles / safe 5xx / global + handler — `error` strings and status codes preserved. +- Alias deprecation (no removals): `Deprecation`/`Sunset`/`Link` + counters on + `POST /login`, `/client`, `/api/client`. +- Docs: `docs/ROUTE_RESPONSE_CONTRACT_INVENTORY.md` HTTP contracts section. +- Tests: `src/__tests__/httpContracts.test.ts`. Intake/`requestSubmission` left + for PR 8. + +### Completion summary (PR 8) + +- Status remains `in_progress` (later slices + remaining P0 items still open). + PR 8 marked complete. +- Baseline before PR 8: 44 suites / 362 tests. After PR 8: **45 suites / 369 + tests** (build + `npm test -- --runInBand --detectOpenHandles` + + security-smoke; no open-handle report). +- Created `src/features/intake/` with domain + (`normalizePublicIntakeSubmission` + DTO rules), application + (`submitPublicRequestForm` + `IntakeLeadRepository` port), infrastructure + (`LegacyRequestFormRepositoryAdapter`), http contract constants, and public + `index.ts`. +- Legacy façade preserved: `POST /requestService/requestSubmission` → + `RequestFormController.createForm` → `RequestFormService.newForm`. +- Domain normalize always on; default write still via repository through façade. + `INTAKE_USE_FEATURE_PACKAGE=true` switches writes to use case. + `INTAKE_SHADOW_COMPARE=true` logs use-case vs façade map parity (no PHI dump). +- Compatibility shim: `src/intake/requestSubmissionDto.ts` re-exports feature + domain helpers. +- Success message constantized (`PUBLIC_INTAKE_SUCCESS_MESSAGE`) — string + unchanged. +- Tests: `src/__tests__/intakeFeaturePackage.test.ts` (+ existing + requestSubmission\* suites still pass). +- Next: later vertical slices (portal eligibility → …). Public intake abuse + protection completed (see below). + +### Completion summary (public intake abuse protection) + +- Status remains `in_progress` (later slices / remaining epic ACs still open). + P0 intake abuse item marked complete. +- `POST /requestService/requestSubmission`: honeypot (fake 200), IP + email rate + limits (`429` + `RATE_LIMITED` + `Retry-After`), optional `Idempotency-Key` + replay, soft email fingerprint dedupe (fake 200). +- Store: in-memory in test; Cloud SQL tables via + `src/db/migrations/add_intake_rate_limits_and_idempotency.sql` in production. +- Jest: rate limits/soft-dedupe off unless `INTAKE_ABUSE_ENFORCE=true` + (dedicated suite sets this). +- FE contract (updated 2026-08-14): success message unchanged; honeypot + + `Idempotency-Key` + 429/`Retry-After` wired on public intake; test-data fill + gated. +- Tests: `src/__tests__/intakeAbuseProtection.test.ts`; full suite + + security-smoke green after wiring. +- Cloud SQL migration applied 2026-08-14 on `sokana_private`: + `intake_rate_limits`, `intake_idempotency_keys`. +- **P0 security confirmed complete** on backend (PR 3–6 + intake abuse + CI/Jest + gates) **and** frontend (role from `/auth/me`, route guards, `fetchWithAuth`, + intake abuse client, 403 ≠ logout). Remaining epic work is P1/P2 structural + (`Later slices`), not security. SPA is aligned with the API, not a vault. + Production host is Cloud Run; Vercel headers do not apply. +- **Encryption (2026-08-14):** Cloud SQL Google-managed at rest; backups + PITR + on; Cloud Run → SQL via connector unix socket; instance + `sslMode: ENCRYPTED_ONLY`; `0.0.0.0/0` removed. This is HIPAA _input_, not a + HIPAA attestation — next is BAA + risk analysis (see + `docs/SECURITY_P0_HARDENING_SUMMARY.md`). + +### Cloud Run safeguards (ongoing) + +- [ ] Keep `sokana-private-api` as same deployable service. +- [ ] Never run migrations automatically at app boot. +- [ ] Additive forward-compatible migrations; deploy BE compatibility before FE. +- [ ] Retain previous Cloud Run revision for rollback. +- [ ] Do not change lightweight `/health` semantics unexpectedly. +- [ ] Avoid combining DB migration + auth transport + module refactor in one + release. + +## API/Contract Notes + +- Endpoint(s): + - Preserve existing public routes and aliases during P0–P2 rollout. + - Priority hardening surfaces: `paymentRoutes`, `contractSigningRoutes`, + `signNowRoutes`, SignNow/QB webhooks, auth token/cookie flows, public + request submission. +- Request/response: + - Preserve existing response fields and status codes while introducing stable + error codes. +- Backward compatibility: + - Dual-support auth transport before retiring JSON/query tokens. + - Deprecation headers/telemetry before alias removal. + +## Data/Migration Notes + +- Tables: no immediate destructive schema changes. +- Required migration: only additive, forward-compatible changes when a vertical + slice needs them. +- Fragmented migration history is a P1/P2 concern (ledger/checksums later). + +## Acceptance Criteria + +- [x] P0 security items completed or explicitly waived with documented + public-endpoint inventory. +- [x] No localhost debug telemetry on production request paths. +- [x] Webhooks provider-authenticated with replay/idempotency protections. +- [x] Staff roles fail closed on authoritative server-managed source. +- [x] Backend test suite + security smoke required in deploy path. +- [x] Request intake routed through the new use-case structure behind the + existing route/controller façade without behavior change unless a bug is + explicitly identified and documented. +- [ ] Every milestone is delivered and verified independently; no PR combines + database migration, auth transport migration, and structural refactoring. +- [ ] Status changes from `open` to `in_progress` only when PR 1 begins; use + `ready_for_verification` for the active milestone; close this handoff only + after the agreed P0 milestone and request intake slice are verified, with + remaining P1/P2 work carried into follow-up handoffs. + +## Verification Steps + +- Backend: + - `npm test` (expect 300+ passing; no open-handle flake) + - Auth/role matrix tests for payment/signing/webhook/public endpoints + - Cloud Run revision smoke before traffic shift; previous revision retained +- Frontend: + - Coordinate with companion frontend handoff for auth transport + QB sync + ownership changes + - Deploy backend compatibility first, frontend second + +## Implementation Notes + +### Feature-first packaging standard + +The first directory under `src/features` is always a recognizable Sokana +business capability. Infrastructure layers are nested inside their owning +feature; they are not top-level navigation categories. + +- New business code starts in `src/features/`. +- Do not add new global `controllers`, `services`, `repositories`, or `routes`. +- Each `index.ts` exposes the feature's supported application/domain API. +- Cross-feature consumers use the public API and must not import another + feature's infrastructure. +- Vendor names such as QuickBooks, SignNow, DocuSign, Stripe, and Supabase + belong below the feature infrastructure that owns the workflow. +- `bootstrap` only assembles dependencies and starts the application; it + contains no business rules. +- `shared` is restricted to domain-neutral config, HTTP, database, logging, + security, and testing mechanisms. + +Target layout (incremental, not big-bang): + +```text +src/ + bootstrap/ + features/ + auth/{domain,application,http,infrastructure} + intake/{domain,application,http,infrastructure} + clients/{domain,application,http,infrastructure} + doulas/{domain,application,http,infrastructure} + matching/{domain,application,http,infrastructure} + portal/{domain,application,http,infrastructure} + contracts/{domain,application,http,infrastructure} + billing/{domain,application,http,infrastructure} + documents/{domain,application,http,infrastructure} + shared/{config,database,http,logging,security,testing} +``` + +### Planned migration (not started) + +This section is planning-only. Do not move source folders until the migration +slice is explicitly approved. + +- [x] Add the feature-package rules in `src/features/README.md`. +- [x] Document request intake ownership and its target + domain/application/HTTP/infrastructure boundaries without moving + production code. +- [x] Move request intake validation and normalization rules under + `src/features/intake/domain` while preserving the existing + route/controller façade. +- [x] Move request intake application, HTTP, and persistence adapters only after + characterization and parity tests pass. +- [ ] Replace temporary cross-feature infrastructure imports with application + ports or public feature operations. +- [ ] Move portal eligibility under `src/features/portal` as the second + structural slice. +- [ ] Move composition from the legacy root `src/index.ts` into `src/bootstrap` + after the first feature slices are stable. + +- Recurring sequence: Stabilize → capture behavior → extract pure rules → port → + adapter → switch one endpoint/screen → monitor → remove old path later. +- Every PR: one use case/endpoint at a time; characterization tests first; no + domain imports of Express/DB/SDK/env; no raw `process.env`/`console`/untyped + expected errors in new code; never log tokens/PHI/unrestricted provider + payloads. +- Safe now: tests/CI, telemetry removal, log redaction, endpoint auth (with + inventory), webhooks/OAuth state, pure-rule extraction, DI/ports, strict TS + per module. +- After pilot: alias removal, Supabase path retirement, migration consolidation, + large renames, multi-service split (not recommended). diff --git a/.cursor/plans/split-db_backend_migration_bb968c96.plan.md b/.cursor/plans/split-db_backend_migration_bb968c96.plan.md new file mode 100644 index 00000000..facda842 --- /dev/null +++ b/.cursor/plans/split-db_backend_migration_bb968c96.plan.md @@ -0,0 +1,1119 @@ +--- +name: Split-DB Backend Migration +overview: + Plan the backend infrastructure changes needed to support the split-database + architecture, separating sensitive data from operational data while + maintaining stable API contracts and frontend compatibility. Frontend never + knows about data boundaries. +todos: + - id: phase0-response-builder + content: + Create standardized response builder (src/utils/responseBuilder.ts) with + success/error/list shapes per mode (shadow vs primary) + status: pending + - id: phase0-dto-structure + content: + Create DTO directory structure and define all 11 committed response DTOs + status: pending + - id: phase0-sensitive-db-client + content: + Create Sensitive DB client module (src/sensitive/sensitiveDatabase.ts) + with connection pooling + status: pending + - id: phase0-db-router + content: + Create database router (src/sensitive/databaseRouter.ts) with explicit + table mapping + status: pending + - id: phase0-rollout-flags + content: + Add rollout feature flags (ENABLE_SPLIT_DB, SPLIT_DB_READ_MODE, + SPLIT_DB_WRITE_MODE) + status: pending + - id: phase1-repo-interfaces + content: + Create missing repository interfaces (assignment, requestForm, + doulaDocument) + status: pending + - id: phase1-sensitive-repos + content: + Create Sensitive repository implementations for client, health, + demographics, audit + status: pending + - id: phase1-composite-repo + content: Create composite client repository with batched reads (no N+1) + status: pending + - id: phase1-update-di + content: Update src/index.ts dependency injection with new repositories + status: pending + - id: phase2-field-classification + content: + Implement field classification per appendix (operational vs sensitive) + status: pending + - id: phase2-client-list-dto + content: + Update GET /clients to return ClientListItemDTO via standardized response + with meta.count in primary mode + status: pending + - id: phase2-client-detail-dto + content: + Update GET /clients/:id to return ClientDetailDTO with sensitive fields + gated per Appendix H (omit when unauthorized) + status: pending + - id: phase2-activities-endpoints + content: + Update Activities endpoints (GET /clients/:id/activities, POST + /clients/:id/activity) to use ActivityDTO with ApiResponse wrapper + status: pending + - id: phase3-update-controllers + content: + Update all 13 committed endpoints to use DTOs and ApiResponse wrapper in + primary mode (no raw entity.toJson()) + status: pending + - id: phase3-sensitive-middleware + content: + Create sensitive access middleware with metadata-only audit logging and + authorization per Appendix H + status: pending + - id: phase3-update-logger + content: Update logger.ts with comprehensive sensitive field redaction + status: pending + - id: phase4-shadow-read + content: + Implement shadow-read validation per Appendix G (reads both, logs diffs, + serves legacy shapes) + status: pending + - id: phase4-update-services + content: + Update services that directly access client_info to use repository layer + status: pending + - id: phase4-contract-tests + content: + Add contract tests for all 13 committed endpoints verifying ApiResponse + wrapper and meta.count on lists in primary mode + status: pending + - id: phase5-cutover + content: + Switch to primary mode after shadow-read validation passes (<1% mismatch + for 48 hours) + status: pending +isProject: false +--- + +# Split-Database Backend Migration Plan + +## Non-Negotiable API Contract With Frontend + +**All endpoints MUST return standardized response shapes in PRIMARY mode. No +exceptions.** + +| Response Type | Shape | +| ------------- | ----------------------------------------------------------------- | +| Success | `{ "success": true, "data": , "meta"?: {...} }` | +| Error | `{ "success": false, "error": "", "code"?: "" }` | +| List | `{ "success": true, "data": , "meta": { "count": number } }` | + +**Hard Rules:** + +1. DTOs are canonical. Controllers NEVER return raw DB rows or `entity.toJson()` +2. Endpoints that frontend depends on MUST keep response fields stable across + migration +3. Frontend never learns about data boundaries - backend is the sole broker +4. **In PRIMARY mode**: All list endpoints MUST include `meta.count`. NO raw + arrays at top-level. +5. **In SHADOW mode**: Legacy response shapes allowed (see Appendix G for + details) + +--- + +## Current State Analysis + +The backend currently uses a single Supabase database for all data. Key +findings: + +- **Repository Pattern**: 6 repositories with partial interface coverage +- **Single DB Client**: Singleton Supabase client injected into all repositories +- **No DTO Layer**: Entities handle their own serialization via `toJson()` +- **Mixed Response Shapes**: Inconsistent wrappers (`success`, `message`, direct + data) +- **Sensitive Data Mixed Everywhere**: `client_info` contains 50+ fields + including health data, demographics, and operational fields in one table + +## Target Architecture + +```mermaid +flowchart TB + subgraph frontend [Frontend - Knows Nothing About Data Split] + FE[React App] + end + + subgraph backend [Backend Layer - Sole Data Broker] + API[Express Routes] + MW[Auth Middleware] + DataRouter[Data Router] + Controllers[Controllers] + UseCases[Use Cases] + Services[Services] + + subgraph repos [Repository Layer] + RepoInterface[Repository Interfaces] + SupabaseRepo[Operational Repositories] + SensitiveRepo[Sensitive Repositories] + CompositeRepo[Composite Repositories] + end + + subgraph dto [DTO Layer - Canonical Contracts] + DTOs[Response DTOs] + Mappers[Entity Mappers] + ResponseBuilder[Response Builder] + end + end + + subgraph data [Data Stores] + Supabase[(Operational DB - Supabase)] + SensitiveDB[(Sensitive DB - Cloud SQL)] + end + + FE --> API + API --> MW + MW --> Controllers + Controllers --> ResponseBuilder + ResponseBuilder --> DTOs + DTOs --> Mappers + Controllers --> UseCases + UseCases --> Services + Services --> CompositeRepo + CompositeRepo --> DataRouter + DataRouter --> SupabaseRepo + DataRouter --> SensitiveRepo + SupabaseRepo --> Supabase + SensitiveRepo --> SensitiveDB +``` + +--- + +## Appendix A: Data Boundary + Table Map (Explicit) + +### Database A - Operational DB (Supabase) + +| Table | Access Pattern | Notes | +| ---------------------------------------------------- | -------------- | ----------------------------------- | +| `users` | Read/Write | Auth metadata, role, account status | +| `assignments` | Read/Write | Doula-client assignments | +| `client_activities` | Read/Write | Notes, timeline events | +| `requests` | Read/Write | Public intake submissions | +| `contracts` | Read/Write | Contract metadata only | +| `contract_payments` | Read/Write | Payment tracking | +| `contract_templates` | Read/Write | PDF templates | +| `contract_signnow_integration` | Read/Write | Signing workflow metadata | +| `payment_schedules` | Read/Write | Installment plans | +| `payment_installments` | Read/Write | Installment execution | +| `payment_reminders` | Read/Write | Reminder scheduling | +| `customers` | Read/Write | Stripe customer mapping | +| `payment_methods` | Read/Write | Tokenized payment methods | +| `charges` | Read/Write | Stripe charge records | +| `invoices` | Read/Write | Accounting records | +| `quickbooks_tokens` | Read/Write | OAuth tokens | +| `hours` | Read/Write | Doula hours worked | +| `notes` | Read/Write | Operational notes | +| `doula_documents` | Read/Write | File references (metadata only) | +| `client_info` | Read/Write | Operational client fields ONLY | +| Views: `payment_dashboard`, `contracts_with_clients` | Read-only | Aggregation views | + +### Database B - Sensitive DB (Cloud SQL PostgreSQL) + +| Table | Access Pattern | Notes | +| -------------------------- | -------------- | ------------------------------------------ | +| `sensitive_clients` | Read/Write | PK: `client_id` (matches Operational DB) | +| `sensitive_health_history` | Read/Write | FK: `client_id` | +| `sensitive_demographics` | Read/Write | FK: `client_id` | +| `sensitive_access_audit` | Append-only | Metadata logging (no raw sensitive values) | + +> **Naming Convention**: Use `sensitive_*` prefix. Frontend never sees this +> naming. + +--- + +## Appendix B: Field Classification + +### Operational Fields (safe in `ClientListItemDTO`) + +| Field | Source Table | Notes | +| --------------------- | -------------- | --------------------------- | +| `client_id` | client_info.id | Primary identifier | +| `first_name` | client_info | Display name | +| `last_name` | client_info | Display name | +| `email` | client_info | If policy allows | +| `phone_number` | client_info | If policy allows | +| `status` | client_info | Workflow status | +| `service_needed` | client_info | Service type | +| `requested_at` | client_info | Timestamp | +| `updated_at` | client_info | Timestamp | +| `portal_status` | client_info | Portal state | +| `is_eligible` | computed | Boolean (eligibility check) | +| `invited_at` | client_info | Portal invite timestamp | +| `last_invite_sent_at` | client_info | Rate limiting | +| `invite_sent_count` | client_info | Rate limiting | + +### Sensitive Fields (only in `ClientDetailDTO` when authorized) + +| Field | Target Table | Notes | +| ---------------------------- | ------------------------ | ----------------- | +| `due_date` | sensitive_clients | Pregnancy info | +| `health_history` | sensitive_health_history | Medical data | +| `health_notes` | sensitive_health_history | Medical data | +| `allergies` | sensitive_health_history | Medical data | +| `pregnancy_number` | sensitive_clients | Pregnancy info | +| `had_previous_pregnancies` | sensitive_clients | Pregnancy history | +| `previous_pregnancies_count` | sensitive_clients | Pregnancy history | +| `living_children_count` | sensitive_clients | Pregnancy history | +| `past_pregnancy_experience` | sensitive_clients | Pregnancy history | +| `baby_sex` | sensitive_clients | Baby info | +| `baby_name` | sensitive_clients | Baby info | +| `number_of_babies` | sensitive_clients | Baby info | +| `race_ethnicity` | sensitive_demographics | Demographics | +| `client_age_range` | sensitive_demographics | Demographics | +| `annual_income` | sensitive_demographics | Demographics | +| `insurance` | sensitive_demographics | Demographics | + +--- + +## Appendix C: Composite Read Strategy (No N+1) + +**CRITICAL: Client list endpoints must NEVER trigger per-client DB calls to +Sensitive DB.** + +### List Endpoint Pattern + +```typescript +// CORRECT: Batched reads +async getClients(clientIds: string[]): Promise { + // Step 1: Fetch all operational data in ONE query + const operationalClients = await this.operationalRepo.findByIds(clientIds); + + // Step 2: Fetch sensitive presence in ONE batched query + const sensitivePresence = await this.sensitiveRepo.checkPresence(clientIds); + // Returns: Map + + // Step 3: Map to DTOs (no sensitive data in list view) + return operationalClients.map(client => + ClientMapper.toListItemDTO(client, sensitivePresence.get(client.id)) + ); +} + +// WRONG: N+1 pattern - DO NOT DO THIS +async getClients(clientIds: string[]): Promise { + return Promise.all(clientIds.map(async id => { + const client = await this.getClientById(id); // N+1 queries! + return ClientMapper.toListItemDTO(client); + })); +} +``` + +### Detail Endpoint Pattern + +```typescript +// CORRECT: Parallel single-row fetches +async getClientById(id: string, includeSensitive: boolean): Promise { + const [operational, sensitive] = await Promise.all([ + this.operationalRepo.findById(id), + includeSensitive ? this.sensitiveRepo.findByClientId(id) : null + ]); + + return ClientMapper.toDetailDTO(operational, sensitive); +} +``` + +## Appendix D: DTO Commitments + +**All 11 DTOs MUST exist in `src/dto/response/` before migration proceeds.** + +| DTO | Endpoint(s) | Status | +| ---------------------- | ---------------------------------------------------------------- | -------- | +| `UserDTO` | `GET /auth/me` | Required | +| `ClientListItemDTO` | `GET /clients` | Required | +| `ClientDetailDTO` | `GET /clients/:id`, `PUT /clients/status` | Required | +| `ActivityDTO` | `GET /clients/:id/activities`, `POST /clients/:id/activity` | Required | +| `HoursEntryDTO` | `GET /api/doulas/hours`, `POST /api/doulas/hours` | Required | +| `DashboardStatsDTO` | `GET /api/dashboard/stats` | Required | +| `DashboardCalendarDTO` | `GET /api/dashboard/calendar` | Required | +| `PortalInviteDTO` | `POST /api/admin/clients/:id/portal/invite`, `.../portal/resend` | Required | +| `PortalStatusDTO` | `POST /api/admin/clients/:id/portal/disable` | Required | +| `PaymentSummaryDTO` | `GET /api/stripe/contract/:id/payment-summary` | Required | +| `ContractSummaryDTO` | `GET /api/contract-signing/status/:id` | Required | + +**Contract Notes:** + +- `GET /clients/me/portal-status` is **NOT** a committed endpoint (Option A + selected) +- `PortalInviteDTO` is reused for both invite and resend operations +- Activities endpoints use singular `activity` for POST (create) and plural + `activities` for GET (list) + +--- + +## Appendix E: Endpoint Contract Commitments + +**These endpoints MUST maintain stable response shapes throughout migration.** + +| Endpoint | DTO | Response Shape (Primary) | Breaking Changes | +| -------------------------------------------- | ------------------------ | ---------------------------------------- | ---------------- | +| `GET /auth/me` | `UserDTO` | `{ success, data: UserDTO }` | No | +| `GET /clients` | `ClientListItemDTO[]` | `{ success, data: [], meta: { count } }` | No | +| `GET /clients/:id` | `ClientDetailDTO` | `{ success, data: ClientDetailDTO }` | No (gated) | +| `PUT /clients/status` | `ClientDetailDTO` | `{ success, data: ClientDetailDTO }` | No | +| `GET /clients/:id/activities` | `ActivityDTO[]` | `{ success, data: [], meta: { count } }` | No | +| `POST /clients/:id/activity` | `ActivityDTO` | `{ success, data: ActivityDTO }` | No | +| `GET /api/doulas/hours` | `HoursEntryDTO[]` | `{ success, data: [], meta: { count } }` | No | +| `POST /api/doulas/hours` | `HoursEntryDTO` | `{ success, data: HoursEntryDTO }` | No | +| `GET /api/dashboard/stats` | `DashboardStatsDTO` | `{ success, data: DashboardStatsDTO }` | No | +| `GET /api/dashboard/calendar` | `DashboardCalendarDTO[]` | `{ success, data: [], meta: { count } }` | No | +| `POST /api/admin/clients/:id/portal/invite` | `PortalInviteDTO` | `{ success, data: PortalInviteDTO }` | No | +| `POST /api/admin/clients/:id/portal/resend` | `PortalInviteDTO` | `{ success, data: PortalInviteDTO }` | No | +| `POST /api/admin/clients/:id/portal/disable` | `PortalStatusDTO` | `{ success, data: PortalStatusDTO }` | No | + +**Endpoint Naming Standardization:** + +- Activities: `GET /clients/:id/activities` (list, plural) and + `POST /clients/:id/activity` (create, singular) +- Do NOT use `POST /clients/:id/activities` - this is incorrect + +--- + +## Appendix F: Rollout Feature Flags + +```env +# Master toggle - default OFF until validated +ENABLE_SPLIT_DB=false + +# Read mode (only when ENABLE_SPLIT_DB=true) +# shadow: reads from both DBs, serves OLD behavior, logs diffs +# primary: reads from split sources, serves DTOs with wrapper +SPLIT_DB_READ_MODE=shadow + +# Write mode (only when ENABLE_SPLIT_DB=true) +# dual: writes to BOTH old + new during migration window +# primary: writes ONLY to split sources after cutover +SPLIT_DB_WRITE_MODE=dual +``` + +### Rollout Sequence + +1. `ENABLE_SPLIT_DB=false` - Current state +2. `ENABLE_SPLIT_DB=true, READ_MODE=shadow, WRITE_MODE=dual` - Shadow validation +3. `ENABLE_SPLIT_DB=true, READ_MODE=primary, WRITE_MODE=dual` - Read cutover +4. `ENABLE_SPLIT_DB=true, READ_MODE=primary, WRITE_MODE=primary` - Full cutover + +--- + +## Appendix G: Shadow Mode Contract Clarification + +**Strategy 1 (Selected)**: Shadow mode serves LEGACY response shapes. Primary +mode serves DTOs + ApiResponse wrapper. + +### Shadow Mode Behavior (`SPLIT_DB_READ_MODE=shadow`) + +| Aspect | Behavior | +| --------------------- | ----------------------------------------------------------------------------------- | +| Data Source | Reads from OLD paths (single Supabase), compares against NEW paths (split DBs) | +| Response Shape | **Legacy shapes** — MAY return `entity.toJson()`, raw arrays, inconsistent wrappers | +| Wrapper Enforcement | NOT required — legacy responses allowed | +| `meta.count` on Lists | NOT required | +| Diff Logging | Enabled — logs mismatches between old and new data sources | +| Purpose | Validate data consistency before cutover without breaking frontend | + +### Primary Mode Behavior (`SPLIT_DB_READ_MODE=primary`) + +| Aspect | Behavior | +| --------------------- | --------------------------------------------------------- | +| Data Source | Reads from NEW paths (split DBs) | +| Response Shape | **DTOs + ApiResponse wrapper** — canonical contracts only | +| Wrapper Enforcement | REQUIRED — all endpoints return `{ success, data, ... }` | +| `meta.count` on Lists | REQUIRED — all list endpoints include `meta: { count }` | +| Raw Arrays | PROHIBITED at top-level | +| Diff Logging | Disabled | +| Purpose | Production-ready split-database operation | + +### Frontend Feature Flag Mapping + +| Backend Mode | Frontend Flag | Frontend Behavior | +| ---------------------------- | --------------------------- | --------------------------------- | +| `SPLIT_DB_READ_MODE=shadow` | `VITE_USE_LEGACY_API=true` | Expects legacy response shapes | +| `SPLIT_DB_READ_MODE=primary` | `VITE_USE_LEGACY_API=false` | Expects DTO + ApiResponse wrapper | + +### Testing Scope by Mode + +| Test Type | Shadow Mode | Primary Mode | +| --------------------------------- | ----------- | ------------ | +| Wrapper/DTO shape validation | No | Yes | +| `meta.count` on list endpoints | No | Yes | +| Diff logging + mismatch threshold | Yes | No | +| Data consistency validation | Yes | Yes | + +**Critical**: Contract tests for wrapper shape and `meta.count` apply ONLY to +PRIMARY mode. Shadow mode tests focus exclusively on diff logging accuracy and +mismatch rate thresholds (<1% for 48 hours), NOT response shape uniformity. + +**Critical**: Backend and frontend flags MUST be synchronized during rollout. + +--- + +## Appendix H: Sensitive Gating Policy for ClientDetailDTO + +### Authorization Rule + +**Who may view sensitive fields in `ClientDetailDTO`:** + +| Role | Can View Sensitive Fields | Condition | +| -------- | ------------------------- | ------------------------------ | +| `admin` | Yes | Always | +| `doula` | Yes | Only if assigned to the client | +| `client` | Yes | Only their own record | + +> **Note**: The `client` role is exclusive to the portal auth context (Supabase +> Auth). Staff cookie sessions (admin, doula) will never return `role='client'`. + +### Implementation Pattern + +```typescript +// In controller or use case +const canAccessSensitive = (user: User, clientId: string): boolean => { + if (user.role === 'admin') return true; + if (user.role === 'doula') return isAssignedToClient(user.id, clientId); + if (user.role === 'client') return user.clientId === clientId; + return false; +}; +``` + +### Unauthorized Behavior + +**When user is NOT authorized to view sensitive fields:** + +Sensitive fields are **OMITTED** from the response (not set to `null`). + +```typescript +// ClientDetailDTO when authorized +{ + client_id: "...", + first_name: "...", + // ... operational fields ... + due_date: "2024-06-15", // INCLUDED + health_history: "...", // INCLUDED + allergies: "..." // INCLUDED +} + +// ClientDetailDTO when NOT authorized +{ + client_id: "...", + first_name: "...", + // ... operational fields ... + // due_date: OMITTED + // health_history: OMITTED + // allergies: OMITTED +} +``` + +**Rationale**: Omitting fields (vs null) makes it clear the data was not +requested/authorized, not that it's missing. + +### Sensitive Fields Never in List Endpoints + +`ClientListItemDTO` NEVER contains sensitive fields regardless of authorization. +Sensitive data is only available via detail endpoints (`GET /clients/:id`) when +authorized. + +--- + +## Phase 0: Infrastructure Foundation + +### 0.1 Create Response Builder + +**File**: `src/utils/responseBuilder.ts` + +```typescript +// Standardized response shapes - ALL controllers MUST use this +export class ApiResponse { + static success(data: T, meta?: Record) { + return { success: true, data, ...(meta && { meta }) }; + } + + static list(data: T[], count: number, meta?: Record) { + return { success: true, data, meta: { count, ...meta } }; + } + + static error(message: string, code?: string) { + return { success: false, error: message, ...(code && { code }) }; + } +} +``` + +### 0.2 Create DTO Layer Structure + +**Directory Structure**: + +``` +src/ +├── dto/ +│ ├── request/ # Incoming request shapes +│ │ ├── ClientCreateDTO.ts +│ │ ├── ClientUpdateDTO.ts +│ │ └── ... +│ ├── response/ # Outgoing response shapes (ALL 11 COMMITTED) +│ │ ├── ClientListItemDTO.ts +│ │ ├── ClientDetailDTO.ts +│ │ ├── ActivityDTO.ts +│ │ ├── HoursEntryDTO.ts +│ │ ├── DashboardStatsDTO.ts +│ │ ├── DashboardCalendarDTO.ts +│ │ ├── PortalInviteDTO.ts +│ │ ├── PortalStatusDTO.ts +│ │ ├── PaymentSummaryDTO.ts +│ │ ├── ContractSummaryDTO.ts +│ │ └── UserDTO.ts +│ └── mappers/ # Entity-to-DTO mappers +│ ├── clientMapper.ts +│ ├── activityMapper.ts +│ └── ... +``` + +### 0.3 Create Sensitive Database Client + +**File**: `src/sensitive/sensitiveDatabase.ts` + +```typescript +// Pattern: Singleton with lazy initialization (matching supabase.ts) +// Configuration via environment variables: +// - SENSITIVE_DATABASE_URL +// - SENSITIVE_DATABASE_SSL_MODE=require +// - SENSITIVE_DATABASE_POOL_MIN=2 +// - SENSITIVE_DATABASE_POOL_MAX=10 +``` + +**Considerations**: + +- Use `pg` library for direct PostgreSQL connection +- Connection pooling via `pg-pool` +- SSL/TLS required +- Separate credentials from Supabase + +### 0.4 Create Database Router + +**File**: `src/sensitive/databaseRouter.ts` + +Central routing logic with explicit table mapping from Appendix A. + +--- + +## Phase 1: Repository Abstraction + +### 1.1 Define Complete Repository Interfaces + +Ensure all repositories have interfaces. + +**Files to create/update**: + +- `src/repositories/interface/assignmentRepository.ts` (new) +- `src/repositories/interface/requestFormRepository.ts` (new) +- `src/repositories/interface/doulaDocumentRepository.ts` (new) +- `src/repositories/interface/clientRepository.ts` (update - add `implements`) + +### 1.2 Create Sensitive Repository Implementations + +New repository implementations for Sensitive database access. + +**Files**: + +- `src/repositories/sensitive/sensitiveClientRepository.ts` +- `src/repositories/sensitive/sensitiveHealthRepository.ts` +- `src/repositories/sensitive/sensitiveDemographicsRepository.ts` +- `src/repositories/sensitive/sensitiveAuditRepository.ts` + +**Pattern**: + +```typescript +// Each Sensitive repository: +// 1. Uses Sensitive database client (not Supabase) +// 2. Logs access METADATA to sensitive_access_audit (no raw values) +// 3. Implements same interface as Supabase counterpart +``` + +### 1.3 Create Composite Repositories + +Repositories that combine data from both databases. + +**File**: `src/repositories/composite/compositeClientRepository.ts` + +```typescript +// Pattern: +// - Implements ClientRepository interface +// - Injects both Supabase and Sensitive repositories +// - Joins data from both sources in memory +// - Returns unified Client entity +``` + +**Data Flow**: + +```mermaid +sequenceDiagram + participant C as Controller + participant UC as UseCase + participant CR as CompositeClientRepo + participant OR as OperationalRepo + participant SR as SensitiveRepo + participant O as Operational DB + participant S as Sensitive DB + + C->>UC: getClientById(id) + UC->>CR: findById(id) + CR->>OR: findById(id) + OR->>O: SELECT from client_info + O-->>OR: operational fields + OR-->>CR: partial client + CR->>SR: findById(id) + SR->>S: SELECT from sensitive_clients + S-->>SR: sensitive fields + SR-->>CR: sensitive data + CR->>CR: merge data + CR-->>UC: complete Client + UC-->>C: Client entity +``` + +### 1.4 Update Dependency Injection + +**File**: `src/index.ts` + +Update the composition root to: + +1. Initialize Sensitive database client +2. Create Sensitive repositories +3. Create composite repositories +4. Inject composite repositories into use cases + +--- + +## Phase 2: Field Classification & Migration + +### 2.1 Operational Client Table Declaration + +`**client_info` remains the operational client table in Supabase. + +After migration, `client_info` will contain ONLY operational fields. All +sensitive fields move to `sensitive_*` tables in the Sensitive DB. + +### 2.2 Client Data Field Classification + +Based on Appendix B, classify `client_info` fields: + +**Sensitive Fields** (move to Sensitive DB): + +- Health: `health_history`, `health_notes`, `allergies` +- Pregnancy: `due_date`, `pregnancy_number`, `had_previous_pregnancies`, + `previous_pregnancies_count`, `living_children_count`, + `past_pregnancy_experience` +- Baby: `baby_sex`, `baby_name`, `number_of_babies` +- Demographics: `race_ethnicity`, `client_age_range`, `annual_income`, + `insurance` +- Personal identifiers: `dob`, `ssn` (if present) + +**Operational Fields** (remain in Supabase `client_info`): + +- Identity: `id`, `user_id`, `email`, `firstname`, `lastname` +- Contact: `phone_number`, `preferred_contact_method` +- Address: `address`, `city`, `state`, `zip_code` +- Service: `service_needed`, `services_interested`, `service_specifics` +- Status: `status`, `portal_status`, `requested`, `updated_at` +- Relationships: `referral_source`, `referral_name` + +### 2.3 Create Sensitive Schema + +**File**: `src/sensitive/migrations/001_create_sensitive_tables.sql` + +Tables to create in Sensitive database: + +1. `sensitive_clients` - Sensitive client fields (PK: `client_id` matches + `client_info.id`) +2. `sensitive_health_history` - Detailed health records (FK: `client_id`) +3. `sensitive_demographics` - Demographic data (FK: `client_id`) +4. `sensitive_access_audit` - Access logging (metadata only, no raw values) + +**Key Constraint**: `sensitive_clients.client_id` must match `client_info.id` in +Supabase + +--- + +## Phase 3: Controller & Response Stabilization + +### 3.1 DTO Wrapper Rule (REQUIRED) + +**In PRIMARY mode, all committed endpoints return ApiResponse wrapper. No +exceptions.** + +```typescript +// List endpoints - ALWAYS wrap with meta.count (PRIMARY MODE) +res.json(ApiResponse.list(dtos, dtos.length)); +// Returns: { success: true, data: [...], meta: { count: N } } + +// Detail endpoints - ALWAYS wrap with success (PRIMARY MODE) +res.json(ApiResponse.success(dto)); +// Returns: { success: true, data: {...} } + +// Errors - ALWAYS wrap with error (BOTH MODES) +res.status(404).json(ApiResponse.error('Not found', 'NOT_FOUND')); +// Returns: { success: false, error: "Not found", code: "NOT_FOUND" } +``` + +**Rules by Mode:** + +| Mode | Wrapper Required | `meta.count` on Lists | Raw Arrays Allowed | +| ------- | ---------------- | --------------------- | ------------------ | +| Shadow | No (legacy OK) | No | Yes | +| Primary | Yes | Yes | No | + +**In PRIMARY mode:** + +- NO raw arrays at top-level +- ALL list endpoints MUST include `meta.count` +- ALL single-item endpoints MUST include `success: true` wrapper + +**In SHADOW mode:** + +- Legacy response shapes allowed (for frontend compatibility) +- See Appendix G for full details + +### 3.2 Update Controllers to Use DTOs + +**Priority Order** (based on data sensitivity): + +1. `ClientController` - Contains most sensitive data +2. `DoulaController` - Client access via doula +3. `AdminController` - Full client access +4. `PortalController` - Portal operations +5. `DashboardRoutes` - Aggregated client data + +**Pattern for each controller**: + +```typescript +// Before: res.json(clients.map(c => c.toJson())) +// After: res.json(ApiResponse.list(clients.map(ClientMapper.toListItemDTO), clients.length)) + +// Before: res.json(client.toJson()) +// After: res.json(ApiResponse.success(ClientMapper.toDetailDTO(client))) +``` + +### 3.3 Update Entity Serialization + +Remove direct DB field exposure from `toJson()` methods. + +**Files**: + +- `src/entities/Client.ts` - Remove sensitive fields from `toJson()` +- `src/entities/User.ts` - Remove sensitive fields from `toJSON()` + +--- + +## Phase 4: Sensitive Access Controls + +### 4.1 Create Sensitive Middleware + +**File**: `src/middleware/sensitiveAccessMiddleware.ts` + +```typescript +// Middleware that: +// 1. Checks if request accesses sensitive data +// 2. Validates user has sensitive access permission +// 3. Logs access METADATA to sensitive_access_audit (field names, NOT values) +// 4. Attaches sensitive context to request +``` + +### 4.2 Create Audit Service + +**File**: `src/services/sensitiveAuditService.ts` + +```typescript +// CRITICAL: Log METADATA only, NEVER log raw sensitive values + +// Service that: +// 1. Logs all sensitive reads with: user_id, client_id, fields_accessed (names only), timestamp +// 2. Logs all sensitive writes with: user_id, client_id, fields_modified (names only), timestamp +// 3. Non-blocking (async, fire-and-forget) +``` + +### 4.3 Update Logger Redaction + +**File**: `src/common/utils/logger.ts` + +Current redaction paths: + +```typescript +const redactPaths = [ + 'email', + 'password', + 'address', + 'ssn', + 'phone', + 'health_history', + 'dob', + '*.email', +]; +``` + +Add additional sensitive fields: + +```typescript +const redactPaths = [ + // Auth & identity + 'email', + 'password', + 'ssn', + 'dob', + '*.email', + '*.password', + + // Contact + 'phone', + 'address', + 'phone_number', + '*.phone', + '*.address', + + // Health & medical + 'health_history', + 'health_notes', + 'allergies', + '*.health_history', + '*.health_notes', + '*.allergies', + + // Pregnancy & baby + 'due_date', + 'baby_sex', + 'baby_name', + 'pregnancy_number', + 'past_pregnancy_experience', + 'number_of_babies', + '*.baby_sex', + '*.baby_name', + '*.due_date', + + // Demographics + 'annual_income', + 'insurance', + 'race_ethnicity', + 'client_age_range', + '*.annual_income', + '*.insurance', + '*.race_ethnicity', + + // Request body patterns + 'req.body.*.health_history', + 'req.body.*.allergies', + 'req.body.*.annual_income', + 'req.body.*.insurance', +]; +``` + +--- + +## Phase 5: Service Layer Updates + +### 5.1 Update Services That Access Client Data + +**Files requiring updates**: + +1. `src/services/portalInviteService.ts` - Uses `client_info` directly +2. `src/services/portalEligibilityService.ts` - Reads client contracts/payments +3. `src/services/contractClientService.ts` - Accesses client for contract + generation +4. `src/services/stripePaymentService.ts` - Joins contracts with clients + +**Pattern**: Replace direct Supabase queries with repository calls + +### 5.2 Update SignNow Contract Processor + +**File**: `src/utils/signNowContractProcessor.ts` + +This file directly upserts to `client_info`. Needs to: + +1. Route writes through repository layer +2. Separate sensitive writes from operational writes +3. Log sensitive field modifications (metadata only) + +--- + +## File Change Summary + +### New Files to Create + +| File | Purpose | +| --------------------------------------------------------------- | ------------------------------------- | +| `src/sensitive/sensitiveDatabase.ts` | Sensitive database client | +| `src/sensitive/databaseRouter.ts` | Database routing logic | +| `src/sensitive/migrations/*.sql` | Sensitive DB schema | +| `src/dto/response/*.ts` | Response DTO definitions (11 DTOs) | +| `src/dto/mappers/*.ts` | Entity-to-DTO mappers | +| `src/utils/responseBuilder.ts` | Standardized API responses | +| `src/utils/featureFlags.ts` | Rollout feature flags | +| `src/repositories/interface/assignmentRepository.ts` | Missing interface | +| `src/repositories/interface/requestFormRepository.ts` | Missing interface | +| `src/repositories/sensitive/sensitiveClientRepository.ts` | Sensitive client repository | +| `src/repositories/sensitive/sensitiveHealthRepository.ts` | Sensitive health repository | +| `src/repositories/sensitive/sensitiveDemographicsRepository.ts` | Sensitive demographics repository | +| `src/repositories/sensitive/sensitiveAuditRepository.ts` | Audit repository | +| `src/repositories/composite/compositeClientRepository.ts` | Dual-DB repository | +| `src/middleware/sensitiveAccessMiddleware.ts` | Sensitive access control | +| `src/services/sensitiveAuditService.ts` | Audit logging service (metadata only) | + +### Existing Files to Modify + +| File | Changes | +| --------------------------------------- | ------------------------------------- | +| `src/index.ts` | Add Sensitive client, composite repos | +| `src/common/utils/logger.ts` | Add sensitive field redaction | +| `src/controllers/clientController.ts` | Use DTOs + ApiResponse wrapper | +| `src/controllers/doulaController.ts` | Use DTOs + ApiResponse wrapper | +| `src/controllers/adminController.ts` | Use DTOs + ApiResponse wrapper | +| `src/routes/dashboardRoutes.ts` | Use DTOs + ApiResponse wrapper | +| `src/entities/Client.ts` | Remove sensitive from `toJson()` | +| `src/services/portalInviteService.ts` | Use repository layer | +| `src/utils/signNowContractProcessor.ts` | Route through repositories | + +--- + +## Environment Variables Required + +```env +# Sensitive Database (Cloud SQL PostgreSQL) +SENSITIVE_DATABASE_URL=postgres://... +SENSITIVE_DATABASE_SSL_MODE=require +SENSITIVE_DATABASE_POOL_MIN=2 +SENSITIVE_DATABASE_POOL_MAX=10 + +# Feature Flags (Rollout Control) +ENABLE_SPLIT_DB=false +SPLIT_DB_READ_MODE=shadow +SPLIT_DB_WRITE_MODE=dual + +# Audit +SENSITIVE_AUDIT_ENABLED=true +``` + +--- + +## Testing Strategy + +### Unit Tests + +- Repository interface compliance +- DTO mapper correctness +- Sensitive field classification validation +- Response builder shape validation (shadow vs primary mode) +- Sensitive gating logic per Appendix H (omit vs null behavior) + +### Integration Tests + +- Composite repository batched reads (no N+1) +- Sensitive audit logging (metadata only, no values) +- Response shape validation against DTOs +- Shadow read comparison logic +- Authorization checks for sensitive field access + +### Contract Tests + +#### Primary-Mode Contract Tests (All 13 Committed Endpoints) + +These tests run ONLY when `SPLIT_DB_READ_MODE=primary`. They verify the +canonical API contract. + +| Category | Test Assertions | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Wrapper Shape** | All endpoints return `{ success: true, data: ... }` or `{ success: false, error: ... }` | +| **List Endpoints** | `GET /clients`, `GET /clients/:id/activities`, `GET /api/doulas/hours`, `GET /api/dashboard/calendar` return `{ success, data: [], meta: { count } }` | +| **Single-Item Endpoints** | All non-list endpoints return `{ success, data: }` | +| **No Raw Arrays** | No endpoint returns a raw array at top-level | +| **DTO Stability** | Response fields match committed DTO definitions | +| **Sensitive in Lists** | Sensitive fields NEVER present in `ClientListItemDTO` | +| **Sensitive Gating** | Sensitive fields OMITTED (not null) in `ClientDetailDTO` when unauthorized | + +#### Shadow-Mode Validation Tests + +These tests run ONLY when `SPLIT_DB_READ_MODE=shadow`. They do NOT validate +wrapper shape or `meta.count`. + +| Category | Test Assertions | +| ---------------------- | ----------------------------------------------------------------- | +| **Mismatch Rate** | Diff logs show <1% mismatch rate for 48 continuous hours | +| **Diff Logging** | All discrepancies between old and new data sources are captured | +| **No Missing Records** | New source returns same record count as old source | +| **Field Consistency** | Field values match between old and new sources (within tolerance) | + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +| ------------------------------ | ----------------------------------------------------- | +| Data inconsistency between DBs | Use same `client_id` as FK, transaction-like patterns | +| Sensitive leakage in logs | Comprehensive redaction, log auditing | +| Performance degradation | Parallel queries, batched reads (no N+1) | +| Frontend breakage | Feature flags, shadow responses, ApiResponse wrapper | +| Webhook duplication | Idempotency keys, deduplication | +| N+1 query patterns | Batched reads enforced in composite repositories | + +--- + +## Acceptance Criteria + +### Primary Mode Acceptance (REQUIRED before cutover) + +| Criteria | Validation Method | +| ------------------------------------------------------------ | ---------------------------- | +| All list endpoints include `meta.count` in primary mode | Contract tests | +| No raw arrays at top-level in primary mode | Contract tests | +| All committed endpoints return ApiResponse wrapper | Contract tests | +| Contract tests verify wrapper for all 13 committed endpoints | CI pipeline | +| DTO fields stable across environments | Contract tests | +| No endpoint returns raw DB records or `entity.toJson()` | Code review + contract tests | + +### Data Integrity Acceptance + +| Criteria | Validation Method | +| ----------------------------------------------------------------- | ------------------------- | +| Client list endpoint never triggers per-client Sensitive DB calls | Query logging + load test | +| Shadow-read diff logs show <1% mismatch for 48 hours | Log analysis | +| Sensitive fields never appear in list endpoints | Contract tests | +| Sensitive fields omitted (not null) when unauthorized | Contract tests | + +### Security & Audit Acceptance + +| Criteria | Validation Method | +| ------------------------------------------------------------------ | ----------------------------- | +| Sensitive audit logs store METADATA only (field names, not values) | Log audit + grep verification | +| No sensitive values in application logs | Log audit + grep verification | +| Sensitive access requires authorization per Appendix H | Integration tests | + +### Frontend Compatibility Acceptance + +| Criteria | Validation Method | +| ------------------------------------------------- | -------------------- | +| Frontend behavior unchanged in shadow mode | E2E tests | +| Frontend receives expected shapes in primary mode | E2E tests | +| Backend/frontend feature flags synchronized | Deployment checklist | + +--- + +## Success Criteria Summary + +1. All sensitive fields accessible only via Sensitive repository +2. No raw DB fields in API responses (DTOs + ApiResponse wrapper only in primary + mode) +3. All list endpoints include `meta.count` in primary mode +4. All sensitive access logged in `sensitive_access_audit` (metadata only) +5. No sensitive data in application logs +6. No N+1 queries in list endpoints +7. Sensitive fields omitted (not null) when unauthorized +8. Sensitive fields never appear in list endpoints +9. Frontend behavior unchanged +10. All existing tests pass +11. Response shapes documented and versioned +12. Shadow validation passes with <1% mismatch rate for 48 hours +13. Backend and frontend feature flags synchronized during rollout diff --git a/.cursor/rules/require-frontend-preflight-skill.mdc b/.cursor/rules/require-frontend-preflight-skill.mdc new file mode 100644 index 00000000..8ab63dd0 --- /dev/null +++ b/.cursor/rules/require-frontend-preflight-skill.mdc @@ -0,0 +1,27 @@ +--- +description: Require frontend preflight skill before each task +alwaysApply: true +--- + +# Mandatory Preflight Skill + +Before starting implementation, first apply: +- `.cursor/skills/sokana-frontend-preflight-scan/SKILL.md` + +## Rule + +- Preflight is required for every task. +- Before any new backend task, backend must check frontend handoff queue first. + +## Required before every backend task + +- Check `.cursor/handoffs/open/` for open `frontend->backend` tasks. +- Explicitly communicate one of: + - `open_handoff_tasks_found` with file names, or + - `no_open_handoff_tasks`. +- If open tasks exist, prioritize them before unrelated new implementation unless user explicitly overrides. + +## Required before coding + +- Update `.cursor/skills/sokana-doula-cloudsql-sync/frontend-context.md` first. +- Record files scanned and compatibility assumptions. diff --git a/.cursor/rules/require-ticket-status-update.mdc b/.cursor/rules/require-ticket-status-update.mdc new file mode 100644 index 00000000..f949dab6 --- /dev/null +++ b/.cursor/rules/require-ticket-status-update.mdc @@ -0,0 +1,16 @@ +--- +description: Always update ticket/handoff status when task completes +alwaysApply: true +--- + +# Ticket Status Completion Rule + +For any task linked to a ticket or handoff file: + +1. Update ticket status at completion (e.g. `open` -> `closed` or `ready_for_verification`). +2. Update checklist items and add a short completion summary. +3. Move ticket file out of `.cursor/handoffs/open/` when closed: + - preferred target: `.cursor/handoffs/closed/` +4. If anything remains, keep status open and explicitly list remaining items. + +Do not finish a task without updating ticket status. diff --git a/.cursor/rules/task-commands.mdc b/.cursor/rules/task-commands.mdc new file mode 100644 index 00000000..fa7974c4 --- /dev/null +++ b/.cursor/rules/task-commands.mdc @@ -0,0 +1,59 @@ +--- +description: Task command interface for open handoffs +alwaysApply: true +--- + +# Task Commands Rule + +Treat the following user phrases as explicit task commands in this repo. + +## Command: `task` + +When the user says `task`: + +1. Read `.cursor/handoffs/open/`. +2. List all open task files. +3. For each open task, provide a detailed explanation: + - title and priority + - why it is needed + - requested changes + - acceptance criteria + - current completion status (checked vs unchecked items) +4. If no open tasks exist, say so explicitly. + +## Command: `run task` + +When the user says `run task` (with or without a task name): + +1. Determine target task: + - If user provides a task file name or clear identifier, use it. + - If not provided and only one open task exists, run that one. + - If multiple open tasks exist and no identifier is provided, ask which one. +2. Implement the task end-to-end. +3. Verify result (tests/lints/checks as appropriate). +4. Update task file: + - mark completed checklist items + - update metadata status (`open` -> `closed` or `ready_for_verification`) + - add completion summary +5. If closed, move file from `.cursor/handoffs/open/` to `.cursor/handoffs/closed/`. +6. Report outcome with what was completed and what (if anything) remains. + +## Command: `status` + +When the user says `status`: + +1. Show current task summary: + - total open tasks + - total closed tasks +2. If a task identifier is provided, show detailed status for that task: + - metadata status + - completed vs remaining checklist items + - blockers/next steps +3. If no identifier is provided, show a concise dashboard of all open tasks. + +## Normalization + +- Treat these variants the same: + - `task`, `tasks`, `list tasks` + - `run task`, `execute task` + - `status`, `task status` diff --git a/.cursor/skills/sokana-cloudsql-local-connect/SKILL.md b/.cursor/skills/sokana-cloudsql-local-connect/SKILL.md new file mode 100644 index 00000000..b3cbdceb --- /dev/null +++ b/.cursor/skills/sokana-cloudsql-local-connect/SKILL.md @@ -0,0 +1,148 @@ +--- +name: sokana-cloudsql-local-connect +description: Connect to Sokana Cloud SQL (Postgres) locally via Cloud SQL Proxy. Use when running migrations, scripts, or backend against the sokana_private database. Never embeds credentials—password stays in shell history or a gitignored file only. +--- + +# Connect to Sokana Cloud SQL Locally + +## Purpose + +Connect to the Sokana Cloud SQL Postgres database from your machine for migrations, scripts, or running the backend. All steps are safe for sharing—no secrets in this doc. + +## Non-Secret Connection Details + +| Property | Value | +| ---------- | ----------------------------------------------------- | +| Instance | `sokana-private-data:us-central1:sokana-phi-postgres` | +| Local host | `127.0.0.1` | +| Local port | `5433` | +| Database | `sokana_private` | +| User | `app_user` | + +--- + +## 1) Start Cloud SQL Proxy (Terminal A) + +```bash +cloud-sql-proxy "sokana-private-data:us-central1:sokana-phi-postgres" --address 127.0.0.1 --port 5433 +``` + +Leave this running. + +--- + +## 2) Set Password + DATABASE_URL (Terminal B) + +### Session-only (fastest, safe) + +```bash +export DB_PASSWORD='PASTE_PASSWORD_HERE' +export DATABASE_URL="postgresql://app_user:${DB_PASSWORD}@127.0.0.1:5433/sokana_private?sslmode=disable" +``` + +Replace `PASTE_PASSWORD_HERE` with your actual password. It stays in your shell only. + +### Verify connection + +```bash +psql "$DATABASE_URL" -P pager=off -c "select current_database() as db, current_user as user, now();" +``` + +--- + +## 3) For Backend (CLOUD_SQL_* env vars) + +If running the backend (`npm run dev`), use: + +```bash +export CLOUD_SQL_HOST=127.0.0.1 +export CLOUD_SQL_PORT=5433 +export CLOUD_SQL_DATABASE=sokana_private +export CLOUD_SQL_USER=app_user +export CLOUD_SQL_PASSWORD='PASTE_PASSWORD_HERE' +export CLOUD_SQL_SSLMODE=disable +``` + +Or add these to `.env` (ensure `.env` is in `.gitignore`). **Preferred:** Use project `.env` which already has `CLOUD_SQL_*` — scripts/check-cloudsql-data.ts and backend load it via dotenv. + +--- + +## 4) No-Prompt, No-Paste Every Time (gitignored file) + +Store password in a local-only, gitignored file: + +```bash +# One-time setup +cd /Users/jerrybony/Documents/GitHub/backend # or your project root +printf "DB_PASSWORD=%s\n" "PASTE_PASSWORD_HERE" > .env.local +``` + +Add `.env.local` to `.gitignore` if not already there. + +Load and use: + +```bash +set -a +source .env.local +set +a +export DATABASE_URL="postgresql://app_user:${DB_PASSWORD}@127.0.0.1:5433/sokana_private?sslmode=disable" +``` + +--- + +## 5) Migration Scripts (if applicable) + +From `migration-script` or similar: + +```bash +cd /Users/jerrybony/Desktop/migration-script +export ARTIFACTS_DIR="./artifacts" +bash run_assignments.sh +bash run_notes_check.sh +``` + +Ensure `DB_PASSWORD` and `DATABASE_URL` are set in the same shell before running. + +--- + +## 6) Retrieve Password From Terminal (if already exported) + +```bash +echo "$DB_PASSWORD" +# or if you used PGPASSWORD previously: +echo "$PGPASSWORD" +``` + +If `DATABASE_URL` is set: + +```bash +printenv DATABASE_URL +``` + +*(That shows the password if embedded—which is why we never put the full URL in docs.)* + +--- + +## Troubleshooting: `invalid_grant` / `invalid_rapt` + +If the proxy logs `auth: "invalid_grant" "reauth related error (invalid_rapt)"`, Application Default Credentials are expired or invalid. + +**Fix:** Run in your terminal (outside automation/sandbox—browser must open): + +```bash +gcloud auth application-default login +``` + +Complete sign-in in the browser. Then restart the proxy. + +**Google Workspace:** If you use a managed Google Workspace account with strict reauth policies, `invalid_rapt` can persist. Options: +- Use a personal Google account that has Cloud project access, or +- Ask your admin to adjust reauth/RAPT requirements for dev access. + +--- + +## Guardrails + +- **Never** put the password or full `DATABASE_URL` into this skill, Notion, GitHub, or screenshots. +- Keep the password in shell history or a gitignored `.env.local` only. +- Use `PASTE_PASSWORD_HERE` (or similar) as the placeholder in shared instructions. diff --git a/.cursor/skills/sokana-doula-cloudsql-sync/SKILL.md b/.cursor/skills/sokana-doula-cloudsql-sync/SKILL.md new file mode 100644 index 00000000..c017c48f --- /dev/null +++ b/.cursor/skills/sokana-doula-cloudsql-sync/SKILL.md @@ -0,0 +1,130 @@ +--- +name: sokana-doula-cloudsql-sync +description: Maintains backend/frontend alignment for Sokana doula workflows using Cloud SQL as source of truth for doulas, clients, assignments, hours, and activities, with Supabase for auth and doula documents. Use when debugging doula dashboard issues, fixing response-shape mismatches, mapping schemas, or implementing doula profile/clients/hours/activities flows across backend and frontend repos. +--- + +# Sokana Doula Cloud SQL Sync + +## Purpose + +Use this skill to keep backend and frontend behavior aligned for doula workflows. + +Primary goals: +- Ensure Cloud SQL is the operational source of truth for doula dashboard data. +- Keep Supabase limited to auth and doula documents/storage. +- Prevent and fix API response-shape drift between backend and frontend. +- Provide a repeatable debugging workflow for "empty tab" and "not showing data" incidents. + +## Source Of Truth Rules + +- **Supabase** + - Auth users/session (`auth.users`, JWT/cookies). + - Doula documents/storage flow. +- **Cloud SQL (`sokana_private`)** + - `public.doulas` (doula profile records keyed by auth user id). + - `public.phi_clients` (client records). + - `public.doula_assignments` (doula-client relationship). + - `public.hours` (time logs). + - `public.client_activities` (activities/notes). + +When implementing or debugging, do not move these responsibilities unless explicitly requested. + +## Active Contracts To Preserve + +### Doula profile +- Backend endpoint: `GET /api/doulas/profile` +- Must resolve auth user, then return profile using Cloud SQL doula row where available. + +### Doula clients +- Backend endpoint: `GET /api/doulas/clients` +- Must be driven by `public.doula_assignments` + `public.phi_clients`. + +### Doula hours +- Backend endpoints: + - `POST /api/doulas/hours` + - `GET /api/doulas/hours` +- Write/read Cloud SQL `public.hours`. +- Request input should accept both: + - snake_case: `client_id`, `start_time`, `end_time` + - camelCase: `clientId`, `startTime`, `endTime` +- Response should remain tolerant for frontend: + - include `start_time` and `startTime` + - include `end_time` and `endTime` + - include `client.id`, `client.firstname`, `client.lastname` + - include legacy fallback `client.user.firstname`, `client.user.lastname` + +### Doula activities +- Backend endpoints: + - `POST /api/doulas/clients/:clientId/activities` + - `GET /api/doulas/clients/:clientId/activities` +- Use Cloud SQL `public.client_activities`. +- Activities act as notes. + +### Doula documents +- Backend endpoint: `GET/POST/DELETE /api/doulas/documents` +- Keep on Supabase. + +## Execution Workflow (Backend + Frontend) + +1. **Confirm data location** + - Verify tables in Cloud SQL before changing code. + - Verify row existence for current doula/client ids. +2. **Trace backend path** + - Route -> controller -> use case -> repository -> SQL table. + - Confirm role/assignment checks use `public.doula_assignments`. +3. **Trace frontend path** + - Tab component -> API service function -> normalization -> UI mapping. + - Confirm expected response wrapper (`{ success, ... }` vs array). +4. **Patch with compatibility** + - Prefer adding tolerant parsing and dual field support before removing old shapes. +5. **Validate quickly** + - Confirm request status codes in logs. + - Confirm rows inserted/returned in Cloud SQL. + - Confirm UI displays row and computed totals. +6. **Document deltas** + - Update `frontend-context.md` in this skill with new contracts once frontend stabilizes. + +## Debug Playbook: "Data Saved But Not Showing" + +Use this exact order: +- Check backend logs for `POST` status (expect `201`) and follow-up `GET` status (`200`). +- Query Cloud SQL table to confirm inserted row. +- Compare API response body shape to frontend parser assumptions. +- Patch parser normalization first if shape mismatch exists. +- Add no-cache headers for highly dynamic dashboard endpoints when stale `304` behavior appears. + +## Guardrails + +- Do not revert unrelated local changes. +- Prefer additive compatibility changes for payload shapes. +- Keep auth behavior unchanged unless issue is explicitly auth-related. +- Keep API normalization in service layer, not scattered across components. +- Remove or reduce noisy logs once a flow is stable. + +## Files Usually Touched + +Backend: +- `src/controllers/doulaController.ts` +- `src/repositories/cloudSqlClientRepository.ts` +- `src/repositories/supabaseUserRepository.ts` +- `src/repositories/cloudSqlActivityRepository.ts` +- `src/services/cloudSqlDoulaAssignmentService.ts` +- `src/db/migrations/*.sql` + +Frontend: +- `frontend-crm/src/api/doulas/doulaService.ts` +- `frontend-crm/src/features/doula-dashboard/components/HoursTab.tsx` +- `frontend-crm/src/features/doula-dashboard/components/ActivitiesTab.tsx` +- `frontend-crm/src/features/doula-dashboard/components/ClientsTab.tsx` +- `frontend-crm/src/features/doula-dashboard/components/DocumentsTab.tsx` + +## Update Policy For This Skill + +When frontend changes land: +1. Append new response contracts and normalization rules to `frontend-context.md`. +2. Mark deprecated shapes and keep migration notes. +3. Keep this `SKILL.md` stable and concise; move detailed, evolving mappings into reference docs. + +## Additional Reference + +- See [frontend-context.md](frontend-context.md) for living contract notes and known fragility points. diff --git a/.cursor/skills/sokana-doula-cloudsql-sync/frontend-context.md b/.cursor/skills/sokana-doula-cloudsql-sync/frontend-context.md new file mode 100644 index 00000000..451e3aa6 --- /dev/null +++ b/.cursor/skills/sokana-doula-cloudsql-sync/frontend-context.md @@ -0,0 +1,2748 @@ +# Frontend Context (Living Reference) + +This file is intentionally updateable as frontend work finishes. + +## Preflight Update 2026-08-25 (HIPAA-05 doula assignment email minimization) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Remove PHI from doula-assignment emails; notify with + client_number + authenticated CRM activities deep-link only (HIPAA-05). +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (deferred; user + prioritized HIPAA-05) +- **Files Scanned**: + - `frontend-crm/src/features/doula-dashboard/DoulaDashboardRoutes.tsx` + - `frontend-crm/src/features/doula-dashboard/DoulaDashboardSidebar.tsx` + - `frontend-crm/src/common/data/sidebar-data.ts` + - `backend/src/services/emailService.ts` (`sendDoulaMatchNotification`) + - `backend/src/controllers/adminController.ts` (`matchDoulaWithClient`) +- **Contract Findings**: + - Doula assignment CRM deep-link: `/doula-dashboard/activities/{clientId}` + (protected doula route). + - Old email used wrong path `/doula/dashboard`; corrected to activities + deep-link matching frontend routing. + - No frontend changes required for this backend email minimization. +- **Status**: [x] Context updated · [x] Implementation complete · [x] Production + deploy `00044-bhf` + +## Preflight Update 2026-08-24 (deep-link not-found quiet UX) + +- **Gate Result**: `run_preflight` +- **Task Intent**: Email CRM deep-link to missing client must not show list + banner ("Error loading clients") or "Client Not Found" modal. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (deferred) +- **Files Scanned**: + - `frontend-crm/src/features/clients/Clients.tsx` + - `frontend-crm/src/common/hooks/clients/useClients.ts` + - `frontend-crm/src/features/clients/components/users-dialogs.tsx` + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` +- **Contract Findings**: + - Deep-link `/admin/clients/:clientId` opens lead modal via + `RouteAwareLeadProfileLoader` + `getClientById`. + - `getClientById` previously wrote 404 into shared `error`, which rendered the + list banner while the missing-client modal also opened. +- **Action**: Quiet redirect to `/admin/clients` (or `/clients`) on miss; detail + 404 does not set list `error`. +- **Status**: [x] Context updated · [x] Implementation complete + +## Preflight Update 2026-08-24 (HIPAA-13F intake email minimization) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Remove clinical/identity payload from public intake staff + emails; notify with client_number + authenticated CRM link only (INV-01). +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (deferred; user + prioritized HIPAA-13F) +- **Files Scanned**: + - `frontend-crm/src/features/request/RequestForm.tsx` + - `frontend-crm/src/features/request/RequestFormDesktop.tsx` + - `frontend-crm/src/features/request/contexts/RequestFormContext.tsx` + - `frontend-crm/src/features/clients/ClientRoutes.tsx` + - `frontend-crm/src/Routes.tsx` + - `backend/src/controllers/requestFormController.ts` + - `backend/src/features/intake/notifications/intakeStaffNotificationEmail.ts` +- **Contract Findings**: + - Public intake posts to `/requestService/requestSubmission`; success message + contract unchanged (`PUBLIC_INTAKE_SUCCESS_MESSAGE`). + - Frontend does not parse staff email content; CRM deep-link + `/admin/clients/:clientId` (and `/clients/:clientId`) already supported. + - No frontend API response-shape change required for this ticket. +- **Drift Risk**: Low — email is backend-only; frontend continues to open leads + via authenticated CRM routes. +- **Required Compatibility**: Keep public intake 200 body + `{ message: PUBLIC_INTAKE_SUCCESS_MESSAGE }`; CRM client routes remain + staff-auth gated. +- **Action**: + - [x] Context updated + - [x] Implementation complete + +## Preflight Update 2026-08-23 (remove legacy birth_outcomes narrative) + +- **Gate Result**: `run_preflight` +- **Task Intent**: Drop free-text `birth_outcomes`; CRM saves via + `PUT /clients/:id/birth-outcomes` only (structured dropdowns/checkboxes). +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (deferred) +- **Files Scanned**: + - `frontend-crm/src/features/doula-dashboard/components/ActivitiesTab.tsx` + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + - `frontend-crm/src/api/services/clients.service.ts` + - `frontend-crm/src/api/doulas/doulaService.ts` + - `backend/src/controllers/clientController.ts` + - `backend/src/constants/phiFields.ts` +- **Contract Findings**: + - Generic `PUT /clients/:id` now returns 400 for any birth-outcomes keys. + - Dedicated route returns + `{ success, data: { birth_outcomes_induction, ... } }`. + - Legacy narrative column no longer exposed in client detail DTO/API. +- **Action**: [x] Context updated · [x] Implementation complete + +## Preflight Update 2026-08-23 (INV-12 birth-outcomes assignment) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Enforce `canAccessSensitive` on + `PUT /clients/:id/birth-outcomes`. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (deferred; user + prioritized INV-12) +- **Files Scanned**: + - `frontend-crm/src/features/doula-dashboard/components/ActivitiesTab.tsx` + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + - `frontend-crm/src/common/utils/updateClient.ts` + - `frontend-crm/src/config/clientFieldRouting.ts` + - `frontend-crm/src/api/doulas/doulaService.ts` + - `backend/src/controllers/clientController.ts` + - `backend/src/routes/clientRoutes.ts` +- **Contract Findings**: + - Doula dashboard and Lead Profile save structured birth outcomes via + `updateClient` → `PUT /clients/:id` (generic), not + `PUT /clients/:id/birth-outcomes`. + - Dedicated birth-outcomes endpoint expects snake_case structured fields; + frontend already uses those keys in save payloads. + - Denied access should surface as non-2xx; frontend shows toast via + `result.success` / HTTP error — no change required for 403. +- **Drift Risk**: Generic `PUT /clients/:id` may still accept birth-outcome + fields without assignment check until separately gated. +- **Required Compatibility**: Keep 200 success shape + `{ success: true, data: { birth_outcomes_induction, ... } }` on dedicated + route; 403 body `{ success: false, error, code: 'FORBIDDEN' }`. +- **Action**: + - [x] Context updated + - [x] Implementation complete + +## Preflight Update 2026-08-23 (payment-schedule migration) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Apply Cloud SQL migration adding `paid_at` to fix + payment-schedule 500. +- **Repos Scanned**: backend only +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (deferred) +- **Files Scanned**: + - `src/db/migrations/20260717_complete_payment_schedules_cloudsql.sql` + - `scripts/run-cloudsql-migration.ts` + - `src/services/installmentInvoiceService.ts` +- **Contract Findings**: DB schema drift caused + `/clients/:id/billing/payment-schedule` 500. +- **Drift Risk**: Local Cloud SQL must stay aligned with service SQL + expectations. +- **Action**: + - [x] Context updated + - [x] Migration applied locally + +## Preflight Update 2026-08-23 (LeadProfileModal DialogDescription) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Add Radix `DialogDescription` to Lead Profile modal (Leads + tab). +- **Repos Scanned**: frontend only +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (non-HIPAA; deferred) +- **Files Scanned**: + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + - `frontend-crm/src/common/components/ui/dialog.tsx` +- **Contract Findings**: UI-only a11y fix. No API contract change. +- **Drift Risk**: None. +- **Required Compatibility**: N/A +- **Manual verification (2026-08-23)**: User opened lead profile from Leads tab; + no PHI in browser console logs (HIPAA-07 spot-check). +- **Action**: + - [x] Context updated + - [x] Implementation started + +## Preflight Entry Checklist + +Use this checklist at the top of every new preflight entry: + +- **Gate Result**: `run_preflight` or `skip_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: one line +- **Repos Scanned**: backend/frontend/both +- **Files Scanned**: list of concrete paths +- **Context Updated**: yes/no +- **Implementation Started After Gate**: yes/no + +## Preflight Update 2026-08-22 (HIPAA-07 frontend sensitive logging) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Remove PHI/token/body console logging from CRM SPA; add CI + gate. +- **Repos Scanned**: both (frontend primary; backend docs/handoff only) +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`, + `2026-08-22-hipaa-07-frontend-sensitive-logging.md` +- **Files Scanned**: + - `frontend-crm/src/common/utils/updateClient.ts` + - `frontend-crm/src/common/utils/deleteClient.ts` + - `frontend-crm/src/common/utils/createContract.ts` + - `frontend-crm/src/features/clients/Clients.tsx` + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + - `frontend-crm/src/api/doulas/doulaService.ts` + - Full `src/` for `console.log` / `console.debug` +- **Contract Findings**: No API contract change. Logging only. Production uses + `logger` no-ops + `safeLog` metadata (`scope`, `operation`, `status`). +- **Drift Risk**: Reintroducing `console.log` of payloads would re-expose PHI in + browser DevTools; CI `check:sensitive-logging` blocks regression. +- **Required Compatibility**: Keep toast/user-visible errors; never log response + bodies. Export/download flows must not console-log CSV/JSON payloads. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-20 (QB-authoritative card on file) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Card-on-file status must check linked QuickBooks customer + cards only. +- **Repos Scanned**: both +- **Files Scanned**: + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + - `frontend-crm/src/api/services/clients.service.ts` + - `backend/src/services/payments/customerPaymentMethodService.ts` +- **Contract Findings**: `GET /api/payment-methods/:clientId` now returns + `message` and treats QuickBooks customer cards as sole on-file authority (no + local fallback for staff messaging). FE displays `message` directly. +- **Drift Risk**: Older FE without `message` still has on_file/status fallbacks. +- **Required Compatibility**: Keep `{ success, data }` wrapper; include + `message`, `on_file`, `source`. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-20 (Payment Schedule HTML 404) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix production Payment Schedule red HTML from missing + `/api/payment-methods/:clientId`. +- **Repos Scanned**: both +- **Files Scanned**: + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + - `frontend-crm/src/api/services/clients.service.ts` + - `frontend-crm/src/api/http.ts` + - `backend/src/server.ts` + - `backend/src/routes/paymentMethodRoutes.ts` +- **Contract Findings**: FE calls `GET /api/payment-methods/:clientId` + + `GET /clients/:id/billing/payment-schedule` via `Promise.all`. Route existed + in code but was gated behind `FEATURE_QUICKBOOKS`; prod docs set that flag + false → Express HTML 404. FE rendered raw HTML as `billingError`. +- **Drift Risk**: Card-on-file path must stay mounted even when QB OAuth is off. +- **Required Compatibility**: Always mount `/api/payment-methods`; keep + `{ success, data }` wrapper for card status. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-20 (HIPAA-13A full-field CSV export) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Expand admin client CSV export to all `phi_clients` columns. +- **Repos Scanned**: both +- **Files Scanned**: + - `frontend-crm/src/features/clients/components/users-primary-buttons.tsx` + - `backend/src/repositories/cloudSqlClientRepository.ts` + - `backend/docs/HIPAA_13A_CLIENT_CSV_EXPORT_STATUS.md` +- **Contract Findings**: Path/auth unchanged (`GET /clients/fetchCSV`, + admin-only). CSV body expands from 4 columns to all `phi_clients` columns + (~88). FE still downloads `demographics.csv` as text/csv — no FE parse of + columns. +- **Drift Risk**: Larger PHI payload on admin export; FE must not log response + body. +- **Required Compatibility**: Keep text/csv download UX for admin Export button. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-20 (HIPAA-13A admin-only CSV export) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Restrict bulk client CSV export to admin; document exported + fields for stakeholders. +- **Repos Scanned**: both +- **Files Scanned**: + - `frontend-crm/src/features/clients/components/users-primary-buttons.tsx` + - `backend/src/routes/clientRoutes.ts` + - `backend/src/usecase/clientUseCase.ts` + - `backend/src/middleware/authorizeRoles.ts` + - `backend/src/repositories/cloudSqlClientRepository.ts` +- **Contract Findings**: Path unchanged (`GET /clients/fetchCSV`). CSV body + unchanged (`first_name,last_name,annual_income,address_line1` all rows). Role + allowlist now admin-only (was admin+client). FE Export button gated to + `user.role === 'admin'`. +- **Drift Risk**: Non-admin callers that previously succeeded now get 403; + doulas on Clients page no longer see Export. +- **Required Compatibility**: Keep `/clients/fetchCSV` + text/csv download for + admin. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-20 (HIPAA-13A CSV export existence check) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Confirm whether bulk client CSV export exists as a CRM + feature (HIPAA-13A / INV-02). +- **Repos Scanned**: both +- **Files Scanned**: + - `frontend-crm/src/features/clients/components/users-primary-buttons.tsx` + - `backend/src/routes/clientRoutes.ts` + - `backend/src/usecase/clientUseCase.ts` + - `backend/src/repositories/cloudSqlClientRepository.ts` +- **Contract Findings**: Feature exists end-to-end. FE Clients toolbar calls + `GET /clients/fetchCSV` and downloads `clients.csv`. BE allows roles `admin` + and `client`; use case re-checks same; repo + `SELECT first_name, last_name, annual_income, address_line1 FROM phi_clients` + with no row filter. +- **Drift Risk**: Tightening BE roles to admin-only will break CSV button for + any non-admin caller that currently succeeds; FE should stay admin-gated or + show clear 403. +- **Required Compatibility**: Keep path `/clients/fetchCSV` and CSV download UX + for admin. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (existence question only) + +## Preflight Update 2026-08-20 (HIPAA board technical re-verify) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Re-verify HIPAA board items against current frontend/backend + code; update technical status list (no implementation). +- **Repos Scanned**: both +- **Files Scanned**: + - `frontend-crm/src/common/utils/updateClient.ts` + - `frontend-crm/src/common/utils/deleteClient.ts` + - `frontend-crm/src/common/hooks/auth/useIdleTimeout.ts` + - `frontend-crm/src/features/clients/Clients.tsx` + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + - `frontend-crm/src/api/doulas/doulaService.ts` + - `backend/src/routes/clientRoutes.ts` + - `backend/src/routes/specificUserRoutes.ts` + - `backend/src/controllers/clientController.ts` (`updateClientBirthOutcomes`, + `exportCSV`) + - `backend/src/services/emailService.ts` (`sendDoulaMatchNotification`) + - `backend/src/services/clientDocumentUploadService.ts` +- **Contract Findings**: No API contract change. Confirmed open: + `GET /clients/fetchCSV` allows `client`; birth-outcomes has no assignment + check; FE logs full client update payloads; assignment emails include client + email + notes. +- **Drift Risk**: None for this pass (docs only). +- **Required Compatibility**: Unchanged. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (status update only) + +## Preflight Update 2026-08-20 + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix Services Interested multiselect not showing/persisting on + Lead Profile (production). +- **Repos Scanned**: both +- **Files Scanned**: + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + - `frontend-crm/src/config/clientFieldRouting.ts` + - `frontend-crm/src/api/mappers/client.mapper.ts` + - `backend/src/controllers/clientController.ts` + - `backend/src/repositories/cloudSqlClientRepository.ts` +- **Context Updated**: yes +- **Implementation Started After Gate**: yes +- **Root cause**: GET `/clients/:id` merged home intake fields but omitted + `services_interested` / service text fields; frontend multiselect without + `altKey` only read `editedData`, not fetched detail. +- **Fix**: Backend `mergeServiceProfileFields`; frontend + `resolveProfileFieldValue` + mapper/init for services fields; same pattern + covers `demographics_multi`. + +## Preflight Update 2026-08-20 (client detail prefetch + cache) + +- **Gate Result**: `run_preflight` +- **Task Intent**: Performance — prefetch `GET /clients/:id` on row click; cache + detail across modal open/close. +- **Files Scanned**: users-table, LeadProfileModal, useClients, clients.service, + Clients deep-link loader +- **Context Updated**: yes +- **Implementation**: `clientDetailCache.ts` (Map cache + in-flight dedupe); row + click prefetch; modal reads cache synchronously on open; force refresh after + save. + +## Preflight Update 2026-08-20 (local services multiselect test) + +- **Gate Result**: `run_preflight` +- **Task Intent**: Local verification — Services Interested green pills not + showing after save/read. +- **Repos Scanned**: both +- **Files Scanned**: LeadProfileModal resolve/save paths, clients.service + fetchClientById, clientController mergeExtendedProfileFields +- **Context Updated**: yes +- **Local setup**: backend `:5050`, frontend `:3001`, + `VITE_USE_CLOUD_RUN=false`, Cloud SQL proxy `:5433`, + `SPLIT_DB_READ_MODE=primary` +- **Diagnostic**: GET `/clients/:id` must include `services_interested` array; + UI reads `servicesInterested` via mapper alias. +- **Follow-up fix**: refetch detail after profile save; + `resolveProfileFieldValue` uses `readProfileFieldFromRecord` for editedData + (camelCase alias support). + +## Preflight Update 2026-08-20 (profile form field audit) + +- **Gate Result**: `run_preflight` +- **Task Intent**: Audit all Lead Profile form fields for read/persist gaps + (same class as Home Type / Services Interested). +- **Repos Scanned**: both +- **Files Scanned**: LeadProfileModal, clientController merge paths, + cloudSqlClientRepository map/update, profileArrayFields, client.mapper +- **Context Updated**: yes +- **Findings**: + - Backend GET omitted many intake scalars (`preferred_contact_method`, + `birth_location`, `primary_language`, `provider_type`, + `relationship_status`, family phones, `demographics_multi`, + `intake_age_years`, `children_expected`, `pets` via incomplete user + mapping). + - `mapRowToUser` skipped columns present on `phi_clients`; + `updateClientOperational` blocked several saves. + - Frontend fixed globally via `resolveProfileFieldValue` + expanded camelCase + aliases (not only services). + - **Not persisted** (by design / no column): `family_pronouns`, `family_email` + on Cloud SQL intake INSERT. + +## Repos + +- Backend: `/Users/jerrybony/Documents/GitHub/backend` +- Frontend: `/Users/jerrybony/Documents/GitHub/sokana-crm-frontend/frontend-crm` + +## Doula Dashboard Map + +- Route container: `src/features/doula-dashboard/DoulaDashboard.tsx` +- Tabs: + - `components/ProfileTab.tsx` + - `components/DocumentsTab.tsx` + - `components/ClientsTab.tsx` + - `components/HoursTab.tsx` + - `components/ActivitiesTab.tsx` +- Main service: `src/api/doulas/doulaService.ts` + +## Auth + Request Transport + +Frontend P0 (2026-08-14) is **done and aligned with the backend**. The SPA is +not a security boundary; the API still rejects unauthorized calls. Full +write-up: `docs/SECURITY_P0_HARDENING_SUMMARY.md` → “Frontend P0”. + +- Role and session: `/auth/me` (not Supabase `user_metadata`). `StaffCrmRoute` / + `ClientPortalRoute`. `403` ≠ logout. +- CRM calls: `fetchWithAuth` (cookie + `Authorization` / `X-Session-Token` from + sessionStorage). No global `window.fetch` patch. Signed storage blob + downloads: raw `fetch` + `credentials: 'omit'`. +- Public intake: honeypot, `Idempotency-Key`, 429/`Retry-After`, + `credentials: 'omit'`; test-data fill only in `DEV` or + `VITE_ENABLE_REQUEST_TEST_DATA`. No `skip_email_notifications`. Contract + verification not in localStorage. +- Host: Cloud Run `sokana-front-end`. API URL baked via Cloud Build + `_VITE_APP_BACKEND_URL`. Vercel is being decommissioned; `vercel.json` headers + do not protect production — put CSP/HSTS on the Cloud Run frontend container. +- Mobile login: frontend and API are different sites (`*.run.app`). + Safari/Chrome on phones often drop the `sb-access-token` cookie even with + `SameSite=None; Secure`. After `POST /auth/login`, store JSON `token` in + sessionStorage and send `Authorization` + `X-Session-Token` on `/auth/me`. + Cookie remains httpOnly for desktop; header token is the mobile fallback. +- Remaining (not this fix): XSS vs sessionStorage token; Google OAuth callback + that only sets a cookie then redirects; Supabase `sb-auth` in localStorage; + mobile layout (UX). + +## Known Response Wrappers To Support + +For doula APIs, frontend currently sees multiple wrappers and should tolerate: + +- Raw array +- `{ success: true, data: [...] }` +- `{ success: true, clients: [...] }` +- `{ success: true, hours: [...] }` +- `{ success: true, activities: [...] }` +- `{ clients: [...] }` +- `{ hours: [...] }` +- `{ activities: [...] }` +- `{ data: [...] }` + +## Hours Contract Notes + +Backend currently returns hours through `GET /api/doulas/hours` as: + +- Wrapper: `{ success: true, hours: [...] }` +- Entries may contain: + - `start_time` and `startTime` + - `end_time` and `endTime` + - `client.id`, `client.firstname`, `client.lastname` + - compatibility nested: `client.user.firstname`, `client.user.lastname` + +Frontend parser in `src/api/doulas/doulaService.ts` should: + +- unwrap wrappers above +- normalize date fields to `startTime`/`endTime` +- compute `hours` if not provided + +## Activities Contract Notes + +- Endpoint: `GET /api/doulas/clients/:clientId/activities` +- Source: Cloud SQL `public.client_activities` +- Frontend should normalize: + - `created_at`/`createdAt` + - `description`/`content` + - `created_by`/`createdBy` + +## Documents Contract Notes + +- Endpoint: `/api/doulas/documents` +- Source: Supabase table/storage. +- Missing table in Supabase should degrade gracefully: + - return empty list and no hard failure in UI. + +## Known Fragility + +- Duplicate normalization logic exists in both service and components. +- Verbose console logs can obscure real issues. +- Mixed API approaches (centralized service layer vs direct fetch modules) can + drift. + +## Stabilization Checklist (Update As Work Finishes) + +- [ ] Consolidate normalization into shared API mappers. +- [ ] Reduce service/component debug logging after verification. +- [ ] Keep backwards compatibility for payload shapes until all tabs are + updated. +- [ ] Add explicit contract tests for `clients`, `hours`, and `activities`. +- [ ] Remove deprecated fields only after frontend rollout confirms parity. + +## Preflight Update 2026-03-02 + +### Task + +- Establish a required frontend pre-task scanning workflow skill. + +### Files Scanned + +- `src/api/doulas/doulaService.ts` +- `src/features/doula-dashboard/components/HoursTab.tsx` +- `src/features/doula-dashboard/components/ClientsTab.tsx` +- `src/features/doula-dashboard/components/ActivitiesTab.tsx` +- `src/features/doula-dashboard/components/DocumentsTab.tsx` +- `src/main.tsx` +- `src/common/contexts/UserContext.tsx` +- `src/common/components/routes/ProtectedRoutes.tsx` +- `src/Routes.tsx` + +### Contract Findings + +- Doula dashboard relies on service-layer normalization for wrapper and field + variance. +- Hours list requires unwrapping `{ success, hours }` and mixed snake/camel + field support. + +### Drift Risk + +- Backend/frontend changes can silently diverge due to mixed API styles and + duplicated transforms. + +### Required Compatibility + +- Preserve wrappers (`data`, `clients`, `hours`, `activities`) and mixed field + shapes until consolidation is complete. + +### Action + +- [x] Context updated +- [x] Preflight skill created + +## Preflight Update 2026-03-02 (Cloud SQL doula bio column) + +### Gate Result + +- `run_preflight` + +### Reason + +- `preflight_required_every_task` + +### Task + +- Add `bio` column to Cloud SQL `public.doulas`. + +### Files Scanned + +- `frontend-crm/src/api/doulas/doulaService.ts` +- `frontend-crm/src/features/doula-dashboard/components/ProfileTab.tsx` +- `.cursor/skills/sokana-doula-cloudsql-sync/frontend-context.md` + +### Contract Findings + +- Frontend expects `profile.bio` in both fetch and update profile flows. +- Backend schema currently lacked `public.doulas.bio`, so Cloud SQL could not + store profile bio. + +### Drift Risk + +- Without a Cloud SQL `bio` column, profile parity remains partial and update + persistence can drift between layers. + +### Required Compatibility + +- Add `bio` as nullable text in Cloud SQL with idempotent migration. + +### Action + +- [x] Context updated +- [x] Implementation started + +## Preflight Update 2026-03-02 (Task command rule set) + +### Gate Result + +- `run_preflight` + +### Reason + +- `preflight_required_every_task` + +### Task + +- Add command-style rule mappings for `task`, `run task`, and `status` in + backend workspace. + +### Files Scanned + +- `.cursor/rules/require-ticket-status-update.mdc` +- `.cursor/rules/require-frontend-preflight-skill.mdc` +- `.cursor/handoffs/open/` + +### Contract Findings + +- Operational workflow needed explicit command semantics for listing, executing, + and reporting handoff tasks. + +### Drift Risk + +- Without command normalization, task handling behavior can vary between + sessions. + +### Required Compatibility + +- Support command aliases (`tasks`, `list tasks`, `execute task`, `task status`) + with consistent behavior. + +### Action + +- [x] Context updated +- [x] Implementation started + +## Preflight Update 2026-03-02 (Ticket closure + status rule) + +### Gate Result + +- `run_preflight` + +### Reason + +- `preflight_required_every_task` + +### Task + +- Close completed handoff ticket and add rule to always update ticket status + after task completion. + +### Files Scanned + +- `.cursor/handoffs/open/2026-03-02-backend-doula-profile-cloudsql-bio.md` +- `.cursor/rules/require-frontend-preflight-skill.mdc` + +### Contract Findings + +- Operational process needed enforcement: completed tasks can remain marked open + unless explicitly closed and moved. + +### Drift Risk + +- Open queue can become inaccurate and cause duplicate work if status hygiene is + not enforced. + +### Required Compatibility + +- Standardize completion workflow for handoff/ticket files: + - status update, + - checklist update, + - completion summary, + - move to closed folder. + +### Action + +- [x] Context updated +- [x] Implementation started + +## Preflight Update 2026-03-02 (Cloud SQL profile field parity) + +### Gate Result + +- `run_preflight` + +### Reason + +- `preflight_required_every_task` + +### Task + +- Execute open handoff for Cloud SQL-first doula profile parity (`bio`, address + fields, account status). + +### Files Scanned + +- `frontend-crm/src/api/doulas/doulaService.ts` +- `frontend-crm/src/features/doula-dashboard/components/ProfileTab.tsx` +- `backend/src/controllers/doulaController.ts` +- `backend/src/services/cloudSqlTeamService.ts` + +### Contract Findings + +- Frontend profile form expects `bio`, `address`, `city`, `state`, `country`, + `zip_code`, `account_status`. +- Backend profile response must remain `{ success, profile }` and tolerate Cloud + SQL-only doula records. + +### Drift Risk + +- If PUT still writes only legacy `users`, Cloud SQL-only doulas fail with + `User not found`. +- Missing Cloud SQL columns prevent round-trip persistence for profile fields. + +### Required Compatibility + +- Cloud SQL-first GET/PUT for doula profile fields. +- Keep profile response compatible with existing frontend parser. + +### Action + +- [x] Context updated +- [x] Implementation started + +## Preflight Update 2026-03-09 (Doula Assign services 400) + +### Task + +- Debug "Failed to assign doula: 400 services is required" — DoulaAssignment.tsx + calls assignDoula without services; backend requires services. + +## Preflight Update 2026-03-10 (Unique client number) + +### Gate Result + +- run_preflight + +### Task + +- Auto-generate unique client_number when new client submits intake/request + form. + +### Files Scanned + +- backend: src/repositories/requestFormRepository.ts, + cloudSqlClientRepository.ts, ClientMapper.ts +- frontend-crm: src/api/dto/client.dto.ts, src/api/mappers/client.mapper.ts, + src/domain/client.ts, src/features/clients/components/users-columns.tsx, + LeadProfileModal.tsx + +### Contract Findings + +- Backend generates `client_number` (format CL-NNNNN) on phi_clients insert via + sequence. +- Client list (GET /clients) and detail (GET /clients/:id) now include + `client_number`. +- Frontend DTOs, mappers, and domain types updated; Client # column added to + leads table; profile modal shows Client #. + +### Drift Risk + +- Existing phi_clients have null client_number; only new form submissions get + one. Frontend tolerates missing value. + +### Required Compatibility + +- Preserve client_number in ClientListItemDTO and ClientDetailDTO; display as + read-only in CRM. + +### Contract Findings + +- DoulaAssignment.tsx: assignDoula(clientId, doulaId, { role }) — no services + sent +- Backend: POST /clients/:id/assign-doula requires services + +## Preflight Update 2026-03-11 (Doula documents ID mismatch) + +### Gate Result + +- run_preflight + +### Task + +- Fix admin doula documents: ID mismatch between Cloud SQL doula id and Supabase + auth user id in doula_documents. + +### Files Scanned + +- backend: src/controllers/doulaController.ts, + src/services/cloudSqlTeamService.ts, + src/repositories/doulaDocumentRepository.ts +- frontend-crm: src/api/doulas/doulaService.ts, + src/features/doula-dashboard/components/DocumentsTab.tsx + +### Contract Findings + +- Admin document endpoints: GET /api/admin/doulas/:doulaId/documents, PATCH + review, GET url. Frontend admin UI calls these with Cloud SQL doula id. +- Documents stored in Supabase doula_documents with doula_id = Supabase auth + user id. When Cloud SQL doula id ≠ auth id, admin saw empty list. + +### Drift Risk + +- None. Backend fallback is transparent; frontend contract unchanged. + +### Required Compatibility + +- No frontend changes. Response shape unchanged. + +### Action + +- [x] Context updated +- [x] Implementation started + +## Preflight Update 2026-03-19 (Doula profile demographics) + +### Gate Result + +- run_preflight + +### Task + +- Doula Profile tab: gender, pronouns, required multi-select race/ethnicity, + optional other details; persisted on `public.doulas`. + +### Contract Findings + +- `GET/PUT /api/doulas/profile` returns/accepts `gender`, `pronouns`, + `race_ethnicity` (string[]), `race_ethnicity_other`, + `other_demographic_details`. +- Migration: `src/db/migrations/add_doula_demographics_to_doulas.sql`. + +### Action + +- [x] Context updated + +## Preflight Update 2026-03-19 (Client-visible doula activities) + +### Gate Result + +- run_preflight + +### Task + +- Doulas mark activities as visible to clients; clients only receive filtered + list on `GET /clients/:id/activities`. + +### Contract Findings + +- `client_activities.metadata` jsonb stores `visibleToClient` (boolean). Default + hidden for legacy rows (strict `=== true` to show). +- `POST /api/doulas/clients/:clientId/activities` accepts `visibleToClient` / + `visible_to_client`. +- `GET /clients/:id/activities` reads Cloud SQL (same store as doula + activities); role `client` allowed for own client id only; response filtered + to visible entries. +- `POST /clients/:id/activity` (admin/doula) accepts optional + `visible_to_client` / `visibleToClient`; persists via Cloud SQL + `createActivity`. +- Activity DTO may include `visible_to_client` and `metadata` for staff UIs. + +### Action + +- [x] Context updated + +## Preflight Update 2026-04-29 (Start backend + Cloud SQL) + +### Gate Result + +- `run_preflight` + +### Reason + +- `preflight_required_every_task` + +### Task + +- Start backend dev server and Cloud SQL proxy for local development. + +### Repos Scanned + +- both + +### Files Scanned + +- `frontend-crm/src/api/doulas/doulaService.ts` +- `frontend-crm/src/features/doula-dashboard/DoulaDashboard.tsx` +- `frontend-crm/src/features/doula-dashboard/components/HoursTab.tsx` +- `frontend-crm/src/features/doula-dashboard/components/ClientsTab.tsx` +- `frontend-crm/src/features/doula-dashboard/components/ActivitiesTab.tsx` +- `frontend-crm/src/features/doula-dashboard/components/DocumentsTab.tsx` +- `backend/.cursor/handoffs/open/2026-03-11-backend-doula-documents-id-mismatch.md` + +### Contract Findings + +- No contract changes needed for starting services. + +### Drift Risk + +- None. + +### Required Compatibility + +- None. + +### Action + +- [x] Context updated +- [ ] Implementation started + +## Preflight Update 2026-04-29 (Cloud SQL doula languages column) + +### Gate Result + +- `run_preflight` + +### Reason + +- `preflight_required_every_task` + +### Task + +- Add Cloud SQL column `public.doulas.languages_other_than_english` (TEXT[]) to + persist doula languages. + +### Repos Scanned + +- both + +### Files Scanned + +- `backend/src/db/migrations/add_doula_demographics_to_doulas.sql` +- `frontend-crm/src/api/doulas/doulaService.ts` +- `frontend-crm/src/features/doula-dashboard/components/ProfileTab.tsx` +- `frontend-crm/src/features/doula-dashboard/DoulaDashboard.tsx` + +### Contract Findings + +- Frontend profile UI reads/writes `languages_other_than_english` as `string[]` + (required field in Profile tab). +- Backend migration already includes + `ADD COLUMN IF NOT EXISTS languages_other_than_english TEXT[]`. +- Frontend `DoulaProfile`/`UpdateProfileData` types in `doulaService.ts` may lag + the UI usage (ensure backend accepts/returns the field regardless of frontend + typing drift). + +### Drift Risk + +- If Cloud SQL schema lacks the column, `PUT /api/doulas/profile` cannot persist + languages and `GET /api/doulas/profile` cannot round-trip the field. + +### Required Compatibility + +- `GET /api/doulas/profile` must return `languages_other_than_english: string[]` + (or `null`/missing tolerated). +- `PUT /api/doulas/profile` must accept `languages_other_than_english: string[]` + and persist to Cloud SQL. + +### Action + +- [x] Context updated +- [ ] Implementation started + +## Preflight Update 2026-04-29 (Client birth outcomes structured) + +### Gate Result + +- `run_preflight` + +### Reason + +- `preflight_required_every_task` + +### Task + +- Add structured birth outcomes fields on `public.phi_clients` and expose + `PUT /api/clients/:id/birth-outcomes`. + +### Repos Scanned + +- both + +### Files Scanned + +- `frontend-crm/src/features/doula-dashboard/components/ActivitiesTab.tsx` +- `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` +- `frontend-crm/src/api/services/clients.service.ts` +- `frontend-crm/src/api/dto/client.dto.ts` +- `frontend-crm/src/api/mappers/client.mapper.ts` + +### Contract Findings + +- Frontend sends `PUT /api/clients/:id/birth-outcomes` with **snake_case** JSON: + - `birth_outcomes_induction` (boolean) + - `birth_outcomes_delivery_type` (string, one of a fixed allowed set) + - `birth_outcomes_medications_used` (string[], non-empty, allowed set) +- Frontend expects `GET /api/clients/:id` to return the new structured fields + when authorized, while keeping legacy `birth_outcomes` (free-text) readable + for display/history. +- `GET /api/doula-assignments` now includes `birthOutcomesInduction`, + `birthOutcomesDeliveryType`, `birthOutcomesMedicationsUsed` per row. +- `GET /api/doulas/clients` list returns birth outcomes fields (via + OPERATIONAL_COLUMNS after migration). + +### Drift Risk + +- If backend accepts camelCase only (or stores inconsistent values), CRM save + flows will fail and reporting fields will be unreliable. + +### Required Compatibility + +- Accept **snake_case** payload for the new birth outcomes endpoint. +- Return new structured fields in client detail responses when authorized; do + not remove legacy `birth_outcomes`. +- Migration `add_phi_clients_birth_outcomes_structured.sql` must be applied to + Cloud SQL before backend restart. + +### Action + +- [x] Context updated +- [x] Implementation started + +## Preflight Update 2026-05-04 (Birth outcomes 404 debug + full spec implementation) + +### Gate Result + +- `run_preflight` + +### Reason + +- `preflight_required_every_task` + +### Task + +- Fix 404 on `PUT /clients/:id/birth-outcomes`; implement full birth outcomes + spec. + +### Files Scanned + +- `frontend-crm/src/features/doula-dashboard/components/ActivitiesTab.tsx` +- `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` +- `frontend-crm/src/common/utils/updateClient.ts` +- `frontend-crm/src/api/services/clients.service.ts` +- `backend/src/controllers/clientController.ts` +- `backend/src/repositories/cloudSqlClientRepository.ts` +- `backend/src/services/doulasService.ts` +- `backend/src/db/migrations/add_phi_clients_birth_outcomes_structured.sql` + +### Contract Findings + +- `PUT /clients/:id/birth-outcomes` route and controller already existed; + returning 404 because migration not applied (columns missing). +- `GET /api/doula-assignments` response now includes `birthOutcomesInduction`, + `birthOutcomesDeliveryType`, `birthOutcomesMedicationsUsed` (camelCase in DTO, + snake_case in DB). +- `GET /api/doulas/clients` list now includes birth outcomes via updated + OPERATIONAL_COLUMNS (with pre-migration fallback). + +### Drift Risk + +- If migration not applied, backend falls back gracefully (lists work, PUT + returns 503 with migration message). + +### Required Compatibility + +- **MIGRATION REQUIRED**: Run + `src/db/migrations/add_phi_clients_birth_outcomes_structured.sql` against + Cloud SQL before restarting backend. + +### Action + +- [x] Context updated +- [x] Implementation started + +--- + +## Preflight Update 2026-05-04 + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Lead → Customer lifecycle with Leads/Customers tabs, QB + customer creation on match +- **Repos Scanned**: both +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +### Files Scanned + +- `src/features/clients/Clients.tsx` +- `src/features/clients/data/schema.ts` +- `src/features/clients/components/data-table-toolbar.tsx` +- `src/features/clients/components/users-table.tsx` +- `src/api/quickbooks/auth/customer.ts` +- `src/controllers/clientController.ts` +- `src/repositories/cloudSqlClientRepository.ts` +- `src/repositories/interface/clientRepository.ts` +- `src/dto/response/ClientDetailDTO.ts` +- `src/mappers/ClientMapper.ts` + +### Contract Findings + +- Frontend `schema.ts` was mapping `customer` status → `'not hired'`. Fixed to + map → `'matched'`. +- Backend `updateClientStatus` now fires `syncMatchedClientToQuickBooks` async + (non-blocking) when `status → matched`. +- `phi_clients` gains `matched_at TIMESTAMPTZ` and `qbo_customer_id TEXT` + (migration required). +- `ClientDetailDTO` and `ClientMapper.toDetailDTO` now expose `matched_at` and + `qbo_customer_id`. +- Frontend `Clients.tsx` renders Leads/Customers tabs; Leads = + `status !== 'matched'`, Customers = `status === 'matched'`. +- `DataTableToolbar` accepts `viewMode` prop; both tabs show Status filter + independently. + +### Drift Risk + +- If migration not applied, `OPERATIONAL_COLUMNS_BASE` query will fail on + restart. Apply migration first. +- QB sync is non-blocking; if QB is not connected, sync fails silently + (warn-level log only). + +### Required Compatibility + +- **MIGRATION REQUIRED**: Run + `src/db/migrations/add_matched_lifecycle_fields_to_phi_clients.sql` against + Cloud SQL (sokana_private). + +## Preflight Update 2026-05-04 + +### Task + +- Prevent duplicate QB customer creation: check by email then by display name + before creating + +### Files Scanned + +- `src/services/customer/syncMatchedClientToQuickBooks.ts` +- `src/services/payments/findCustomerInQuickBooks.ts` +- `src/controllers/clientController.ts` + +### Contract Findings + +- `syncMatchedClientToQuickBooks` now runs a 3-tier dedup check before creating: + 1. CRM record already has `qbo_customer_id` → skip + 2. QB query by `PrimaryEmailAddr` → found → link existing ID, skip creation + 3. QB query by `DisplayName` (First Last) → found → link existing ID, skip + creation + 4. Not found by either → create new QB customer +- `SyncMatchedClientResult` gains `alreadyExisted: boolean` field. +- Controller log differentiates "linked existing" vs "created new". + +### Drift Risk + +- No frontend contract changes; `qbo_customer_id` is stored the same way + regardless of path. + +### Action + +- [x] Context updated +- [x] Implementation started + +## Preflight Update 2026-05-04 (Test Results Review) + +### Task + +- Review successful test run results and backend health status + +### Files Scanned + +- Terminal output showing test results (all 118 tests passing) +- Backend test coverage across request forms, email service, QB sync + +### Contract Findings + +- All test suites passing (17 passed, 17 total) +- Request form validation working correctly +- Email service handling both success and failure scenarios +- QuickBooks sync logic operational with proper deduplication + +### Drift Risk + +- None. Backend is in healthy state with full test coverage passing. + +### Required Compatibility + +- No changes needed - all systems operational + +### Action + +- [x] Context updated +- No implementation needed - observational preflight only + +## Preflight Update 2026-05-04 (Client documents storage bucket RLS) + +### Gate Result + +- `run_preflight` + +### Task Intent + +- Fix lazy bucket creation for client-documents: must use Supabase service role, + not user JWT / anon. + +### Repos Scanned + +- backend + +### Files Scanned + +- `src/services/clientDocumentUploadService.ts` +- `src/supabase.ts` +- `src/index.ts` (wiring) +- `frontend-crm` (per handoff): `clientDocuments.ts` / + `formatClientDocumentErrorMessage` (UX only; no code change this pass) + +### Contract Findings + +- `ensureBucketExists` now calls `getSupabaseAdmin()` for `getBucket` / + `createBucket` so `storage.buckets` inserts are not subject to end-user RLS. +- Follow-up: upload/delete also use the service admin client (see next preflight + entry); the `ClientDocumentUploadService` no longer takes an injected client. + +### Drift Risk + +- If `SUPABASE_SERVICE_ROLE_KEY` is wrong or missing in an environment, bucket + ensure still fails; error text now nudges Dashboard pre-creation when RLS is + detected. + +### Action + +- [x] Context updated +- [x] Implementation started + +## Preflight Update 2026-05-04 (Client insurance / Medicaid card uploads) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Make client portal insurance and Medicaid ID photo uploads + reliable (same `POST /api/clients/me/documents` with `documentType` / + `document_type` = `insurance_card`). + +- **Repos Scanned**: both + +- **Files Scanned**: + + - `sokana-crm-frontend/frontend-crm/src/api/clients/clientDocuments.ts` + (`uploadInsuranceCard`, `formatClientDocumentErrorMessage`) + - `src/services/clientDocumentUploadService.ts` + - `src/constants/clientDocuments.ts` + - `src/controllers/clientController.ts` (`uploadMyDocument`) + - `src/index.ts` + - `src/db/migrations/create_client_documents_table.sql` + +- **Contract Findings**: + + - Frontend sends `file`, `documentType` and `document_type` = + `insurance_card`, and `category` = `billing`. + - All Storage API calls for this feature use `getSupabaseAdmin()` (service + role), including upload and delete, so Storage RLS on `INSERT` does not + apply to the server path. + - Bucket `allowed_mime_types` is widened to include `image/jpg` and common + phone formats; an `updateBucket` sync patches existing buckets that were + created with a narrow list (a frequent cause of “RLS”/upload failures that + are really MIME mismatch). + +- **Drift Risk**: Tightening allowed MIME types in the API without updating + `CLIENT_DOCUMENT_BUCKET_MIME_TYPES` and the Supabase bucket can reintroduce + storage rejections. + +- **Required Compatibility**: Only `insurance_card` remains the supported + `documentType` for this route; list/URL/delete contracts unchanged. + +- **Context Updated**: yes + +- **Implementation Started After Gate**: yes + +- **Action**: + - [x] Context updated + - [x] Implementation + +## Preflight Update 2026-05-11 (Request form — referral name field) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Confirm referral free-text exists end-to-end; align public + form label and ensure staff client detail API returns saved referral fields + when PHI-authorized. + +- **Repos Scanned**: both + +- **Files Scanned**: + + - `sokana-crm-frontend/frontend-crm/src/features/request/Step3Home.tsx` + (`Step4Referral`, `referral_name` input + `useRequestForm` schema) + - `sokana-crm-frontend/frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + (Referral Information section) + - `src/controllers/clientController.ts` (`getClientById` PHI merge) + - `src/services/RequestFormService.ts` / + `src/repositories/requestFormRepository.ts` (persistence) + - `src/dto/response/ClientDetailDTO.ts` + +- **Contract Findings**: + + - Intake payload uses snake_case `referral_source`, `referral_name`, + `referral_email`; `referral_name` is optional in zod. + - Canonical `GET /clients/:id` merges extra fields from + `findClientDetailedById().user` for authorized callers; referral fields must + be included in that merge for CRM to display saved intake values. + +- **Drift Risk**: CRM assumes API returns `referral_*` on client detail for + authorized staff. + +- **Required Compatibility**: Optional `referral_name` on submit; authorized + client detail must include `referral_source`, `referral_name`, + `referral_email` when present on the Cloud SQL row. + +- **Context Updated**: yes + +- **Implementation Started After Gate**: yes + +- **Action**: + - [x] Context updated + - [x] Implementation started + +## Preflight Update 2026-05-26 (Invoices: Cloud SQL ledger, QBO SOR) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Remove Supabase invoice persistence; use QuickBooks as + invoice object source-of-truth and Cloud SQL `phi_invoices` as CRM ledger + source-of-truth. +- **Repos Scanned**: both +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +### Files Scanned + +- `sokana-crm-frontend/frontend-crm/src/api/financial/invoicesApi.ts` +- `sokana-crm-frontend/frontend-crm/src/api/quickbooks/auth/invoice.ts` +- `sokana-crm-frontend/frontend-crm/src/api/quickbooks/auth/customer.ts` +- `sokana-crm-frontend/frontend-crm/src/features/InvoicesPage/InvoicesPage.tsx` +- `backend/src/routes/invoiceRoutes.ts` +- `backend/src/repositories/cloudSqlInvoiceRepository.ts` +- `backend/src/controllers/quickbooksController.ts` +- `backend/src/services/customer/getInvoiceableCustomers.ts` +- `backend/src/services/invoice/createInvoice.ts` +- `backend/src/services/invoice/createInvoiceInQuickBooks.ts` +- `backend/src/services/invoice/persistInvoiceToSupabase.ts` + +### Contract Findings + +- Invoice list UI reads **Cloud SQL** via `GET /api/invoices` and tolerates + `{ success: true, data: [...] }` (frontend normalizes wrapper/array). +- Invoice creation UI posts to `POST /quickbooks/invoice` with + `{ internalCustomerId, lineItems, dueDate, memo }` (cookies/credentials + included). +- Invoiceable customers list uses `GET /quickbooks/customers/invoiceable` and + expects `{ id, qboCustomerId, name, email }[]` where `id` is the **Cloud SQL** + client id. + +### Drift Risk + +- Backend invoice creation currently looks up `qbo_customer_id` from + **Supabase** `customers`, which can drift from the Cloud SQL client list used + by the UI. +- Writing invoices into Supabase `invoices` causes Cloud SQL `GET /api/invoices` + to miss newly created invoices, breaking ledger/reporting parity. + +### Required Compatibility + +- Keep `GET /api/invoices` response shape stable: + `{ success: true, data: InvoiceRow[] }`. +- Keep `GET /quickbooks/customers/invoiceable` stable and Cloud SQL-based. +- `POST /quickbooks/invoice` must create invoice in QBO, then **upsert** a Cloud + SQL `phi_invoices` ledger row keyed to Cloud SQL `phi_clients.id`. + +### Action + +- [x] Context updated +- [ ] Implementation started + +## Preflight Update 2026-05-11 (Expanded primary insurance / Medicaid parity) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Align backend billing + intake with CRM expanded insurance + fields (policy holder, plan type; optional group `policy_number` for all + insurance payment methods including Medicaid). + +- **Repos Scanned**: both + +- **Files Scanned**: + + - `sokana-crm-frontend/frontend-crm/src/features/client-dashboard/components/ClientProfileTab.tsx` + (billing GET/PUT, `primaryInsuranceDetails`-style fields, + `needsInsuranceDetails`) + - `sokana-crm-frontend/frontend-crm/src/features/request/__tests__/useRequestForm.test.tsx` + (intake field names) + - Backend (this task): `clientController`, `RequestFormService`, + `requestFormRepository`, `cloudSqlClientRepository`, migrations + +- **Contract Findings**: + + - Portal billing PUT sends snake*case: + `insurance_policy_holder*\*`, `insurance_plan_type`, optional `policy_number`, plus legacy `insurance` + mirroring provider. + - Billing GET merge tolerates snake_case and camelCase for display + (`insurancePolicyHolderName`, etc.). + - Intake uses `RequestFormService.newForm` → Cloud SQL `phi_clients` INSERT. + +- **Drift Risk**: CRM validates required insurance fields client-side; backend + must enforce the same when payment is Commercial, Private, or Medicaid or + saves will diverge. + +- **Required Compatibility**: Support four new columns on read/write; do not + require `policy_number` for Medicaid; return new fields on billing and merged + client detail. + +- **Context Updated**: yes + +- **Implementation Started After Gate**: yes + +- **Action**: + - [x] Context updated + - [x] Implementation started + +## Preflight Update 2026-05-11 (Handoff: `referral_source_other` intake + CRM) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Open backend handoff ticket for `referral_source_other` + validation, persistence on `phi_clients`, and staff client APIs; no + implementation in this step. + +- **Repos Scanned**: both + +- **Files Scanned**: + + - `sokana-crm-frontend/frontend-crm/src/features/request/Step3Home.tsx` + (referral fields, clear `referral_source_other` when leaving `Other`) + - `sokana-crm-frontend/frontend-crm/src/features/request/__tests__/useRequestForm.test.tsx` + (zod: `Other` requires non-empty `referral_source_other`) + - `sokana-crm-frontend/frontend-crm/src/api/dto/client.dto.ts` + (`referral_source_other`) + - Backend: `src/routes/requestRoute.ts`, `src/services/RequestFormService.ts`, + `src/repositories/requestFormRepository.ts`, + `src/repositories/cloudSqlClientRepository.ts`, + `src/controllers/clientController.ts`, `src/dto/response/ClientDetailDTO.ts` + +- **Contract Findings**: + + - Intake and CRM expect **snake_case** `referral_source_other` alongside + `referral_source`, `referral_name`, `referral_email`. + - Frontend requires trimmed non-empty `referral_source_other` when + `referral_source === "Other"`; allowed sources include `Google`, + `Doula Match`, `Former client`, `Sokana Member`, `Social Media`, + `Email Blast`, `Other`. + +- **Drift Risk**: Backend omitting the column, INSERT list, allowlist, or DTO + merge will drop the field silently after frontend ships. + +- **Required Compatibility**: Validate `Other` + required other-text on + `POST /requestService/requestSubmission`; persist and return on Cloud SQL + client row; staff update can set/clear; clearing when source ≠ `Other` should + match ticket (server-side clear recommended). + +- **Context Updated**: yes + +- **Implementation Started After Gate**: yes (completed 2026-05-11) + +- **Action**: + - [x] Context updated + - [x] Implementation (see + `.cursor/handoffs/closed/2026-05-11-backend-request-intake-referral-source-other.md`) + +## Preflight Update 2026-05-19 (birth place + intake payment) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: `POST /requestService/requestSubmission` — validate/persist + `birth_location` + `birth_hospital`; intake payment four CRM labels; reject + Medicaid. + +- **Repos Scanned**: both + +- **Files Scanned**: + + - `frontend-crm/docs/BACKEND_REQUEST_FORM_BIRTH_LOCATION_AND_PAYMENT_VERIFY_PROMPT.md` + - `frontend-crm/src/features/request/useRequestForm.ts`, + `src/lib/paymentRules.ts`, `dummyTestLead.ts` + - Backend: `src/intake/requestSubmissionDto.ts`, `RequestFormService.ts`, + `requestFormRepository.ts` + +- **Contract Findings**: `birth_hospital` required with `birth_location`; four + intake payment labels; Medicaid 400 on public path; + `Private/Commercial Insurance` → `Commercial Insurance` in DB. + +- **Drift Risk**: Legacy Medicaid/self-pay labels accepted on intake; birth + fields not validated or inserted. + +- **Required Compatibility**: Location-specific 400 messages; both birth columns + on INSERT; staff Medicaid via client APIs unchanged. + +- **Context Updated**: yes | **Implementation**: yes + +## Preflight Update 2026-05-24 (request submission — full CRM POST → phi_clients) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Handoff prompt — tests + persistence for CRM `/request` + submit (`DUMMY_TEST_LEAD` shape): age, provider_type, address parts, birth + place, pronouns/contact, pets, `services_interested` / + `service_support_details`, `service_needed`, insurance paths. + +- **Repos Scanned**: both + +- **Files Scanned**: + + - `frontend-crm/src/features/request/dummyTestLead.ts`, `RequestForm.tsx` + (submit transforms), `useRequestForm.ts` + - Backend: `src/intake/requestSubmissionDto.ts`, `RequestFormService.ts`, + `requestFormRepository.ts`, + `src/db/migrations/add_phi_clients_intake_crm_fields.sql` + - Tests: `requestSubmissionDto.test.ts`, `requestSubmissionFlow.test.ts`, + `requestEndpoint.test.ts` + +- **Contract Findings**: + + - POST body = spread `RequestFormValues` + numeric `number_of_babies` + + `service_needed` from services join or support text. + - `age` → `intake_age_years`; `provider_type` includes `Family Doctor` → + `Family Physician`; `Private/Commercial Insurance` → + `Commercial Insurance` + expanded primary/secondary billing validation. + +- **Drift Risk**: INSERT omitting CRM keys leaves Cloud SQL null while DevTools + shows data; intake payment labels outside four-option set 400. + +- **Required Compatibility**: Persist `city`, `state`, `zip_code`, + `birth_location`, `birth_hospital`, `provider_type`, `pronouns`, + `preferred_contact_method`, `intake_age_years`, `pets`, + `services_interested[]`, `service_support_details`, `service_needed`; run + migration on PHI DB before manual QA. + +- **Context Updated**: yes | **Implementation**: yes + +- **Action**: + - [x] Context updated + - [x] Implementation started + +## Preflight Update 2026-05-24 (home step persistence) + +- **Gate Result**: `run_preflight` | **Handoffs**: `no_open_handoff_tasks` +- **Task Intent**: Persist CRM home step on `phi_clients`: `home_access`, + `home_types[]`, `home_type_other`, `home_adults_count`, `home_youth_count` (+ + legacy `home_type` VARCHAR). +- **Files Scanned**: `dummyTestLead.ts`, `useRequestForm.ts`, + `homeTypeOptions.ts`, `homePeopleCountOptions.ts`; backend + `requestFormRepository.ts`, `RequestFormService.ts`, + `requestSubmissionDto.ts`. +- **Required Compatibility**: CRM sends `home_type` as string array; counts + `0`–`5+`; validate counts on intake; migration + `add_phi_clients_home_intake_fields.sql` before manual QA. +- **Context Updated**: yes | **Implementation**: yes + +## Preflight Update 2026-05-24 (birth place + intake payment verification) + +- **Gate Result**: `run_preflight` | **Handoffs**: `no_open_handoff_tasks` +- **Task Intent**: Verify May 2026 prompt — `birth_location` + `birth_hospital` + validation/persistence; four intake payment labels; reject Medicaid on public + `requestSubmission` only. +- **Files Scanned**: `requestSubmissionDto.ts`, `RequestFormService.ts`, + `requestFormRepository.ts`, `clientController.ts`, + `clientBillingEndpoint.test.ts`; frontend `useRequestForm.ts`, + `dummyTestLead.ts`. +- **Contract Findings**: `validateIntakeBirthPlace` + `parseIntakePaymentMethod` + in intake DTO; `newForm` applies both; INSERT binds + `birth_location`/`birth_hospital` (params 9–10); staff billing still accepts + Medicaid via client APIs. +- **Definition of done**: all checklist items satisfied; PHI DB has + `birth_location` + `birth_hospital` columns. +- **Context Updated**: yes | **Implementation**: verified (no mapper changes + required) + +## Preflight Update 2026-05-12 (request submission tests + intake DTO) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Align backend tests with CRM + `POST /requestService/requestSubmission` contract (age, provider_type, + secondary insurance, payment label, `number_of_babies` / `service_needed`). + +- **Repos Scanned**: both + +- **Files Scanned**: + + - `sokana-crm-frontend/frontend-crm/docs/BACKEND_REQUEST_SUBMISSION_TEST_PROMPT.md` + - `sokana-crm-frontend/frontend-crm/src/features/request/dummyTestLead.ts` + (`DUMMY_TEST_LEAD`) + - `sokana-crm-frontend/frontend-crm/src/features/request/useRequestForm.ts` + (age 1–120; `Private/Commercial Insurance`; provider options include + `Family Doctor`) + - Backend: `src/services/RequestFormService.ts`, + `src/repositories/requestFormRepository.ts`, `src/routes/requestRoute.ts` + +- **Contract Findings**: + + - Submit sets `number_of_babies` as a number and `service_needed` to + `services_interested.join(', ')` or trimmed support text. + - CRM payment option `Private/Commercial Insurance` must map to backend + commercial path (`Commercial Insurance`) for validation/persistence. + - `provider_type` options include `Family Doctor` (backend enum uses + `Family Physician`). + +- **Drift Risk**: Tests that omit `age` / `provider_type` no longer reflect the + CRM full submit path; payment string mismatch would 400 on intake. + +- **Required Compatibility**: Normalize `Private/Commercial Insurance` → + `Commercial Insurance`; validate age 1–120 and provider_type (with + `Family Doctor` alias); enforce secondary fields when + `has_secondary_insurance` is true (shared with `expandedInsuranceBilling`). + +- **Context Updated**: yes + +- **Implementation Started After Gate**: yes + +- **Action**: + - [x] Context updated + - [x] Implementation started + +## Preflight Update 2026-05-26 + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix production `GET /clients/team/all` UNION column mismatch + (`listTeamMembers`). + +- **Repos Scanned**: both + +- **Files Scanned**: + + - `sokana-crm-frontend/frontend-crm/src/features/teams/teams.tsx` (`fetch` → + `/clients/team/all`, raw array) + - `backend/src/services/cloudSqlTeamService.ts` (`listTeamMembers` UNION) + - `backend/src/controllers/userController.ts` + +- **Contract Findings**: + + - Frontend expects a JSON array of team members with `role` in `admin` | + `doula`; errors surface as toast + console. + +- **Drift Risk**: Admin UNION branch must pad the same nullable columns as + doulas (`languages_other_than_english` before `role`). + +- **Required Compatibility**: No response-shape change; fix SQL only. + +- **Context Updated**: yes + +- **Implementation Started After Gate**: yes + +- **Action**: + - [x] Context updated + - [x] Implementation started + +## Preflight Update 2026-07-08 + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Backend-owned portal eligibility, onboarding readiness + persistence, and client API readiness fields. +- **Repos Scanned**: backend + frontend-crm +- **Files Scanned**: + + - `frontend-crm/src/api/dto/client.dto.ts` + - `frontend-crm/src/api/mappers/client.mapper.ts` + - `frontend-crm/src/features/clients/utils/portalStatus.ts` + - `frontend-crm/src/features/clients/Clients.tsx` + - `frontend-crm/docs/FAMILY_ONBOARDING_SOP.md` + - `backend/src/controllers/clientController.ts` + - `backend/src/dto/response/ClientDetailDTO.ts` + - `backend/src/dto/response/ClientListItemDTO.ts` + +- **Contract Findings**: + + - Frontend already prefers backend `is_eligible` in `portalStatus.ts` but + still has client-side contract/payment fallbacks. + - Frontend DTO placeholders include `payment_authorization_status`; backend + now returns `payment_authorization_required`, + `payment_authorization_satisfied`, `card_on_file`, `portal_blockers`, + `primary_portal_blocker`, and `allowed_actions`. Historical + verification-invoice metadata remains deprecated and reconciliation-only. + - Client mappers currently map only `is_eligible`; new readiness fields are + additive. + +- **Drift Risk**: + + - Local frontend blocker logic can disagree with backend `allowed_actions`. + +- **Required Compatibility**: + + - Preserve `is_eligible` on list/detail responses. + - Additive snake_case readiness fields on GET `/clients` and GET + `/clients/:id`. + - Keep legacy `qbo_customer_id` while also exposing `qb_customer_id`. + +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +- **Action**: + - [x] Context updated + - [x] Implementation started + +## Preflight Update 2026-07-08 (portal readiness API test) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Run portal readiness API oracle with staff test admin + `info@techluminateacademy.com`. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: backend only (API verification) +- **Files Scanned**: `docs/PORTAL_READINESS_TEST_PLAN.md`, + `scripts/test/.env.test-readiness.example`, `.env` +- **Compatibility**: No API contract change; staff JWT login confirmed for GET + `/api/clients/:id` readiness fields. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-10 (Cloud Run gradual cutover probe) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Probe terminal access to Cloud Run private API before gradual + env-flag cutover from Vercel. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: backend docs only (no frontend contract change yet) +- **Files Scanned**: + - `docs/dev-cloudrun-auth.md` + - `docs/CLOUD_SQL_LOCAL_TEST.md` + - `docs/PRODUCTION_CLOUD_SQL_VERCEL.md` +- **Contract Findings**: + - App auth remains Supabase JWT (cookie/`Authorization`/`X-Session-Token`). + - Cloud Run service URL is IAM-gated; terminal/scripts need a Google identity + token in addition to Supabase session for protected invoke. +- **Drift Risk**: None yet — no env-flag routing implemented. +- **Compatibility assumptions**: Keep frontend `NEXT_PUBLIC_API_URL` / Vercel + base URL unchanged until explicit cutover flag; Supabase login flow unchanged. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (access probe only) + +## Preflight Update 2026-08-10 (Cloud Run Cloud SQL SSL/password fix) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix Cloud Run Cloud SQL SSL and password so `/clients` can + read sokana_private. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: backend +- **Files Scanned**: `src/db/cloudSqlPool.ts`, Cloud Run service env/secrets, + `deploy.sh` +- **Findings**: + - Unix socket `/cloudsql/...` must use `CLOUD_SQL_SSLMODE=disable`; prior pool + code forced SSL when `NODE_ENV=production`. + - `DB_PASSWORD` secret v1 mismatched local `CLOUD_SQL_PASSWORD`; synced to + secret v2 and bound as `latest`. +- **Compatibility**: No frontend contract change; Supabase remains app auth. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-10 (frontend Cloud Run cutover flag) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Gradual frontend cutover to Cloud Run API for local login + test. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: frontend-crm + backend CORS +- **Files Scanned**: + - `frontend-crm/src/config/env.ts` + - `frontend-crm/src/api/http.ts` + - `frontend-crm/src/common/contexts/UserContext.tsx` + - `frontend-crm/.env` + - `backend/src/config/env.ts` (getAllowedOrigins) +- **Contract Findings**: + - Frontend uses `VITE_USE_CLOUD_RUN=true` → `VITE_CLOUD_RUN_API_URL` for API + base. + - Auth remains cookie mode (`credentials: include`) against Cloud Run; Cloud + Run must allow `http://localhost:3001` in `FRONTEND_ORIGIN`. +- **Drift Risk**: Missing CORS origin causes browser login "Failed to fetch". +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-10 (team members empty on Cloud Run cutover) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix Team page empty list after Cloud Run cutover. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: frontend-crm + backend logs +- **Files Scanned**: `frontend-crm/src/features/teams/teams.tsx`, local backend + logs (`/clients/team/all` 401) +- **Findings**: Team page hard-coded `VITE_APP_BACKEND_URL` (localhost:5050), + bypassing `VITE_USE_CLOUD_RUN` / `apiBaseUrl`. Cookie from Cloud Run login was + not sent to localhost → 401 empty UI. +- **Fix**: Use `buildUrl` + `fetchWithAuth` for team list/update/delete/invite. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-10 (hard-coded backend URL sweep) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Replace hard-coded `VITE_APP_BACKEND_URL` fetches with + `apiBaseUrl` / `buildUrl` / `fetchWithAuth` so Cloud Run cutover flag works + app-wide. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Files changed (prod)**: teams + adminService, doulaApi, notes, + doulaAssignments, signNowService, qb status, client utils, hooks, Clients, + auth, contracts, hours, request, integrations, ClientProfileTab. +- **Left intentional**: `env.ts` resolver, type defs, error strings, test stubs. +- **Context Updated**: yes + +## Preflight Update 2026-08-10 (backend test run) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Run backend unit/build checks after Cloud Run cutover work. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: backend only +- **Files Scanned**: `package.json` scripts +- **Compatibility**: No API/frontend contract changes in this verification pass. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes (test execution only) + +## Preflight Update 2026-08-10 (Cloud Run FE→API cutover wiring) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Wire Cloud Run frontend login to Cloud Run API via CORS + + frontend redeploy. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Actions**: + - API `FRONTEND_ORIGIN` now includes Cloud Run FE URLs + localhost + Vercel. + - Triggered frontend Cloud Build (bake `VITE_APP_BACKEND_URL` = Cloud Run + API). Build SUCCESS. +- **Context Updated**: yes + +## Preflight Update 2026-08-10 (contract templates locate + download) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Locate contract templates in Supabase storage / local repo; + download existing templates locally. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: both +- **Files Scanned**: `frontend-crm/src/common/hooks/contracts/useTemplates.ts`, + `frontend-crm/src/common/types/template.ts`, backend + `supabaseContractService`, storage bucket `contract-templates` +- **Findings**: + - Supabase table `public.contract_templates` missing (PGRST205). + - Bucket `contract-templates` has 2 DOCX templates (Postpartum + Labor + Support). + - No source template DOCX/PDF in repo; only generated outputs under + `generated/`. + - Downloaded both to `backend/templates/`. +- **FE contract expect**: `GET /contracts/templates` → + `{ id, name, depositFee, serviceFee, storagePath }[]` (currently 404 on API). +- **Context Updated**: yes +- **Implementation Started After Gate**: download only (no route wiring yet) + +## Preflight Update 2026-08-10 (contracts templates storage list) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Load existing Supabase storage DOCX templates into Contracts + UI Templates panel via storage-only listing. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: both +- **Files Scanned**: `frontend-crm/src/common/hooks/contracts/useTemplates.ts`, + `PdfPreview.tsx`, `NewTemplateDialog.tsx`, `EditTemplateDialog.tsx`, + `Viewport.tsx`, backend `supabaseContractService.ts`, `server.ts` +- **Compatibility assumptions**: + - FE calls `GET /contracts/templates` expecting + `{ id, name, depositFee, serviceFee, storagePath }[]`. + - Storage-only mode returns depositFee/serviceFee as 0 (no + `contract_templates` table). + - Template display name is storage filename without extension. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-10 (templates empty UI auth/cache) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix Contracts Templates panel empty despite storage templates + existing. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Root cause**: GET /contracts/templates returned 304 under React Strict Mode + double-fetch; FE treated !ok and cleared list. Cookie-only auth also + intermittent after Cloud Run cutover. +- **Fix**: Bearer+cookie in getRequestAuth; cache:no-store on template fetch; + Cache-Control:no-store on route; show error in Viewport. +- **Context Updated**: yes + +## Preflight Update 2026-08-10 (contracts preview + layout) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix template preview on select + widen Contracts templates + panel. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: both +- **Files Scanned/Changed**: `PdfPreview.tsx` (Office Online embed of public + DOCX), `Viewport.tsx`, `TemplateItem.tsx` +- **Compatibility**: Preview no longer depends on POST + `/contracts/templates/generate` + CloudConvert; uses public Supabase storage + URL + Office viewer. +- **Context Updated**: yes + +## Preflight Update 2026-08-10 (Customers QB not-connected UX) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Replace raw HTML 404 on Customers page with friendly “connect + QuickBooks” empty state. +- **Handoff inbox**: `no_open_handoff_tasks` +- **Files**: `createCustomer.tsx`, `api/quickbooks/auth/customer.ts` +- **Behavior**: Check `/quickbooks/status` first; if disconnected or invoiceable + route missing, show CTA to `/integrations/quickbooks` instead of error HTML. +- **Context Updated**: yes + +## Preflight Update 2026-08-10 (ship trimmed PRs to main) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Ship contracts templates API + FE Contracts/Customers UX + enhancements to main via trimmed PRs (exclude docs, binaries, local-only + churn). +- **Handoff inbox**: `no_open_handoff_tasks` +- **Repos Scanned**: both +- **Files Scanned (shipping)**: + - Backend: `contractTemplateRoutes.ts`, `server.ts`, + `supabaseContractService.ts/.js`, `contractController.ts`, + `authorizeRoles.ts` + - Frontend: `http.ts`, `useTemplates.ts`, `Viewport.tsx`, `PdfPreview.tsx`, + `ContractRoutes.tsx`, `sidebar-data.ts(+test)`, `createCustomer.tsx`, + `quickbooks/auth/customer.ts` +- **Excluded**: `templates/*.docx`, architecture docs, portal-readiness + doc/script edits, `.env` +- **Compatibility**: + - FE expects `GET /contracts/templates` → template array; BE lists Supabase + storage (+ filename fallback). + - Preview uses public storage URL + Office Online embed (no + generate/CloudConvert required). + - Customers page soft-fails when QB disconnected / invoiceable route 404. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes (PR ship) + +## Preflight Update 2026-08-10 (architecture boundary todos) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Capture architecture assessment as open frontend + backend + handoff todos (no implementation). +- **Handoff inbox**: after this task → `open_handoff_tasks_found`: + - `2026-08-10-backend-architecture-boundary-refactor.md` + - frontend `2026-08-10-frontend-architecture-boundary-refactor.md` +- **Repos Scanned**: both (assessment + handoff conventions only) +- **Files Scanned**: + - Backend: `.cursor/handoffs/README.md`, `todo.md`, + `docs/Backend_Architecture_Boundary_Assessment.docx` (referenced) + - Frontend: `.cursor/handoffs/README.md`, + `.cursor/skills/sokana-cross-repo-handoff/SKILL.md` +- **Contract Findings**: FE still has global fetch patch + multi-credential + HTTP; BE has alias surface + partial composition root; auth transport + dual-support required before cutover. +- **Drift Risk**: Independent BE/FE auth or QB-sync ownership changes without + dual-support will break pilot flows. +- **Required Compatibility**: Preserve routes/responses; dual-support + cookies/headers during token migration; FE QB sync removal only after BE + idempotent ownership. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (todo/handoff creation only) + +## Preflight Update 2026-08-11 (PR 1 feature-package guardrails) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Document backend feature-package guardrails only + (`src/features/README.md`); no production moves or API changes. +- **Handoff inbox**: `open_handoff_tasks_found`: + - `2026-08-10-backend-architecture-boundary-refactor.md` + (architecture-assessment→backend; this task) + - frontend companion: `2026-08-10-frontend-architecture-boundary-refactor.md` + (out of scope) +- **Repos Scanned**: both (docs/architecture only; no contract edits) +- **Files Scanned**: + - Backend: + `.cursor/handoffs/open/2026-08-10-backend-architecture-boundary-refactor.md`, + `src/features/` (existing `invoices`, `quickbooks` only), + `src/controllers/requestFormController.ts`, `src/routes/requestRoute.ts`, + `src/services/RequestFormService.ts` + - Frontend: `src/features/` package list (incl. `request`), companion handoff + path only +- **Contract Findings**: No request/response contract changes in this PR. Public + request intake remains on legacy controllers/routes/services until a later + structural slice. +- **Drift Risk**: None for this PR (documentation + handoff status only). +- **Required Compatibility**: Preserve all existing routes and payloads; do not + create empty feature packages or move imports yet. +- **Compatibility assumptions**: Frontend continues to call current + intake/portal endpoints unchanged; backend package layout docs do not imply + runtime relocation. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes (docs/handoff only) + +## Preflight Update 2026-08-11 (PR 2 baseline and CI) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Baseline + CI gate only (route inventory, Jest open-handle + fix, GH Actions test gate, security-smoke scaffold); no API/security/behavior + changes. +- **Handoff inbox**: `open_handoff_tasks_found`: + - `2026-08-10-backend-architecture-boundary-refactor.md` (this task) + - frontend companion remains open (out of scope) +- **Repos Scanned**: both (contracts referenced for freeze docs only) +- **Files Scanned**: + - Backend: `src/server.ts`, `src/routes/*.ts`, `jest.config.js`, + `.github/workflows/lint.yaml`, `cloudbuild.yaml`, + `src/__tests__/requestEndpoint.test.ts` + - Frontend: feature package list / companion handoff only (no FE edits) +- **Contract Findings**: Inventory frozen in + `docs/ROUTE_RESPONSE_CONTRACT_INVENTORY.md`; wrappers remain mixed + (`ApiResponse`, `{success,…}`, `{data,meta}`, portal `{ok}`). +- **Drift Risk**: None from this PR if CI/docs-only; FE still depends on + existing aliases and cookie auth. +- **Required Compatibility**: Preserve routes, status codes, response fields, + `/health` semantics, Cloud Run single service. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-11 (PR 2.1 deployment gate alignment) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Enforce Cloud Build deploy-path test gate + align lint + workflow to Node 20; no API/auth/runtime changes. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: backend only (CI/deploy config) +- **Files Scanned**: `cloudbuild.yaml`, `.github/workflows/lint.yaml`, + `.github/workflows/test.yml`, `package.json` engines +- **Contract Findings**: No request/response changes. +- **Drift Risk**: None for FE contracts; deploy now blocked when the test gate + fails. +- **Required Compatibility**: Preserve Cloud Run service `sokana-private-api`, + region, Artifact Registry image path, entrypoint `node dist/cloudrun.js`. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-12 (PR 3 immediate containment) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Remove localhost telemetry; redact sensitive logs; sanitize + API error bodies; add containment regression tests. No auth/route/folder + migration. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: backend (controllers/routes/services logging + error + paths); FE companion not modified +- **Contract Findings**: Success bodies unchanged. Some 500 error bodies + intentionally sanitized (security bug fixes). +- **Drift Risk**: FE that displayed raw `error.details` / provider messages on + contract-signing/SignNow failures will now see generic messages. +- **Required Compatibility**: Preserve success JSON/status codes; endpoint auth + remains PR 4. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-12 (PR 4 endpoint authorization) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Authorization matrix + protect previously anonymous + payment/signing/QB/email routes; ownership policies; auth-matrix tests. No + webhook signatures / no folder moves. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: both (FE callers for createContract / signNow / + paymentsApi) +- **Files Scanned**: FE `createContract.ts`, `signNowService.ts`, + `paymentsApi.ts`; BE route modules listed in matrix +- **Contract Findings**: Success bodies unchanged. Newly denied anonymous calls + return existing 401/403 shapes. QB invoice-paid webhook no longer requires CRM + session. +- **Drift Risk**: Unauthenticated scripts hitting signing/payment tooling will + now get 401 (intentional security fix). +- **Required Compatibility**: Preserve public URLs/aliases; FE admin cookie auth + required for contract generation / SignNow send (already used). +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-12 (PR4 auth matrix audit) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Produce complete Express route auth matrix (authenticated vs + unauthenticated) with PR4 hardening plan; no implementation edits yet. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: both (backend route files + FE callers for + contract-signing/payments/QB customers) +- **Files Scanned**: + - backend: `src/server.ts`, all listed `src/routes/*.ts` + - frontend: `src/common/utils/createContract.ts`, + `src/services/signNowService.ts`, `src/api/financial/paymentsApi.ts`, + `src/api/quickbooks/auth/customer.ts` +- **Contract Findings**: FE already sends credentials via global fetch wrapper; + contract-signing + SignNow send + QB customers calls assume session cookies + work once auth is added. +- **Drift Risk**: Adding `authMiddleware`+`authorizeRoles` to currently-open + payment/contract/SignNow/QB-customer routes will 401 unauthenticated callers; + FE admin contract flows must remain logged-in. +- **Required Compatibility**: Keep public: health, login/signup/OAuth, + requestSubmission, SignNow `/callback`, QB `/auth`+`/callback`. Prefer moving + QB webhook registration before `authMiddleware` in PR4. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (audit-only) + +## Preflight Update 2026-08-14 (PR 5 webhooks and OAuth) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Provider webhook signature + replay/idempotency; + cryptographically secure single-use QB OAuth state. Keep public URLs. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: both +- **Files Scanned**: + - backend: SignNow/QB webhook controllers + routes, `quickbooksAuthService`, + `quickbooksController`, `server.ts` body parser + - frontend: `useQuickBooksIntegration.ts`, `api/quickbooks/auth/route.ts` + (expects `{ url }` from `/quickbooks/auth/url` or `/auth`; does not parse + OAuth state) +- **Contract Findings**: FE OAuth success contract remains `{ url: string }`. + Callback is browser redirect (not FE JSON). Webhooks are provider→backend only + (no FE callers). +- **Drift Risk**: Unsigned webhook POSTs return 401 when secrets are configured + / in production. Invalid/reused OAuth `state` fails callback (redirect to + `?quickbooks=error`). Requires Cloud SQL tables `webhook_events` + + `oauth_states` and env `SIGNNOW_WEBHOOK_SECRET` / `QB_WEBHOOK_VERIFIER_TOKEN`. +- **Required Compatibility**: Preserve paths `POST /api/signnow/callback`, + `POST /quickbooks/webhooks/invoice-paid` (+ `/api` alias), + `GET /quickbooks/auth`, `/callback`, `/auth/url`. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-14 (PR 6 auth exploration) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Explore authentication for PR 6 (authoritative roles, cookie + stability, dual-support token transport + legacy telemetry). Exploration only + — no implementation. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: both +- **Files Scanned**: + - backend: `src/middleware/authMiddleware.ts`, `authorizeRoles.ts`, + `authController.ts`, `supabaseAuthService.ts`, `usecase/authUseCase.ts`, + `repositories/supabaseUserRepository.ts`, `services/cloudSqlTeamService.ts`, + `services/portalInviteService.ts`, `security/authorizationPolicies.ts`, + `server.ts`, `routes/authRoutes.ts`, `controllers/debugController.ts` + - frontend: `src/api/http.ts`, `src/api/config.ts`, `src/api/authToken.ts`, + `src/common/contexts/UserContext.tsx`, + `src/common/components/routes/ProtectedRoutes.tsx`, + `src/common/auth/roles.ts`, `src/features/auth/AuthCallback.tsx`, + `src/main.tsx`, `ClientProfileTab.tsx` (Bearer + X-Session-Token) +- **Contract Findings**: + - FE default `VITE_AUTH_MODE=cookie`; `getRequestAuth` always attaches + Bearer + `X-Session-Token` when Supabase session exists, and uses + `credentials: 'include'` in cookie mode. + - Global `main.tsx` fetch patch forces `credentials: 'include'` for + non-Supabase URLs. + - Login cookie mode expects `Set-Cookie: sb-access-token` + optional JSON + `token`; `/auth/me` drives `user.role` for sidebar/route guards. + - OAuth callback posts JSON `{ access_token }` to `POST /auth/callback` + (legacy body token path). + - No FE usage of query-string session tokens for API auth; hash + `#access_token=` used only for Supabase recovery/set-password flows. +- **Drift Risk**: If `/auth/me` stops preferring `user_metadata.role`, FE + admin/doula/billing nav depends on DB/Cloud SQL role being correct. Cookie + name split (`sb-access-token` vs `session`) can strand OAuth users. +- **Required Compatibility**: Keep cookie + Bearer + X-Session-Token + dual-support; keep login JSON `token` field until telemetry proves unused; do + not fail-closed clients on missing staff row. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (exploration-only) + +## Preflight Update 2026-08-14 (PR 6 authentication compatibility) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Authoritative DB/app-managed roles (no staff from + `user_metadata`); standardize `sb-access-token` cookies; dual-support + header/cookie (+ legacy `session` cookie); measure legacy token transports + without retiring them. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: both (reuse PR 6 exploration scan) +- **Contract Findings**: FE still expects `/auth/me` `{ …, role }` and login + `{ user, token }` + `Set-Cookie`. Role source changes from metadata override + to Cloud SQL / `public.users` only. +- **Drift Risk**: Users whose only staff signal was forged/stale + `user_metadata.role` lose staff access (intentional). + OAuth/`POST /auth/callback` now sets `sb-access-token` (also clears legacy + `session`). +- **Required Compatibility**: Preserve cookie + Bearer + `X-Session-Token`; keep + JSON `token` on login; keep body `access_token` on POST callback; temporarily + still accept legacy `session` cookie with telemetry. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-14 (PR 7 HTTP contracts exploration) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Explore PR 7 (canonical envelope + Zod + alias deprecation + telemetry). Exploration only — no implementation. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: both +- **Files Scanned**: + - backend: `src/middleware/authMiddleware.ts`, `authorizeRoles.ts`, + `validateRequest.ts`, `common/utils/safeLogging.ts`, + `security/authorizationPolicies.ts`, `utils/responseBuilder.ts`, + `controllers/authController.ts`, `server.ts`, `routes/authRoutes.ts`, + `routes/paymentMethodRoutes.ts`, `docs/ROUTE_RESPONSE_CONTRACT_INVENTORY.md` + - frontend: `src/api/http.ts`, `src/api/config.ts`, + `src/common/contexts/UserContext.tsx`, `src/features/auth/Login.tsx`, + `src/api/doulas/doulaService.ts`, `src/api/admin/adminService.ts` +- **Contract Findings**: + - Canonical FE `ApiResponse`: `{ success: true, data }` / + `{ success: false, error, code? }`. `normalizeError` prefers `error` then + `message`. + - `requestCanonical` requires boolean `success` on OK responses; login uses + raw `fetch` and only reads `data.error` on failure — do not wrap login + success in `{ success, data }` without FE change. + - Auth middleware errors are `{ error }` (no `success: false`); safe 5xx often + `{ success: false, error }`. + - Default `VITE_USE_LEGACY_API` is off → most `get/post` use canonical parser; + many services still use `fetchWithAuth` + `error.error || error.message`. +- **Drift Risk**: Adding `code` is safe additive; removing `error` string or + forcing `success` wrapper on `/auth/login` / `/auth/me` / `/health` breaks FE. + Alias Deprecation headers must not change JSON bodies. +- **Required Compatibility**: Preserve existing status codes and top-level + `error` / `message` / `success` fields; additive `code` / `success: false` + only; keep `/login` and `/client(s)` aliases live with telemetry. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (exploration-only) + +## Preflight Update 2026-08-14 (PR 7 HTTP contracts) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Introduce canonical error codes + Zod validation + incrementally; deprecation headers/telemetry on legacy aliases; preserve + fields/status codes; no intake move (PR 8). +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: both (reuse PR 7 exploration) +- **Contract Findings**: Login success stays `{ message, user, token }`; + validation failures become + `{ success: false, error, code: 'VALIDATION_ERROR', details? }` with string + `error` preserved for UserContext. Alias JSON bodies unchanged; + Deprecation/Sunset/Link headers additive. +- **Drift Risk**: Low if `error` string retained. FE may ignore new headers. +- **Required Compatibility**: Keep `/health`, `/auth/login`, `/auth/me` + unwrapped success shapes; do not remove aliases. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-14 (PR 8 intake characterization) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Characterize public request intake for PR 8 structural + migration into `src/features/intake` (exploration only — no code move). +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (direction + `architecture-assessment->backend`, not `frontend->backend`) +- **Repos Scanned**: both +- **Files Scanned**: + - backend: `src/server.ts`, `src/routes/requestRoute.ts`, + `src/controllers/requestFormController.ts`, + `src/services/RequestFormService.ts`, + `src/repositories/requestFormRepository.ts`, + `src/intake/requestSubmissionDto.ts`, `src/constants/referralSource.ts`, + `src/billing/expandedInsuranceBilling.ts`, `src/index.ts`, + `src/__tests__/requestEndpoint.test.ts`, + `src/__tests__/requestSubmissionFlow.test.ts`, + `src/__tests__/requestSubmissionDto.test.ts`, + `docs/ROUTE_RESPONSE_CONTRACT_INVENTORY.md` + - frontend: `src/features/request/RequestForm.tsx`, + `src/features/request/useRequestForm.ts`, + `src/features/request/contexts/RequestFormContext.tsx`, + `src/api/__tests__/requestSubmission.test.ts`, e2e helpers under + `e2e/helpers/requestForm.ts` +- **Contract Findings**: + - Public URL: `POST {apiBaseUrl}/requestService/requestSubmission` (no `/api` + prefix, no auth). + - FE success check: `response.ok && !responseData.error`; toast is FE-owned + (`Request Form Submitted Successfully!`), not the BE message string. + - BE happy path: `200 { message: "Form data received, onto processing" }` — no + id/data payload. + - BE validation/service failures: `400 { error: string }` (not canonical + `{ success: false, … }` envelope). + - FE also sends `skip_email_notifications` / `submission_source`; backend + currently ignores both (emails always attempt after save). +- **Drift Risk**: Changing status codes, wrapping success in + `{ success, data }`, renaming `error`/`message`, or requiring auth would break + CRM submit. Returning client id is additive-safe if FE ignores unknown fields. +- **Required Compatibility**: Preserve public path, `200` + `{ message }`, + `400` + `{ error }` string for PR 8 façade. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (characterization only) + +## Preflight Update 2026-08-14 (PR 8 intake structural slice) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Migrate request intake into `src/features/intake` behind + existing route/controller façade; domain validation/normalization; use case + + ports; shadow-compare flag; preserve URL and response shapes. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` +- **Repos Scanned**: both (reuse characterization) +- **Contract Findings**: Unchanged public contract. Legacy + `src/intake/requestSubmissionDto.ts` becomes a re-export shim. +- **Drift Risk**: Low if normalize parity holds; + `INTAKE_USE_FEATURE_PACKAGE=true` flips write path to use case. +- **Required Compatibility**: `POST /requestService/requestSubmission` → + `200 { message: "Form data received, onto processing" }` / `400 { error }`. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-14 (intake abuse protection) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Rate limit + idempotency + abuse protection on public + `POST /requestService/requestSubmission`. +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` + (`no_open_handoff_tasks` for `frontend->backend`) +- **Repos Scanned**: both +- **Files Scanned**: BE `requestRoute.ts`, `requestFormController.createForm`, + `intakeAbuseProtection.ts`; FE `RequestForm.tsx` (checks `ok && !error`; no + Idempotency-Key today) +- **Contract Findings**: Happy path message unchanged. New + `429 { error, code: RATE_LIMITED }` for rate limits (FE already toasts + `error`). Honeypot bots get fake `200` success. Optional `Idempotency-Key` + header; soft email dedupe covers double-submit without FE changes. Jest + disables rate/soft-dedupe unless `INTAKE_ABUSE_ENFORCE=true`. +- **Drift Risk**: Legitimate multi-submit from same email within window may get + soft-deduped 200 without a second lead (intentional). +- **Required Compatibility**: Preserve `200 { message }` success string; keep + path public/unauthenticated. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-14 (security summary doc) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Document P0 security completion + GCP encryption guidance +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: backend +- **Files Scanned**: handoff P0 checklist; existing + `docs/ENDPOINT_AUTHORIZATION_MATRIX.md`, `PRODUCTION_CLOUD_SQL_VERCEL.md` +- **Contract Findings**: Docs-only; no FE API change +- **Context Updated**: yes (`docs/SECURITY_P0_HARDENING_SUMMARY.md`) +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-14 (PR to main — test gate + deploy) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Run automated tests and open a PR to `main` for Cloud Run + deploy +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: backend CI (`test.yml`, `cloudbuild.yaml`) +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Document Cloud SQL/Cloud Run at-rest and in-transit + encryption and how P0 ties into starting HIPAA +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: `SECURITY_P0_HARDENING_SUMMARY.md`; live `gcloud sql` / + `gcloud run` describe (no secrets copied into docs) +- **Context Updated**: yes +- **Implementation Started After Gate**: n/a (docs only) + +## Preflight Update 2026-08-14 (record FE P0 status in security summary) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Document frontend P0 as aligned-with-backend, Cloud Run host, + not a finished security program +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both (user status + `SECURITY_P0_HARDENING_SUMMARY.md`, + epic handoff) +- **Context Updated**: yes +- **Implementation Started After Gate**: n/a (docs only) + +## Preflight Update 2026-08-14 (FE security medium closed — sync) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Record frontend medium-risk security closures against backend + intake/auth contracts +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both (user report + BE `intakeAbuseProtection.ts`, + `authController.handleToken`) +- **Contract Findings**: Honeypot field names match BE exactly. Fake 200 + + `RATE_LIMITED`/`Retry-After`/`Idempotency-Key` header match. Body + `access_token` still dual-supported on BE (`legacy.body_access_token` + telemetry) — keep until unused. +- **Context Updated**: yes +- **Implementation Started After Gate**: n/a (sync only) + +## Preflight Update 2026-08-14 (FE security P0 closed — sync) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Record frontend high-risk security closures against backend + auth/intake contracts +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both (user report + BE intake/auth notes) +- **Contract Findings**: FE now trusts `/auth/me` for role; aligns with BE + authoritative role. Intake no longer pretends it can skip emails. BE already + has honeypot/rate-limit on submit; FE honeypot field still a medium follow-up. +- **Context Updated**: yes +- **Implementation Started After Gate**: n/a (sync only) + +## Preflight Update 2026-08-14 (Account state dropdown blank) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix Account State dropdown not showing saved state while + city/address do +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both +- **Files Scanned**: FE `UpdateAccount.tsx`, `50States.tsx`; BE Cloud SQL + `admins.state` for jerry@techluminateacademy.com +- **Contract Findings**: Backend returns `state: "Illinois"` correctly. UI bug: + Select used `defaultValue` (not controlled after `/auth/me` reset) and + SelectItem values were full names while form defaults used abbreviations. +- **Compatibility Assumptions**: Persist/display state as USPS codes (`IL`); + accept legacy full names on read. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-14 (admin first/last name split) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix Account form spilling multi-word first name into last + name after save +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both +- **Files Scanned**: FE `UpdateAccount.tsx`, `saveUser`; BE + `cloudSqlTeamService.ts`, `userController.ts` `/users/update`, migration + `add_admin_first_last_name.sql` +- **Contract Findings**: FE sends separate `firstname`/`lastname`. Admins + previously only stored `full_name` and re-split on first whitespace on read → + multi-word first names corrupted last name. Fix: persist + `admins.first_name`/`last_name` and prefer those on read. +- **Compatibility Assumptions**: Account UI continues to use + `user.firstname`/`user.lastname` from `/auth/me` and `/users/update` response; + no FE contract change required. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-14 (admin role client portal) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Diagnose admin login landing on Client Portal for + jerrybony5@gmail.com +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both +- **Files Scanned**: BE `resolveAuthoritativeRole.ts`, `supabaseAuthService.ts`; + FE client portal screenshot / ProtectedRoutes +- **Contract Findings**: PR 6 ignores Supabase metadata for staff. User had + `user_metadata`/`app_metadata` admin but no Cloud SQL `admins` row → defaulted + to `client`. Added to `public.admins`. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-17 (HIPAA technical PHI inventory) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Read-only HIPAA PHI/data-flow inventory across backend + + frontend (no application code changes) +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both +- **Files Scanned**: FE `src/features/request/useRequestForm.ts`, + `src/api/dto/client.dto.ts`, `src/config/phi.ts`, `src/common/auth/roles.ts`, + `src/Routes.tsx`, `src/common/contexts/UserContext.tsx`, + `src/api/sessionAccessToken.ts`, `src/features/client-dashboard/`; BE + `src/constants/phiFields.ts`, `src/security/authorizationPolicies.ts`, + `src/controllers/clientController.ts`, + `src/controllers/requestFormController.ts`, + `src/repositories/requestFormRepository.ts`, `src/services/emailService.ts`, + `src/services/customer/buildCustomerPayload.ts`, + `src/utils/sensitiveAccess.ts`, `docs/ENDPOINT_AUTHORIZATION_MATRIX.md` +- **Contract Findings**: Public intake schema in `useRequestForm.ts` matches + Cloud SQL `phi_clients` insert in `requestFormRepository.ts`. Frontend + `PHI_KEYS` treats name/email/phone as PHI; backend `PHI_FIELDS` / + `ClientMapper` treat those as operational identifiers. Client portal vs staff + CRM is frontend-routed (`StaffCrmRoute` / `ClientPortalRoute`) and + backend-enforced via `/auth/me` roles. +- **Drift Risk**: Inventory is read-only. No API contract change. +- **Required Compatibility**: No implementation this task. +- **Context Updated**: yes +- **Implementation Started After Gate**: no (read-only) + +## Preflight Update 2026-08-17 (mobile login session verification) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Production mobile login fails after success: "Signed in, but + the session could not be verified" +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both +- **Files Scanned**: FE `UserContext.tsx`, `http.ts`, `sessionAccessToken.ts`, + `Login.tsx`, `AuthCallback.tsx`, `config.ts`; BE `authController.ts`, + `sessionCookies.ts`, `server.ts` CORS, `authMiddleware.ts` +- **Contract Findings**: Cookie-mode login `POST /auth/login` returns + `{ message, user, token }` and `Set-Cookie: sb-access-token`. Frontend + immediately calls `GET /auth/me` via `fetchWithAuth`. `getRequestAuth()` + already sends `Authorization` + `X-Session-Token` from sessionStorage, but + `login()` never stored the JSON token. Desktop still sends the cookie; + Safari/Chrome on phones treat frontend (`*.run.app`) → API (`*.run.app`) as + third-party and drop the cookie, so `/auth/me` returns 401. +- **Drift Risk**: Mobile login stays broken if frontend ships without storing + `token`, or if backend stops returning JSON `token`. +- **Required Compatibility**: Keep JSON `token` on login; frontend must store it + and send header auth on `/auth/me`. Cookie + `SameSite=None; Secure; Partitioned` remains the desktop path. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-17 (deploy mobile session fix) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: PR + merge to `main` so Cloud Build deploys the mobile + session verification fix (frontend token storage + backend partitioned + cookies) +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: both (deploy path only) +- **Files Scanned**: BE `cloudbuild.yaml`; FE `frontend-crm/cloudbuild.yaml` +- **Contract Findings**: Unchanged from mobile login preflight. Frontend must + ship for phones to work; backend `Partitioned` cookie is Chrome-only help. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-17 (frontend lint on mobile login PR) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix Prettier on `AuthCallback.tsx` and unused `accessToken` + in `UserContext.updatePassword` (PR #80 lint) +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: frontend +- **Files Scanned**: `AuthCallback.tsx`, `UserContext.tsx`, + `.github/workflows/lint.yaml` +- **Contract Findings**: No API change. Reset-password now stores `accessToken` + for the same header-token fallback used at login. +- **Context Updated**: yes +- **Implementation Started After Gate**: yes + +## Preflight Update 2026-08-19 (Phase 1 network foundation verification) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Phase 1 only — verify Cloud SQL / Cloud Run network + foundation (read-only; no infra mutations) +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: backend (infra/docs); frontend not relevant +- **Files Scanned**: `cloudbuild.yaml`, `src/db/cloudSqlPool.ts`, + `docs/SECURITY_P0_HARDENING_SUMMARY.md`, + `.cursor/skills/sokana-cloudsql-local-connect/SKILL.md` +- **Contract Findings**: No frontend impact +- **Live verification (2026-08-19)**: Cloud SQL private IP `10.109.240.3` on + `default` VPC; PSA reserved `10.109.240.0/20`; public IP still on with ACL + `189.60.28.42/32`; Cloud Run uses connector socket only (no Direct VPC yet) +- **Action**: No changes needed (verification only) +- **Context Updated**: yes +- **Implementation Started After Gate**: no + +## Preflight Update 2026-08-19 (Phase 4 private IP production cutover) + +- **Gate Result**: `run_preflight` +- **Task Intent**: Switch prod `CLOUD_SQL_HOST` to `10.109.240.3` + `require` + TLS +- **Result**: Revision `sokana-private-api-00033-8wc`; `/health` 200; pool boot + OK; connector kept for rollback; public IP unchanged +- **Docs**: `docs/CLOUD_SQL_NETWORK_HARDENING.md` Phase 4 +- **Context Updated**: yes + +## Preflight Update 2026-08-19 (Phase 3 private IP connectivity test) + +- **Gate Result**: `run_preflight` +- **Task Intent**: TCP probe `10.109.240.3:5432` via Direct VPC; no prod DB + change +- **Result**: Job `cloudsql-private-ip-probe-hx56n` TCP OK; no-VPC control timed + out; production still on `/cloudsql/...`; `/health` 200 +- **Docs**: `docs/CLOUD_SQL_NETWORK_HARDENING.md` Phase 3 +- **Context Updated**: yes + +## Preflight Update 2026-08-19 (Phase 2 Direct VPC egress) + +- **Gate Result**: `run_preflight` +- **Task Intent**: Attach Direct VPC egress to `sokana-private-api`; keep + connector +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Contract Findings**: No frontend/API contract change +- **Result**: Revision `sokana-private-api-00032-5wc`; network-interfaces on + `default`/`default`; vpc-egress `private-ranges-only`; connector + + `/cloudsql/` host unchanged; `/health` 200 +- **Docs**: `docs/CLOUD_SQL_NETWORK_HARDENING.md` +- **Context Updated**: yes + +## Preflight Update 2026-08-19 (Cloud SQL private IP / Direct VPC assessment) + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Assess whether Cloud SQL private IP + Cloud Run Direct VPC + egress criteria are done (read-only; no API change) +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md`; + `no_open_handoff_tasks` for `frontend->backend` +- **Repos Scanned**: backend (infra/docs); frontend not relevant +- **Files Scanned**: `cloudbuild.yaml`, `src/db/cloudSqlPool.ts`, + `docs/SECURITY_P0_HARDENING_SUMMARY.md`, + `docs/PILOT_JOURNEYS_AND_ROLLBACK.md`, `docs/PRODUCTION_CLOUD_SQL_VERCEL.md`, + `.cursor/skills/sokana-cloudsql-local-connect/SKILL.md` +- **Contract Findings**: No frontend contract impact. Production DB path is + Cloud Run unix socket `/cloudsql/...`, not a private IP host. +- **Drift Risk**: None for FE. If backend later switches `CLOUD_SQL_HOST` from + `/cloudsql/...` to a private IP, FE is unaffected; Cloud Run env + SSL mode + must change together. +- **Required Compatibility**: No changes needed +- **Action**: No changes needed (assessment only) +- **Context Updated**: yes +- **Implementation Started After Gate**: no + +## Preflight Update 2026-08-19 + +- **Gate Result**: `run_preflight` +- **Reason**: `preflight_required_every_task` +- **Task Intent**: Fix local client profile save — operational fields wrongly + routed to `/clients/:id/phi` +- **Handoff inbox**: `open_handoff_tasks_found`: + `2026-08-10-backend-architecture-boundary-refactor.md` (unrelated; + user-reported bug) +- **Repos Scanned**: backend + frontend +- **Files Scanned**: + - `backend/src/constants/phiFields.ts` (PHI_FIELDS vs + OPERATIONAL_UPDATE_COLUMNS, FIELD_ALIAS_MAP) + - `backend/src/controllers/clientController.ts` (`updateClientPhi` validation) + - `frontend-crm/src/config/phi.ts` (PHI_KEYS — redaction-only, too broad for + save routing) + - `frontend-crm/src/features/clients/components/dialog/LeadProfileModal.tsx` + (save split) + - `frontend-crm/src/common/utils/updateClient.ts` (strip list) + - `frontend-crm/src/api/services/clients.service.ts` (`updateClientPhi`) +- **Contract Findings**: `PUT /clients/:id/phi` accepts only `PHI_FIELDS` + (snake_case after normalize). Fields like `paymentMethod`, `pregnancyNumber`, + `babyName`, `raceEthnicity`, `clientAgeRange`, `annualIncome`, + `hasSecondaryInsurance` are operational/billing — must not go to `/phi`. +- **Drift Risk**: Frontend `PHI_KEYS` used for save routing caused 400 on + `/phi`; operational fields were also stripped from `PUT /clients/:id` payload. +- **Required Compatibility**: Added `clientFieldRouting.ts` with backend-aligned + split; updated LeadProfileModal, updateClient, updateClientPhi; expanded + backend FIELD_ALIAS_MAP camelCase aliases. +- **Action**: Frontend routing fix + backend alias hardening +- **Context Updated**: yes +- **Implementation Started After Gate**: yes diff --git a/.cursor/skills/sokana-frontend-preflight-scan/SKILL.md b/.cursor/skills/sokana-frontend-preflight-scan/SKILL.md new file mode 100644 index 00000000..d0776416 --- /dev/null +++ b/.cursor/skills/sokana-frontend-preflight-scan/SKILL.md @@ -0,0 +1,121 @@ +--- +name: sokana-frontend-preflight-scan +description: Runs a mandatory pre-task scan of frontend-crm and updates shared context before every task. Use for any backend task to keep frontend/backend context aligned. +--- + +# Sokana Frontend Preflight Scan + +## Purpose + +Use this skill to prevent backend/frontend drift for every task. + +Run this skill when a task touches: +- Doula dashboard behavior +- API response/request contracts +- Auth/session transport behavior +- Any endpoint consumed by `frontend-crm` + +This skill runs for all tasks, including UI-only changes, to keep a consistent preflight record. + +## Mandatory Startup Gate (Handoff Inbox) + +Before starting any new backend task: +1. Check `.cursor/handoffs/open/` in backend repo. +2. Identify open `frontend->backend` tasks. +3. Communicate status explicitly: + - `open_handoff_tasks_found: ` or + - `no_open_handoff_tasks`. +4. If open tasks exist, pick them up first unless user explicitly asks to defer. + +## Repositories + +- Backend: `/Users/jerrybony/Documents/GitHub/backend` +- Frontend: `/Users/jerrybony/Documents/GitHub/sokana-crm-frontend/frontend-crm` + +## Required Preflight (Run Every Task) + +1. Scan frontend files relevant to the task (components + API service + route context). +2. Cross-check backend contracts. +3. Identify frontend response-shape assumptions and drift risk. +4. Update: + - `.cursor/skills/sokana-doula-cloudsql-sync/frontend-context.md` +5. Only then start implementation edits. + +If no context changes are needed, add a short “No changes needed” note with date in `frontend-context.md`. + +## Cross-Repo Capability (Mandatory For Integration Tasks) + +When running from backend workspace: +- Read frontend directly at: + - `/Users/jerrybony/Documents/GitHub/sokana-crm-frontend/frontend-crm` + +When running from frontend workspace: +- Read backend context directly at: + - `/Users/jerrybony/Documents/GitHub/backend/.cursor/skills/sokana-doula-cloudsql-sync/SKILL.md` + - `/Users/jerrybony/Documents/GitHub/backend/.cursor/skills/sokana-doula-cloudsql-sync/frontend-context.md` + +If a task requires edits in the other repo, perform cross-repo edits in that repo path instead of guessing. + +## Scan Targets (Minimum) + +Always inspect these when the task is doula dashboard-related: +- `src/api/doulas/doulaService.ts` +- `src/features/doula-dashboard/DoulaDashboard.tsx` +- `src/features/doula-dashboard/components/HoursTab.tsx` +- `src/features/doula-dashboard/components/ClientsTab.tsx` +- `src/features/doula-dashboard/components/ActivitiesTab.tsx` +- `src/features/doula-dashboard/components/DocumentsTab.tsx` + +Inspect these when auth/routing/request behavior may affect task: +- `src/main.tsx` +- `src/common/contexts/UserContext.tsx` +- `src/common/components/routes/ProtectedRoutes.tsx` +- `src/Routes.tsx` + +## Update Template + +Use this structure when updating `frontend-context.md`: + +```md +## Preflight Update YYYY-MM-DD + +### Task +- + +### Files Scanned +- +- + +### Contract Findings +- + +### Drift Risk +- + +### Required Compatibility +- + +### Action +- [ ] Context updated +- [ ] Implementation started +``` + +## Decision Rules + +- If frontend parser already handles wrapper variants, prefer backend consistency plus minimal frontend normalization. +- If frontend relies on legacy fields, add backward compatibility first, then deprecate later. +- For dynamic dashboard endpoints, consider no-cache behavior to avoid stale/304 artifacts. +- Keep normalization centralized in API service layer, not duplicated across components. + +## Output Expectations For Agent + +Before implementation, report: +- gate result: `run_preflight` +- Which frontend files were scanned +- What contract assumptions were found +- What was added/changed in `frontend-context.md` +- The specific compatibility strategy for this task + +## Related Skill + +- Use with: `.cursor/skills/sokana-doula-cloudsql-sync/SKILL.md` diff --git a/.env.example b/.env.example index 180575af..f2e259a0 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,33 @@ SUPABASE_URL= #your supabase url here SUPABASE_ANON_KEY= # your supabase anon key here PORT=5050 # this is the default that we used when making the template -FRONTEND_URL=http://localhost:3001 # this is the default we used when making the template -API_URL=http://localhost:5050 # this is the default we used when making the template -FRONTEND_URL_DEV=http://localhost:3001 # this is the default we used when making the template -NODE_ENV=development # NOTE: you should change this to `production` when you deploy to vercel!!!! \ No newline at end of file +NODE_ENV=development # NOTE: change to `production` when deploying to Vercel + +# CORS: required in production so frontend can call API (comma-separated for multiple) +FRONTEND_ORIGIN=https://sokanacrm.vercel.app +# Legacy (also used for CORS if FRONTEND_ORIGIN not set): +FRONTEND_URL=http://localhost:3001 +FRONTEND_URL_DEV=http://localhost:3001 +API_URL=http://localhost:5050 + +# Provider webhooks (PR 5) — required in production +SIGNNOW_WEBHOOK_SECRET= # HMAC secret_key for SignNow event subscription (X-SignNow-Signature) +QB_WEBHOOK_VERIFIER_TOKEN= # Intuit app verifier token (intuit-signature) +# INTUIT_WEBHOOK_VERIFIER_TOKEN= # alias for QB_WEBHOOK_VERIFIER_TOKEN +# QUICKBOOKS_ENVIRONMENT=production # or sandbox for a separate token row +# Local laptops share Cloud SQL with prod — leave unset/false so local never refresh/save/delete QB tokens. +# Cloud Run sets K_SERVICE and is allowed by default. Only set true for an intentional local sandbox. +# QUICKBOOKS_ALLOW_TOKEN_WRITES=false + +# Intake feature package (PR 8) — default keeps legacy write path behind façade +# INTAKE_USE_FEATURE_PACKAGE=false +# INTAKE_SHADOW_COMPARE=false + +# Public intake abuse protection (P0) +# INTAKE_ABUSE_STORE=memory # or db (default: memory in test, db otherwise) +# INTAKE_ABUSE_ENFORCE=true # force rate limits/soft-dedupe in test; default on outside test +# INTAKE_RATE_LIMIT_IP_MAX=10 +# INTAKE_RATE_LIMIT_EMAIL_MAX=3 +# INTAKE_RATE_LIMIT_WINDOW_MS=3600000 +# INTAKE_IDEMPOTENCY_TTL_MS=86400000 +# INTAKE_SOFT_DEDUPE_WINDOW_MS=300000 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2207780c..a33db6dc 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -10,14 +10,32 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 with: - node-version: '18' + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + # Must match package.json engines.node (20.x) and .github/workflows/test.yml + node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - - name: Check formatting - run: npm run format:check - - name: Run linting - run: npm run lint + - name: Check formatting (files changed vs main) + run: | + set -euo pipefail + BASE="${{ github.event.pull_request.base.sha || github.event.before }}" + if [ -z "${BASE}" ] || [ "${BASE}" = "0000000000000000000000000000000000000000" ]; then + BASE="origin/main" + fi + git fetch --no-tags --depth=1 origin main || true + mapfile -t files < <(git diff --name-only --diff-filter=ACMR "${BASE}...HEAD" | grep -E '\.(ts|tsx|js|jsx|mjs|cjs|json|yml|yaml|md)$' || true) + if [ ${#files[@]} -eq 0 ]; then + echo "No matching files changed" + exit 0 + fi + printf '%s\n' "${files[@]}" + npx prettier --check "${files[@]}" + - name: Lint new security/feature JS (repo-wide eslint is not clean yet) + run: | + set -euo pipefail + npx eslint --no-error-on-unmatched-pattern src/security src/features src/common/http diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..c70e078f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Backend Test Gate + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + name: Install, build, test, security smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + # Must match package.json engines.node (20.x) + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Production build + run: npm run build + + - name: Backend unit/integration tests + run: npm test -- --runInBand + + - name: Security smoke + run: npm run test:security-smoke diff --git a/.gitignore b/.gitignore index f5e36b3e..58dd5810 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,15 @@ .DS_STORE .env -node_modules/ \ No newline at end of file +.env.backup +.env.local +.env.development +.env.production +.env.staging +*.env +node_modules/ +dist/ +coverage/ +generated/ +*.log +*.tmp +*.temp \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..f27575a8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run precommit diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..16d570e7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ms-vscode.vscode-typescript-next", + "bradlc.vscode-tailwindcss", + "ms-vscode.vscode-json", + "formulahendry.auto-rename-tag", + "christian-kohler.path-intellisense", + "ms-vscode.vscode-npm-script" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index c58f9c05..7a781e67 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,49 @@ { - "editor.defaultFormatter": "rvest.vs-code-prettier-eslint", - "editor.formatOnType": false, "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { - "source.organizeImports": "explicit", - "source.fixAll.eslint": "explicit" + "source.fixAll.eslint": "explicit", + "source.organizeImports": "explicit" }, - "editor.formatOnSaveMode": "file", - "vs-code-prettier-eslint.prettierLast": false, - "editor.tabSize": 2 + "eslint.validate": [ + "javascript", + "typescript" + ], + "eslint.workingDirectories": [ + "." + ], + "prettier.singleQuote": true, + "prettier.semi": true, + "prettier.tabWidth": 2, + "prettier.useTabs": false, + "prettier.printWidth": 80, + "prettier.trailingComma": "es5", + "typescript.preferences.importModuleSpecifier": "relative", + "javascript.preferences.importModuleSpecifier": "relative", + "files.exclude": { + "**/node_modules": true, + "**/dist": true, + "**/.git": true, + "**/.DS_Store": true + }, + "search.exclude": { + "**/node_modules": true, + "**/dist": true, + "**/.git": true + }, + "files.associations": { + "*.js": "javascript", + "*.ts": "typescript" + }, + "emmet.includeLanguages": { + "javascript": "javascriptreact", + "typescript": "typescriptreact" + }, + "editor.rulers": [80], + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..7bdd0a2a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# ---- Stage 1: Build ---- +FROM node:20-slim AS build +WORKDIR /app + +# Install dependencies +COPY package.json package-lock.json* ./ +RUN npm ci + +# Copy source and build +COPY . . +RUN npm run build + +# ---- Stage 2: Runtime ---- +FROM node:20-slim AS runtime +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=8080 + +EXPOSE 8080 + +# Copy only runtime necessities +COPY --from=build /app/package.json /app/package-lock.json* ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist + +CMD ["node", "dist/cloudrun.js"] diff --git a/FRONTEND_API_URL_DEBUG_PROMPT.md b/FRONTEND_API_URL_DEBUG_PROMPT.md new file mode 100644 index 00000000..325d1f8f --- /dev/null +++ b/FRONTEND_API_URL_DEBUG_PROMPT.md @@ -0,0 +1,79 @@ +# Frontend API URL Configuration Debug Prompt + +## Issue + + +The production frontend (`https://sokanacrm.vercel.app`) is trying to call +`http://localhost:5050/quickbooks/disconnect` instead of the production backend +URL (`https://crmbackend-six-wine.vercel.app`). + +This causes CORS errors because: + +1. Production frontend cannot reach localhost (not accessible from the internet) +2. Local backend doesn't allow CORS from production frontend origin + +## Task + +Find where the API base URL is configured in the frontend and ensure it uses the +correct URL based on environment. + +## What to Check + +1. **Search for hardcoded localhost URLs:** + + - Search for: `localhost:5050`, `http://localhost:5050`, `localhost:5050` + - Check if any API calls have hardcoded localhost URLs + +2. **Find API base URL configuration:** + + - Look for environment variables like: + - `NEXT_PUBLIC_API_URL` + - `REACT_APP_API_URL` + - `VITE_API_URL` + - `API_URL` + - `BASE_URL` + - Check `.env`, `.env.local`, `.env.production` files + - Check Vercel environment variables + +3. **Check API client/axios configuration:** + + - Find where fetch/axios is configured + - Look for base URL settings + - Check if there's an API client wrapper or utility + +4. **Check QuickBooks integration code:** + + - Find the file that calls `/quickbooks/disconnect` + - Check how it constructs the URL + - Verify if it uses a base URL or hardcoded path + +5. **Verify environment variable usage:** + - Check if the frontend reads environment variables correctly + - Ensure production environment variables are set in Vercel + - Verify the variable name matches what the code expects + +## Expected Configuration + +**Local Development:** + +- API URL: `http://localhost:5050` + +**Production:** + +- API URL: `https://crmbackend-six-wine.vercel.app` + +## What to Report + +1. Where the API base URL is configured (file and line) +2. What environment variable is used (if any) +3. If there are any hardcoded localhost URLs +4. What the current production environment variable value is (in Vercel) +5. How the QuickBooks disconnect endpoint is being called + +## Fix Required + +The frontend should: + +- Use `http://localhost:5050` in local development +- Use `https://crmbackend-six-wine.vercel.app` in production +- Read from environment variables, not hardcoded values diff --git a/Labor Support Agreement Updated.docx b/Labor Support Agreement Updated.docx new file mode 100644 index 00000000..5263fd2c Binary files /dev/null and b/Labor Support Agreement Updated.docx differ diff --git a/Labor Support Agreement for Service (1).docx b/Labor Support Agreement for Service (1).docx new file mode 100644 index 00000000..736facef Binary files /dev/null and b/Labor Support Agreement for Service (1).docx differ diff --git a/Labor Support Agreement for Service.docx b/Labor Support Agreement for Service.docx new file mode 100644 index 00000000..bc3b806a Binary files /dev/null and b/Labor Support Agreement for Service.docx differ diff --git a/Labor Support Agreement for Service.docx.pdf b/Labor Support Agreement for Service.docx.pdf new file mode 100644 index 00000000..cf6081a8 Binary files /dev/null and b/Labor Support Agreement for Service.docx.pdf differ diff --git a/Labor Support Agreement for Service.pdf b/Labor Support Agreement for Service.pdf new file mode 100644 index 00000000..d964eaa5 Binary files /dev/null and b/Labor Support Agreement for Service.pdf differ diff --git a/Labor Support Agreement with Tags.html b/Labor Support Agreement with Tags.html new file mode 100644 index 00000000..f8acbdd9 --- /dev/null +++ b/Labor Support Agreement with Tags.html @@ -0,0 +1,11 @@ + + + + + + Labor Support Agreement for Service + + +
Labor Support Agreement for Service
As a Labor Support Client you will receive:
• Unlimited prenatal support via email, phone, text, video call
• In-person/live prenatal visits scheduled between you and your doula (up to 3)
• On-call availability starting at 37 weeks of pregnancy through birth/42wks
• Continuous in-person support during labor, birth, and the immediate postpartum period*
• A partner doula to work with your assigned doula should backup support be needed
• A postpartum visit within the first week of delivery
• Up to 2 visits with a certified lactation counselor for breast/chest/infant feeding support**
*If COVID restrictions make it so the doula cannot be in person we are happy to provide virtual support and or move your payment to postpartum doula services.
**If more than 2 visits of support are needed then an extra fee will occur
Understanding the role of your labor support doula
• A Sokana Collective doula is here to provide non-medical support. They provide education, comfort measures, emotional support and help the client find their voice to advocate for themselves.
• They are not doing any clinical/medical tasks such as diagnosing, are not checking fetal tones, blood pressures, vaginal exams etc.
• My doula will help me and my partner obtain the information necessary to make informed decisions and will not make decisions for me.
• My doula will listen to me and my partners concerns me and suggest options
CANCELLATION OF SERVICES
I understand that if I cancel services more than 4 weeks before my due date, Sokana Collective will retain 30% of my contract rate. I may use the remainder of the balance for any additional services listed above or Sokana Collective can refund the balance. I understand that canceling services less than 4 weeks before my due date will result in no refunds but may still be transferred to an additional service.
REFUND POLICY
I understand that if I have contacted my doula team to request support and no one was able to provide support during labor or birth because they were unavailable, the fees paid for labor support services will be refunded. I understand that no refund will be made if my plans regarding labor support change because:
• I failed to call/connect with my doula (primary or backup and admin if necessary) when I was in labor, or did not request their support.
• I delayed contacting them and they were not able to support me in time.
• I changed my birth plan and I decided to cancel services within 4 weeks of my due date.
• I have an unplanned cesarean called during labor.
YOUR RESPONSIBILITY TO NOTIFY SOKANA COLLECTIVE
Due to the occasional failure of communications technologies, it is your responsibility to make a thorough effort to contact your doula/backup doula/s. Texting alone is not always sufficient. The earliest possible notice will give them the best chance to accommodate your request for support.
If you cannot reach your doula(s) then promptly contact Sokana Collective directly 847-701-5527
FINANCIAL AGREEMENT
After considering the conditions set forth in this agreement, I/we agree to pay the following amount:
Doula services: {{t:t;r:y;o:"Signer 1";l:"Total Amount";}}
Today I agree to pay {{t:t;r:y;o:"Signer 1";l:"Deposit Amount";}} as a deposit (invoice sent separately) and the balance of {{t:t;r:y;o:"Signer 1";l:"Balance Amount";}} to be paid in full by the 36th week of your pregnancy.
I/We have read and agree to what is outlined in this contract and agree that Sokana Collective and the doula are not liable in any way for the outcome of the birth, nor for the health and wellbeing of the pregnant person or the baby. We agree that the presence of a labor support doula is not a substitute for a trained birth attendant, such as a doctor, midwife, or nurse.
I/We understand that it is our responsibility to contact the doula/s when we suspect that labor is beginning, and to communicate regarding our needs as labor is established. I/We understand that our doula will do their best to arrive as quickly as possible, but that it may take up to 2 hours for our doula to arrive from the point at which we request their presence.
I/We agree to the above conditions regarding fees and refunds and agree to pay for any additional services that are requested beyond the labor support package.
Client name: {{t:t;r:y;o:"Signer 1";l:"Client Name";}}
Client Signature: {{t:s;r:y;o:"Signer 1";}}
Date: {{t:d;r:y;o:"Signer 1";}}
INFORMATION DISCLOSURE
I give my permission for my doula to take notes about me, including personal information I choose to disclose to them, and information regarding the labor, birth, and the postpartum period pertaining to myself and my child(ren). I understand that this information will be securely stored as part of my client record at Sokana Collective and that the doula may use this information to provide me with a summary for my own personal use.
Initials: {{t:t;r:y;o:"Signer 1";l:"Initials";}}
+ + \ No newline at end of file diff --git a/Labor Support Agreement with Tags.txt b/Labor Support Agreement with Tags.txt new file mode 100644 index 00000000..d1f2b4b3 --- /dev/null +++ b/Labor Support Agreement with Tags.txt @@ -0,0 +1,40 @@ +Labor Support Agreement for Service +As a Labor Support Client you will receive: + • Unlimited prenatal support via email, phone, text, video call + • In-person/live prenatal visits scheduled between you and your doula (up to 3) + • On-call availability starting at 37 weeks of pregnancy through birth/42wks + • Continuous in-person support during labor, birth, and the immediate postpartum period* + • A partner doula to work with your assigned doula should backup support be needed + • A postpartum visit within the first week of delivery + • Up to 2 visits with a certified lactation counselor for breast/chest/infant feeding support** +*If COVID restrictions make it so the doula cannot be in person we are happy to provide virtual support and or move your payment to postpartum doula services. +**If more than 2 visits of support are needed then an extra fee will occur +Understanding the role of your labor support doula + • A Sokana Collective doula is here to provide non-medical support. They provide education, comfort measures, emotional support and help the client find their voice to advocate for themselves. + • They are not doing any clinical/medical tasks such as diagnosing, are not checking fetal tones, blood pressures, vaginal exams etc. + • My doula will help me and my partner obtain the information necessary to make informed decisions and will not make decisions for me. + • My doula will listen to me and my partners concerns me and suggest options +CANCELLATION OF SERVICES +I understand that if I cancel services more than 4 weeks before my due date, Sokana Collective will retain 30% of my contract rate. I may use the remainder of the balance for any additional services listed above or Sokana Collective can refund the balance. I understand that canceling services less than 4 weeks before my due date will result in no refunds but may still be transferred to an additional service. +REFUND POLICY +I understand that if I have contacted my doula team to request support and no one was able to provide support during labor or birth because they were unavailable, the fees paid for labor support services will be refunded. I understand that no refund will be made if my plans regarding labor support change because: + • I failed to call/connect with my doula (primary or backup and admin if necessary) when I was in labor, or did not request their support. + • I delayed contacting them and they were not able to support me in time. + • I changed my birth plan and I decided to cancel services within 4 weeks of my due date. + • I have an unplanned cesarean called during labor. +YOUR RESPONSIBILITY TO NOTIFY SOKANA COLLECTIVE +Due to the occasional failure of communications technologies, it is your responsibility to make a thorough effort to contact your doula/backup doula/s. Texting alone is not always sufficient. The earliest possible notice will give them the best chance to accommodate your request for support. +If you cannot reach your doula(s) then promptly contact Sokana Collective directly 847-701-5527 +FINANCIAL AGREEMENT +After considering the conditions set forth in this agreement, I/we agree to pay the following amount: +Doula services: {{t:t;r:y;o:"Signer 1";l:"Total Amount";}} +Today I agree to pay {{t:t;r:y;o:"Signer 1";l:"Deposit Amount";}} as a deposit (invoice sent separately) and the balance of {{t:t;r:y;o:"Signer 1";l:"Balance Amount";}} to be paid in full by the 36th week of your pregnancy. +I/We have read and agree to what is outlined in this contract and agree that Sokana Collective and the doula are not liable in any way for the outcome of the birth, nor for the health and wellbeing of the pregnant person or the baby. We agree that the presence of a labor support doula is not a substitute for a trained birth attendant, such as a doctor, midwife, or nurse. +I/We understand that it is our responsibility to contact the doula/s when we suspect that labor is beginning, and to communicate regarding our needs as labor is established. I/We understand that our doula will do their best to arrive as quickly as possible, but that it may take up to 2 hours for our doula to arrive from the point at which we request their presence. +I/We agree to the above conditions regarding fees and refunds and agree to pay for any additional services that are requested beyond the labor support package. +Client name: {{t:t;r:y;o:"Signer 1";l:"Client Name";}} +Client Signature: {{t:s;r:y;o:"Signer 1";}} +Date: {{t:d;r:y;o:"Signer 1";}} +INFORMATION DISCLOSURE +I give my permission for my doula to take notes about me, including personal information I choose to disclose to them, and information regarding the labor, birth, and the postpartum period pertaining to myself and my child(ren). I understand that this information will be securely stored as part of my client record at Sokana Collective and that the doula may use this information to provide me with a summary for my own personal use. +Initials: {{t:t;r:y;o:"Signer 1";l:"Initials";}} \ No newline at end of file diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md new file mode 100644 index 00000000..84097224 --- /dev/null +++ b/PRODUCTION_READINESS.md @@ -0,0 +1,174 @@ +# Production Readiness Guide + +This document covers feature flags, environment variables, Cloud Run deployment, +and PHI boundary verification for the split-db (PHI vs non-PHI) backend. + +--- + +## Feature Flags + +| Flag | Default | Description | +| ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `FEATURE_STRIPE` | `false` | Enable Stripe payment processing. When `false`, Stripe routes are not mounted and `STRIPE_SECRET_KEY` is not required. | +| `FEATURE_QUICKBOOKS` | `false` | Enable QuickBooks OAuth/CRM integration routes. When `false`, `/quickbooks` and `/api/quickbooks` are not mounted; QB env vars are not required. **`/api/payment-methods` remains mounted** (card-on-file / Payment Schedule). | +| `FEATURE_EMAIL` | `false` | Enable email (SMTP) sending. When `false`, SMTP vars are not required. | +| `ENABLE_DEBUG_ENDPOINTS` | — | Only honored when `NODE_ENV !== "production"`. Enables `/debug` routes for local testing. **Never enabled in production.** | + +--- + +## Required Environment Variables + +### Always Required + +| Variable | Purpose | +| ------------------------------------------------- | ------------------------------------------------------------- | +| `SUPABASE_URL` | Supabase project URL | +| `SUPABASE_SERVICE_ROLE_KEY` | Service role key for backend operations | +| `PHI_BROKER_URL` | PHI Broker base URL (sokana-private) | +| `PHI_BROKER_SECRET` or `PHI_BROKER_SHARED_SECRET` | HMAC secret for PHI Broker requests | +| `FRONTEND_ORIGIN` | Comma-separated CORS origins (e.g. `https://app.example.com`) | + +### Required When `FEATURE_STRIPE=true` + +| Variable | Purpose | +| ----------------------- | ---------------------------------------------------- | +| `STRIPE_SECRET_KEY` | Stripe API secret key | +| `STRIPE_WEBHOOK_SECRET` | (Optional) For Stripe webhook signature verification | + +### Required When `FEATURE_EMAIL=true` + +| Variable | Purpose | +| -------------------------------------------------- | ------------------------------------------ | +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` | SMTP configuration for transactional email | + +### Required When `FEATURE_QUICKBOOKS=true` + +| Variable | Purpose | +| ------------------ | ------------------------------ | +| `QB_CLIENT_ID` | QuickBooks OAuth client ID | +| `QB_CLIENT_SECRET` | QuickBooks OAuth client secret | +| `QB_REDIRECT_URI` | QuickBooks OAuth redirect URI | + +--- + +## Deployment Scripts + +### Docker Build (linux/amd64) + +```bash +docker buildx build --platform linux/amd64 -t gcr.io/PROJECT_ID/backend:latest . +``` + +### Run Locally (production-like) + +```bash +NODE_ENV=production PORT=8080 npm start +``` + +### Test Health (no external deps) + +```bash +curl http://localhost:8080/health +# Expect: {"status":"ok","service":"sokana-private-api","timestamp":"..."} +``` + +--- + +## Cloud Run Deployment + +### Deploy (Authenticated) + +1. **Require IAM invoker** so only authorized services can call the API: + + ```bash + gcloud run deploy backend \ + --image gcr.io/PROJECT_ID/backend:latest \ + --platform managed \ + --region us-central1 \ + --no-allow-unauthenticated + ``` + +2. **Set environment variables** via Secret Manager (recommended) or Cloud Run + env: + + ```bash + gcloud run services update backend \ + --set-env-vars "NODE_ENV=production,PORT=8080,FEATURE_STRIPE=false,FEATURE_QUICKBOOKS=false" + ``` + + For secrets: + + ```bash + gcloud run services update backend \ + --set-secrets "SUPABASE_SERVICE_ROLE_KEY=supabase-key:latest,PHI_BROKER_SECRET=phi-broker-secret:latest" + ``` + +3. **Listening configuration** + + - Host: `0.0.0.0` (default) + - Port: `PORT` (default `8080`) + + Cloud Run injects `PORT=8080` automatically. + +--- + +## Testing + +### Health Check (no auth required for Cloud Run internal) + +```bash +curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" \ + https://YOUR-SERVICE-URL/health +``` + +Expected: + +```json +{ "status": "ok", "service": "sokana-private-api", "timestamp": "..." } +``` + +### Clients List (Supabase token required) + +```bash +curl -H "Authorization: Bearer " \ + https://YOUR-SERVICE-URL/clients +``` + +### Verify PHI Boundaries + +1. **List endpoint (`GET /clients`)** + + - Must return only operational fields (no PHI). + - In production, any PHI keys in the response are stripped and a security + warning is logged (values never logged). + +2. **Detail endpoint (`GET /clients/:id`)** + + - Returns operational-only if requester is not authorized for PHI. + - If authorized (admin or assigned doula), PHI is merged from the PHI Broker. + +3. **Update endpoint (`PUT /clients/:id`)** + - Uses `splitClientPatch` to separate operational vs PHI. + - Operational fields → Supabase. + - PHI fields → PHI Broker (403 if requester not authorized for PHI). + +--- + +## Security + +- **CORS** origins are locked to `FRONTEND_ORIGIN` (and localhost in + non-production). +- **helmet** is applied for basic HTTP hardening. +- **x-powered-by** is disabled. +- **Logging** redacts: Authorization header, cookies, PHI fields. +- **Debug routes** (`/debug/*`) are never mounted in production. +- **Cookie auth** is disabled in production; use `Authorization: Bearer ` + or `X-Session-Token`. + +--- + +## Secrets Management + +Use Google Secret Manager (or equivalent) to inject secrets as environment +variables. No code changes are needed; the app reads from `process.env` as +usual. Document required vars per feature as above. diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..16089354 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: node dist/cloudrun.js diff --git a/QUICKBOOKS_AUTH_DEBUG_PROMPT.md b/QUICKBOOKS_AUTH_DEBUG_PROMPT.md new file mode 100644 index 00000000..c244c7bb --- /dev/null +++ b/QUICKBOOKS_AUTH_DEBUG_PROMPT.md @@ -0,0 +1,73 @@ +# QuickBooks Auth URL Debug Prompt + +## Task +Check if the authentication token is being sent properly when calling the `/quickbooks/auth/url` endpoint. The backend is returning 401 Unauthorized errors, which means either: +1. No token is being sent in the request +2. The token is invalid or expired +3. The token is in the wrong format + +## What to Check + +1. **Find where the frontend calls `/quickbooks/auth/url`** + - Search for: `quickbooks/auth/url`, `/quickbooks/auth/url`, or `auth/url` + - Look for fetch/axios/api calls to this endpoint + +2. **Verify the request includes authentication:** + - Check if `Authorization: Bearer ` header is included + - Check if `session` cookie is being sent (if using cookie-based auth) + - Verify the token is being retrieved from the auth system (localStorage, cookies, context, etc.) + +3. **Check the request configuration:** + - Ensure `credentials: 'include'` is set if using cookies + - Verify headers are being set correctly + - Check if there's an axios interceptor or fetch wrapper that should add the token + +4. **Verify token format:** + - Token should be a valid JWT/session token + - Should be sent as: `Authorization: Bearer ` (with space after "Bearer") + - Or as a cookie named `session` + +5. **Check for CORS issues:** + - Verify the request includes credentials if needed + - Check if CORS is blocking the Authorization header + +## Expected Request Format + +The backend expects one of these: + +**Option 1: Authorization Header** +```javascript +fetch('/quickbooks/auth/url', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + credentials: 'include' +}) +``` + +**Option 2: Session Cookie** +```javascript +fetch('/quickbooks/auth/url', { + credentials: 'include', // This sends cookies + headers: { + 'Content-Type': 'application/json' + } +}) +``` + +## What to Report + +1. Where the API call is made (file and function/component) +2. How the token is retrieved (localStorage, context, cookies, etc.) +3. How the token is sent (header, cookie, or not at all) +4. The exact request code/configuration +5. Any interceptors or wrappers that modify requests + +## Backend Expectations + +The backend auth middleware checks for: +- `req.headers.authorization` (expects `Bearer `) +- `req.cookies.session` (expects session cookie) + +If neither is present or valid, it returns 401. diff --git a/README.md b/README.md index 2390057b..423a2b3a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,15 @@ Start the server in production mode: npm start ``` +Seed Cloud SQL `public.admins` from Supabase Auth IDs: + +``` +export SUPABASE_URL=... +export SUPABASE_SERVICE_ROLE_KEY=... +export DATABASE_URL=... +npm run seed:admins +``` + ## File organization Feel free to modify stuff inside the `src` directory or the `README`: @@ -139,3 +148,9 @@ For testing, we use [Jest](https://jestjs.io/) as our testing framework. [Nodemon](https://nodemon.io/) is used for automatic server restarts during development. + +## Cloud Run verification +- npm run build +- PORT=8080 node dist/cloudrun.js +- curl -i http://localhost:8080/health +- Expect a single log line: "Cloud Run listening on 8080" diff --git a/Vercel_HIPAA_Data_Handling_Assessment.md b/Vercel_HIPAA_Data_Handling_Assessment.md new file mode 100644 index 00000000..b4a4c700 --- /dev/null +++ b/Vercel_HIPAA_Data_Handling_Assessment.md @@ -0,0 +1,224 @@ +# Vercel HIPAA Data Handling Assessment + +## Executive Summary + +**⚠️ HIPAA BAA REQUIRED FOR VERCEL** + +This backend system processes, stores, and transmits significant amounts of +Protected Health Information (PHI) that passes through Vercel's serverless +infrastructure. A Business Associate Agreement (BAA) with Vercel is **required** +for HIPAA compliance. + +## PHI Data Identified + +### 1. Client Personal Identifiers + +- **Names**: `firstname`, `lastname`, `preferred_name` +- **Contact Information**: `email`, `phone_number`, `home_phone`, + `mobile_phone`, `work_phone` +- **Addresses**: `address`, `city`, `state`, `zip_code` +- **Demographics**: `race_ethnicity`, `client_age_range`, `pronouns` + +### 2. Health-Related Information + +- **Medical History**: `health_history`, `allergies`, `health_notes` +- **Pregnancy Data**: `due_date`, `pregnancy_number`, `had_previous_pregnancies` +- **Birth Information**: `birth_location`, `birth_hospital`, `baby_name`, + `baby_sex` +- **Provider Information**: `provider_type`, `insurance` +- **Service Details**: `service_needed`, `service_specifics`, + `service_support_details` + +### 3. Contract and Document Data + +- **Contract Content**: Contains client names, health information, and service + details +- **PDF Generation**: Health data embedded in contract documents +- **Email Attachments**: PHI transmitted via email notifications + +## Data Flow Analysis + +### ✅ Safe Routes (No PHI Processing) + +- **Authentication endpoints** (`/auth/*`) - Only handles login/logout +- **Health check** (`/`) - System status only +- **Template management** - Document templates without client data + +### ⚠️ At-Risk Routes (PHI Passes Through Vercel) + +#### 1. Request Form Submission (`/requestService/requestSubmission`) + +**PHI Risk Level: HIGH** + +- **Data Handled**: Complete client profile including health history, pregnancy + details, demographics +- **Vercel Processing**: + - Request body contains full PHI payload + - Data logged in console statements + - Email notifications with PHI content sent +- **External Services**: Data forwarded to Supabase, email service + +#### 2. Client Management (`/clients/*`) + +**PHI Risk Level: HIGH** + +- **Data Handled**: Client profiles, health information, contact details +- **Vercel Processing**: + - Full client objects processed and logged + - Detailed console logging of client data + - CSV export functionality with PHI +- **External Services**: Supabase database operations + +#### 3. Contract Processing (`/api/contract/*`, `/api/pdf-contract/*`) + +**PHI Risk Level: HIGH** + +- **Data Handled**: Client information embedded in contracts +- **Vercel Processing**: + - PDF generation with client data + - Contract email notifications with PHI + - Document processing and storage +- **External Services**: Supabase storage, SignNow, DocuSign + +#### 4. Payment Processing (`/api/payments/*`, `/api/stripe/*`) + +**PHI Risk Level: MEDIUM** + +- **Data Handled**: Client names, contract details, payment amounts +- **Vercel Processing**: + - Payment intent creation with client metadata + - Webhook processing with client information +- **External Services**: Stripe, QuickBooks Online + +#### 5. Email Services (`/email/*`) + +**PHI Risk Level: HIGH** + +- **Data Handled**: Client notifications with health information +- **Vercel Processing**: + - Email content generation with PHI + - HTML templates with client data +- **External Services**: SMTP email service + +## Vercel-Specific Risk Factors + +### 1. Serverless Function Logging + +**Risk**: PHI data logged in Vercel function logs + +- Console.log statements throughout codebase +- Error logging with client data +- Debug information in production logs + +**Evidence**: + +```javascript +console.log('Client:', clientName); +console.log('Email:', clientEmail); +console.log('Data object values:', Object.values(data)); +``` + +### 2. Request/Response Processing + +**Risk**: PHI data temporarily stored in Vercel's serverless environment + +- Request bodies containing full PHI payloads +- Response data with client information +- Memory storage during function execution + +### 3. External Service Integration + +**Risk**: PHI data transmitted through Vercel to external services + +- Supabase database operations +- Email service API calls +- Payment processing integrations +- Document signing services + +## Compliance Violations Identified + +### 1. Data Minimization + +- **Issue**: Full client profiles processed even when only basic info needed +- **Example**: Complete health history processed for simple status updates + +### 2. Audit Logging + +- **Issue**: Insufficient audit trail for PHI access +- **Gap**: No comprehensive logging of who accessed what PHI when + +### 3. Data Encryption + +- **Issue**: No evidence of field-level encryption for sensitive data +- **Gap**: PHI stored in plain text in database + +### 4. Access Controls + +- **Issue**: Role-based access but no PHI-specific access controls +- **Gap**: No minimum necessary standard implementation + +## Recommendations + +### Immediate Actions (Required for HIPAA Compliance) + +1. **Obtain Vercel BAA** + + - Contact Vercel to establish Business Associate Agreement + - Ensure Vercel can meet HIPAA requirements + +2. **Implement Data Minimization** + + - Process only necessary PHI fields for each operation + - Remove unnecessary data from request/response payloads + +3. **Enhance Logging Security** + + - Remove PHI from console.log statements + - Implement structured logging without sensitive data + - Add audit logging for PHI access + +4. **Add Field-Level Encryption** + - Encrypt sensitive fields before database storage + - Implement encryption for data in transit + +### Medium-Term Improvements + +1. **Move PHI Processing to Supabase Functions** + + - Process sensitive data in Supabase Edge Functions + - Reduce Vercel's exposure to PHI + +2. **Implement Data Masking** + + - Mask PHI in logs and error messages + - Use tokens/IDs instead of names in logs + +3. **Add PHI-Specific Access Controls** + - Implement minimum necessary standard + - Add PHI access audit trails + +### Long-Term Architecture Changes + +1. **Hybrid Architecture** + + - Keep non-PHI operations on Vercel + - Move PHI operations to HIPAA-compliant infrastructure + +2. **Data Classification** + - Implement data classification system + - Route PHI through compliant channels only + +## Conclusion + +The current backend architecture processes significant amounts of PHI through +Vercel's serverless infrastructure, making a Business Associate Agreement with +Vercel **mandatory** for HIPAA compliance. The system requires immediate +modifications to logging practices and data handling to meet HIPAA requirements. + +**Priority**: High - Immediate action required to achieve HIPAA compliance. + +**Estimated Compliance Timeline**: 2-4 weeks with dedicated effort. + +**Risk Level**: High - Current implementation has multiple HIPAA violations that +could result in significant penalties. + diff --git a/annotated-1761168560783.pdf b/annotated-1761168560783.pdf new file mode 100644 index 00000000..a8ca7847 Binary files /dev/null and b/annotated-1761168560783.pdf differ diff --git a/check-assignments-table.sql b/check-assignments-table.sql new file mode 100644 index 00000000..6cf184c1 --- /dev/null +++ b/check-assignments-table.sql @@ -0,0 +1,44 @@ +-- Check if assignments table exists and its structure +SELECT + table_name, + table_type +FROM information_schema.tables +WHERE table_name = 'assignments'; + +-- Get all columns in the assignments table +SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length +FROM information_schema.columns +WHERE table_name = 'assignments' +ORDER BY ordinal_position; + +-- Check for indexes +SELECT + indexname, + indexdef +FROM pg_indexes +WHERE tablename = 'assignments'; + +-- Check for foreign keys +SELECT + tc.constraint_name, + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name +FROM information_schema.table_constraints AS tc +JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name +JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name +WHERE tc.table_name = 'assignments' + AND tc.constraint_type = 'FOREIGN KEY'; + +-- Sample data (if any) +SELECT * +FROM assignments +LIMIT 5; diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 00000000..4c1e74df --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,53 @@ +# Cloud Build: builds and pushes to Artifact Registry (NOT gcr.io). +# The trigger MUST use this file: Configuration type = "Cloud Build configuration file", +# Location = "cloudbuild.yaml". If the trigger uses Autodetect/Dockerfile/Buildpack +# instead, Cloud Build uses a default image name (gcr.io/...) and you get push errors. +# +# Deployment gate (PR 2.1): npm ci / build / tests / security-smoke must pass before +# buildpack, push, and Cloud Run update. A failed gate skips all later steps. +steps: + - id: test-gate + name: node:20 + entrypoint: bash + args: + - -c + - | + set -euo pipefail + npm ci + npm run build + npm test -- --runInBand + npm run test:security-smoke + + - id: buildpack + name: gcr.io/k8s-skaffold/pack + waitFor: ['test-gate'] + args: + - build + - us-central1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/backend/sokana-private-api:$COMMIT_SHA + - --builder + - gcr.io/buildpacks/builder:v1 + - --trust-builder + - --network=cloudbuild + - --path + - . + env: + - GOOGLE_ENTRYPOINT=node dist/cloudrun.js + + - id: push + name: gcr.io/cloud-builders/docker + waitFor: ['buildpack'] + args: + - push + - us-central1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/backend/sokana-private-api:$COMMIT_SHA + + - id: deploy + name: gcr.io/google.com/cloudsdktool/cloud-sdk + waitFor: ['push'] + entrypoint: gcloud + args: + - run + - services + - update + - sokana-private-api + - --image=us-central1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/backend/sokana-private-api:$COMMIT_SHA + - --region=us-central1 diff --git a/coverage/base.css b/coverage/base.css new file mode 100644 index 00000000..f418035b --- /dev/null +++ b/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js new file mode 100644 index 00000000..cc121302 --- /dev/null +++ b/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selecter that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/favicon.png b/coverage/favicon.png new file mode 100644 index 00000000..c1525b81 Binary files /dev/null and b/coverage/favicon.png differ diff --git a/coverage/index.html b/coverage/index.html new file mode 100644 index 00000000..afdc2b1b --- /dev/null +++ b/coverage/index.html @@ -0,0 +1,371 @@ + + + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 0% + Statements + 0/2371 +
+ + +
+ 0% + Branches + 0/905 +
+ + +
+ 0% + Functions + 0/348 +
+ + +
+ 0% + Lines + 0/2270 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
src +
+
0%0/1300%0/330%0/130%0/130
src/api +
+
0%0/280%0/70%0/10%0/28
src/api/qbo +
+
0%0/10100%0/00%0/10%0/10
src/config +
+
0%0/50%0/7100%0/00%0/5
src/controllers +
+
0%0/6080%0/3090%0/730%0/589
src/db +
+
0%0/230%0/50%0/20%0/22
src/domains/errors +
+
0%0/30100%0/00%0/60%0/30
src/entities +
+
0%0/1200%0/710%0/110%0/120
src/middleware +
+
0%0/550%0/110%0/70%0/53
src/repositories +
+
0%0/3260%0/2040%0/580%0/284
src/routes +
+
0%0/168100%0/00%0/530%0/152
src/services +
+
0%0/2390%0/980%0/360%0/230
src/services/auth +
+
0%0/390%0/110%0/50%0/39
src/services/customer +
+
0%0/420%0/100%0/70%0/40
src/services/invoice +
+
0%0/800%0/320%0/70%0/78
src/services/payments +
+
0%0/1510%0/300%0/110%0/147
src/usecase +
+
0%0/1550%0/470%0/410%0/155
src/utils +
+
0%0/1620%0/300%0/160%0/158
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/base.css b/coverage/lcov-report/base.css new file mode 100644 index 00000000..f418035b --- /dev/null +++ b/coverage/lcov-report/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/lcov-report/block-navigation.js b/coverage/lcov-report/block-navigation.js new file mode 100644 index 00000000..cc121302 --- /dev/null +++ b/coverage/lcov-report/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selecter that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/lcov-report/favicon.png b/coverage/lcov-report/favicon.png new file mode 100644 index 00000000..c1525b81 Binary files /dev/null and b/coverage/lcov-report/favicon.png differ diff --git a/coverage/lcov-report/index.html b/coverage/lcov-report/index.html new file mode 100644 index 00000000..f7b7070b --- /dev/null +++ b/coverage/lcov-report/index.html @@ -0,0 +1,371 @@ + + + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 0% + Statements + 0/2371 +
+ + +
+ 0% + Branches + 0/905 +
+ + +
+ 0% + Functions + 0/348 +
+ + +
+ 0% + Lines + 0/2270 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
src +
+
0%0/1300%0/330%0/130%0/130
src/api +
+
0%0/280%0/70%0/10%0/28
src/api/qbo +
+
0%0/10100%0/00%0/10%0/10
src/config +
+
0%0/50%0/7100%0/00%0/5
src/controllers +
+
0%0/6080%0/3090%0/730%0/589
src/db +
+
0%0/230%0/50%0/20%0/22
src/domains/errors +
+
0%0/30100%0/00%0/60%0/30
src/entities +
+
0%0/1200%0/710%0/110%0/120
src/middleware +
+
0%0/550%0/110%0/70%0/53
src/repositories +
+
0%0/3260%0/2040%0/580%0/284
src/routes +
+
0%0/168100%0/00%0/530%0/152
src/services +
+
0%0/2390%0/980%0/360%0/230
src/services/auth +
+
0%0/390%0/110%0/50%0/39
src/services/customer +
+
0%0/420%0/100%0/70%0/40
src/services/invoice +
+
0%0/800%0/320%0/70%0/78
src/services/payments +
+
0%0/1510%0/300%0/110%0/147
src/usecase +
+
0%0/1550%0/470%0/410%0/155
src/utils +
+
0%0/1620%0/300%0/160%0/158
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/prettify.css b/coverage/lcov-report/prettify.css new file mode 100644 index 00000000..b317a7cd --- /dev/null +++ b/coverage/lcov-report/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/lcov-report/prettify.js b/coverage/lcov-report/prettify.js new file mode 100644 index 00000000..b3225238 --- /dev/null +++ b/coverage/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/lcov-report/sort-arrow-sprite.png b/coverage/lcov-report/sort-arrow-sprite.png new file mode 100644 index 00000000..6ed68316 Binary files /dev/null and b/coverage/lcov-report/sort-arrow-sprite.png differ diff --git a/coverage/lcov-report/sorter.js b/coverage/lcov-report/sorter.js new file mode 100644 index 00000000..2bb296a8 --- /dev/null +++ b/coverage/lcov-report/sorter.js @@ -0,0 +1,196 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if ( + row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()) + ) { + row.style.display = ''; + } else { + row.style.display = 'none'; + } + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/coverage/lcov-report/src/api/index.html b/coverage/lcov-report/src/api/index.html new file mode 100644 index 00000000..3fae7933 --- /dev/null +++ b/coverage/lcov-report/src/api/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/api + + + + + + + + + +
+
+

All files src/api

+
+ +
+ 0% + Statements + 0/28 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/28 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
index.ts +
+
0%0/5100%0/0100%0/00%0/5
simulate-payment.ts +
+
0%0/230%0/70%0/10%0/23
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/api/index.ts.html b/coverage/lcov-report/src/api/index.ts.html new file mode 100644 index 00000000..87344a41 --- /dev/null +++ b/coverage/lcov-report/src/api/index.ts.html @@ -0,0 +1,106 @@ + + + + + + Code coverage report for src/api/index.ts + + + + + + + + + +
+
+

All files / src/api index.ts

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8  +  +  +  +  +  +  + 
import { Router } from 'express';
+import qboStatusRouter from './qbo/status';
+ 
+const router = Router();
+ 
+router.use('/qbo', qboStatusRouter);
+ 
+export default router; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/api/qbo/index.html b/coverage/lcov-report/src/api/qbo/index.html new file mode 100644 index 00000000..21913245 --- /dev/null +++ b/coverage/lcov-report/src/api/qbo/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/api/qbo + + + + + + + + + +
+
+

All files src/api/qbo

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
status.ts +
+
0%0/10100%0/00%0/10%0/10
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/api/qbo/status.ts.html b/coverage/lcov-report/src/api/qbo/status.ts.html new file mode 100644 index 00000000..de7d5ce0 --- /dev/null +++ b/coverage/lcov-report/src/api/qbo/status.ts.html @@ -0,0 +1,130 @@ + + + + + + Code coverage report for src/api/qbo/status.ts + + + + + + + + + +
+
+

All files / src/api/qbo status.ts

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Router } from 'express';
+import { getValidAccessToken } from '../../utils/tokenUtils';
+ 
+const router = Router();
+ 
+router.get('/status', async (req, res) => {
+  try {
+    const accessToken = await getValidAccessToken();
+    res.json({ connected: !!accessToken });
+  } catch (error) {
+    console.error('Error checking QBO status:', error);
+    res.json({ connected: false });
+  }
+});
+ 
+export default router; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/api/simulate-payment.ts.html b/coverage/lcov-report/src/api/simulate-payment.ts.html new file mode 100644 index 00000000..78b6192c --- /dev/null +++ b/coverage/lcov-report/src/api/simulate-payment.ts.html @@ -0,0 +1,262 @@ + + + + + + Code coverage report for src/api/simulate-payment.ts + + + + + + + + + +
+
+

All files / src/api simulate-payment.ts

+
+ +
+ 0% + Statements + 0/23 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/23 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express from 'express';
+import { getValidAccessToken } from '../utils/tokenUtils';
+ 
+const router = express.Router();
+ 
+// Change the route path to match the router mount in server.ts
+router.post('/simulate-payment', async (req, res) => {
+  try {
+    const { amount, card } = req.body;
+    Iif (!amount || !card) {
+      res.status(400).json({ error: 'Missing amount or card details' });
+      return;
+    }
+ 
+    // Get access token (hardcoded or from user context)
+    const accessToken = await getValidAccessToken();
+    Iif (!accessToken) {
+      res.status(401).json({ error: 'Could not get QuickBooks access token' });
+      return;
+    }
+ 
+    // Prepare payload for QuickBooks Payments API
+    const payload = {
+      amount: amount.toString(),
+      currency: 'USD',
+      card: {
+        number: card.number,
+        expMonth: card.expMonth,
+        expYear: card.expYear,
+        cvc: card.cvc
+      },
+      context: {
+        isEcommerce: true
+      }
+    };
+ 
+    // Call QuickBooks Payments API
+    const response = await fetch('https://sandbox.api.intuit.com/quickbooks/v4/payments/charges', {
+      method: 'POST',
+      headers: {
+        'Authorization': `Bearer ${accessToken}`,
+        'Content-Type': 'application/json',
+        'Accept': 'application/json'
+      },
+      body: JSON.stringify(payload)
+    });
+ 
+    const data = await response.json();
+    Iif (!response.ok) {
+      res.status(response.status).json({ error: data });
+      return;
+    }
+    res.json(data);
+  } catch (error) {
+    console.error('Simulate payment error:', error);
+    res.status(500).json({ error: error.message || 'Internal server error' });
+  }
+});
+ 
+export default router; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/config/index.html b/coverage/lcov-report/src/config/index.html new file mode 100644 index 00000000..8c70d505 --- /dev/null +++ b/coverage/lcov-report/src/config/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/config + + + + + + + + + +
+
+

All files src/config

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
index.ts +
+
0%0/10%0/6100%0/00%0/1
stripe.ts +
+
0%0/40%0/1100%0/00%0/4
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/config/index.ts.html b/coverage/lcov-report/src/config/index.ts.html new file mode 100644 index 00000000..f2f18ecc --- /dev/null +++ b/coverage/lcov-report/src/config/index.ts.html @@ -0,0 +1,103 @@ + + + + + + Code coverage report for src/config/index.ts + + + + + + + + + +
+
+

All files / src/config index.ts

+
+ +
+ 0% + Statements + 0/1 +
+ + +
+ 0% + Branches + 0/6 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/1 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7  +  +  +  +  +  + 
export const config = {
+  jwtSecret: process.env.JWT_SECRET || 'your-default-secret-key',
+  stripe: {
+    secretKey: process.env.STRIPE_SECRET_KEY || '',
+    publicKey: process.env.STRIPE_PUBLIC_KEY || '',
+  }
+}; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/config/stripe.ts.html b/coverage/lcov-report/src/config/stripe.ts.html new file mode 100644 index 00000000..c9a62090 --- /dev/null +++ b/coverage/lcov-report/src/config/stripe.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/config/stripe.ts + + + + + + + + + +
+
+

All files / src/config stripe.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import Stripe from 'stripe';
+ 
+Iif (!process.env.STRIPE_SECRET_KEY) {
+  throw new Error('STRIPE_SECRET_KEY environment variable is required');
+}
+ 
+export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
+  apiVersion: '2023-10-16', // Use the latest API version
+}); 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/authController.ts.html b/coverage/lcov-report/src/controllers/authController.ts.html new file mode 100644 index 00000000..8a66821d --- /dev/null +++ b/coverage/lcov-report/src/controllers/authController.ts.html @@ -0,0 +1,1270 @@ + + + + + + Code coverage report for src/controllers/authController.ts + + + + + + + + + +
+
+

All files / src/controllers authController.ts

+
+ +
+ 0% + Statements + 0/108 +
+ + +
+ 0% + Branches + 0/21 +
+ + +
+ 0% + Functions + 0/15 +
+ + +
+ 0% + Lines + 0/107 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from 'express';
+ 
+import {
+  AuthenticationError,
+  AuthorizationError,
+  ConflictError,
+  NotFoundError,
+  ValidationError
+} from '../domains/errors';
+import supabase from '../supabase';
+import {
+  AuthRequest,
+  LoginBody,
+  PasswordResetBody,
+  SignupBody,
+  TokenBody,
+  UpdatePasswordBody,
+} from '../types';
+import { AuthUseCase } from '../usecase/authUseCase.js';
+ 
+ 
+export class AuthController {
+  private authUseCase: AuthUseCase;
+ 
+  constructor(authUseCase: AuthUseCase) {
+    this.authUseCase = authUseCase;
+    this.handleError = this.handleError.bind(this);
+  }
+ 
+  //
+  // signup()
+  //
+  // Handles user sign up after being approved by admin (by invite from Admin)
+  //
+  // returns:
+  //    User
+  //
+  async signup(
+    req: Request<object, object, SignupBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { email, password, firstname, lastname } = req.body;
+      // call useCase to grab newly created user
+      const user = await this.authUseCase.signup(email, password, firstname, lastname);
+      res.status(201).json({ message: 'User created successfully', user: user.toJSON() })
+    } 
+    catch (signUpError) {
+      const error = this.handleError(signUpError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+  
+  //
+  // login()
+  //
+  // Handles user login using email and password for authentication.
+  //
+  // returns:
+  //    User
+  //    Token
+  //
+  async login(
+    req: Request<object, object, LoginBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { email, password } = req.body;
+      // call useCase to grab the user and token
+      const result = await this.authUseCase.login(email, password);
+      res.status(200).json({ message: 'Login successful', user: result.user.toJSON() , token: result.token });
+    } 
+    catch (loginError) {
+      const error = this.handleError(loginError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+ 
+  //
+  // getMe()
+  //
+  // Grabs the current user from a token session
+  //
+  // returns:
+  //    User
+  //
+  async getMe(req: Request, res: Response): Promise<void> {
+    try {
+      const token = req.cookies?.session || req.headers.authorization?.split(' ')[1]
+      Iif (!token) {
+        res.status(401).json({ error: 'No session token provided' })
+        return
+      }
+  
+      // 1) Get your app user
+      const appUser = await this.authUseCase.getMe(token)
+      Iif (!appUser) {
+        res.status(404).json({ error: 'User not found' })
+        return
+      }
+      const base = appUser.toJSON()
+  
+      // 2) Fetch Supabase user metadata
+      const { data: sbUser, error } = await supabase.auth.getUser(token)
+      let finalRole = (base as any).role  // fallback to the DB role
+  
+      Iif (!error && sbUser.user) {
+        const meta = (sbUser.user.user_metadata as any) || {}
+        Iif (typeof meta.role === 'string') {
+          finalRole = meta.role
+        }
+      }
+  
+      // 3) Merge and return
+      res.json({
+        ...(base as any),
+        role: finalRole
+      })
+    } catch (err: any) {
+      const errorInfo = this.handleError(err, res)
+      res.status(errorInfo.status).json({ error: errorInfo.message })
+    }
+  }
+  
+  
+ 
+  
+  //
+  // logout()
+  //
+  // Signs out of current user and releases session cookie
+  //
+  // returns:
+  //    None
+  //
+  async logout(
+    _req: Request, 
+    res: Response
+  ): Promise<void> {
+    res.clearCookie('session');
+    await this.authUseCase.logout();
+    console.log('logged out')
+    res.json({ message: 'Logged out successfully' });
+  }
+  
+  //
+  // verifyEmail()
+  //
+  // Verifies the email after user signs up and redirects to success page
+  //
+  // returns:
+  //    None
+  //
+  async verifyEmail(
+    req: Request, 
+    res: Response
+  ): Promise<void> {
+    try {
+      const token_hash = req.query.token_hash as string;
+      const type = req.query.type as string;
+      // call useCase to return success, query params, and error message
+      const queryParams = await this.authUseCase.verifyEmail(token_hash, type);
+        
+      // Redirect with tokens if verification is successful
+      return res.redirect(`${process.env.FRONTEND_URL}/auth/callback?${queryParams}`);
+    } 
+    catch (error) {
+      res.redirect(`${process.env.FRONTEND_URL}/auth/callback?error=${error.message}`);
+    }
+  }
+  
+  //
+  // getAllUsers()
+  //
+  // Retrieves all users from the users table
+  //
+  // returns:
+  //    users => user.toJSON()
+  //
+  async getAllUsers(
+    _req: AuthRequest, 
+    res: Response
+  ): Promise<void> {
+    try {
+      const users = await this.authUseCase.getAllUsers();
+      res.status(200).json(users.map(user => user.toJSON()));
+    }
+    catch (getAllUsersError) {
+      const error = this.handleError(getAllUsersError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+  
+  //
+  // googleAuth()
+  //
+  // Initiates google oath
+  //
+  // returns:
+  //    url - OAuth URL
+  //
+  async googleAuth(
+    _req: Request, 
+    res: Response
+  ): Promise<void> {
+    try {
+      console.log('starting google auth');
+      const redirectTo = `${process.env.FRONTEND_URL}/auth/callback`;
+      const url = await this.authUseCase.googleAuth(redirectTo);
+      res.json({ url });
+    } 
+    catch (googleAuthError) {
+      const error = this.handleError(googleAuthError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+  
+  //
+  // handleOAuthCallback()
+  //
+  // Handles OAuth initiatiation with a cookie and user (new if not existing)
+  //
+  // returns:
+  //    none
+  //
+  async handleOAuthCallback(
+    req: Request, 
+    res: Response
+  ): Promise<void> {
+    try {
+      console.log('OAuth callback received:', req.query);
+      const code = req.query.code as string;
+ 
+      // call useCase to retrieve current session and user
+      const data = await this.authUseCase.handleOAuthCallback(code);
+      // create our cookie
+      console.log("creating cookie");
+      res.cookie('session', data.session.access_token, {
+        httpOnly: true,
+        secure: process.env.NODE_ENV === 'production',
+        sameSite: 'lax',
+        maxAge: 3600 * 1000,
+        path: '/',
+      });
+      // Redirect to home page
+      res.redirect(`${process.env.FRONTEND_URL}`);
+    } catch (error) {
+      res.redirect(
+        `${process.env.FRONTEND_URL}/login?error=` + encodeURIComponent(error.message)
+      );
+    }
+  }
+  
+  //
+  // handleToken()
+  //
+  // Checks that the token is valid and is associated with a user
+  //
+  // returns:
+  //    users => user.toJSON()
+  //
+  async handleToken(
+    req: Request<object, object, TokenBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { access_token } = req.body;
+ 
+      Iif (!access_token) {
+        res.status(401).json({ error: 'No access token provided' });
+      }
+      
+      const user = await this.authUseCase.handleToken(access_token);
+  
+      res.cookie('session', access_token, {
+        httpOnly: true,
+        secure: process.env.NODE_ENV === 'production',
+        sameSite: 'lax',
+        maxAge: 3600 * 1000,
+        path: '/',
+      });
+ 
+      res.json({ success: true , user: user.toJSON()});
+    } catch (handleTokenError) {
+      console.log(handleTokenError);
+      // const error = this.handleError(handleTokenError, res);
+      // res.status(error.status).json({ error: error.message})
+    }
+  }
+  
+  //
+  // requestPasswordReset()
+  //
+  // Request password reset and sends link to user
+  //
+  // returns:
+  //    None
+  //
+  async requestPasswordReset(
+    req: Request<object, object, PasswordResetBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { email } = req.body;
+      const redirectTo = `${process.env.FRONTEND_URL}/auth/reset-password`;
+      
+      // call useCase to redirect user to reset password and check for errors
+      await this.authUseCase.requestPasswordReset(email, redirectTo);
+      
+      res.status(200).json({ message: 'Password reset instructions sent to email'});
+    } catch (requestPasswordError) {
+      const error = this.handleError(requestPasswordError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+ 
+  //
+  // handlePasswordRecovery()
+  //
+  // Verify session and directs user to password recovery
+  //
+  // returns:
+  //    None
+  //
+  async handlePasswordRecovery(
+    req: Request, 
+    res: Response
+  ): Promise<void> {
+    try {
+      const token_hash = req.query.token_hash as string;
+      const type = req.query.type as string;
+      
+      // call useCase to retrieve access and refresh tokens.
+      const queryParams = await this.authUseCase.handlePasswordRecovery(token_hash, type);
+  
+      const redirectUrl = `${process.env.FRONTEND_URL}/auth/reset-password?${queryParams.toString()}`;
+      res.redirect(redirectUrl);
+    } catch {
+      res.redirect(
+        `${process.env.FRONTEND_URL}/auth/reset-password?error=${encodeURIComponent(
+          'Failed to process password recovery'
+        )}`
+      );
+    }
+  }
+ 
+  //
+  // updatePassword()
+  //
+  // After being verified, allows user to update password
+  //
+  // returns:
+  //    user
+  //
+  async updatePassword(
+    req: Request<object, object, UpdatePasswordBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { password } = req.body;
+      const token = req.headers.authorization?.split(' ')[1];
+      
+      const user = await this.authUseCase.updatePassword(password, token);
+  
+      res.status(200).json({
+        message: 'Password updated successfully',
+        user: user.toJSON(),
+      });
+    } catch (updatePasswordError) {
+      const error = this.handleError(updatePasswordError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+ 
+  // Helper method to handle errors
+  private handleError(
+    error: Error, 
+    res: Response
+  ): { status: number, message: string } {
+    console.error('Error:', error.message);
+    
+    if (error instanceof ValidationError) {
+      return { status: 400, message: error.message};
+    } else if (error instanceof ConflictError) {
+      return { status: 409, message: error.message};
+    } else if (error instanceof AuthenticationError) {
+      return { status: 401, message: error.message};
+    } else if (error instanceof NotFoundError) {
+      return { status: 404, message: error.message};
+    } else if (error instanceof AuthorizationError) {
+      return { status: 403, message: error.message};
+    } else {
+      return { status: 500, message: error.message};
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/clientController.ts.html b/coverage/lcov-report/src/controllers/clientController.ts.html new file mode 100644 index 00000000..2fa37a32 --- /dev/null +++ b/coverage/lcov-report/src/controllers/clientController.ts.html @@ -0,0 +1,877 @@ + + + + + + Code coverage report for src/controllers/clientController.ts + + + + + + + + + +
+
+

All files / src/controllers clientController.ts

+
+ +
+ 0% + Statements + 0/75 +
+ + +
+ 0% + Branches + 0/23 +
+ + +
+ 0% + Functions + 0/9 +
+ + +
+ 0% + Lines + 0/74 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Response } from 'express';
+import {
+    AuthenticationError,
+    AuthorizationError,
+    ConflictError,
+    NotFoundError,
+    ValidationError
+} from '../domains/errors';
+import { Client } from '../entities/Client';
+ 
+import { AuthRequest } from '../types';
+import { ClientUseCase } from '../usecase/clientUseCase';
+ 
+export class ClientController {
+  private clientUseCase: ClientUseCase;
+ 
+  constructor (clientUseCase: ClientUseCase) {
+    this.clientUseCase = clientUseCase;
+  };
+ 
+  //
+  // getClients()
+  //
+  // Grabs all clients (lite or detailed) based on role or query param
+  //
+  // returns:
+  //    Clients[]
+  //
+  async getClients(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const { id, role } = req.user;
+      const { detailed } = req.query;
+ 
+      const clients = detailed === 'true'
+        ? await this.clientUseCase.getClientsDetailed(id, role)
+        : await this.clientUseCase.getClientsLite(id, role);
+ 
+      console.log("clients:", clients);
+ 
+      res.json(clients.map(client => client.toJson()));
+    } catch (getError) {
+      const error = this.handleError(getError, res);
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message });
+      }
+    }
+  }
+//
+  // getCSVClients()
+  //
+  // Grabs all client data in CSV form
+  //
+  // returns:
+  //    CSV of users
+  //
+  async exportCSV(
+    req: AuthRequest,
+    res: Response,
+  ): Promise<void> {
+    try {
+      const {role} = req.user;
+      const clientsCSV = await this.clientUseCase.exportCSV(role);
+      res.header("Content-Type", "text/csv");
+      res.attachment("clients.csv");
+ 
+      res.send(clientsCSV);
+    } 
+    catch (getError) {
+      const error = this.handleError(getError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+  //
+  // getClientById()
+  //
+  // Grab a specific client with detailed information
+  //
+  // returns:
+  //    Client
+  //
+  async getClientById(req: AuthRequest, res: Response): Promise<void> {
+  try {
+    const { id } = req.params;
+    const { detailed } = req.query;
+ 
+    Iif (!id) {
+      res.status(400).json({ error: 'Missing client ID' });
+      return;
+    }
+ 
+    const client = detailed === 'true'
+      ? await this.clientUseCase.getClientDetailed(id)
+      : await this.clientUseCase.getClientLite(id);
+ 
+    res.json(client.toJson());
+  } catch (error) {
+    const err = this.handleError(error, res);
+    Iif (!res.headersSent) {
+      res.status(err.status).json({ error: err.message });
+    }
+  }
+}
+ 
+ 
+ 
+  //
+  // updateClientStatus
+  //
+  // Updates client status in client_info table by grabbing the client to update in the request body
+  //
+  // returns:
+  //    Client with updatedAt timestamp
+  //
+  async updateClientStatus(
+    req: AuthRequest,
+    res: Response,
+  ): Promise<void> {
+    const { clientId, status } = req.body;
+    console.log(clientId, status);
+ 
+    Iif (!clientId || !status) {
+      res.status(400).json({ message: 'Missing client ID or status' });
+      return;
+    }
+ 
+    try {
+      // Update client status directly in client_info table
+      const client = await this.clientUseCase.updateClientStatus(clientId, status);
+      
+      res.json({
+        success: true,
+        client: {
+          id: client.id,
+          status: client.status,
+          updatedAt: client.updatedAt,
+          firstname: client.user.firstname,
+          lastname: client.user.lastname,
+          email: client.user.email,
+          role: client.user.role,
+          serviceNeeded: client.serviceNeeded,
+          requestedAt: client.requestedAt
+        }
+      });
+    }
+    catch (statusError) {
+      const error = this.handleError(statusError, res);
+      res.status(error.status).json({ error: error.message });
+    }
+  }
+ 
+  //
+  // updateClient
+  //
+  // Updates client profile fields
+  //
+  // returns:
+  //    Client with updatedAt timestamp
+  //
+  async updateClient(
+    req: AuthRequest,
+    res: Response,
+  ): Promise<void> {
+    const { id } = req.params;
+    const updateData = req.body;
+ 
+    console.log('Controller: Request details:', {
+      method: req.method,
+      url: req.url,
+      originalUrl: req.originalUrl,
+      path: req.path,
+      params: req.params,
+      id,
+      idType: typeof id
+    });
+ 
+    Iif (!id) {
+      res.status(400).json({ error: 'Missing client ID' });
+      return;
+    }
+ 
+    // Validate that id looks like a UUID
+    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+    Iif (!uuidRegex.test(id)) {
+      console.error('Controller: Invalid client ID format:', id);
+      res.status(400).json({ error: `Invalid client ID format: ${id}. Expected UUID format.` });
+      return;
+    }
+ 
+    console.log('Controller: Updating client:', { 
+      id, 
+      idType: typeof id,
+      updateData,
+      updateDataKeys: Object.keys(updateData)
+    });
+ 
+    try {
+      const client = await this.clientUseCase.updateClientProfile(
+        id,
+        updateData
+      );
+      
+      console.log('Controller: Client updated successfully:', client.id);
+      
+      res.json({
+        success: true,
+        client: {
+          id: client.id,
+          updatedAt: client.updatedAt,
+          firstname: client.user.firstname,
+          lastname: client.user.lastname,
+          email: client.user.email,
+          phoneNumber: client.phoneNumber, // Get from Client entity
+          role: client.user.role,
+          status: client.status,
+          serviceNeeded: client.serviceNeeded,
+          requestedAt: client.requestedAt
+        }
+      });
+    }
+    catch (error) {
+      console.error('Controller: Error updating client:', error);
+      const err = this.handleError(error, res);
+      res.status(err.status).json({ error: err.message });
+    }
+  }
+ 
+  // Helper method to handle errors
+  private handleError(
+    error: Error, 
+    res: Response
+  ): { status: number, message: string } {
+    console.error('Error:', error.message);
+    
+    if (error instanceof ValidationError) {
+      return { status: 400, message: error.message};
+    } else if (error instanceof ConflictError) {
+      return { status: 409, message: error.message};
+    } else if (error instanceof AuthenticationError) {
+      return { status: 401, message: error.message};
+    } else if (error instanceof NotFoundError) {
+      return { status: 404, message: error.message};
+    } else if (error instanceof AuthorizationError) {
+      return { status: 403, message: error.message};
+    } else {
+      return { status: 500, message: error.message};
+    }
+  }
+ 
+  // Helper for returning basic summary of a client
+  private mapToClientSummary(client: Client) {
+    return {
+      id: client.user.id.toString(),
+      firstname: client.user.firstname,
+      lastname: client.user.lastname,
+      serviceNeeded: client.serviceNeeded,
+      requestedAt: client.requestedAt,
+      updatedAt: client.updatedAt,
+      status: client.status,
+    };
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/contractController.ts.html b/coverage/lcov-report/src/controllers/contractController.ts.html new file mode 100644 index 00000000..b331e29b --- /dev/null +++ b/coverage/lcov-report/src/controllers/contractController.ts.html @@ -0,0 +1,871 @@ + + + + + + Code coverage report for src/controllers/contractController.ts + + + + + + + + + +
+
+

All files / src/controllers contractController.ts

+
+ +
+ 0% + Statements + 0/86 +
+ + +
+ 0% + Branches + 0/29 +
+ + +
+ 0% + Functions + 0/11 +
+ + +
+ 0% + Lines + 0/81 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from 'express';
+import {
+  AuthenticationError,
+  AuthorizationError,
+  ConflictError,
+  NotFoundError,
+  ValidationError
+} from '../domains/errors';
+import { Client } from '../entities/Client';
+ 
+import { UpdateRequest } from '../types';
+import { ContractUseCase } from '../usecase/contractUseCase';
+ 
+export class ContractController {
+  private contractUseCase: ContractUseCase;
+ 
+  constructor (contractUseCase: ContractUseCase) {
+    this.contractUseCase = contractUseCase;
+  };
+ 
+  //
+  // Generate and save a contract (finalized)
+  //
+  async generateContract(
+    req: UpdateRequest, 
+    res: Response
+  ): Promise<void> {
+    try {
+      const { templateId, clientId, fields, note, fee, deposit } = req.body;
+ 
+      Iif (!templateId || !clientId || !fields) {
+        throw new ValidationError('Missing required fields.');
+      }
+ 
+      // Delegate to use case for PDF generation + upload + DB write
+      const contract = await this.contractUseCase.createContract({
+        templateId,
+        clientId,
+        fields,
+        note,
+        fee,
+        deposit,
+        generatedBy: req.user.id,
+      });
+ 
+      res.status(201).json(contract);
+    } catch (err) {
+      const error = this.handleError(err, res);
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message });
+      }
+    }
+  }
+ 
+  //
+  // Preview a generated contract PDF
+  //
+  async previewContract(req: Request, res: Response): Promise<void> {
+    try {
+      const contractId = req.params.id;
+      Iif (!contractId) throw new ValidationError('Missing contract ID');
+ 
+      const { buffer, filename } = await this.contractUseCase.fetchContractPDF(contractId);
+ 
+      res.setHeader('Content-Type', 'application/pdf');
+      res.setHeader('Content-Disposition', `inline; filename=${filename}`);
+      res.send(buffer);
+    } catch (err) {
+      const error = this.handleError(err, res);
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message });
+      }
+    }
+  }
+ 
+  //
+  // getTemplates
+  //
+  // Get a list of all templates
+  //
+  // returns:
+  //    Templates
+  //
+  async getAllTemplates(
+    req: Request,
+    res: Response,
+  ): Promise<void> {
+    try {
+      const templates = await this.contractUseCase.getAllTemplates();
+      res.status(200).json(templates.map((template) => template.toJson()));
+    }
+    catch (getError) {
+      const error = this.handleError(getError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+  //
+  // deleteTemplate
+  //
+  // Delete a template
+  //
+  // returns:
+  //    None
+  //
+  async deleteTemplate(
+    req: Request,
+    res: Response
+  ): Promise<void> {
+    const name = req.params.name;
+ 
+    try {
+      const result = await this.contractUseCase.deleteTemplate(name);
+      res.status(204).send();
+    }
+    catch (delError) {
+      const error = this.handleError(delError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+    //
+  // deleteTemplate
+  //
+  // Delete a template
+  //
+  // returns:
+  //    None
+  //
+  async updateTemplate(
+    req: UpdateRequest,
+    res: Response
+  ): Promise<void> {
+    const name = req.params.name;
+    const file = req.file;
+    const { deposit, fee } = req.body;
+ 
+    try {
+      const result = await this.contractUseCase.updateTemplate(name, deposit, fee, file);
+      res.status(204).send();
+    }
+    catch (delError) {
+      const error = this.handleError(delError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+  //
+  // uploadTemplate()
+  //
+  // Upload template to storage
+  //
+  // returns:
+  //    none
+  //
+  async uploadTemplate(
+    req: UpdateRequest,
+    res: Response,
+  ): Promise<void> {
+    try {
+      const file = req.file;
+      const { name, deposit, fee } = req.body;
+  
+      Iif (!file) throw new ValidationError('No file uploaded');
+      Iif (!name) throw new ValidationError('No contract name specified');
+  
+      await this.contractUseCase.uploadTemplate(file, name, deposit, fee);
+  
+      res.status(201).json({ success: true });
+    } 
+    catch (getError) {
+      const error = this.handleError(getError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+ 
+  //
+  // Generate a filled template
+  //
+  // returns:
+  //    none
+  //
+  async generateTemplate(
+    req: Request,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { name, fields } = req.body;
+      const download = req.query.download === 'true';
+ 
+      Iif (!name) throw new ValidationError('No template name provided');
+ 
+      // generate the template as pdf
+      const pdfBuffer = await this.contractUseCase.generateTemplate(name, fields ?? {});
+      
+      if (download) {
+        res.setHeader('Content-Disposition', `attachment; filename=${fields.clientname}-${name}.pdf`);
+        res.setHeader('Content-Type', 'application/pdf');
+      }
+      else {
+        res.setHeader('Content-Type', 'application/pdf');
+        res.setHeader('Content-Disposition', `inline; filename=${fields.clientname}-${name}-preview.pdf`);
+      }
+ 
+      res.send(pdfBuffer);
+    }
+    catch (genError) {
+      const error = this.handleError(genError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message });
+      }
+    }
+  }
+ 
+  // Helper method to handle errors
+  private handleError(
+    error: Error, 
+    res: Response
+  ): { status: number, message: string } {
+    console.error('Error:', error.message);
+    
+    if (error instanceof ValidationError) {
+      return { status: 400, message: error.message};
+    } else if (error instanceof ConflictError) {
+      return { status: 409, message: error.message};
+    } else if (error instanceof AuthenticationError) {
+      return { status: 401, message: error.message};
+    } else if (error instanceof NotFoundError) {
+      return { status: 404, message: error.message};
+    } else if (error instanceof AuthorizationError) {
+      return { status: 403, message: error.message};
+    } else {
+      return { status: 500, message: error.message};
+    }
+  }
+ 
+  // Helper for returning basic summary of a client
+  private mapToClientSummary(client: Client) {
+    return {
+      id: client.user.id.toString(),
+      firstname: client.user.firstname,
+      lastname: client.user.lastname,
+      serviceNeeded: client.serviceNeeded,
+      requestedAt: client.requestedAt,
+      updatedAt: client.updatedAt,
+      status: client.status,
+    };
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/emailController.ts.html b/coverage/lcov-report/src/controllers/emailController.ts.html new file mode 100644 index 00000000..55a35d20 --- /dev/null +++ b/coverage/lcov-report/src/controllers/emailController.ts.html @@ -0,0 +1,319 @@ + + + + + + Code coverage report for src/controllers/emailController.ts + + + + + + + + + +
+
+

All files / src/controllers emailController.ts

+
+ +
+ 0% + Statements + 0/23 +
+ + +
+ 0% + Branches + 0/13 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/23 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from 'express';
+import { NodemailerService } from '../services/emailService';
+ 
+export class EmailController {
+  private emailService: NodemailerService;
+ 
+  constructor() {
+    this.emailService = new NodemailerService();
+  }
+ 
+  async sendClientApproval(req: Request, res: Response): Promise<void> {
+    try {
+      const { email, name, signupUrl } = req.body;
+ 
+      Iif (!email || !name || !signupUrl) {
+        res.status(400).json({ 
+          success: false, 
+          error: 'Missing required fields: email, name, or signupUrl' 
+        });
+        return;
+      }
+ 
+      await this.emailService.sendClientApprovalEmail(
+        email,
+        name,
+        signupUrl
+      );
+ 
+      res.status(200).json({ 
+        success: true, 
+        message: `Approval email sent to ${email}` 
+      });
+    } catch (error) {
+      console.error('Error sending approval email:', error);
+      res.status(500).json({ 
+        success: false, 
+        error: error.message || 'Failed to send email' 
+      });
+    }
+  }
+ 
+  async sendTeamInvite(req: Request, res: Response): Promise<void> {
+    try {
+      const { email, firstname, lastname, role } = req.body;
+ 
+      Iif (!email || !firstname || !lastname || !role) {
+        console.log('Missing required fields:', { email, firstname, lastname, role });
+        res.status(400).json({ 
+          success: false, 
+          error: 'Missing required fields: email, firstname, lastname, or role' 
+        });
+        return;
+      }
+ 
+      await this.emailService.sendTeamInviteEmail(
+        email,
+        firstname,
+        lastname,
+        role
+      );
+ 
+      res.status(200).json({ 
+        success: true, 
+        message: `Invite email sent to ${email}` 
+      });
+    } catch (error) {
+      console.error('Error sending team invite email:', error);
+      console.error('Error details:', {
+        name: error.name,
+        message: error.message,
+        stack: error.stack
+      });
+      res.status(500).json({ 
+        success: false, 
+        error: error.message || 'Failed to send email' 
+      });
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/index.html b/coverage/lcov-report/src/controllers/index.html new file mode 100644 index 00000000..200e72ce --- /dev/null +++ b/coverage/lcov-report/src/controllers/index.html @@ -0,0 +1,221 @@ + + + + + + Code coverage report for src/controllers + + + + + + + + + +
+
+

All files src/controllers

+
+ +
+ 0% + Statements + 0/608 +
+ + +
+ 0% + Branches + 0/309 +
+ + +
+ 0% + Functions + 0/73 +
+ + +
+ 0% + Lines + 0/589 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
authController.ts +
+
0%0/1080%0/210%0/150%0/107
clientController.ts +
+
0%0/750%0/230%0/90%0/74
contractController.ts +
+
0%0/860%0/290%0/110%0/81
emailController.ts +
+
0%0/230%0/130%0/30%0/23
paymentController.ts +
+
0%0/540%0/130%0/50%0/54
quickbooksController.ts +
+
0%0/690%0/10%0/90%0/59
requestFormController.ts +
+
0%0/1140%0/1860%0/80%0/114
userController.ts +
+
0%0/790%0/230%0/130%0/77
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/paymentController.ts.html b/coverage/lcov-report/src/controllers/paymentController.ts.html new file mode 100644 index 00000000..1bea0e7f --- /dev/null +++ b/coverage/lcov-report/src/controllers/paymentController.ts.html @@ -0,0 +1,607 @@ + + + + + + Code coverage report for src/controllers/paymentController.ts + + + + + + + + + +
+
+

All files / src/controllers paymentController.ts

+
+ +
+ 0% + Statements + 0/54 +
+ + +
+ 0% + Branches + 0/13 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/54 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from 'express';
+import { z } from 'zod';
+import { StripePaymentService } from '../services/payments/stripePaymentService';
+ 
+const paymentService = new StripePaymentService();
+ 
+// Validation schemas
+const saveCardSchema = z.object({
+  cardToken: z.string(),
+});
+ 
+const chargeCardSchema = z.object({
+  amount: z.number().positive(),
+  description: z.string().optional(),
+});
+ 
+const updateCardSchema = z.object({
+  cardToken: z.string(),
+});
+ 
+class PaymentController {
+  async saveCard(req: Request, res: Response): Promise<void> {
+    try {
+      const { customerId } = req.params;
+      const { cardToken } = req.body;
+ 
+      // Verify the authenticated user has permission for this customer
+      Iif (req.user.id !== customerId && req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Not authorized to perform this action'
+        });
+        return;
+      }
+ 
+      const card = await paymentService.saveCard({
+        customerId,
+        cardToken,
+      });
+ 
+      res.json({
+        success: true,
+        data: card,
+      });
+    } catch (error) {
+      console.error('Error saving card:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+ 
+  async processCharge(req: Request, res: Response): Promise<void> {
+    try {
+      const { customerId } = req.params;
+      const { amount, description } = req.body;
+ 
+      // Verify the authenticated user has permission for this customer
+      Iif (req.user.id !== customerId && req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Not authorized to perform this action'
+        });
+        return;
+      }
+ 
+      const charge = await paymentService.chargeCard({
+        customerId,
+        amount,
+        description,
+      });
+ 
+      res.json({
+        success: true,
+        data: charge,
+      });
+    } catch (error) {
+      console.error('Error processing charge:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+ 
+  async updatePaymentMethod(req: Request, res: Response): Promise<void> {
+    try {
+      const { customerId, paymentMethodId } = req.params;
+      const { cardToken } = req.body;
+ 
+      // Verify the authenticated user has permission for this customer
+      Iif (req.user.id !== customerId && req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Not authorized to perform this action'
+        });
+        return;
+      }
+ 
+      const updatedCard = await paymentService.updateCard({
+        customerId,
+        cardToken,
+        paymentMethodId,
+      });
+ 
+      res.json({
+        success: true,
+        data: updatedCard,
+      });
+    } catch (error) {
+      console.error('Error updating payment method:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+ 
+  async getPaymentMethods(req: Request, res: Response): Promise<void> {
+    try {
+      const { customerId } = req.params;
+ 
+      // Verify the authenticated user has permission for this customer
+      Iif (req.user.id !== customerId && req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Not authorized to perform this action'
+        });
+        return;
+      }
+ 
+      const paymentMethods = await paymentService.getPaymentMethods(customerId);
+ 
+      res.json({
+        success: true,
+        data: paymentMethods,
+      });
+    } catch (error) {
+      console.error('Error fetching payment methods:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+ 
+  async getCustomersWithStripeId(req: Request, res: Response): Promise<void> {
+    try {
+      // Only allow admins to fetch all customers
+      Iif (req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Admin access required'
+        });
+        return;
+      }
+ 
+      const customers = await paymentService.getCustomersWithStripeId();
+ 
+      res.json({
+        success: true,
+        data: customers,
+      });
+    } catch (error) {
+      console.error('Error fetching customers with Stripe ID:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+}
+ 
+export const paymentController = new PaymentController(); 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/quickbooksController.ts.html b/coverage/lcov-report/src/controllers/quickbooksController.ts.html new file mode 100644 index 00000000..a7fae671 --- /dev/null +++ b/coverage/lcov-report/src/controllers/quickbooksController.ts.html @@ -0,0 +1,511 @@ + + + + + + Code coverage report for src/controllers/quickbooksController.ts + + + + + + + + + +
+
+

All files / src/controllers quickbooksController.ts

+
+ +
+ 0% + Statements + 0/69 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/9 +
+ + +
+ 0% + Lines + 0/59 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/controller/quickbooksController.ts
+import { RequestHandler } from 'express';
+import {
+    disconnectQuickBooks,
+    generateConsentUrl,
+    handleAuthCallback,
+    isConnected
+} from '../services/auth/quickbooksAuthService';
+import createCustomerService, { CreateCustomerParams } from '../services/customer/createCustomer';
+import createInvoiceService from '../services/invoice/createInvoice';
+import supabase from '../supabase';
+// ← 1) Import your invoiceable-customers logic
+import getInvoiceableCustomers from '../services/customer/getInvoiceableCustomers';
+// Ensure you have SUPABASE_JWT_SECRET in your env
+const JWT_SECRET = process.env.SUPABASE_JWT_SECRET!
+ 
+/**
+ * JSON endpoint: return the Intuit consent URL for AJAX calls.
+ */
+export const quickBooksAuthUrl: RequestHandler = (_req, res, next) => {
+  try {
+    const state = Math.random().toString(36).substring(2)
+    const url   = generateConsentUrl(state)
+    res.json({ url })
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * Redirect endpoint: used by window.open to start OAuth directly.
+ */
+export const connectQuickBooks: RequestHandler = (req, res, next) => {
+  try {
+    const state = Math.random().toString(36).substring(2)
+    const url   = generateConsentUrl(state)
+    res.redirect(url)
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * OAuth callback: exchange code for tokens, persist them, then notify the opener.
+ */
+export const handleQuickBooksCallback: RequestHandler = async (req, res, next) => {
+  try {
+    const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
+    await handleAuthCallback(fullUrl)
+    res.send(`
+      <html><body>
+        <script>
+           window.opener.postMessage({ success: true }, 'http://localhost:3001')
+          window.close()
+        </script>
+      </body></html>
+    `)
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * Create an invoice
+ */
+export const createInvoice: RequestHandler = async (req, res, next) => {
+  try {
+    const invoice = await createInvoiceService(req.body);
+    res.status(201).json(invoice);
+  } catch (err) {
+    next(err);
+  }
+}
+ 
+/**
+ * Get invoiceable customers
+ */
+export const getInvoiceableCustomersController: RequestHandler = async (_req, res, next) => {
+  try {
+    const customers = await getInvoiceableCustomers(supabase);
+    res.json(customers);
+  } catch (err: any) {
+    next(err);
+  }
+}
+ 
+/**
+ * Create a customer
+ */
+export const createCustomer: RequestHandler = async (req, res, next) => {
+  try {
+    const params: CreateCustomerParams = req.body;
+    const result = await createCustomerService(params)
+    res.status(201).json(result)
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * Get QuickBooks connection status
+ */
+export const quickBooksStatus: RequestHandler = async (req, res, next) => {
+  try {
+    console.log('🔍 [QB Status] Checking connection status...');
+    const connected = await isConnected();
+    console.log('📊 [QB Status] Connection result:', connected);
+    res.json({ connected });
+  } catch (err) {
+    console.error('❌ [QB Status] Error checking status:', err);
+    next(err);
+  }
+}
+ 
+/**
+ * Disconnect QuickBooks
+ */
+export const quickBooksDisconnect: RequestHandler = async (req, res, next) => {
+  try {
+    await disconnectQuickBooks()
+    res.json({ disconnected: true })
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * GET /quickbooks/invoices
+ * Returns all invoices you've saved in Supabase
+ */
+export const getInvoices: RequestHandler = async (_req, res, next) => {
+  try {
+    const { data, error } = await supabase
+      .from('invoices')
+      .select('*')
+      .order('created_at', { ascending: false })
+ 
+    Iif (error) throw error
+    res.json(data)
+  } catch (err) {
+    next(err)
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/requestFormController.ts.html b/coverage/lcov-report/src/controllers/requestFormController.ts.html new file mode 100644 index 00000000..59220374 --- /dev/null +++ b/coverage/lcov-report/src/controllers/requestFormController.ts.html @@ -0,0 +1,1438 @@ + + + + + + Code coverage report for src/controllers/requestFormController.ts + + + + + + + + + +
+
+

All files / src/controllers requestFormController.ts

+
+ +
+ 0% + Statements + 0/114 +
+ + +
+ 0% + Branches + 0/186 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/114 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from "express";
+import { NodemailerService } from '../services/emailService';
+import { RequestFormService } from "../services/RequestFormService";
+import { AuthRequest, RequestFormData, RequestStatus } from "../types";
+ 
+const notificationEmail = 'jerrybony5@gmail.com';
+const emailService = new NodemailerService();
+ 
+export class RequestFormController {
+    private service: RequestFormService;
+ 
+    constructor(requestFormService: RequestFormService) {
+        this.service = requestFormService;
+    }
+ 
+    async createRequest(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.body) {
+                res.status(400).json({ error: 'No body found in request' });
+                return;
+            }
+ 
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            const formData: RequestFormData = req.body;
+            const result = await this.service.createRequest(formData);
+            
+            res.status(201).json({
+                message: "Request form submitted successfully",
+                data: result
+            });
+        } catch (error) {
+            console.error("Error creating request:", error);
+            res.status(400).json({ error: error.message });
+        }
+    }
+ 
+    async getUserRequests(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            const requests = await this.service.getUserRequests(req.user.id);
+            res.status(200).json({
+                message: "User requests retrieved successfully",
+                data: requests
+            });
+        } catch (error) {
+            console.error("Error getting user requests:", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    async getRequestById(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            const { id } = req.params;
+            Iif (!id) {
+                res.status(400).json({ error: 'Request ID is required' });
+                return;
+            }
+ 
+            const request = await this.service.getRequestById(id, req.user.id);
+            Iif (!request) {
+                res.status(404).json({ error: 'Request not found' });
+                return;
+            }
+ 
+            res.status(200).json({
+                message: "Request retrieved successfully",
+                data: request
+            });
+        } catch (error) {
+            console.error("Error getting request by ID:", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    async getAllRequests(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            // Check if user is admin
+            Iif (req.user.role !== 'admin') {
+                res.status(403).json({ error: 'Admin access required' });
+                return;
+            }
+ 
+            const requests = await this.service.getAllRequests();
+            res.status(200).json({
+                message: "All requests retrieved successfully",
+                data: requests
+            });
+        } catch (error) {
+            console.error("Error getting all requests:", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    async getRequestByIdAdmin(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            // Check if user is admin
+            Iif (req.user.role !== 'admin') {
+                res.status(403).json({ error: 'Admin access required' });
+                return;
+            }
+ 
+            const { id } = req.params;
+            Iif (!id) {
+                res.status(400).json({ error: 'Request ID is required' });
+                return;
+            }
+ 
+            const request = await this.service.getRequestByIdAdmin(id);
+            Iif (!request) {
+                res.status(404).json({ error: 'Request not found' });
+                return;
+            }
+ 
+            res.status(200).json({
+                message: "Request retrieved successfully",
+                data: request
+            });
+        } catch (error) {
+            console.error("Error getting request by ID (admin):", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    async updateRequestStatus(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            // Check if user is admin
+            Iif (req.user.role !== 'admin') {
+                res.status(403).json({ error: 'Admin access required' });
+                return;
+            }
+ 
+            const { id } = req.params;
+            const { status } = req.body;
+ 
+            Iif (!id) {
+                res.status(400).json({ error: 'Request ID is required' });
+                return;
+            }
+ 
+            Iif (!status) {
+                res.status(400).json({ error: 'Status is required' });
+                return;
+            }
+ 
+            const validStatuses = Object.values(RequestStatus);
+            Iif (!validStatuses.includes(status)) {
+                res.status(400).json({ 
+                    error: 'Invalid status value',
+                    validStatuses: validStatuses
+                });
+                return;
+            }
+ 
+            const updatedRequest = await this.service.updateRequestStatus(id, status);
+            res.status(200).json({
+                message: "Request status updated successfully",
+                data: updatedRequest
+            });
+        } catch (error) {
+            console.error("Error updating request status:", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    // Updated method to handle all 10-step form fields
+    async createForm(req: Request, res: Response): Promise<void> {
+        try {
+            Iif (!req.body) {
+                res.status(400).json({ error: 'No body found in request' });
+                return;
+            }
+            const formData = req.body;
+            const savedForm = await this.service.newForm(formData);
+ 
+            // Send notification email
+            try {
+                const subject = 'New Lead Submitted via Request Form';
+                
+                // Create comprehensive text version
+                const text = `A new lead has been submitted via the request form.
+ 
+CLIENT DETAILS:
+Name: ${savedForm.firstname} ${savedForm.lastname}
+Email: ${savedForm.email}
+Phone: ${savedForm.phone_number}
+Pronouns: ${savedForm.pronouns || 'Not specified'}${savedForm.pronouns_other ? ` (${savedForm.pronouns_other})` : ''}
+Children Expected: ${savedForm.children_expected || 'Not specified'}
+ 
+HOME DETAILS:
+Address: ${savedForm.address}
+City: ${savedForm.city}
+State: ${savedForm.state}
+Zip Code: ${savedForm.zip_code}
+Home Phone: ${savedForm.home_phone || 'Not provided'}
+Home Type: ${savedForm.home_type || 'Not specified'}
+Home Access: ${savedForm.home_access || 'Not specified'}
+Pets: ${savedForm.pets || 'None'}
+ 
+FAMILY MEMBERS:
+Relationship Status: ${savedForm.relationship_status || 'Not specified'}
+Partner Name: ${savedForm.first_name || 'Not provided'} ${savedForm.last_name || ''} ${savedForm.middle_name ? `(${savedForm.middle_name})` : ''}
+Partner Mobile: ${savedForm.mobile_phone || 'Not provided'}
+Partner Work Phone: ${savedForm.work_phone || 'Not provided'}
+ 
+REFERRAL:
+Source: ${savedForm.referral_source || 'Not specified'}
+Referral Name: ${savedForm.referral_name || 'Not provided'}
+Referral Email: ${savedForm.referral_email || 'Not provided'}
+ 
+HEALTH HISTORY:
+Health History: ${savedForm.health_history || 'None reported'}
+Allergies: ${savedForm.allergies || 'None reported'}
+Health Notes: ${savedForm.health_notes || 'None'}
+ 
+PAYMENT INFO:
+Annual Income: ${savedForm.annual_income || 'Not specified'}
+Service Needed: ${savedForm.service_needed}
+Service Specifics: ${savedForm.service_specifics || 'Not provided'}
+ 
+PREGNANCY/BABY:
+Due Date: ${savedForm.due_date ? new Date(savedForm.due_date).toLocaleDateString() : 'Not specified'}
+Birth Location: ${savedForm.birth_location || 'Not specified'}
+Birth Hospital: ${savedForm.birth_hospital || 'Not specified'}
+Number of Babies: ${savedForm.number_of_babies || 'Not specified'}
+Baby Name: ${savedForm.baby_name || 'Not specified'}
+Provider Type: ${savedForm.provider_type || 'Not specified'}
+Pregnancy Number: ${savedForm.pregnancy_number || 'Not specified'}
+Hospital: ${savedForm.hospital || 'Not specified'}
+ 
+PAST PREGNANCIES:
+Had Previous Pregnancies: ${savedForm.had_previous_pregnancies ? 'Yes' : 'No'}
+Previous Pregnancies Count: ${savedForm.previous_pregnancies_count || '0'}
+Living Children Count: ${savedForm.living_children_count || '0'}
+Past Pregnancy Experience: ${savedForm.past_pregnancy_experience || 'None'}
+ 
+SERVICES INTERESTED:
+Services: ${Array.isArray(savedForm.services_interested) ? savedForm.services_interested.join(', ') : savedForm.services_interested || 'Not specified'}
+Service Support Details: ${savedForm.service_support_details || 'Not provided'}
+ 
+DEMOGRAPHICS:
+Race/Ethnicity: ${savedForm.race_ethnicity || 'Not specified'}
+Primary Language: ${savedForm.primary_language || 'Not specified'}
+Client Age Range: ${savedForm.client_age_range || 'Not specified'}
+Insurance: ${savedForm.insurance || 'Not specified'}
+Demographics: ${Array.isArray(savedForm.demographics_multi) ? savedForm.demographics_multi.join(', ') : savedForm.demographics_multi || 'None'}
+ 
+FORM SUBMISSION DETAILS:
+Submission Date: ${new Date().toLocaleString()}
+Status: lead`;
+ 
+ 
+ 
+                // Create comprehensive HTML version
+                const html = `
+                  <div style="font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; background-color: #f9f9f9; padding: 20px;">
+                    <div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
+                      <h1 style="color: #4CAF50; text-align: center; margin-bottom: 30px; border-bottom: 3px solid #4CAF50; padding-bottom: 10px;">New Lead Submitted</h1>
+                      
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">👤 Client Details</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Name:</td><td style="padding: 8px;">${savedForm.firstname} ${savedForm.lastname}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Email:</td><td style="padding: 8px;"><a href="mailto:${savedForm.email}">${savedForm.email}</a></td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Phone:</td><td style="padding: 8px;"><a href="tel:${savedForm.phone_number}">${savedForm.phone_number}</a></td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Pronouns:</td><td style="padding: 8px;">${savedForm.pronouns || 'Not specified'}${savedForm.pronouns_other ? ` (${savedForm.pronouns_other})` : ''}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Children Expected:</td><td style="padding: 8px;">${savedForm.children_expected || 'Not specified'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">🏠 Home Details</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Address:</td><td style="padding: 8px;">${savedForm.address}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">City/State/Zip:</td><td style="padding: 8px;">${savedForm.city}, ${savedForm.state} ${savedForm.zip_code}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Home Phone:</td><td style="padding: 8px;">${savedForm.home_phone || 'Not provided'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Home Type:</td><td style="padding: 8px;">${savedForm.home_type || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Home Access:</td><td style="padding: 8px;">${savedForm.home_access || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Pets:</td><td style="padding: 8px;">${savedForm.pets || 'None'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">👨‍👩‍👧‍👦 Family Members</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Relationship Status:</td><td style="padding: 8px;">${savedForm.relationship_status || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Partner Name:</td><td style="padding: 8px;">${savedForm.first_name || 'Not provided'} ${savedForm.last_name || ''} ${savedForm.middle_name ? `(${savedForm.middle_name})` : ''}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Partner Mobile:</td><td style="padding: 8px;">${savedForm.mobile_phone || 'Not provided'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Partner Work Phone:</td><td style="padding: 8px;">${savedForm.work_phone || 'Not provided'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">📞 Referral</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Source:</td><td style="padding: 8px;">${savedForm.referral_source || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Referral Name:</td><td style="padding: 8px;">${savedForm.referral_name || 'Not provided'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Referral Email:</td><td style="padding: 8px;">${savedForm.referral_email || 'Not provided'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">🏥 Health History</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Health History:</td><td style="padding: 8px;">${savedForm.health_history || 'None reported'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Allergies:</td><td style="padding: 8px;">${savedForm.allergies || 'None reported'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Health Notes:</td><td style="padding: 8px;">${savedForm.health_notes || 'None'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">💰 Payment Info</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Annual Income:</td><td style="padding: 8px;">${savedForm.annual_income || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Service Needed:</td><td style="padding: 8px; font-weight: bold; color: #4CAF50;">${savedForm.service_needed}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Service Specifics:</td><td style="padding: 8px;">${savedForm.service_specifics || 'Not provided'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">👶 Pregnancy/Baby</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Due Date:</td><td style="padding: 8px;">${savedForm.due_date ? new Date(savedForm.due_date).toLocaleDateString() : 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Birth Location:</td><td style="padding: 8px;">${savedForm.birth_location || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Birth Hospital:</td><td style="padding: 8px;">${savedForm.birth_hospital || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Number of Babies:</td><td style="padding: 8px;">${savedForm.number_of_babies || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Baby Name:</td><td style="padding: 8px;">${savedForm.baby_name || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Provider Type:</td><td style="padding: 8px;">${savedForm.provider_type || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Pregnancy Number:</td><td style="padding: 8px;">${savedForm.pregnancy_number || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Hospital:</td><td style="padding: 8px;">${savedForm.hospital || 'Not specified'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">📋 Past Pregnancies</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Had Previous Pregnancies:</td><td style="padding: 8px;">${savedForm.had_previous_pregnancies ? 'Yes' : 'No'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Previous Pregnancies Count:</td><td style="padding: 8px;">${savedForm.previous_pregnancies_count || '0'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Living Children Count:</td><td style="padding: 8px;">${savedForm.living_children_count || '0'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Past Pregnancy Experience:</td><td style="padding: 8px;">${savedForm.past_pregnancy_experience || 'None'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">🎯 Services Interested</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Services:</td><td style="padding: 8px;">${Array.isArray(savedForm.services_interested) ? savedForm.services_interested.join(', ') : savedForm.services_interested || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Service Support Details:</td><td style="padding: 8px;">${savedForm.service_support_details || 'Not provided'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">📊 Demographics</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Race/Ethnicity:</td><td style="padding: 8px;">${savedForm.race_ethnicity || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Primary Language:</td><td style="padding: 8px;">${savedForm.primary_language || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Client Age Range:</td><td style="padding: 8px;">${savedForm.client_age_range || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Insurance:</td><td style="padding: 8px;">${savedForm.insurance || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Demographics:</td><td style="padding: 8px;">${Array.isArray(savedForm.demographics_multi) ? savedForm.demographics_multi.join(', ') : savedForm.demographics_multi || 'None'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">📋 Form Submission Details</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Submission Date:</td><td style="padding: 8px;">${new Date().toLocaleString()}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Status:</td><td style="padding: 8px;">lead</td></tr>
+                        </table>
+                      </div>
+ 
+                    </div>
+                  </div>
+                `;
+ 
+ 
+                
+                await emailService.sendEmail(notificationEmail, subject, text, html);
+            } catch (emailError) {
+                console.error('Failed to send notification email:', emailError);
+                // Do not block form submission if email fails
+            }
+ 
+            // Send confirmation email to the person who submitted the request
+            try {
+                const confirmationSubject = 'Request Received - We\'re Working on Your Match';
+                
+                const confirmationText = `Dear ${savedForm.firstname} ${savedForm.lastname},
+ 
+Thank you for submitting your request for doula services. We have received your information and are working on finding the perfect match for you.
+ 
+Best regards,
+The Sokana Collective Team`;
+ 
+                const confirmationHtml = `
+                  <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background-color: #f9f9f9; padding: 20px;">
+                    <div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
+                      <h1 style="color: #4CAF50; text-align: center; margin-bottom: 30px; border-bottom: 3px solid #4CAF50; padding-bottom: 10px;">Request Received</h1>
+                      
+                      <p style="font-size: 18px; color: #333; margin-bottom: 20px;">Dear ${savedForm.firstname} ${savedForm.lastname},</p>
+                      
+                      <p style="font-size: 16px; color: #555; line-height: 1.6; margin-bottom: 20px;">
+                        Thank you for submitting your request for doula services. We have received your information and are working on finding the perfect match for you.
+                      </p>
+                      
+                      <div style="text-align: center; margin-top: 30px; padding: 20px; background-color: #f5f5f5; border-radius: 5px;">
+                        <p style="margin: 0; font-weight: bold; color: #333;">Best regards,</p>
+                        <p style="margin: 5px 0 0 0; color: #4CAF50; font-weight: bold;">The Sokana Collective Team</p>
+                      </div>
+                    </div>
+                  </div>
+                `;
+                
+                await emailService.sendEmail(savedForm.email, confirmationSubject, confirmationText, confirmationHtml);
+            } catch (confirmationEmailError) {
+                console.error('Failed to send confirmation email:', confirmationEmailError);
+                // Do not block form submission if confirmation email fails
+            }
+ 
+            res.status(200).json({ message: "Form data received, onto processing" });
+        } catch (error) {
+            console.error("Error processing form data:", error);
+            res.status(400).json({ error: error.message });
+        }
+    }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/controllers/userController.ts.html b/coverage/lcov-report/src/controllers/userController.ts.html new file mode 100644 index 00000000..27e84e6c --- /dev/null +++ b/coverage/lcov-report/src/controllers/userController.ts.html @@ -0,0 +1,538 @@ + + + + + + Code coverage report for src/controllers/userController.ts + + + + + + + + + +
+
+

All files / src/controllers userController.ts

+
+ +
+ 0% + Statements + 0/79 +
+ + +
+ 0% + Branches + 0/23 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 0% + Lines + 0/77 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Response } from 'express';
+import { AuthenticationError, AuthorizationError, ConflictError, NotFoundError, ValidationError } from '../domains/errors';
+import { AuthRequest, UpdateRequest } from '../types';
+import { UserUseCase } from "../usecase/userUseCase";
+ 
+ 
+export class UserController {
+  private userUseCase: UserUseCase;
+ 
+  constructor(userUseCase: UserUseCase) {
+    this.userUseCase = userUseCase;
+  }
+ 
+  async getUserById(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const targetUserId = req.params.id;
+ 
+      const user = await this.userUseCase.getUserById(targetUserId);
+      res.status(200).json(user.toJSON());
+    } catch (error) {
+      this.handleError(error, res);
+    }
+  }
+ 
+  async getAllUsers(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const users = await this.userUseCase.getAllUsers();
+      res.status(200).json(users.map(user => user.toJSON()));
+    } catch (error) {
+      this.handleError(error, res);
+    }
+  }
+  async getAllTeamMembers(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const users = await this.userUseCase.getAllTeamMembers();
+      res.status(200).json(users.map(user => user.toJSON()));
+    } catch (error) {
+      this.handleError(error, res);
+    }
+  }
+ 
+  async deleteMember(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const userId = req.params.id;
+      await this.userUseCase.deleteMember(userId);
+    } catch (error) {
+      this.handleError(error, res);
+    }
+  }
+ 
+  async addMember(req: AuthRequest, res: Response): Promise<void>{
+    try{
+      const userName = req.params.firstname
+      const userEmail = req.params.email
+      const userRole = req.params.role
+      const userBio = req.params.bio
+      const user = await this.userUseCase.addMember(userName, userEmail, userRole, userBio)
+      res.status(200).json(user.toJSON())
+    } catch (error) {
+      this.handleError(error, res)
+    }
+  }
+ 
+  async getHours(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const { id, role } = req.user;
+      if(role === "admin") {
+        const allHoursData = await this.userUseCase.getAllHours();
+        res.status(200).json(allHoursData);
+      } else {
+        const specificHoursData = await this.userUseCase.getHoursById(id);
+        res.status(200).json(specificHoursData);
+      }
+    } catch (error) {
+      console.log("Error when retrieving user's work data");
+      this.handleError(error, res);
+    }
+  }
+ 
+  async addNewHours(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const { doula_id, client_id, start_time, end_time, note } = req.body;
+ 
+      Iif(!doula_id || !client_id || !start_time|| !end_time) {
+        console.log(`${doula_id}, ${client_id}, ${start_time}, ${end_time}`);
+        throw new Error(`Error: missing doula_id, client_id, start_time, or end_time`);
+      }
+ 
+      const newWorkEntry = await this.userUseCase.addNewHours(doula_id, client_id, new Date(start_time), new Date(end_time), note);
+      res.status(200).json(newWorkEntry);
+    } catch (error) {
+      console.log("Error trying to add new work entry");
+      this.handleError(error, res);
+    }
+  }
+ 
+  async updateUser(req: UpdateRequest, res: Response): Promise<void> {
+    try {
+      const user = req.user
+      const updateData = req.body;
+      const profilePicture = req.file;
+      
+      // upload profile picture to supabase storage so we can grab it later
+      Iif (profilePicture) {
+        const imageUrl = await this.userUseCase.uploadProfilePicture(user, profilePicture);
+        updateData.profile_picture = imageUrl;
+      }
+      
+      // Here we will handle which fields to update
+      const updatedUser = await this.userUseCase.updateUser(user, updateData);
+  
+      res.status(200).json(updatedUser.toJSON());
+    } catch(error) {
+      res.status(400).json({ error: error.message});
+    }
+  }
+ 
+  async addTeamMember(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const { firstname, lastname, email, role } = req.body;
+ 
+      Iif (!firstname || !lastname || !email || !role) {
+        res.status(400).json({ error: 'Missing required fields' });
+        return;
+      }
+ 
+      const newMember = await this.userUseCase.addMember(firstname, lastname, email, role);
+      res.status(201).json(newMember);
+    } catch (error) {
+      console.error('Error adding team member:', error);
+      res.status(500).json({ error: error.message });
+    }
+  }
+ 
+  private handleError(error: Error, res: Response): void {
+    console.error('Error:', error.message);
+    
+    if (error instanceof ValidationError) {
+      res.status(400).json({ error: error.message });
+    } else if (error instanceof ConflictError) {
+      res.status(409).json({ error: error.message });
+    } else if (error instanceof AuthenticationError) {
+      res.status(401).json({ error: error.message });
+    } else if (error instanceof NotFoundError) {
+      res.status(404).json({ error: error.message });
+    } else if (error instanceof AuthorizationError) {
+      res.status(403).json({ error: error.message });
+    } else {
+      res.status(500).json({ error: error.message });
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/db/checkTables.ts.html b/coverage/lcov-report/src/db/checkTables.ts.html new file mode 100644 index 00000000..275d7752 --- /dev/null +++ b/coverage/lcov-report/src/db/checkTables.ts.html @@ -0,0 +1,184 @@ + + + + + + Code coverage report for src/db/checkTables.ts + + + + + + + + + +
+
+

All files / src/db checkTables.ts

+
+ +
+ 0% + Statements + 0/13 +
+ + +
+ 0% + Branches + 0/4 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/13 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import supabase from '../supabase';
+ 
+async function checkTables() {
+  console.log('Checking database tables...');
+ 
+  // Check payment_methods table
+  const { data: paymentMethodsData, error: paymentMethodsError } = await supabase
+    .from('payment_methods')
+    .select('*')
+    .limit(1);
+ 
+  console.log('\nPayment Methods Table:');
+  if (paymentMethodsError) {
+    console.error('Error:', paymentMethodsError.message);
+  } else {
+    console.log('✅ Table exists');
+  }
+ 
+  // Check charges table
+  const { data: chargesData, error: chargesError } = await supabase
+    .from('charges')
+    .select('*')
+    .limit(1);
+ 
+  console.log('\nCharges Table:');
+  if (chargesError) {
+    console.error('Error:', chargesError.message);
+  } else {
+    console.log('✅ Table exists');
+  }
+}
+ 
+// Run the check
+checkTables().catch(console.error); 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/db/index.html b/coverage/lcov-report/src/db/index.html new file mode 100644 index 00000000..9a40072e --- /dev/null +++ b/coverage/lcov-report/src/db/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/db + + + + + + + + + +
+
+

All files src/db

+
+ +
+ 0% + Statements + 0/23 +
+ + +
+ 0% + Branches + 0/5 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/22 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
checkTables.ts +
+
0%0/130%0/40%0/10%0/13
setupStripeDb.ts +
+
0%0/100%0/10%0/10%0/9
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/db/setupStripeDb.ts.html b/coverage/lcov-report/src/db/setupStripeDb.ts.html new file mode 100644 index 00000000..4eaea8c9 --- /dev/null +++ b/coverage/lcov-report/src/db/setupStripeDb.ts.html @@ -0,0 +1,307 @@ + + + + + + Code coverage report for src/db/setupStripeDb.ts + + + + + + + + + +
+
+

All files / src/db setupStripeDb.ts

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/9 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import supabase from '../supabase';
+ 
+async function setupStripeDb() {
+  const sql = `
+    -- First, create the update_updated_at_column function if it doesn't exist
+    create or replace function update_updated_at_column()
+    returns trigger as $$
+    begin
+        new.updated_at = now();
+        return new;
+    end;
+    $$ language 'plpgsql';
+ 
+    -- Drop existing objects if they exist
+    drop trigger if exists update_payment_methods_updated_at on payment_methods;
+    drop trigger if exists update_charges_updated_at on charges;
+    drop table if exists charges;
+    drop table if exists payment_methods;
+ 
+    -- Create payment_methods table
+    create table payment_methods (
+      id uuid default uuid_generate_v4() primary key,
+      customer_id uuid references customers(id) not null,
+      stripe_payment_method_id text not null,
+      card_last4 text not null,
+      card_brand text not null,
+      card_exp_month integer not null,
+      card_exp_year integer not null,
+      is_default boolean default false,
+      created_at timestamp with time zone default now(),
+      updated_at timestamp with time zone default now()
+    );
+ 
+    -- Create charges table
+    create table charges (
+      id uuid default uuid_generate_v4() primary key,
+      customer_id uuid references customers(id) not null,
+      payment_method_id uuid references payment_methods(id) not null,
+      stripe_payment_intent_id text not null,
+      amount integer not null,  -- Amount in cents
+      status text not null,    -- 'succeeded', 'failed', etc.
+      description text,
+      created_at timestamp with time zone default now(),
+      updated_at timestamp with time zone default now()
+    );
+ 
+    -- Create indexes
+    create index if not exists payment_methods_customer_id_idx on payment_methods(customer_id);
+    create index if not exists charges_customer_id_idx on charges(customer_id);
+    create index if not exists charges_payment_method_id_idx on charges(payment_method_id);
+ 
+    -- Create triggers
+    create trigger update_payment_methods_updated_at
+        before update on payment_methods
+        for each row
+        execute procedure update_updated_at_column();
+ 
+    create trigger update_charges_updated_at
+        before update on charges
+        for each row
+        execute procedure update_updated_at_column();
+  `;
+ 
+  try {
+    const { error } = await supabase.rpc('exec_sql', { sql });
+    Iif (error) throw error;
+    console.log('Successfully set up Stripe database tables');
+  } catch (error) {
+    console.error('Error setting up Stripe database:', error);
+    throw error;
+  }
+}
+ 
+// Run the setup
+setupStripeDb().catch(console.error); 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/domains/errors/AuthenticationError.ts.html b/coverage/lcov-report/src/domains/errors/AuthenticationError.ts.html new file mode 100644 index 00000000..b9379b57 --- /dev/null +++ b/coverage/lcov-report/src/domains/errors/AuthenticationError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/AuthenticationError.ts + + + + + + + + + +
+
+

All files / src/domains/errors AuthenticationError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from '././DomainError';
+ 
+export class AuthenticationError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, AuthenticationError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/domains/errors/AuthorizationError.ts.html b/coverage/lcov-report/src/domains/errors/AuthorizationError.ts.html new file mode 100644 index 00000000..c1a3fd5c --- /dev/null +++ b/coverage/lcov-report/src/domains/errors/AuthorizationError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/AuthorizationError.ts + + + + + + + + + +
+
+

All files / src/domains/errors AuthorizationError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from '././DomainError';
+ 
+export class AuthorizationError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, AuthorizationError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/domains/errors/ConflictError.ts.html b/coverage/lcov-report/src/domains/errors/ConflictError.ts.html new file mode 100644 index 00000000..dd574fb7 --- /dev/null +++ b/coverage/lcov-report/src/domains/errors/ConflictError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/ConflictError.ts + + + + + + + + + +
+
+

All files / src/domains/errors ConflictError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from '././DomainError';
+ 
+export class ConflictError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, ConflictError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/domains/errors/DomainError.ts.html b/coverage/lcov-report/src/domains/errors/DomainError.ts.html new file mode 100644 index 00000000..feb1cc2b --- /dev/null +++ b/coverage/lcov-report/src/domains/errors/DomainError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/DomainError.ts + + + + + + + + + +
+
+

All files / src/domains/errors DomainError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
export class DomainError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = this.constructor.name;
+    // This is necessary to make instanceof work properly in TypeScript
+    Object.setPrototypeOf(this, DomainError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/domains/errors/NotFoundError.ts.html b/coverage/lcov-report/src/domains/errors/NotFoundError.ts.html new file mode 100644 index 00000000..acacd8cc --- /dev/null +++ b/coverage/lcov-report/src/domains/errors/NotFoundError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/NotFoundError.ts + + + + + + + + + +
+
+

All files / src/domains/errors NotFoundError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from './DomainError';
+ 
+export class NotFoundError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, NotFoundError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/domains/errors/ValidationError.ts.html b/coverage/lcov-report/src/domains/errors/ValidationError.ts.html new file mode 100644 index 00000000..46a3df7b --- /dev/null +++ b/coverage/lcov-report/src/domains/errors/ValidationError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/ValidationError.ts + + + + + + + + + +
+
+

All files / src/domains/errors ValidationError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from './DomainError';
+ 
+export class ValidationError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, ValidationError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/domains/errors/index.html b/coverage/lcov-report/src/domains/errors/index.html new file mode 100644 index 00000000..e1439941 --- /dev/null +++ b/coverage/lcov-report/src/domains/errors/index.html @@ -0,0 +1,206 @@ + + + + + + Code coverage report for src/domains/errors + + + + + + + + + +
+
+

All files src/domains/errors

+
+ +
+ 0% + Statements + 0/30 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/6 +
+ + +
+ 0% + Lines + 0/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
AuthenticationError.ts +
+
0%0/4100%0/00%0/10%0/4
AuthorizationError.ts +
+
0%0/4100%0/00%0/10%0/4
ConflictError.ts +
+
0%0/4100%0/00%0/10%0/4
DomainError.ts +
+
0%0/4100%0/00%0/10%0/4
NotFoundError.ts +
+
0%0/4100%0/00%0/10%0/4
ValidationError.ts +
+
0%0/4100%0/00%0/10%0/4
index.ts +
+
0%0/6100%0/0100%0/00%0/6
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/domains/errors/index.ts.html b/coverage/lcov-report/src/domains/errors/index.ts.html new file mode 100644 index 00000000..9f9e899e --- /dev/null +++ b/coverage/lcov-report/src/domains/errors/index.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/index.ts + + + + + + + + + +
+
+

All files / src/domains/errors index.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
// src/domain/errors/index.ts
+export * from './AuthenticationError';
+export * from './AuthorizationError';
+export * from './ConflictError';
+export * from './DomainError';
+export * from './NotFoundError';
+export * from './ValidationError';
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/entities/Activity.ts.html b/coverage/lcov-report/src/entities/Activity.ts.html new file mode 100644 index 00000000..7d0f45b8 --- /dev/null +++ b/coverage/lcov-report/src/entities/Activity.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/entities/Activity.ts + + + + + + + + + +
+
+

All files / src/entities Activity.ts

+
+ +
+ 0% + Statements + 0/9 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/9 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export interface ActivityMetadata {
+  [key: string]: any;
+}
+ 
+export class Activity {
+  constructor(
+    public id: string,
+    public clientId: string,
+    public type: string,
+    public description?: string,
+    public metadata?: ActivityMetadata,
+    public timestamp: Date = new Date(),
+    public createdBy?: string
+  ) {}
+ 
+  toJson(): Object {
+    return {
+      id: this.id,
+      clientId: this.clientId,
+      type: this.type,
+      description: this.description,
+      metadata: this.metadata,
+      timestamp: this.timestamp,
+      createdBy: this.createdBy
+    };
+  }
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/entities/Client.ts.html b/coverage/lcov-report/src/entities/Client.ts.html new file mode 100644 index 00000000..3974d4e4 --- /dev/null +++ b/coverage/lcov-report/src/entities/Client.ts.html @@ -0,0 +1,232 @@ + + + + + + Code coverage report for src/entities/Client.ts + + + + + + + + + +
+
+

All files / src/entities Client.ts

+
+ +
+ 0% + Statements + 0/18 +
+ + +
+ 0% + Branches + 0/20 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { CLIENT_STATUS, ServiceTypes } from '../types';
+import { User } from './User';
+ 
+export class Client {
+  constructor(
+    public id: string,
+    public user: User,
+    public serviceNeeded: ServiceTypes,
+    public requestedAt: Date,
+    public updatedAt: Date,
+    public status: CLIENT_STATUS,
+ 
+    // Optional detailed fields from client_info
+    public childrenExpected?: string,
+    public pronouns?: string,
+    public health_history?: string,
+    public allergies?: string,
+    public due_date?: Date,
+    public hospital?: string,
+    public baby_sex?: string,
+    public annual_income?: string,
+    public service_specifics?: string,
+    public phoneNumber?: string, // Add phone number field
+  ) {}
+ 
+  toJson(): Object {
+    return (
+      {
+        id: this.id,
+        user: this.user,
+        serviceNeeded: this.serviceNeeded,
+        requestedAt: this.requestedAt,
+        updatedAt: this.updatedAt,
+        status: this.status,
+ 
+        // Optional detailed fields from client_info
+        ...(this.childrenExpected && { childrenExpected: this.childrenExpected }),
+        ...(this.pronouns && { pronouns: this.pronouns }),
+        ...(this.health_history && { health_history: this.health_history }),
+        ...(this.allergies && { allergies: this.allergies }),
+        ...(this.due_date && { due_date: this.due_date }),
+        ...(this.hospital && { hospital: this.hospital }),
+        ...(this.baby_sex && { baby_sex: this.baby_sex }),
+        ...(this.annual_income && { annual_income: this.annual_income }),
+        ...(this.service_specifics && { service_specifics: this.service_specifics }),
+        ...(this.phoneNumber && { phoneNumber: this.phoneNumber }) // Include phone number in JSON
+      }
+    );
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/entities/Hours.ts.html b/coverage/lcov-report/src/entities/Hours.ts.html new file mode 100644 index 00000000..bb296f23 --- /dev/null +++ b/coverage/lcov-report/src/entities/Hours.ts.html @@ -0,0 +1,151 @@ + + + + + + Code coverage report for src/entities/Hours.ts + + + + + + + + + +
+
+

All files / src/entities Hours.ts

+
+ +
+ 0% + Statements + 0/2 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/2 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export class WORK_ENTRY {
+  id: string;
+  start_time: Date;
+  end_time: Date;
+  doula: {
+      id: string;
+      firstname: string;
+      lastname: string;
+  };
+  client: {
+      id: string;
+      firstname: string;
+      lastname: string;
+  };
+};
+ 
+export class WORK_ENTRY_ROW {
+  id: string;
+  doula_id: string;
+  client_id: string;
+  start_time: Date;
+  end_time: Date;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/entities/Note.ts.html b/coverage/lcov-report/src/entities/Note.ts.html new file mode 100644 index 00000000..aebff5f4 --- /dev/null +++ b/coverage/lcov-report/src/entities/Note.ts.html @@ -0,0 +1,118 @@ + + + + + + Code coverage report for src/entities/Note.ts + + + + + + + + + +
+
+

All files / src/entities Note.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12  +  +  +  +  +  +  +  +  +  +  + 
export enum VISIBILITY {
+  PUBLIC = "public",
+  PRIVATE = "private"
+}
+ 
+export class NOTE {
+  id: string;
+  content: string;
+  created_by: string;
+  work_log_id: string;
+  visibility: VISIBILITY;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/entities/RequestForm.ts.html b/coverage/lcov-report/src/entities/RequestForm.ts.html new file mode 100644 index 00000000..9c4aa8d0 --- /dev/null +++ b/coverage/lcov-report/src/entities/RequestForm.ts.html @@ -0,0 +1,370 @@ + + + + + + Code coverage report for src/entities/RequestForm.ts + + + + + + + + + +
+
+

All files / src/entities RequestForm.ts

+
+ +
+ 0% + Statements + 0/51 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/51 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import {
+    ClientAgeRange,
+    HomeType,
+    IncomeLevel,
+    Pronouns,
+    ProviderType,
+    RelationshipStatus,
+    RequestStatus,
+    ServiceTypes,
+    STATE
+} from '../types';
+ 
+export class RequestForm {
+  public id?: string;
+  public status?: RequestStatus; // Remove default value
+  public user_id?: string;
+  public created_at?: Date;
+  public updated_at?: Date;
+  public requested?: string;
+  
+  constructor(
+    // Step 1: Client Details (Required)
+    public firstname: string,
+    public lastname: string,
+    public email: string,
+    public phone_number: string,
+    public service_needed: ServiceTypes,
+    
+    // Step 2: Home Details (Required)
+    public address: string,
+    public city: string,
+    public state: STATE,
+    public zip_code: string,
+    
+    // Step 1: Client Details (Optional)
+    public pronouns?: Pronouns,
+    public pronouns_other?: string,
+    public children_expected?: string,
+    
+    // Step 2: Home Details (Optional)
+    public home_phone?: string,
+    public home_type?: HomeType,
+    public home_access?: string,
+    public pets?: string,
+    
+    // Step 3: Family Members
+    public relationship_status?: RelationshipStatus,
+    public first_name?: string,
+    public last_name?: string,
+    public middle_name?: string,
+    public mobile_phone?: string,
+    public work_phone?: string,
+    
+    // Step 4: Referral
+    public referral_source?: string,
+    public referral_name?: string,
+    public referral_email?: string,
+    
+    // Step 5: Health History
+    public health_history?: string,
+    public allergies?: string,
+    public health_notes?: string,
+    
+    // Step 6: Payment Info (Optional)
+    public annual_income?: IncomeLevel,
+    public service_specifics?: string,
+    
+    // Step 7: Pregnancy/Baby
+    public due_date?: Date,
+    public birth_location?: string,
+    public birth_hospital?: string,
+    public number_of_babies?: number,
+    public baby_name?: string,
+    public provider_type?: ProviderType,
+    public pregnancy_number?: number,
+    public hospital?: string,
+    public baby_sex?: string,
+    
+    // Step 8: Past Pregnancies
+    public had_previous_pregnancies?: boolean,
+    public previous_pregnancies_count?: number,
+    public living_children_count?: number,
+    public past_pregnancy_experience?: string,
+    
+    // Step 9: Services Interested
+    public services_interested?: string[],
+    public service_support_details?: string,
+    
+    // Step 10: Client Demographics (Optional)
+    public race_ethnicity?: string,
+    public primary_language?: string,
+    public client_age_range?: ClientAgeRange,
+    public insurance?: string,
+    public demographics_multi?: string[]
+  ) {}
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/entities/Template.ts.html b/coverage/lcov-report/src/entities/Template.ts.html new file mode 100644 index 00000000..ec27a72a --- /dev/null +++ b/coverage/lcov-report/src/entities/Template.ts.html @@ -0,0 +1,139 @@ + + + + + + Code coverage report for src/entities/Template.ts + + + + + + + + + +
+
+

All files / src/entities Template.ts

+
+ +
+ 0% + Statements + 0/7 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/7 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export class Template {
+  constructor(
+    public id: string,
+    public name: string,
+    public depositFee: number,
+    public serviceFee: number,
+    public storagePath: string,
+  ) {}
+ 
+  toJson() {
+    return {
+      id: this.id,
+      name: this.name,
+      depositFee: this.depositFee,
+      serviceFee: this.serviceFee,
+      storagePath: this.storagePath,
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/entities/User.ts.html b/coverage/lcov-report/src/entities/User.ts.html new file mode 100644 index 00000000..0c986cea --- /dev/null +++ b/coverage/lcov-report/src/entities/User.ts.html @@ -0,0 +1,433 @@ + + + + + + Code coverage report for src/entities/User.ts + + + + + + + + + +
+
+

All files / src/entities User.ts

+
+ +
+ 0% + Statements + 0/29 +
+ + +
+ 0% + Branches + 0/48 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/29 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { ACCOUNT_STATUS, ROLE, STATE } from '../types';
+ 
+export class User {
+  id: string;
+  email?: string;
+  firstname?: string;
+  lastname?: string;
+  created_at?: Date;
+  updated_at?: Date;
+  role?: ROLE;
+  address?: string;
+  city?: string;
+  state?: STATE;
+  country?: string;
+  zip_code?: number;
+  children_expected?:string;
+  pronouns?:string;
+  health_history?:string;
+  allergies?:string;
+  due_date?:string;
+  annual_income?:string;
+  status?:string;
+  hospital?:string;
+  service_needed?:string;
+  profile_picture?: File;  
+  account_status?: ACCOUNT_STATUS;
+  business?: string;
+  bio?: string;
+ 
+  constructor(data: {
+    id?: string;
+    email?: string;
+    firstname?: string;
+    lastname?: string;
+    created_at?: Date;
+    updated_at?: Date;
+    role?: ROLE;
+    address?: string;
+    children_expected?:string;
+    service_needed?:string;
+    pronouns?:string;
+    health_history?:string;
+    allergies?:string;
+    due_date?:string;
+    annual_income?:string;
+    status?:string;
+    hospital?:string;
+    city?: string;
+    state?: STATE;
+    country?: string;
+    zip_code?: number;
+    profile_picture?: File;
+    account_status?: ACCOUNT_STATUS;
+    business?: string;
+    bio?: string;  
+    }) {
+      this.id = data.id;
+      this.email = data.email || "";
+      this.firstname = data.firstname || '';
+      this.lastname = data.lastname || '';
+      this.created_at = data.created_at || new Date();
+      this.updated_at = data.updated_at || new Date();
+      this.role = data.role || ROLE.CLIENT;
+      this.children_expected = data.children_expected || "";
+      this.service_needed = data.service_needed ||"";
+      this.health_history = data.health_history || "";
+      this.allergies = data.allergies || "";
+      this.due_date = data.due_date || "";
+      this.annual_income = data.annual_income || "";
+      this.status = data.status || "";
+      this.hospital = data.hospital || "";
+      this.address = data.address || "";
+      this.city = data.city || "";
+      this.state = data.state || STATE.IL;
+      this.country = data.country || "";
+      this.zip_code = data.zip_code || -1;
+      this.profile_picture = data.profile_picture || null;
+      this.account_status = data.account_status || ACCOUNT_STATUS.PENDING; 
+      this.business = data.business || "";
+      this.bio = data.bio || "";    
+      this.service_needed = data.service_needed || "";
+  }
+ 
+  getFullName(): string {
+    return `${this.firstname} ${this.lastname}`.trim();
+  }
+ 
+  toJSON(): object {
+    return {
+      id: this.id,
+      email: this.email,
+      firstname: this.firstname,
+      lastname: this.lastname,
+      fullName: this.getFullName(),
+      children_expected: this.children_expected,
+      service_needed: this.service_needed,
+      health_history: this.health_history,
+      allergies: this.allergies,
+      due_date:this.due_date,
+      annual_income:this.annual_income,
+      status:this.status,
+      hospital:this.hospital,
+      created_at: this.created_at,
+      updatedAt: this.updated_at,
+      role: this.role,
+      address: this.address,
+      city: this.city,
+      state: this.state,
+      country: this.country,
+      zip_code: this.zip_code,
+      profile_picture: this.profile_picture,
+      account_status: this.account_status,
+      business: this.business,
+      bio: this.bio
+    };
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/entities/index.html b/coverage/lcov-report/src/entities/index.html new file mode 100644 index 00000000..4ae3d7b8 --- /dev/null +++ b/coverage/lcov-report/src/entities/index.html @@ -0,0 +1,206 @@ + + + + + + Code coverage report for src/entities + + + + + + + + + +
+
+

All files src/entities

+
+ +
+ 0% + Statements + 0/120 +
+ + +
+ 0% + Branches + 0/71 +
+ + +
+ 0% + Functions + 0/11 +
+ + +
+ 0% + Lines + 0/120 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
Activity.ts +
+
0%0/90%0/10%0/20%0/9
Client.ts +
+
0%0/180%0/200%0/20%0/18
Hours.ts +
+
0%0/2100%0/0100%0/00%0/2
Note.ts +
+
0%0/40%0/20%0/10%0/4
RequestForm.ts +
+
0%0/51100%0/00%0/10%0/51
Template.ts +
+
0%0/7100%0/00%0/20%0/7
User.ts +
+
0%0/290%0/480%0/30%0/29
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/index.html b/coverage/lcov-report/src/index.html new file mode 100644 index 00000000..9de00d48 --- /dev/null +++ b/coverage/lcov-report/src/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+ +
+ 0% + Statements + 0/130 +
+ + +
+ 0% + Branches + 0/33 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 0% + Lines + 0/130 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
supabase.ts +
+
0%0/90%0/7100%0/00%0/9
types.ts +
+
0%0/1210%0/260%0/130%0/121
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/middleware/auth.ts.html b/coverage/lcov-report/src/middleware/auth.ts.html new file mode 100644 index 00000000..4a7681fb --- /dev/null +++ b/coverage/lcov-report/src/middleware/auth.ts.html @@ -0,0 +1,193 @@ + + + + + + Code coverage report for src/middleware/auth.ts + + + + + + + + + +
+
+

All files / src/middleware auth.ts

+
+ +
+ 0% + Statements + 0/17 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/16 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NextFunction, Request, Response } from 'express';
+import jwt from 'jsonwebtoken';
+import { config } from '../config';
+ 
+export const authenticateUser = async (
+  req: Request,
+  res: Response,
+  next: NextFunction
+): Promise<void> => {
+  try {
+    const authHeader = req.headers.authorization;
+    
+    Iif (!authHeader?.startsWith('Bearer ')) {
+      res.status(401).json({ error: 'No token provided' });
+      return;
+    }
+ 
+    const token = authHeader.split(' ')[1];
+    
+    const decoded = jwt.verify(token, config.jwtSecret) as {
+      id: string;
+      role?: string;
+    };
+ 
+    // Create a User instance from the decoded token data
+    req.user = {
+      id: decoded.id,
+      role: decoded.role,
+      getFullName: () => '',
+      toJSON: () => ({ id: decoded.id, role: decoded.role })
+    } as any;
+    next();
+  } catch (error) {
+    console.error('Authentication error:', error);
+    res.status(401).json({ error: 'Invalid token' });
+  }
+}; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/middleware/authMiddleware.ts.html b/coverage/lcov-report/src/middleware/authMiddleware.ts.html new file mode 100644 index 00000000..862a72ea --- /dev/null +++ b/coverage/lcov-report/src/middleware/authMiddleware.ts.html @@ -0,0 +1,214 @@ + + + + + + Code coverage report for src/middleware/authMiddleware.ts + + + + + + + + + +
+
+

All files / src/middleware authMiddleware.ts

+
+ +
+ 0% + Statements + 0/20 +
+ + +
+ 0% + Branches + 0/6 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NextFunction, Response } from 'express';
+import { authService } from '../index';
+import supabase from '../supabase';
+import type { AuthRequest } from '../types';
+ 
+const authMiddleware = async (
+  req: AuthRequest,
+  res: Response,
+  next: NextFunction
+): Promise<void> => {
+  try {
+    const authHeader = req.headers.authorization
+    const cookieToken = req.cookies?.session
+    const token = authHeader ? authHeader.split(' ')[1] : cookieToken
+ 
+    Iif (!token) {
+      res.status(401).json({ error: 'No session token provided' })
+      return
+    }
+ 
+    const {
+      data: { user },
+      error
+    } = await supabase.auth.getUser(token)
+ 
+    Iif (error || !user) {
+      res.status(401).json({ error: 'Invalid or expired session token' })
+      return
+    }
+ 
+    // Your app’s user object
+    const user_entity = await authService.getUserFromToken(token)
+    req.user = user_entity;
+    next();
+  } catch {
+    console.error('Auth middleware error:');
+    res.status(500).json({ error: 'Internal server error' });
+  }
+};
+ 
+ 
+ 
+export default authMiddleware
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/middleware/authorizeRoles.ts.html b/coverage/lcov-report/src/middleware/authorizeRoles.ts.html new file mode 100644 index 00000000..26bc6ba0 --- /dev/null +++ b/coverage/lcov-report/src/middleware/authorizeRoles.ts.html @@ -0,0 +1,181 @@ + + + + + + Code coverage report for src/middleware/authorizeRoles.ts + + + + + + + + + +
+
+

All files / src/middleware authorizeRoles.ts

+
+ +
+ 0% + Statements + 0/11 +
+ + +
+ 0% + Branches + 0/4 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NextFunction, Response } from 'express';
+import type { AuthRequest } from '../types';
+ 
+// authorizeRoles
+//
+// Takes in an array of authorized roles (in lowercase) of 'patient', 'doula', 'admin'.
+//
+ 
+const authorizeRoles = async (
+  req: AuthRequest,
+  res: Response,
+  next: NextFunction,
+  allowedRoles: string[]
+): Promise<void> => {
+  try {
+    Iif (!req.user || !req.user.email) {
+      res.status(401).json({ error: 'Unauthorized: No user found' })
+      return   // ← stop here!
+    }
+ 
+    Iif (!allowedRoles.includes(req.user.role)) {
+      res.status(403).json({ error: 'Forbidden: Insufficient permissions' })
+      return   // ← and stop here!
+    }
+ 
+    next()
+  } catch {
+    res.status(500).json({ error: 'Internal server error' })
+  }
+}
+ 
+export default authorizeRoles
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/middleware/index.html b/coverage/lcov-report/src/middleware/index.html new file mode 100644 index 00000000..3e3f68cb --- /dev/null +++ b/coverage/lcov-report/src/middleware/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/middleware + + + + + + + + + +
+
+

All files src/middleware

+
+ +
+ 0% + Statements + 0/55 +
+ + +
+ 0% + Branches + 0/11 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/53 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
auth.ts +
+
0%0/170%0/10%0/30%0/16
authMiddleware.ts +
+
0%0/200%0/60%0/10%0/20
authorizeRoles.ts +
+
0%0/110%0/40%0/10%0/11
validateRequest.ts +
+
0%0/7100%0/00%0/20%0/6
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/middleware/validateRequest.ts.html b/coverage/lcov-report/src/middleware/validateRequest.ts.html new file mode 100644 index 00000000..fd5f10b5 --- /dev/null +++ b/coverage/lcov-report/src/middleware/validateRequest.ts.html @@ -0,0 +1,133 @@ + + + + + + Code coverage report for src/middleware/validateRequest.ts + + + + + + + + + +
+
+

All files / src/middleware validateRequest.ts

+
+ +
+ 0% + Statements + 0/7 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NextFunction, Request, Response } from 'express';
+import { AnyZodObject } from 'zod';
+ 
+export const validateRequest = (schema: AnyZodObject) => {
+  return async (req: Request, res: Response, next: NextFunction) => {
+    try {
+      await schema.parseAsync(req.body);
+      next();
+    } catch (error) {
+      res.status(400).json({
+        success: false,
+        error: 'Invalid request data',
+        details: error.errors
+      });
+    }
+  };
+}; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/repositories/index.html b/coverage/lcov-report/src/repositories/index.html new file mode 100644 index 00000000..c9dd800b --- /dev/null +++ b/coverage/lcov-report/src/repositories/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/repositories + + + + + + + + + +
+
+

All files src/repositories

+
+ +
+ 0% + Statements + 0/326 +
+ + +
+ 0% + Branches + 0/204 +
+ + +
+ 0% + Functions + 0/58 +
+ + +
+ 0% + Lines + 0/284 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
requestFormRepository.ts +
+
0%0/550%0/80%0/70%0/55
supabaseActivityRepository.ts +
+
0%0/180%0/30%0/70%0/16
supabaseClientRepository.ts +
+
0%0/1230%0/1380%0/180%0/91
supabaseUserRepository.ts +
+
0%0/1300%0/550%0/260%0/122
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/repositories/requestFormRepository.ts.html b/coverage/lcov-report/src/repositories/requestFormRepository.ts.html new file mode 100644 index 00000000..f02865e1 --- /dev/null +++ b/coverage/lcov-report/src/repositories/requestFormRepository.ts.html @@ -0,0 +1,715 @@ + + + + + + Code coverage report for src/repositories/requestFormRepository.ts + + + + + + + + + +
+
+

All files / src/repositories requestFormRepository.ts

+
+ +
+ 0% + Statements + 0/55 +
+ + +
+ 0% + Branches + 0/8 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/55 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { SupabaseClient } from "@supabase/supabase-js";
+import { RequestFormData, RequestFormResponse, RequestStatus } from "../types";
+ 
+export class RequestFormRepository {
+    private supabaseClient: SupabaseClient;
+ 
+    constructor(supabaseClient: SupabaseClient) {
+        this.supabaseClient = supabaseClient;
+    }
+ 
+    async saveData(formData: RequestFormData): Promise<RequestFormResponse> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('client_info')
+                .insert([
+                    {
+                        // Step 1: Client Details
+                        firstname: formData.firstname,
+                        lastname: formData.lastname,
+                        email: formData.email,
+                        phone_number: formData.phone_number,
+                        pronouns: formData.pronouns,
+                        pronouns_other: formData.pronouns_other,
+                        
+                        // Step 2: Home Details
+                        address: formData.address,
+                        city: formData.city,
+                        state: formData.state,
+                        zip_code: formData.zip_code,
+                        home_phone: formData.home_phone,
+                        home_type: formData.home_type,
+                        home_access: formData.home_access,
+                        pets: formData.pets,
+                        
+                        // Step 3: Family Members
+                        relationship_status: formData.relationship_status,
+                        first_name: formData.first_name,
+                        last_name: formData.last_name,
+                        middle_name: formData.middle_name,
+                        mobile_phone: formData.mobile_phone,
+                        work_phone: formData.work_phone,
+                        
+                        // Step 4: Referral
+                        referral_source: formData.referral_source,
+                        referral_name: formData.referral_name,
+                        referral_email: formData.referral_email,
+                        
+                        // Step 5: Health History
+                        health_history: formData.health_history,
+                        allergies: formData.allergies,
+                        health_notes: formData.health_notes,
+                        
+                        // Step 6: Payment Info
+                        annual_income: formData.annual_income,
+                        service_needed: formData.service_needed,
+                        service_specifics: formData.service_specifics,
+                        
+                        // Step 7: Pregnancy/Baby
+                        due_date: formData.due_date,
+                        birth_location: formData.birth_location,
+                        birth_hospital: formData.birth_hospital,
+                        number_of_babies: formData.number_of_babies,
+                        baby_name: formData.baby_name,
+                        provider_type: formData.provider_type,
+                        pregnancy_number: formData.pregnancy_number,
+                        
+                        // Step 8: Past Pregnancies
+                        had_previous_pregnancies: formData.had_previous_pregnancies,
+                        previous_pregnancies_count: formData.previous_pregnancies_count,
+                        living_children_count: formData.living_children_count,
+                        past_pregnancy_experience: formData.past_pregnancy_experience,
+                        
+                        // Step 9: Services Interested
+                        services_interested: formData.services_interested,
+                        service_support_details: formData.service_support_details,
+                        
+                        // Step 10: Client Demographics
+                        race_ethnicity: formData.race_ethnicity,
+                        primary_language: formData.primary_language,
+                        client_age_range: formData.client_age_range,
+                        insurance: formData.insurance,
+                        demographics_multi: formData.demographics_multi,
+                        
+                        // System fields
+                        status: 'lead'
+                    }
+                ])
+                .select()
+                .single();
+ 
+            Iif (error) {
+                console.error("Supabase insert error:", error);
+                throw new Error("Database insertion failed: " + error.message);
+            }
+ 
+            console.log('Request form saved successfully:', data);
+            return data as RequestFormResponse;
+ 
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async getUserRequests(userId: string): Promise<RequestFormResponse[]> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .select('*')
+                .eq('user_id', userId)
+                .order('created_at', { ascending: false });
+ 
+            Iif (error) {
+                console.error("Supabase select error:", error);
+                throw new Error("Database query failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse[];
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async getRequestById(requestId: string, userId: string): Promise<RequestFormResponse | null> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .select('*')
+                .eq('id', requestId)
+                .eq('user_id', userId)
+                .single();
+ 
+            Iif (error) {
+                Iif (error.code === 'PGRST116') {
+                    return null; // No rows returned
+                }
+                console.error("Supabase select error:", error);
+                throw new Error("Database query failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse;
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async getAllRequests(): Promise<RequestFormResponse[]> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .select('*')
+                .order('created_at', { ascending: false });
+ 
+            Iif (error) {
+                console.error("Supabase select error:", error);
+                throw new Error("Database query failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse[];
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async getRequestByIdAdmin(requestId: string): Promise<RequestFormResponse | null> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .select('*')
+                .eq('id', requestId)
+                .single();
+ 
+            Iif (error) {
+                Iif (error.code === 'PGRST116') {
+                    return null; // No rows returned
+                }
+                console.error("Supabase select error:", error);
+                throw new Error("Database query failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse;
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async updateRequestStatus(requestId: string, status: RequestStatus): Promise<RequestFormResponse> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .update({ status })
+                .eq('id', requestId)
+                .select()
+                .single();
+ 
+            Iif (error) {
+                console.error("Supabase update error:", error);
+                throw new Error("Database update failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse;
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/repositories/supabaseActivityRepository.ts.html b/coverage/lcov-report/src/repositories/supabaseActivityRepository.ts.html new file mode 100644 index 00000000..a3d6fe62 --- /dev/null +++ b/coverage/lcov-report/src/repositories/supabaseActivityRepository.ts.html @@ -0,0 +1,295 @@ + + + + + + Code coverage report for src/repositories/supabaseActivityRepository.ts + + + + + + + + + +
+
+

All files / src/repositories supabaseActivityRepository.ts

+
+ +
+ 0% + Statements + 0/18 +
+ + +
+ 0% + Branches + 0/3 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/16 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { SupabaseClient } from '@supabase/supabase-js';
+import { Activity } from '../entities/Activity';
+import { ActivityRepository } from './interface/activityRepository';
+ 
+export class SupabaseActivityRepository implements ActivityRepository {
+  private supabaseClient: SupabaseClient;
+ 
+  constructor(supabaseClient: SupabaseClient) {
+    this.supabaseClient = supabaseClient;
+  }
+ 
+  async createActivity(activityData: Omit<Activity, 'id'>): Promise<Activity> {
+    const { data, error } = await this.supabaseClient
+      .from('client_activities')
+      .insert({
+        client_id: activityData.clientId,
+        type: activityData.type,
+        description: activityData.description,
+        metadata: activityData.metadata,
+        timestamp: activityData.timestamp,
+        created_by: activityData.createdBy
+      })
+      .select()
+      .single();
+ 
+    Iif (error) {
+      throw new Error(`Failed to create activity: ${error.message}`);
+    }
+ 
+    return this.mapToActivity(data);
+  }
+ 
+  async getActivitiesByClientId(clientId: string): Promise<Activity[]> {
+    const { data, error } = await this.supabaseClient
+      .from('client_activities')
+      .select('*')
+      .eq('client_id', clientId)
+      .order('timestamp', { ascending: false });
+ 
+    Iif (error) {
+      throw new Error(`Failed to fetch activities: ${error.message}`);
+    }
+ 
+    return data.map(row => this.mapToActivity(row));
+  }
+ 
+  async getAllActivities(): Promise<Activity[]> {
+    const { data, error } = await this.supabaseClient
+      .from('client_activities')
+      .select('*')
+      .order('timestamp', { ascending: false });
+ 
+    Iif (error) {
+      throw new Error(`Failed to fetch all activities: ${error.message}`);
+    }
+ 
+    return data.map(row => this.mapToActivity(row));
+  }
+ 
+  private mapToActivity(data: any): Activity {
+    return new Activity(
+      data.id,
+      data.client_id,
+      data.type,
+      data.description,
+      data.metadata,
+      new Date(data.timestamp),
+      data.created_by
+    );
+  }
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/repositories/supabaseClientRepository.ts.html b/coverage/lcov-report/src/repositories/supabaseClientRepository.ts.html new file mode 100644 index 00000000..b92b3776 --- /dev/null +++ b/coverage/lcov-report/src/repositories/supabaseClientRepository.ts.html @@ -0,0 +1,1204 @@ + + + + + + Code coverage report for src/repositories/supabaseClientRepository.ts + + + + + + + + + +
+
+

All files / src/repositories supabaseClientRepository.ts

+
+ +
+ 0% + Statements + 0/123 +
+ + +
+ 0% + Branches + 0/138 +
+ + +
+ 0% + Functions + 0/18 +
+ + +
+ 0% + Lines + 0/91 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+import { SupabaseClient } from '@supabase/supabase-js';
+import { Client } from '../entities/Client';
+import { User } from '../entities/User';
+import { ROLE } from '../types';
+ 
+export class SupabaseClientRepository  {
+  private supabaseClient: SupabaseClient;
+  
+  constructor(
+    supabaseClient: SupabaseClient
+  ) {
+    this.supabaseClient = supabaseClient;
+  }
+ 
+  async findClientsLiteAll(): Promise<Client[]> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        id,
+        firstname,
+        lastname,
+        email,
+        phone_number,
+        status,
+        service_needed,
+        requested,
+        updated_at,
+        users (
+          firstname,
+          lastname,
+          profile_picture
+        )
+      `);
+ 
+    Iif (error) throw new Error(error.message);
+    return data.map(row => this.mapToClient(row));
+  }
+ 
+  async exportCSV():Promise<string | null>{
+    const {data,error} = await this.supabaseClient
+    .from('client_info')
+    .select('firstname,lastname,zip_code,annual_income,pronouns')
+    .csv()
+ 
+    Iif(error || !data){
+      throw new Error(`Failed to fetch CSV Data ${error.message}`);
+    }
+    return data;
+  }
+ 
+  async findClientsLiteByDoula(userId: string): Promise<Client[]> {
+    const clientIds = await this.getClientIdsAssignedToDoula(userId);
+    
+    Iif (clientIds.length === 0) {
+      console.log("clientIDs.length is 0");
+      return [];
+    }
+    // console.log("clientIds is ", clientIds);
+ 
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        id,
+        firstname,
+        lastname,
+        email,
+        phone_number,
+        status,
+        users (
+          firstname,
+          lastname,
+          profile_picture
+        )
+      `)
+      .in('id', clientIds);
+ 
+ 
+    Iif (error) throw new Error(error.message);
+    return data.map(user => this.mapToClient(user));
+  }
+ 
+  async findClientsDetailedAll(): Promise<Client[]> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        *,
+        users (
+          *
+        )
+        `);
+        
+        Iif (error) throw new Error(error.message);
+        return data.map(user => this.mapToClient(user));
+      }
+      
+      async findClientsDetailedByDoula(userId: string): Promise<Client[]> {
+        const clientIds = await this.getClientIdsAssignedToDoula(userId);
+        
+        Iif (clientIds.length === 0) return [];
+        
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        *,
+        users (
+          *
+        )
+      `)
+      .in('id', clientIds);
+ 
+    Iif (error) throw new Error(error.message);
+    // return data.map(this.mapToClient);
+    return data.map(user => this.mapToClient(user));
+  }
+  
+  async findClientLiteById(clientId: string): Promise<Client> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        id,
+        firstname,
+        lastname,
+        email,
+        phone_number,
+        status,
+        users (
+          firstname,
+          lastname,
+          profile_picture
+        )
+      `)
+      .eq('id', clientId)
+      .single();
+ 
+    Iif (error) throw new Error(error.message);
+    return this.mapToClient(data);
+  }
+ 
+  async findClientDetailedById(clientId: string): Promise<Client> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        *,
+        users (*)
+      `)
+      .eq('id', clientId)
+      .single();
+ 
+    Iif (error) throw new Error(error.message);
+    return this.mapToClient(data);
+  }
+ 
+  async updateStatus(clientId: string, status: string): Promise<Client> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .update({ status })
+      .eq('id', clientId)
+      .select(`
+        id,
+        firstname,
+        lastname,
+        phone_number,
+        service_needed,
+        requested,
+        updated_at,
+        status,
+        user_id,
+        users (
+          profile_picture,
+          firstname,
+          lastname
+        )
+      `)
+      .single()
+ 
+    Iif (error) {
+      throw new Error(`${error.message}`);
+    }
+ 
+    return this.mapToClient(data);
+  }
+ 
+  async updateClient(clientId: string, fieldsToUpdate: any): Promise<Client> {
+    console.log('Repository: Updating client with ID:', clientId);
+    console.log('Repository: Fields to update:', JSON.stringify(fieldsToUpdate, null, 2));
+    
+    // Map request body fields to database column names
+    const updateData: any = {};
+    
+    // Map the fields from the request body to database columns
+    Iif (fieldsToUpdate.user?.firstname !== undefined) updateData.firstname = fieldsToUpdate.user.firstname;
+    Iif (fieldsToUpdate.user?.lastname !== undefined) updateData.lastname = fieldsToUpdate.user.lastname;
+    Iif (fieldsToUpdate.user?.email !== undefined) updateData.email = fieldsToUpdate.user.email;
+    Iif (fieldsToUpdate.user?.role !== undefined) updateData.role = fieldsToUpdate.user.role;
+    Iif (fieldsToUpdate.serviceNeeded !== undefined) updateData.service_needed = fieldsToUpdate.serviceNeeded;
+    Iif (fieldsToUpdate.childrenExpected !== undefined) updateData.children_expected = fieldsToUpdate.childrenExpected;
+    Iif (fieldsToUpdate.pronouns !== undefined) updateData.pronouns = fieldsToUpdate.pronouns;
+    Iif (fieldsToUpdate.health_history !== undefined) updateData.health_history = fieldsToUpdate.health_history;
+    Iif (fieldsToUpdate.allergies !== undefined) updateData.allergies = fieldsToUpdate.allergies;
+    Iif (fieldsToUpdate.due_date !== undefined) updateData.due_date = fieldsToUpdate.due_date;
+    Iif (fieldsToUpdate.hospital !== undefined) updateData.hospital = fieldsToUpdate.hospital;
+    Iif (fieldsToUpdate.annual_income !== undefined) updateData.annual_income = fieldsToUpdate.annual_income;
+    Iif (fieldsToUpdate.service_specifics !== undefined) updateData.service_specifics = fieldsToUpdate.service_specifics;
+ 
+    // Handle direct field mappings from request body
+    Iif (fieldsToUpdate.firstname !== undefined) updateData.firstname = fieldsToUpdate.firstname;
+    Iif (fieldsToUpdate.lastname !== undefined) updateData.lastname = fieldsToUpdate.lastname;
+    Iif (fieldsToUpdate.email !== undefined) updateData.email = fieldsToUpdate.email;
+    Iif (fieldsToUpdate.phoneNumber !== undefined) updateData.phone_number = fieldsToUpdate.phoneNumber;
+    Iif (fieldsToUpdate.phone_number !== undefined) updateData.phone_number = fieldsToUpdate.phone_number;
+    Iif (fieldsToUpdate.status !== undefined) updateData.status = fieldsToUpdate.status;
+ 
+    console.log('Repository: phoneNumber field check:', {
+      hasPhoneNumber: 'phoneNumber' in fieldsToUpdate,
+      phoneNumberValue: fieldsToUpdate.phoneNumber,
+      phoneNumberType: typeof fieldsToUpdate.phoneNumber
+    });
+    console.log('Repository: Mapped update data:', updateData);
+ 
+    // Check if client exists first
+    const { data: existingClient, error: checkError } = await this.supabaseClient
+      .from('client_info')
+      .select('id, firstname, lastname, phone_number')
+      .eq('id', clientId)
+      .maybeSingle();
+ 
+    Iif (checkError) {
+      console.error('Repository: Error checking client existence:', checkError);
+      throw new Error(`Error checking client existence: ${checkError.message}`);
+    }
+ 
+    Iif (!existingClient) {
+      console.error('Repository: Client not found with ID:', clientId);
+      throw new Error(`Client not found with ID: ${clientId}`);
+    }
+ 
+    console.log('Repository: Found existing client:', existingClient);
+ 
+    // Perform the update
+    const { data: updateResult, error: updateError } = await this.supabaseClient
+      .from('client_info')
+      .update(updateData)
+      .eq('id', clientId);
+ 
+    Iif (updateError) {
+      console.error('Repository: Update error:', updateError);
+      throw new Error(`Failed to update client: ${updateError.message}`);
+    }
+ 
+    console.log('Repository: Update completed, fetching updated data');
+ 
+    // Fetch the updated client data
+    const { data, error: fetchError } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        *,
+        users (*)
+      `)
+      .eq('id', clientId)
+      .single();
+ 
+    Iif (fetchError) {
+      console.error('Repository: Error fetching updated client:', fetchError);
+      throw new Error(`Failed to fetch updated client: ${fetchError.message}`);
+    }
+ 
+    Iif (!data) {
+      console.error('Repository: No data returned after update');
+      throw new Error(`No data returned after update for client ID: ${clientId}`);
+    }
+ 
+    console.log('Repository: Raw database response after update:', data);
+    console.log('Repository: Update successful, mapping data');
+    return this.mapToClient(data);
+  }
+ 
+  // Helper to find client id's for a given doula
+  private async getClientIdsAssignedToDoula(doulaId: string): Promise<string[]> {
+    const { data, error } = await this.supabaseClient
+      .from('assignments')
+      .select('client_id')
+      .eq('doula_id', doulaId);
+ 
+    Iif (error) throw new Error(error.message);
+    return data.map(entry => entry.client_id);
+  }
+ 
+  // Helper to map database user to domain User
+  private mapToUser(data: any): User {
+    return new User({
+      id: data.id,
+      email: data.email,
+      firstname: data.firstname,
+      lastname: data.lastname,
+      created_at: new Date(data.created_at || Date.now()),
+      updated_at: new Date(data.updated_at || Date.now()),
+      role: data.role || ROLE.CLIENT,
+      address: data.address,
+      city: data.city,
+      state: data.state,
+      country: data.country,
+      zip_code: data.zip_code,
+      profile_picture: data.profile_picture,
+      account_status: data.account_status,
+      business: data.business,
+      bio: data.bio,
+      children_expected: data.children_expected,
+      service_needed: data.service_needed,
+      health_history: data.health_history,
+      allergies: data.allergies,
+      due_date: data.due_date,
+      annual_income:data.annual_income,
+      status: data.status,
+      hospital:data.hospital,
+ 
+    });
+  }
+ 
+  private mapToClient(data: any): Client {
+    const userRecord = data.users ?? {};
+ 
+    const user = this.mapToUser({
+      id: userRecord.id || data.user_id || data.id,
+      email: userRecord.email || data.email || '',
+      firstname: userRecord.firstname || data.firstname || '',
+      lastname: userRecord.lastname || data.lastname || '',
+      created_at: userRecord.created_at || data.created_at,
+      updated_at: userRecord.updated_at || data.updated_at,
+      role: userRecord.role || 'client',
+      address: userRecord.address || data.address || '',
+      city: userRecord.city || data.city || '',
+      state: userRecord.state || data.state || '',
+      country: userRecord.country || data.country || '',
+      zip_code: userRecord.zip_code || data.zip_code || '',
+      profile_picture: userRecord.profile_picture || '',
+      account_status: userRecord.account_status || null,
+      business: userRecord.business || null,
+      bio: userRecord.bio || '',
+      children_expected: userRecord.children_expected || data.children_expected || '',
+      service_needed: userRecord.service_needed || data.service_needed || '',
+      health_history: userRecord.health_history || data.health_history || '',
+      allergies: userRecord.allergies || data.allergies || '',
+      due_date: userRecord.due_date || data.due_date || '',
+      annual_income: userRecord.annual_income || data.annual_income || '',
+      status: userRecord.status || data.status || '',
+      hospital: userRecord.hospital || data.hospital|| ''
+ 
+ 
+    });
+ 
+    return new Client(
+      data.id,
+      user,
+      data.service_needed ?? null,
+      data.requested ? new Date(data.requested) : null,
+      data.updated_at ? new Date(data.updated_at) : new Date(),
+      data.status ?? 'lead',
+ 
+      // Optional detailed fields
+      data.children_expected ?? undefined,
+      data.pronouns ?? undefined,
+      data.health_history ?? undefined,
+      data.allergies ?? undefined,
+      data.due_date ? new Date(data.due_date) : undefined,
+      data.hospital ?? undefined,
+      data.baby_sex ?? undefined,
+      data.annual_income ?? undefined,
+      data.service_specifics ?? undefined,
+      data.phone_number ?? undefined // Add phone number mapping
+    );
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/repositories/supabaseUserRepository.ts.html b/coverage/lcov-report/src/repositories/supabaseUserRepository.ts.html new file mode 100644 index 00000000..033f5141 --- /dev/null +++ b/coverage/lcov-report/src/repositories/supabaseUserRepository.ts.html @@ -0,0 +1,1714 @@ + + + + + + Code coverage report for src/repositories/supabaseUserRepository.ts + + + + + + + + + +
+
+

All files / src/repositories supabaseUserRepository.ts

+
+ +
+ 0% + Statements + 0/130 +
+ + +
+ 0% + Branches + 0/55 +
+ + +
+ 0% + Functions + 0/26 +
+ + +
+ 0% + Lines + 0/122 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+import { SupabaseClient } from '@supabase/supabase-js';
+import { File as MulterFile } from 'multer';
+import { Client } from '../entities/Client';
+import { WORK_ENTRY_ROW } from '../entities/Hours';
+import { NOTE } from '../entities/Note';
+import { User } from '../entities/User';
+import { UserRepository } from '../repositories/interface/userRepository';
+import { ROLE } from '../types';
+ 
+export class SupabaseUserRepository implements UserRepository {
+  private supabaseClient: SupabaseClient;
+  
+  constructor(
+    supabaseClient: SupabaseClient
+  ) {
+    this.supabaseClient = supabaseClient;
+  }
+  
+  async findByEmail(email: string): Promise<User | null> {
+    const { data, error } = await this.supabaseClient
+      .from('users')
+      .select('*')
+      .eq('email', email)
+      .single();
+      
+    Iif (error || !data) {
+      return null;
+    }
+    
+    return this.mapToUser(data);
+  }
+ 
+ 
+  async findByRole(role: string): Promise<User[]> {
+    const { data, error } = await this.supabaseClient
+      .from('users')
+      .select('*')
+      .eq('role', role)
+      .order('first_name', { ascending: true });
+ 
+    Iif (error) {
+      throw new Error(`Failed to fetch ${role} users: ${error.message}`);
+    }
+ 
+    return data.map(this.mapToUser);
+  }
+ 
+  // async findClientsAll(): Promise<any> {
+  //   const { data, error } = await this.supabaseClient
+  //     .from('client_info')
+  //     .select('first_name, last_name, service_needed, requested, updated_at, status');
+ 
+  //   if (error) {
+  //     throw new Error(`Failed to fetch clients: ${error.message}`);
+  //   }
+ 
+  //   return data.map((client) => ({
+  //     firstName: client.first_name,
+  //     lastName: client.last_name,
+  //     serviceNeeded: client.service_needed,
+  //     requestedAt: new Date(client.requested), // Ensure it's a Date object
+  //     updatedAt: new Date(client.updated_at), // Ensure it's a Date object
+  //     status: client.status,
+  //   }));
+  // }
+ 
+// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+async findClientsAll(): Promise<any[]> {
+  const { data, error } = await this.supabaseClient
+    .from('client_info')
+    .select(`
+      id,
+      user_id,           
+      firstname,
+      lastname,
+      email,
+      service_needed,
+      requested,
+      updated_at,
+      status
+    `)
+ 
+  Iif (error) {
+    throw new Error(`Failed to fetch clients: ${error.message}`)
+  }
+ 
+  return (data as any[]).map(client => ({
+    id:            client.id,
+    userId:        client.user_id,        // expose the real UUID
+    firstName:     client.firstname,
+    lastName:      client.lastname,
+    email:         client.email,
+    serviceNeeded: client.service_needed,
+    requestedAt:   new Date(client.requested),
+    updatedAt:     new Date(client.updated_at),
+    status:        client.status,
+  }))
+}
+ 
+ 
+// Add this method inside the SupabaseUserRepository class
+ 
+async updateClientStatusToCustomer(userId: string): Promise<void> {
+  console.log('Updating client_info where user_id =', userId);
+ 
+  const { error } = await this.supabaseClient
+    .from('client_info')
+    .update({ status: 'customer' })      // set the new status
+    .eq('user_id', userId);              // match by user_id (UUID)
+ 
+  Iif (error) {
+    throw new Error(`Failed to update client status: ${error.message}`);
+  }
+}
+async findClientsById(id: string): Promise<any> {
+  const { data, error } = await this.supabaseClient
+    .from('client_info')
+    .select(`
+      id,
+      firstname,
+      lastname,
+      email,
+      service_needed,
+      requested,
+      updated_at,
+      status,
+      user_id,
+      users (
+        profile_picture,
+        firstname,
+        lastname
+      )
+    `)
+    .eq('id', id);
+ 
+  Iif (error) {
+    throw new Error(`${error.message}`);
+  }
+ 
+  Iif (!data || data.length === 0) {
+    console.log("GOING TO EERROR: NO DATA, client id is", id);
+    return null;
+  }
+  
+  return this.mapToClient(data[0]); 
+}
+ 
+ 
+  async findClientsByDoula(doulaId: string): Promise<Client[]> {
+    const { data: assignments, error: assignmentsError } = await this.supabaseClient
+      .from('assignments')
+      .select('client_id')
+      .eq('doula_id', doulaId)
+ 
+    Iif (assignmentsError) {
+      throw new Error(`Failed to fetch assignments: ${assignmentsError.message}`);
+    }
+ 
+    // Return if there are no assigned clients
+    Iif (!assignments || assignments.length === 0) {
+      return [];
+    }
+ 
+    // store out client ids into an array
+    const clientIds = assignments.map(assignment => assignment.client_id);
+ 
+    // console.log("clientIds are ", clientIds);
+ 
+    // grab our users
+    const { data: users, error: getUsersError } = await this.supabaseClient
+      .from('client_info')
+      .select('*')
+      .in('id', clientIds);
+ 
+    Iif (getUsersError) {
+      throw new Error(`${getUsersError.message}`);
+    }
+    // console.log("after call to client_info");
+ 
+    return users.map(user => this.mapToClient(user));
+  }
+  
+  async save(user: User): Promise<User> {
+    const { data, error } = await this.supabaseClient
+      .from('users')
+      .upsert({
+        id: user.id,
+        email: user.email,
+        firstname: user.firstname,
+        lastname: user.lastname,
+      }, { onConflict: 'email' })
+      .select()
+      .single();
+      
+    Iif (error) {
+      throw new Error(error.message);
+    }
+    
+    return this.mapToUser(data);
+  }
+ 
+  async update(userId: string, fieldsToUpdate: Partial<User>): Promise<User> {
+ 
+    const { data: updatedUser, error: updatedUserError } = await this.supabaseClient
+      .from('users')
+      .update(fieldsToUpdate)
+      .eq('id', userId)
+      .select()
+      .single()
+ 
+ 
+    Iif (updatedUserError) throw new Error(updatedUserError.message);
+    return this.mapToUser(updatedUser);
+  }
+  
+  async findAll(): Promise<User[]> {
+    const { data, error } = await this.supabaseClient
+    .from('users')
+    .select('email, firstname, lastname')
+    .order('firstname', { ascending: true });
+    
+    Iif (error) {
+      throw new Error(`Failed to fetch users: ${error.message}`);
+    }
+    
+    return data.map(this.mapToUser);
+  }
+ 
+  async findAllTeamMembers(): Promise<User[]> {
+    try {
+      const { data, error } = await this.supabaseClient
+      .from('users')
+      .select('id, firstname, lastname, email, role, bio')
+      .in('role', ['doula','admin'])
+ 
+      Iif (error) {
+        throw new Error(`Failed to retrieve team members: ${error.message}`);
+      }
+ 
+      const mappedUsers = data.map(this.mapToUser);
+      return mappedUsers;
+    } catch (err) {
+      throw new Error(`Failed to fetch team members: ${err.message}`);
+    }
+  }
+ 
+  async addMember(firstname: string, lastname: string, userEmail: string, userRole: string): Promise<User> {
+    try {
+      const { data, error } = await this.supabaseClient
+        .from('users')
+        .insert([
+          { 
+            firstname:firstname,
+            lastname:lastname,
+            email: userEmail, 
+            role: userRole
+          },
+        ])
+        .select()
+        .single()
+ 
+      Iif (error) {
+        throw new Error(`Failed to add member: ${error.message}`);
+      }
+ 
+      return this.mapToUser(data);
+    } catch (err) {
+      throw new Error(`Failed to add member: ${err.message}`);
+    }
+  }
+ 
+  async getHoursById(id: string): Promise<any> {
+    try {
+      // Get all hours entries for this doula
+      const { data: hoursData, error: hoursError } = await this.supabaseClient
+        .from('hours')
+        .select('*')
+        .eq('doula_id', id);
+      
+      Iif (hoursError) throw new Error(hoursError.message);
+      Iif (!hoursData) {
+        return []
+      };
+      
+      // Get doula data once (since it's the same for all entries)
+      const doulaData = await this.findById(id);
+      Iif (!doulaData) throw new Error(`Doula with ID ${id} not found`);
+      
+      // Process each hour entry to include client data
+      const result = await Promise.all(hoursData.map(async (entry) => {
+        const clientData = await this.findClientsById(entry.client_id);
+        Iif(!clientData) {
+          console.log("clientData is null, entry is", entry);
+        }
+        // console.log("in getHoursById in supabaseUsersRepository, clientData (to which we are accessing clientData.firstname) is ", clientData);
+        const noteData = await this.findNoteByWorkLogId(entry.id);
+ 
+        
+ 
+        return {
+          id: entry.id,
+          start_time: entry.start_time,
+          end_time: entry.end_time,
+          doula: {
+            id: doulaData.id,
+            firstname: doulaData.firstname,
+            lastname: doulaData.lastname
+          },
+          client: clientData ? {
+            id: clientData.user.id,
+            firstname: clientData.user.firstname,
+            lastname: clientData.user.lastname
+          } : null,
+          note: noteData ? noteData : null
+        };
+      }));
+      
+      return result;
+    } catch (error) {
+      throw new Error(`Failed to get user's hours: ${error.message}`);
+    }
+  }
+ 
+  async getAllHours(): Promise<any> {
+    try {
+      // Get all hours entries for this doula
+      const { data: hoursData, error: hoursError } = await this.supabaseClient
+        .from('hours')
+        .select('*')
+      
+      Iif (hoursError) throw new Error(hoursError.message);
+      Iif (!hoursData) {
+        return []
+      };
+      
+      // Process each hour entry to include client data
+      const result = await Promise.all(hoursData.map(async (entry) => {
+        // console.log("entry is", entry);
+        const clientData = await this.findClientsById(entry.client_id);
+        const noteData = await this.findNoteByWorkLogId(entry.id);
+        const doulaData = await this.findById(entry.doula_id);
+        Iif (!doulaData) throw new Error(`Doula with the ID ${entry.doula_id} not found, inside getAllHours()`);
+ 
+        Iif(!clientData) {
+          console.log("clientData is null in getAllHours, entry is", entry);
+        }
+ 
+        
+        return {
+          id: entry.id,
+          start_time: entry.start_time,
+          end_time: entry.end_time,
+          doula: {
+            id: doulaData.id,
+            firstname: doulaData.firstname,
+            lastname: doulaData.lastname
+          },
+          client: clientData ? {
+            id: clientData.id,
+            firstname: clientData.user.firstname,
+            lastname: clientData.user.lastname
+          } : null,
+          note: noteData ? noteData : null
+        };
+      }));
+      
+      return result;
+    } catch (error) {
+      throw new Error(`Failed to get all hours: ${error.message}`);
+    }
+  }
+  
+  async findById(id: string): Promise<User | null> {
+    const { data, error } = await this.supabaseClient
+      .from('users')
+      .select('*')
+      .eq('id', id)
+      .single();
+      
+    Iif (error || !data) {
+      return null;
+    }
+    
+    return this.mapToUser(data);
+  }
+ 
+  async findNoteByWorkLogId(id: string): Promise<NOTE | null> {
+    
+    const { data, error } = await this.supabaseClient
+    .from('notes')
+    .select('*')
+    .eq('work_log_id', id)
+ 
+    Iif(error) {
+      console.log(`Given this work_log_id: ${id} error finding note correspimonding to it: ${error.message}`);
+    }
+ 
+    return data[0];
+  }
+  
+  async delete(id: string): Promise<void> {
+    const { error } = await this.supabaseClient
+      .from('users')
+      .delete()
+      .eq('id', id);
+      
+    Iif (error) {
+      throw new Error(`Failed to delete user: ${error.message}`);
+    }
+  }
+  
+  async uploadProfilePicture(user: User, profilePicture: MulterFile) {
+    const filePath = `${user.id}/${Date.now()}_${profilePicture.originalname}`;
+ 
+    // upload to supabase
+    const { data, error: uploadError } = await this.supabaseClient.storage
+    .from('profile-pictures')
+    .upload(filePath, profilePicture.buffer, {
+      contentType: profilePicture.mimetype,
+      upsert: true,
+    });
+ 
+    Iif (uploadError) {
+      console.log('Upload error', uploadError);
+      throw new Error('failed to stash profile picture');
+    }
+ 
+    // grab the link to it
+    const { data: { publicUrl }} = await this.supabaseClient.storage
+      .from('profile-pictures')
+      .getPublicUrl(filePath);
+ 
+    return publicUrl;
+  }
+ 
+  // Helper to map database user to domain User
+  private mapToUser(data: any): User {
+    return new User({
+      id: data.id,
+      email: data.email,
+      firstname: data.firstname,
+      lastname: data.lastname,
+      created_at: new Date(data.created_at || Date.now()),
+      updated_at: new Date(data.updated_at || Date.now()),
+      role: data.role || ROLE.CLIENT,
+      address: data.address,
+      city: data.city,
+      state: data.state,
+      country: data.country,
+      zip_code: data.zip_code,
+      profile_picture: data.profile_picture,
+      account_status: data.account_status,
+      business: data.business,
+      bio: data.bio
+    });
+  }
+ 
+  // Helper to map to client entity
+  private mapToClient(data: any): Client {
+    // If the user has created a profile, grab user data from users table. If not, grab details
+    // from the request form (client_info table).
+    const userData = data.users ? {
+      id: data.users.user_id,
+      firstname: data.users.firstname,
+      lastname: data.users.lastname,
+      profile_picture: data.users,
+    } :
+    {
+      id: data.id,
+      firstname: data.firstname,
+      lastname: data.lastname,
+      profile_picture: ''
+    };
+ 
+    // if user doesn't exist (not approved), we fill fields from client_info table
+    const user = this.mapToUser({
+      id: userData.id ?? data.id,
+      firstname: userData.firstname,
+      lastname: userData.lastname,
+      profile_picture: userData.profile_picture,
+      role: 'client',
+    })
+ 
+    return new Client(
+      data.id,
+      user,
+      data.service_needed,
+      new Date(data.requested),
+      new Date(data.updated_at),
+      data.status
+    )
+  }
+ 
+  async addNewHours(doula_id: string, client_id: string, start_time: Date, end_time: Date, note: string): Promise<WORK_ENTRY_ROW> {
+    const { data: hoursData, error: hoursError } = await this.supabaseClient
+      .from('hours')
+      .insert([
+        {
+          doula_id: doula_id, 
+          client_id: client_id, 
+          start_time: start_time, 
+          end_time: end_time
+        }
+      ])
+      .select();
+      
+      Iif (hoursError) {
+        throw new Error(`Failed to post new user: ${hoursError.message}`);
+      }
+ 
+      // console.log("hoursData is" , hoursData);
+      // console.log("the id contained in hoursData is", hoursData[0].id);
+ 
+    Iif(note != "") {
+      // console.log("note is not empty and about to call https call, note is", note);
+      const { data: noteData, error: noteError } = await this.supabaseClient
+      .from('notes')
+      .insert([
+        {
+          content: note,
+          created_by: doula_id,
+          work_log_id: hoursData[0].id,
+          visibility: "public"
+        }
+      ])
+      .select();
+      
+      Iif(noteError) {
+        throw new Error(`The note field is nonempty but failed to add note, ${noteError.message}`);
+      }
+    }
+    
+    return hoursData[0];
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/EmailRoutes.ts.html b/coverage/lcov-report/src/routes/EmailRoutes.ts.html new file mode 100644 index 00000000..3c60e682 --- /dev/null +++ b/coverage/lcov-report/src/routes/EmailRoutes.ts.html @@ -0,0 +1,142 @@ + + + + + + Code coverage report for src/routes/EmailRoutes.ts + + + + + + + + + +
+
+

All files / src/routes EmailRoutes.ts

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { emailController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+ 
+const emailRoutes: Router = express.Router();
+ 
+// Protect all email routes with authentication
+emailRoutes.use(authMiddleware);
+ 
+// Route for sending client approval emails
+emailRoutes.post('/client-approval', (req, res) => 
+  emailController.sendClientApproval(req, res)
+);
+ 
+// Route for sending team invite emails
+emailRoutes.post('/team-invite', (req, res) => 
+  emailController.sendTeamInvite(req, res)
+);
+ 
+export default emailRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/authRoutes.ts.html b/coverage/lcov-report/src/routes/authRoutes.ts.html new file mode 100644 index 00000000..3192b66b --- /dev/null +++ b/coverage/lcov-report/src/routes/authRoutes.ts.html @@ -0,0 +1,193 @@ + + + + + + Code coverage report for src/routes/authRoutes.ts + + + + + + + + + +
+
+

All files / src/routes authRoutes.ts

+
+ +
+ 0% + Statements + 0/29 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/12 +
+ + +
+ 0% + Lines + 0/17 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { authController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+ 
+ 
+const authRoutes: Router = express.Router();
+ 
+// Signup route
+authRoutes.post('/signup', (req, res) => authController.signup(req, res));
+ 
+// Login route
+authRoutes.post('/login', (req, res) => authController.login(req, res));
+ 
+// Get current user route
+authRoutes.get('/me', (req, res) => authController.getMe(req, res));
+ 
+// Get all users route
+authRoutes.get('/users', authMiddleware, (req, res) => authController.getAllUsers(req, res));
+ 
+// Logout route
+authRoutes.post('/logout', (req, res) => authController.logout(req, res));
+ 
+// Email verification route
+authRoutes.get('/verify', (req, res) => authController.verifyEmail(req, res));
+ 
+// Google OAuth routes
+authRoutes.get('/google', (req, res) => authController.googleAuth(req, res));
+authRoutes.get('/callback', (req, res) => authController.handleOAuthCallback(req, res));
+authRoutes.post('/callback', (req, res) => authController.handleToken(req, res));
+ 
+// Password reset routes
+authRoutes.post('/reset-password', (req, res) => authController.requestPasswordReset(req, res));
+authRoutes.get('/password-recovery', (req, res) => authController.handlePasswordRecovery(req, res));
+authRoutes.put('/reset-password', (req, res) => authController.updatePassword(req, res));
+ 
+export default authRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/clientRoutes.ts.html b/coverage/lcov-report/src/routes/clientRoutes.ts.html new file mode 100644 index 00000000..0bdc2f4b --- /dev/null +++ b/coverage/lcov-report/src/routes/clientRoutes.ts.html @@ -0,0 +1,265 @@ + + + + + + Code coverage report for src/routes/clientRoutes.ts + + + + + + + + + +
+
+

All files / src/routes clientRoutes.ts

+
+ +
+ 0% + Statements + 0/30 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/16 +
+ + +
+ 0% + Lines + 0/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { clientController, userController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+import authorizeRoles from '../middleware/authorizeRoles';
+ 
+const clientRoutes: Router = express.Router();
+ 
+// Team specific routes
+clientRoutes.get('/team/all',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']),
+  (req, res) => userController.getAllTeamMembers(req, res)
+);
+ 
+clientRoutes.delete('/team/:id',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => userController.deleteMember(req, res)
+);
+ 
+clientRoutes.post("/team/add",
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => userController.addTeamMember(req, res)
+);
+ 
+// Client specific routes - ORDER MATTERS! Specific routes first
+clientRoutes.get('/fetchCSV', 
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin','client']), 
+  (req, res) => clientController.exportCSV(req, res)
+);
+ 
+clientRoutes.get('/', 
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']), 
+  (req, res) => clientController.getClients(req, res)
+);
+ 
+// Specific routes must come before generic /:id route
+clientRoutes.put('/status',
+  authMiddleware, 
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']), 
+  (req, res) => clientController.updateClientStatus(req, res)
+);
+ 
+// Generic routes last
+clientRoutes.get('/:id',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula', 'client']),
+  (req, res) => clientController.getClientById(req, res)
+);
+ 
+clientRoutes.put('/:id',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']),
+  (req, res) => clientController.updateClient(req, res)
+);
+ 
+export default clientRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/contractRoutes.ts.html b/coverage/lcov-report/src/routes/contractRoutes.ts.html new file mode 100644 index 00000000..664cae58 --- /dev/null +++ b/coverage/lcov-report/src/routes/contractRoutes.ts.html @@ -0,0 +1,286 @@ + + + + + + Code coverage report for src/routes/contractRoutes.ts + + + + + + + + + +
+
+

All files / src/routes contractRoutes.ts

+
+ +
+ 0% + Statements + 0/29 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/14 +
+ + +
+ 0% + Lines + 0/29 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import multer from 'multer';
+import { contractController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+import authorizeRoles from '../middleware/authorizeRoles';
+ 
+ 
+const clientRoutes: Router = express.Router();
+ 
+const upload = multer({ 
+  storage: multer.memoryStorage(),
+  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
+ });
+ 
+// generate a contract for a client given a template
+clientRoutes.post('/',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => contractController.generateContract(req, res)
+)
+ 
+// get a preview of an already generated contract
+clientRoutes.get('/:id/preview',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula', 'client']),
+  (req, res) => contractController.previewContract(req, res)
+)
+ 
+// get the list of templates
+clientRoutes.get('/templates',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['doula', 'admin']),
+  (req, res) => contractController.getAllTemplates(req, res),
+)
+ 
+// delete a template
+clientRoutes.delete('/templates/:name',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => contractController.deleteTemplate(req, res),
+)
+ 
+// update a template
+clientRoutes.put('/templates/:name',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  upload.single('contract'),
+  (req, res) => contractController.updateTemplate(req, res),
+)
+ 
+// upload a template
+clientRoutes.post('/templates', 
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']), 
+  upload.single('contract'),
+  (req, res) => contractController.uploadTemplate(req, res)
+);
+ 
+// request a filled template
+clientRoutes.post('/templates/generate',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => contractController.generateTemplate(req, res),
+)
+ 
+ 
+export default clientRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/customersRoutes.ts.html b/coverage/lcov-report/src/routes/customersRoutes.ts.html new file mode 100644 index 00000000..fc66a5e5 --- /dev/null +++ b/coverage/lcov-report/src/routes/customersRoutes.ts.html @@ -0,0 +1,127 @@ + + + + + + Code coverage report for src/routes/customersRoutes.ts + + + + + + + + + +
+
+

All files / src/routes customersRoutes.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/routes/customersRoutes.js
+import { Router } from 'express';
+ 
+import { createCustomer, getInvoiceableCustomersController } from '../controllers/quickbooksController';
+const router = Router();
+ 
+// POST /quickbooks/customers
+router.post('/', createCustomer);
+ 
+ 
+// GET /quickbooks/customers/invoiceable
+router.get('/invoiceable', getInvoiceableCustomersController);
+ 
+export default router;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/doulaRoutes.ts.html b/coverage/lcov-report/src/routes/doulaRoutes.ts.html new file mode 100644 index 00000000..95f83722 --- /dev/null +++ b/coverage/lcov-report/src/routes/doulaRoutes.ts.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for src/routes/doulaRoutes.ts + + + + + + + + + +
+
+

All files / src/routes doulaRoutes.ts

+
+ +
+ 0% + Statements + 0/12 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 0% + Lines + 0/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { clientController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+import authorizeRoles from '../middleware/authorizeRoles';
+ 
+const doulaRoutes: Router = express.Router();
+ 
+doulaRoutes.get('/', 
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']), 
+  (req, res) => clientController.getClients(req, res)
+);
+doulaRoutes.put('/status', 
+  authMiddleware, 
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']), 
+  (req, res) => clientController.updateClientStatus(req, res)
+);
+ 
+ 
+ 
+export default doulaRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/index.html b/coverage/lcov-report/src/routes/index.html new file mode 100644 index 00000000..b9207dc8 --- /dev/null +++ b/coverage/lcov-report/src/routes/index.html @@ -0,0 +1,251 @@ + + + + + + Code coverage report for src/routes + + + + + + + + + +
+
+

All files src/routes

+
+ +
+ 0% + Statements + 0/168 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/53 +
+ + +
+ 0% + Lines + 0/152 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
EmailRoutes.ts +
+
0%0/10100%0/00%0/20%0/10
authRoutes.ts +
+
0%0/29100%0/00%0/120%0/17
clientRoutes.ts +
+
0%0/30100%0/00%0/160%0/30
contractRoutes.ts +
+
0%0/29100%0/00%0/140%0/29
customersRoutes.ts +
+
0%0/6100%0/0100%0/00%0/6
doulaRoutes.ts +
+
0%0/12100%0/00%0/40%0/12
paymentRoutes.ts +
+
0%0/16100%0/0100%0/00%0/16
quickbooksRoutes.ts +
+
0%0/15100%0/0100%0/00%0/15
requestRoute.ts +
+
0%0/6100%0/00%0/10%0/6
specificUserRoutes.ts +
+
0%0/15100%0/00%0/40%0/11
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/paymentRoutes.ts.html b/coverage/lcov-report/src/routes/paymentRoutes.ts.html new file mode 100644 index 00000000..51e40269 --- /dev/null +++ b/coverage/lcov-report/src/routes/paymentRoutes.ts.html @@ -0,0 +1,259 @@ + + + + + + Code coverage report for src/routes/paymentRoutes.ts + + + + + + + + + +
+
+

All files / src/routes paymentRoutes.ts

+
+ +
+ 0% + Statements + 0/16 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/16 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Router } from 'express';
+import { z } from 'zod';
+import { paymentController } from '../controllers/paymentController';
+import authMiddleware from '../middleware/authMiddleware';
+import { validateRequest } from '../middleware/validateRequest';
+ 
+const router = Router();
+ 
+// Validation schemas
+const saveCardSchema = z.object({
+  cardToken: z.string(),
+});
+ 
+const chargeCardSchema = z.object({
+  amount: z.number().positive(),
+  description: z.string().optional(),
+});
+ 
+const updateCardSchema = z.object({
+  cardToken: z.string(),
+});
+ 
+// All payment routes should be authenticated
+router.use(authMiddleware);
+ 
+// Save a new card
+router.post(
+  '/customers/:customerId/cards',
+  validateRequest(saveCardSchema),
+  paymentController.saveCard.bind(paymentController)
+);
+ 
+// Update an existing card
+router.put(
+  '/customers/:customerId/cards/:paymentMethodId',
+  validateRequest(updateCardSchema),
+  paymentController.updatePaymentMethod.bind(paymentController)
+);
+ 
+// Process a charge
+router.post(
+  '/customers/:customerId/charge',
+  validateRequest(chargeCardSchema),
+  paymentController.processCharge.bind(paymentController)
+);
+ 
+// Get stored payment methods for a customer
+router.get(
+  '/customers/:customerId/cards',
+  paymentController.getPaymentMethods.bind(paymentController)
+);
+ 
+// Get all customers with Stripe IDs
+router.get(
+  '/customers',
+  paymentController.getCustomersWithStripeId.bind(paymentController)
+);
+ 
+export default router; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/quickbooksRoutes.ts.html b/coverage/lcov-report/src/routes/quickbooksRoutes.ts.html new file mode 100644 index 00000000..3ada53eb --- /dev/null +++ b/coverage/lcov-report/src/routes/quickbooksRoutes.ts.html @@ -0,0 +1,187 @@ + + + + + + Code coverage report for src/routes/quickbooksRoutes.ts + + + + + + + + + +
+
+

All files / src/routes quickbooksRoutes.ts

+
+ +
+ 0% + Statements + 0/15 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/15 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/routes/quickbooksRoutes.ts
+import { Router } from 'express'
+import {
+  connectQuickBooks,
+  createInvoice,
+  getInvoices,
+  handleQuickBooksCallback,
+  quickBooksAuthUrl,
+  quickBooksDisconnect,
+  quickBooksStatus
+} from '../controllers/quickbooksController'
+import authMiddleware from '../middleware/authMiddleware'
+import { simulatePaymentController } from '../services/payments/paymentsController'
+ 
+const router = Router()
+ 
+// 1️⃣ Public OAuth endpoints (no auth required for redirect/callback)
+router.get('/auth', connectQuickBooks)
+router.get('/callback', handleQuickBooksCallback)
+ 
+// 2️⃣ Now apply auth + admin guard to the rest
+router.use(authMiddleware)
+ 
+// 3️⃣ Protected AJAX endpoints
+router.get('/auth/url', quickBooksAuthUrl)
+router.get('/status', quickBooksStatus)
+router.get('/invoices', getInvoices)
+router.post('/disconnect', quickBooksDisconnect)
+router.post('/invoice', createInvoice)
+ 
+// Simulate payment endpoint
+router.post('/simulate-payment', simulatePaymentController)
+ 
+export default router
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/requestRoute.ts.html b/coverage/lcov-report/src/routes/requestRoute.ts.html new file mode 100644 index 00000000..33b80d39 --- /dev/null +++ b/coverage/lcov-report/src/routes/requestRoute.ts.html @@ -0,0 +1,115 @@ + + + + + + Code coverage report for src/routes/requestRoute.ts + + + + + + + + + +
+
+

All files / src/routes requestRoute.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { requestFormController } from '../index';
+ 
+const requestRouter: Router = express.Router();
+ 
+// Updated endpoint to handle all 10-step form fields
+requestRouter.post('/requestSubmission', 
+  (req, res) => requestFormController.createForm(req, res));
+ 
+export default requestRouter;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/routes/specificUserRoutes.ts.html b/coverage/lcov-report/src/routes/specificUserRoutes.ts.html new file mode 100644 index 00000000..474a22b1 --- /dev/null +++ b/coverage/lcov-report/src/routes/specificUserRoutes.ts.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for src/routes/specificUserRoutes.ts + + + + + + + + + +
+
+

All files / src/routes specificUserRoutes.ts

+
+ +
+ 0% + Statements + 0/15 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 0% + Lines + 0/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import multer from 'multer';
+import { userController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+ 
+const userRoutes: Router = express.Router();
+ 
+// route for retrieving specific user's information
+userRoutes.get('/:id', authMiddleware, (req, res) => userController.getUserById(req, res));
+ 
+userRoutes.get('/:id/hours', authMiddleware, (req, res) => userController.getHours(req, res));
+ 
+userRoutes.post('/:id/addhours', authMiddleware, (req, res) => userController.addNewHours(req, res));
+ 
+// uploading a profile picture requires multer
+const upload = multer({ 
+  storage: multer.memoryStorage(),
+  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
+ });
+userRoutes.put('/update', authMiddleware, upload.single('profile_picture'), (req, res) => userController.updateUser(req, res));
+ 
+export default userRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/RequestFormService.ts.html b/coverage/lcov-report/src/services/RequestFormService.ts.html new file mode 100644 index 00000000..c0a8efd9 --- /dev/null +++ b/coverage/lcov-report/src/services/RequestFormService.ts.html @@ -0,0 +1,862 @@ + + + + + + Code coverage report for src/services/RequestFormService.ts + + + + + + + + + +
+
+

All files / src/services RequestFormService.ts

+
+ +
+ 0% + Statements + 0/58 +
+ + +
+ 0% + Branches + 0/35 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/58 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { ValidationError } from "../domains/errors";
+import { RequestForm } from '../entities/RequestForm';
+import { RequestFormRepository } from "../repositories/requestFormRepository";
+import {
+    RequestFormData,
+    RequestFormResponse,
+    RequestStatus
+} from "../types";
+ 
+export class RequestFormService {
+  private repository: RequestFormRepository;
+ 
+  constructor(requestFormRepository: RequestFormRepository) {
+    this.repository = requestFormRepository;
+  }
+ 
+  async createRequest(formData: RequestFormData): Promise<RequestFormResponse> {
+    // Validate required fields
+    Iif (!formData.firstname || !formData.lastname) {
+      throw new ValidationError("Missing required fields: first name and last name");
+    }
+ 
+    Iif (!formData.service_needed) {
+      throw new ValidationError("Missing required field: service_needed");
+    }
+    
+    Iif (!formData.email || !formData.email.includes('@')) {
+      throw new ValidationError("Valid email is required");
+    }
+    
+    Iif (!formData.phone_number) {
+      throw new ValidationError("Phone number is required");
+    }
+ 
+    Iif (!formData.address || !formData.city || !formData.state || !formData.zip_code) {
+      throw new ValidationError("Complete address is required");
+    }
+ 
+    // Validate email format
+    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+    Iif (!emailRegex.test(formData.email)) {
+      throw new ValidationError("Invalid email format");
+    }
+ 
+    // Validate phone number format (basic validation)
+    const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/;
+    Iif (!phoneRegex.test(formData.phone_number.replace(/[\s\-\(\)]/g, ''))) {
+      throw new ValidationError("Invalid phone number format");
+    }
+ 
+    // Validate zip code format
+    const zipRegex = /^\d{5}(-\d{4})?$/;
+    Iif (!zipRegex.test(formData.zip_code)) {
+      throw new ValidationError("Invalid zip code format");
+    }
+ 
+    // Save to repository (no userId)
+    return await this.repository.saveData(formData);
+  }
+ 
+  async getUserRequests(userId: string): Promise<RequestFormResponse[]> {
+    return await this.repository.getUserRequests(userId);
+  }
+ 
+  async getRequestById(requestId: string, userId: string): Promise<RequestFormResponse | null> {
+    return await this.repository.getRequestById(requestId, userId);
+  }
+ 
+  async getAllRequests(): Promise<RequestFormResponse[]> {
+    return await this.repository.getAllRequests();
+  }
+ 
+  async getRequestByIdAdmin(requestId: string): Promise<RequestFormResponse | null> {
+    return await this.repository.getRequestByIdAdmin(requestId);
+  }
+ 
+  async updateRequestStatus(requestId: string, status: RequestStatus): Promise<RequestFormResponse> {
+    // Validate status
+    const validStatuses = Object.values(RequestStatus);
+    Iif (!validStatuses.includes(status)) {
+      throw new ValidationError("Invalid status value");
+    }
+ 
+    return await this.repository.updateRequestStatus(requestId, status);
+  }
+ 
+  // Updated method to handle all 10-step form fields
+  async newForm(formData: any): Promise<RequestForm> {
+    try {
+      // Validate required fields
+      Iif (!formData.firstname || !formData.lastname) {
+        throw new ValidationError("Missing required fields: first name and last name");
+      }
+ 
+      Iif (!formData.service_needed) {
+        throw new ValidationError("Missing required field: service_needed");
+      }
+      
+      Iif (!formData.email || !formData.email.includes('@')) {
+        throw new ValidationError("Valid email is required");
+      }
+      
+      Iif (!formData.phone_number) {
+        throw new ValidationError("Phone number is required");
+      }
+ 
+      Iif (!formData.address || !formData.city || !formData.state || !formData.zip_code) {
+        throw new ValidationError("Complete address is required");
+      }
+ 
+      // Validate email format
+      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+      Iif (!emailRegex.test(formData.email)) {
+        throw new ValidationError("Invalid email format");
+      }
+ 
+      // Validate phone number format (basic validation)
+      const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/;
+      Iif (!phoneRegex.test(formData.phone_number.replace(/[\s\-\(\)]/g, ''))) {
+        throw new ValidationError("Invalid phone number format");
+      }
+ 
+      // Validate zip code format
+      const zipRegex = /^\d{5}(-\d{4})?$/;
+      Iif (!zipRegex.test(formData.zip_code)) {
+        throw new ValidationError("Invalid zip code format");
+      }
+ 
+      // Convert to RequestFormData format
+      const newFormData: RequestFormData = {
+        // Step 1: Client Details
+        firstname: formData.firstname,
+        lastname: formData.lastname,
+        email: formData.email,
+        phone_number: formData.phone_number,
+        pronouns: formData.pronouns,
+        pronouns_other: formData.pronouns_other,
+        
+        // Step 2: Home Details
+        address: formData.address,
+        city: formData.city,
+        state: formData.state,
+        zip_code: formData.zip_code,
+        home_phone: formData.home_phone,
+        home_type: formData.home_type,
+        home_access: formData.home_access,
+        pets: formData.pets,
+        
+        // Step 3: Family Members
+        relationship_status: formData.relationship_status,
+        first_name: formData.first_name,
+        last_name: formData.last_name,
+        middle_name: formData.middle_name,
+        mobile_phone: formData.mobile_phone,
+        work_phone: formData.work_phone,
+        
+        // Step 4: Referral
+        referral_source: formData.referral_source,
+        referral_name: formData.referral_name,
+        referral_email: formData.referral_email,
+        
+        // Step 5: Health History
+        health_history: formData.health_history,
+        allergies: formData.allergies,
+        health_notes: formData.health_notes,
+        
+        // Step 6: Payment Info
+        annual_income: formData.annual_income,
+        service_needed: formData.service_needed,
+        service_specifics: formData.service_specifics,
+        
+        // Step 7: Pregnancy/Baby
+        due_date: formData.due_date,
+        birth_location: formData.birth_location,
+        birth_hospital: formData.birth_hospital,
+        number_of_babies: formData.number_of_babies,
+        baby_name: formData.baby_name,
+        provider_type: formData.provider_type,
+        pregnancy_number: formData.pregnancy_number,
+        
+        // Step 8: Past Pregnancies
+        had_previous_pregnancies: formData.had_previous_pregnancies,
+        previous_pregnancies_count: formData.previous_pregnancies_count,
+        living_children_count: formData.living_children_count,
+        past_pregnancy_experience: formData.past_pregnancy_experience,
+        
+        // Step 9: Services Interested
+        services_interested: formData.services_interested,
+        service_support_details: formData.service_support_details,
+        
+        // Step 10: Client Demographics
+        race_ethnicity: formData.race_ethnicity,
+        primary_language: formData.primary_language,
+        client_age_range: formData.client_age_range,
+        insurance: formData.insurance,
+        demographics_multi: formData.demographics_multi
+      };
+ 
+      // Save to repository (no userId)
+      const response = await this.repository.saveData(newFormData);
+      
+      // Return the complete RequestForm with all fields
+      return new RequestForm(
+        response.firstname,
+        response.lastname,
+        response.email,
+        response.phone_number,
+        response.service_needed,
+        response.address,
+        response.city,
+        response.state,
+        response.zip_code,
+        response.pronouns,
+        response.pronouns_other,
+        response.children_expected,
+        response.home_phone,
+        response.home_type,
+        response.home_access,
+        response.pets,
+        response.relationship_status,
+        response.first_name,
+        response.last_name,
+        response.middle_name,
+        response.mobile_phone,
+        response.work_phone,
+        response.referral_source,
+        response.referral_name,
+        response.referral_email,
+        response.health_history,
+        response.allergies,
+        response.health_notes,
+        response.annual_income,
+        response.service_specifics,
+        response.due_date ? new Date(response.due_date) : undefined,
+        response.birth_location,
+        response.birth_hospital,
+        response.number_of_babies,
+        response.baby_name,
+        response.provider_type,
+        response.pregnancy_number,
+        response.hospital,
+        response.baby_sex,
+        response.had_previous_pregnancies,
+        response.previous_pregnancies_count,
+        response.living_children_count,
+        response.past_pregnancy_experience,
+        response.services_interested,
+        response.service_support_details,
+        response.race_ethnicity,
+        response.primary_language,
+        response.client_age_range,
+        response.insurance,
+        response.demographics_multi
+      );
+    } catch (error) {
+      console.error("Error in newForm:", error);
+      throw error;
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/auth/index.html b/coverage/lcov-report/src/services/auth/index.html new file mode 100644 index 00000000..1097205f --- /dev/null +++ b/coverage/lcov-report/src/services/auth/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/services/auth + + + + + + + + + +
+
+

All files src/services/auth

+
+ +
+ 0% + Statements + 0/39 +
+ + +
+ 0% + Branches + 0/11 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/39 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
quickbooksAuthService.ts +
+
0%0/390%0/110%0/50%0/39
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/auth/quickbooksAuthService.ts.html b/coverage/lcov-report/src/services/auth/quickbooksAuthService.ts.html new file mode 100644 index 00000000..fd9fb3ab --- /dev/null +++ b/coverage/lcov-report/src/services/auth/quickbooksAuthService.ts.html @@ -0,0 +1,433 @@ + + + + + + Code coverage report for src/services/auth/quickbooksAuthService.ts + + + + + + + + + +
+
+

All files / src/services/auth quickbooksAuthService.ts

+
+ +
+ 0% + Statements + 0/39 +
+ + +
+ 0% + Branches + 0/11 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/39 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/auth/quickbooksAuthService.ts
+ 
+import OAuthClient from 'intuit-oauth';
+import { URL } from 'url';
+import {
+    deleteTokens,
+    getTokens,
+    saveTokens,
+    TokenStore
+} from '../../utils/tokenUtils';
+ 
+const {
+  QB_CLIENT_ID     = '',
+  QB_CLIENT_SECRET = '',
+  QB_REDIRECT_URI  = '',
+  QBO_ENV          = 'production'
+} = process.env;
+ 
+const oauthClient = new OAuthClient({
+  clientId:     QB_CLIENT_ID,
+  clientSecret: QB_CLIENT_SECRET,
+  environment:  QBO_ENV === 'sandbox' ? 'sandbox' : 'production',
+  redirectUri:  QB_REDIRECT_URI
+});
+ 
+/**
+ * Build the Intuit consent URL.
+ */
+export function generateConsentUrl(state: string): string {
+  return oauthClient.authorizeUri({
+    scope: [ OAuthClient.scopes.Accounting ],
+    state
+  });
+}
+ 
+/**
+ * Handle Intuit's callback:
+ *   1) Exchange the code for tokens
+ *   2) Extract realmId (from the JSON or the URL query)
+ *   3) Persist tokens
+ *   4) Return them
+ */
+export async function handleAuthCallback(
+  callbackUrl: string
+): Promise<Omit<TokenStore, 'userId'>> {
+  // Exchange code for tokens
+  const authResponse = await oauthClient.createToken(callbackUrl);
+  const json = authResponse.getJson() as {
+    access_token:  string;
+    refresh_token: string;
+    expires_in:    number;
+    realmId?:      string;
+  };
+ 
+  // Intuit sometimes returns realmId in JSON or URL query
+  const realmId = json.realmId ?? new URL(callbackUrl).searchParams.get('realmId');
+ 
+  Iif (!realmId) {
+    throw new Error('Missing realmId in QuickBooks callback');
+  }
+ 
+  // Build TokenStore
+  const tokens: TokenStore = {
+    realmId,
+    accessToken:  json.access_token,
+    refreshToken: json.refresh_token,
+    expiresAt:    new Date(Date.now() + json.expires_in * 1000).toISOString()
+  };
+ 
+  // Persist tokens
+  await saveTokens(tokens);
+  return tokens;
+}
+ 
+/**
+ * Check if connected (tokens exist & are not expired).
+ * If tokens are expired, attempt to refresh them.
+ */
+export async function isConnected(): Promise<boolean> {
+  console.log('🔍 [QB Auth] Checking if QuickBooks is connected...');
+  
+  const tokens = await getTokens();
+  Iif (!tokens) {
+    console.log('❌ [QB Auth] No tokens found - not connected');
+    return false;
+  }
+  
+  const now = new Date();
+  const expiresAt = new Date(tokens.expiresAt);
+  const isExpired = expiresAt <= now;
+  
+  console.log('⏰ [QB Auth] Current time:', now.toISOString());
+  console.log('📅 [QB Auth] Token expires at:', expiresAt.toISOString());
+  console.log('🔍 [QB Auth] Token expired?', isExpired);
+  
+  Iif (isExpired) {
+    console.log('🔄 [QB Auth] Token expired, attempting refresh...');
+    // Import and use getValidAccessToken which handles refresh
+    const { getValidAccessToken } = await import('../../utils/tokenUtils');
+    const validToken = await getValidAccessToken();
+    const refreshSuccessful = !!validToken;
+    console.log('📊 [QB Auth] Refresh successful?', refreshSuccessful);
+    return refreshSuccessful;
+  }
+  
+  console.log('📊 [QB Auth] Connected? true (token valid)');
+  return true;
+}
+ 
+/**
+ * Disconnect QuickBooks by deleting stored tokens.
+ */
+export async function disconnectQuickBooks(): Promise<void> {
+  await deleteTokens();
+}
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/customer/buildCustomerPayload.ts.html b/coverage/lcov-report/src/services/customer/buildCustomerPayload.ts.html new file mode 100644 index 00000000..794ae701 --- /dev/null +++ b/coverage/lcov-report/src/services/customer/buildCustomerPayload.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/services/customer/buildCustomerPayload.ts + + + + + + + + + +
+
+

All files / src/services/customer buildCustomerPayload.ts

+
+ +
+ 0% + Statements + 0/3 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/3 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export interface BuildCustomerPayloadResult {
+  fullName: string;
+  payload: {
+    GivenName: string;
+    FamilyName: string;
+    DisplayName: string;
+    PrimaryEmailAddr: { Address: string };
+  };
+}
+ 
+export default function buildCustomerPayload(
+  firstName: string,
+  lastName: string,
+  email: string
+): BuildCustomerPayloadResult {
+  const fullName = `${firstName} ${lastName}`;
+  return {
+    fullName,
+    payload: {
+      GivenName: firstName,
+      FamilyName: lastName,
+      DisplayName: fullName,
+      PrimaryEmailAddr: { Address: email }
+    }
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/customer/createCustomer.ts.html b/coverage/lcov-report/src/services/customer/createCustomer.ts.html new file mode 100644 index 00000000..65601abb --- /dev/null +++ b/coverage/lcov-report/src/services/customer/createCustomer.ts.html @@ -0,0 +1,235 @@ + + + + + + Code coverage report for src/services/customer/createCustomer.ts + + + + + + + + + +
+
+

All files / src/services/customer createCustomer.ts

+
+ +
+ 0% + Statements + 0/18 +
+ + +
+ 0% + Branches + 0/5 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { createClient } from '@supabase/supabase-js';
+import { SupabaseUserRepository } from '../../repositories/supabaseUserRepository';
+import buildCustomerPayload, { BuildCustomerPayloadResult } from './buildCustomerPayload';
+import createCustomerInQuickBooks from './createCustomerInQuickBooks';
+import saveQboCustomerId from './saveQboCustomerId';
+import upsertInternalCustomer from './upsertInternalCustomer';
+ 
+const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)
+const userRepository = new SupabaseUserRepository(supabase)
+ 
+export interface CreateCustomerParams {
+  internalCustomerId: string;
+  firstName: string;
+  lastName: string;
+  email: string;
+}
+ 
+export interface CreateCustomerResult {
+  internalCustomerId: string;
+  qboCustomerId: string;
+  fullName: string;
+}
+ 
+export default async function createCustomer(
+  params: CreateCustomerParams
+): Promise<CreateCustomerResult> {
+  const { internalCustomerId, firstName, lastName, email } = params;
+ 
+  Iif (!internalCustomerId || !firstName || !lastName || !email) {
+    throw new Error('Missing required fields to create customer.');
+  }
+ 
+  // 1) Build payload
+  const { fullName, payload }: BuildCustomerPayloadResult =
+    buildCustomerPayload(firstName, lastName, email);
+ 
+  // 2) Upsert internal record
+  await upsertInternalCustomer(internalCustomerId, fullName, email);
+ 
+  // 3) Create in QuickBooks
+  const qboCustomer = await createCustomerInQuickBooks(payload);
+ 
+  // 4) Save QBO customer ID back internally
+  await saveQboCustomerId(internalCustomerId, qboCustomer.Id);
+ 
+  // 5) Update client_info status to 'customer'
+  await userRepository.updateClientStatusToCustomer(internalCustomerId);
+ 
+  return { internalCustomerId, qboCustomerId: qboCustomer.Id, fullName };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/customer/createCustomerInQuickBooks.ts.html b/coverage/lcov-report/src/services/customer/createCustomerInQuickBooks.ts.html new file mode 100644 index 00000000..242b14e1 --- /dev/null +++ b/coverage/lcov-report/src/services/customer/createCustomerInQuickBooks.ts.html @@ -0,0 +1,118 @@ + + + + + + Code coverage report for src/services/customer/createCustomerInQuickBooks.ts + + + + + + + + + +
+
+

All files / src/services/customer createCustomerInQuickBooks.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12  +  +  +  +  +  +  +  +  +  +  + 
import { qboRequest } from '../../utils/qboClient';
+ 
+export default async function createCustomerInQuickBooks(
+  qboPayload: any
+): Promise<any> {
+  const { Customer } = await qboRequest(
+    '/customer?minorversion=65',
+    { method: 'POST', body: JSON.stringify(qboPayload) }
+  );
+  return Customer;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/customer/getInvoiceableCustomers.ts.html b/coverage/lcov-report/src/services/customer/getInvoiceableCustomers.ts.html new file mode 100644 index 00000000..b4e865fc --- /dev/null +++ b/coverage/lcov-report/src/services/customer/getInvoiceableCustomers.ts.html @@ -0,0 +1,166 @@ + + + + + + Code coverage report for src/services/customer/getInvoiceableCustomers.ts + + + + + + + + + +
+
+

All files / src/services/customer getInvoiceableCustomers.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 0% + Branches + 0/3 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/customer/getInvoiceableCustomers.ts
+import { SupabaseClient } from '@supabase/supabase-js';
+ 
+export interface InvoiceableCustomer {
+  id: string;               // UUID PK
+  name: string;             // full name
+  email: string;
+  qboCustomerId: string | null;
+}
+ 
+export default async function getInvoiceableCustomers(
+  supabase: SupabaseClient
+): Promise<InvoiceableCustomer[]> {
+  const { data, error } = await supabase
+    .from('customers')
+    .select('id, name, email, qbo_customer_id')
+    .order('name', { ascending: true });
+ 
+  Iif (error) throw new Error(`Error fetching customers: ${error.message}`);
+ 
+  return (data || []).map((row: any) => ({
+    id: row.id,
+    name: row.name,
+    email: row.email,
+    qboCustomerId: row.qbo_customer_id,
+  }));
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/customer/index.html b/coverage/lcov-report/src/services/customer/index.html new file mode 100644 index 00000000..2aee2267 --- /dev/null +++ b/coverage/lcov-report/src/services/customer/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for src/services/customer + + + + + + + + + +
+
+

All files src/services/customer

+
+ +
+ 0% + Statements + 0/42 +
+ + +
+ 0% + Branches + 0/10 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/40 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
buildCustomerPayload.ts +
+
0%0/3100%0/00%0/10%0/3
createCustomer.ts +
+
0%0/180%0/50%0/10%0/18
createCustomerInQuickBooks.ts +
+
0%0/4100%0/00%0/10%0/4
getInvoiceableCustomers.ts +
+
0%0/60%0/30%0/20%0/4
saveQboCustomerId.ts +
+
0%0/50%0/10%0/10%0/5
upsertInternalCustomer.ts +
+
0%0/60%0/10%0/10%0/6
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/customer/saveQboCustomerId.ts.html b/coverage/lcov-report/src/services/customer/saveQboCustomerId.ts.html new file mode 100644 index 00000000..fe05e426 --- /dev/null +++ b/coverage/lcov-report/src/services/customer/saveQboCustomerId.ts.html @@ -0,0 +1,130 @@ + + + + + + Code coverage report for src/services/customer/saveQboCustomerId.ts + + + + + + + + + +
+
+

All files / src/services/customer saveQboCustomerId.ts

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import supabase from '../../supabase';
+ 
+export default async function saveQboCustomerId(
+  internalCustomerId: string,
+  qboCustomerId: string
+): Promise<void> {
+  const { error } = await supabase
+    .from('customers')
+    .update({ qbo_customer_id: qboCustomerId })
+    .eq('id', internalCustomerId);
+ 
+  Iif (error) {
+    throw new Error(`Supabase error saving qbo_customer_id: ${error.message}`);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/customer/upsertInternalCustomer.ts.html b/coverage/lcov-report/src/services/customer/upsertInternalCustomer.ts.html new file mode 100644 index 00000000..ee9ff893 --- /dev/null +++ b/coverage/lcov-report/src/services/customer/upsertInternalCustomer.ts.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for src/services/customer/upsertInternalCustomer.ts + + + + + + + + + +
+
+

All files / src/services/customer upsertInternalCustomer.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import supabase from '../../supabase';
+ 
+export default async function upsertInternalCustomer(
+  internalCustomerId: string,
+  fullName: string,
+  email: string
+): Promise<any> {
+  const { data, error } = await supabase
+    .from('customers')
+    .upsert(
+      { id: internalCustomerId, name: fullName, email },
+      { onConflict: 'id' }
+    )
+    .single();
+ 
+  Iif (error) {
+    throw new Error(`Supabase error upserting internal customer: ${error.message}`);
+  }
+ 
+  return data;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/emailService.ts.html b/coverage/lcov-report/src/services/emailService.ts.html new file mode 100644 index 00000000..16149cbb --- /dev/null +++ b/coverage/lcov-report/src/services/emailService.ts.html @@ -0,0 +1,658 @@ + + + + + + Code coverage report for src/services/emailService.ts + + + + + + + + + +
+
+

All files / src/services emailService.ts

+
+ +
+ 0% + Statements + 0/34 +
+ + +
+ 0% + Branches + 0/16 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/34 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import nodemailer from 'nodemailer';
+import { EmailService } from './interface/emailServiceInterface';
+ 
+export class NodemailerService implements EmailService {
+  private transporter: nodemailer.Transporter;
+ 
+  constructor() {
+    this.transporter = nodemailer.createTransport({
+      host: process.env.EMAIL_HOST,
+      port: parseInt(process.env.EMAIL_PORT || '587'),
+      secure: process.env.EMAIL_SECURE === 'true',
+      auth: {
+        user: process.env.EMAIL_USER,
+        pass: process.env.EMAIL_PASSWORD,
+      },
+    });
+  }
+ 
+  async sendEmail(to: string, subject: string, text: string, html?: string): Promise<void> {
+    // Check if we're in test mode
+    Iif (process.env.USE_TEST_EMAIL === 'true') {
+      console.log('Test email mode enabled - email not sent');
+      console.log({
+        to,
+        subject,
+        text,
+        html: html ? 'HTML content available' : 'No HTML content'
+      });
+      return;
+    }
+ 
+    try {
+      const mailOptions = {
+        from: process.env.EMAIL_FROM || 'Sokana CRM <noreply@sokanacrm.org>',
+        to,
+        subject,
+        text,
+        html: html || undefined,
+      };
+ 
+      const info = await this.transporter.sendMail(mailOptions);
+    } catch (error) {
+      console.error('Failed to send email:', error);
+      throw new Error(`Failed to send email: ${error.message}`);
+    }
+  }
+ 
+  async sendInvoiceEmail(
+    to: string,
+    customerName: string,
+    invoiceNumber: string,
+    amount: string,
+    dueDate: string,
+    invoicePdfBuffer: Buffer,
+    customHtml?: string,
+    customText?: string
+  ): Promise<void> {
+    const subject = `Invoice ${invoiceNumber} from Sokana CRM`;
+    
+    // Use custom text content if provided, otherwise use default
+    const text = customText || `Dear ${customerName},
+ 
+Please find attached invoice ${invoiceNumber} for ${amount}.
+ 
+Invoice Details:
+- Invoice Number: ${invoiceNumber}
+- Amount: ${amount}
+- Due Date: ${dueDate}
+ 
+Please remit payment by the due date. If you have any questions about this invoice, please contact us.
+ 
+Thank you for your business!
+ 
+Best regards,
+The Sokana Team`;
+ 
+    // Use custom HTML content if provided, otherwise use default
+    const html = customHtml || `
+      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+        <h2 style="color: #333;">Invoice ${invoiceNumber}</h2>
+        <p>Dear ${customerName},</p>
+        <p>Please find attached your invoice for <strong>${amount}</strong>.</p>
+        
+        <div style="background-color: #f5f5f5; padding: 20px; border-radius: 5px; margin: 20px 0;">
+          <h3 style="margin-top: 0; color: #333;">Invoice Details:</h3>
+          <ul style="list-style: none; padding: 0; margin: 0;">
+            <li style="margin: 10px 0;"><strong>Invoice Number:</strong> ${invoiceNumber}</li>
+            <li style="margin: 10px 0;"><strong>Amount:</strong> ${amount}</li>
+            <li style="margin: 10px 0;"><strong>Due Date:</strong> ${dueDate}</li>
+          </ul>
+        </div>
+ 
+        <div style="text-align: center; margin: 30px 0;">
+          <a href="https://app.sandbox.qbo.intuit.com/app/invoice?txnId=\${invoice.Id}"
+             style="background-color: #4CAF50; color: white; padding: 15px 30px; text-decoration: none; 
+                    border-radius: 5px; font-weight: bold; font-size: 16px; display: inline-block;
+                    box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
+            Pay Invoice Now
+          </a>
+        </div>
+        
+        <p>Please remit payment by the due date. If you have any questions about this invoice, please contact us.</p>
+        <p>Thank you for your business!</p>
+        <p>Best regards,<br>The Sokana Team</p>
+      </div>
+    `;
+ 
+    // Check if we're in test mode
+    Iif (process.env.USE_TEST_EMAIL === 'true') {
+      console.log('Test email mode enabled - email with attachment not sent');
+      console.log({
+        to,
+        subject,
+        text,
+        html: 'HTML content available',
+        attachments: [
+          {
+            filename: `invoice-${invoiceNumber}.pdf`,
+            content: `Buffer with ${invoicePdfBuffer.length} bytes`
+          }
+        ]
+      });
+      return;
+    }
+ 
+    try {
+      const mailOptions = {
+        from: process.env.EMAIL_FROM || 'Sokana CRM <noreply@sokanacrm.org>',
+        to,
+        subject,
+        text,
+        html,
+        attachments: [
+          {
+            filename: `invoice-${invoiceNumber}.pdf`,
+            content: invoicePdfBuffer,
+            contentType: 'application/pdf'
+          }
+        ]
+      };
+ 
+      const info = await this.transporter.sendMail(mailOptions);
+      console.log('Invoice email sent successfully:', info.messageId);
+    } catch (error) {
+      console.error('Failed to send invoice email:', error);
+      throw new Error(`Failed to send invoice email: ${error.message}`);
+    }
+  }
+ 
+  async sendClientApprovalEmail(to: string, name: string, signupUrl: string): Promise<void> {
+    const subject = 'Your Sokana CRM Account Request Has Been Approved';
+    const text = `Dear ${name},\n\nYour request for Sokana services has been approved! You can now create an account using the following link: ${signupUrl}\n\nBest regards,\nThe Sokana Team`;
+    const html = `
+      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+        <h2>Welcome to Sokana!</h2>
+        <p>Dear ${name},</p>
+        <p>We're pleased to inform you that your service request has been approved!</p>
+        <p>You can now create your account by clicking the button below:</p>
+        <div style="text-align: center; margin: 25px 0;">
+          <a href="${signupUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Create Account</a>
+        </div>
+        <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
+        <p>${signupUrl}</p>
+        <p>Best regards,<br>The Sokana Team</p>
+      </div>
+    `;
+    
+    await this.sendEmail(to, subject, text, html);
+  }
+ 
+  async sendTeamInviteEmail(to: string, firstname: string, lastname: string, role: string): Promise<void> {
+    const signupUrl = `${process.env.FRONTEND_URL}/signup`;
+    const subject = 'Welcome to the Sokana CRM Team!';
+    const text = `Dear ${firstname} ${lastname},\n\nYou have been invited to join the Sokana CRM team as a ${role}. Please fill out the sign up form to create an account and make sure to use this same email address.${signupUrl}\n\nBest regards,\nThe Sokana Team`;
+    const html = `
+      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+        <h2>Welcome to the Sokana Team!</h2>
+        <p>Dear ${firstname} ${lastname},</p>
+        <p>We're excited to have you join our team as a ${role}!</p>
+        <div>    
+        <p>Please fill out the</p>
+        <a href="${signupUrl}" style="font-weight: bold;">Sign Up Form</a>
+        <p> to create a new account and make sure to use this same email address.</p>
+        </div>
+        <p>If you have any questions, please don't hesitate to reach out.</p>
+        <p>Best regards,<br>The Sokana Team</p>
+      </div>
+    `;
+    
+    await this.sendEmail(to, subject, text, html);
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/index.html b/coverage/lcov-report/src/services/index.html new file mode 100644 index 00000000..316e0f0f --- /dev/null +++ b/coverage/lcov-report/src/services/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/services + + + + + + + + + +
+
+

All files src/services

+
+ +
+ 0% + Statements + 0/239 +
+ + +
+ 0% + Branches + 0/98 +
+ + +
+ 0% + Functions + 0/36 +
+ + +
+ 0% + Lines + 0/230 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
RequestFormService.ts +
+
0%0/580%0/350%0/80%0/58
emailService.ts +
+
0%0/340%0/160%0/50%0/34
supabaseAuthService.ts +
+
0%0/710%0/170%0/140%0/71
supabaseContractService.ts +
+
0%0/760%0/300%0/90%0/67
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/invoice/buildInvoicePayload.ts.html b/coverage/lcov-report/src/services/invoice/buildInvoicePayload.ts.html new file mode 100644 index 00000000..395b8ee4 --- /dev/null +++ b/coverage/lcov-report/src/services/invoice/buildInvoicePayload.ts.html @@ -0,0 +1,187 @@ + + + + + + Code coverage report for src/services/invoice/buildInvoicePayload.ts + + + + + + + + + +
+
+

All files / src/services/invoice buildInvoicePayload.ts

+
+ +
+ 0% + Statements + 0/3 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/3 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/invoice/buildInvoicePayload.ts
+export interface RawLineItem {
+  DetailType: string;
+  Amount: number;
+  Description?: string;
+  SalesItemLineDetail: {
+    ItemRef: { value: string };
+    UnitPrice: number;
+    Qty: number;
+  };
+}
+ 
+/**
+ * Construct a QuickBooks Invoice payload with the correct QBO customer reference
+ */
+export default function buildInvoicePayload(
+  qboCustomerId: string,
+  opts: { lineItems: RawLineItem[]; dueDate: string; memo?: string; customerEmail: string }
+) {
+  const { lineItems, dueDate, memo, customerEmail } = opts;
+  return {
+    CustomerRef: { value: qboCustomerId },
+    Line: lineItems,
+    TxnDate: dueDate,
+    DueDate: dueDate,
+    PrivateNote: memo || "",
+    BillEmail: { Address: customerEmail },
+    AllowOnlineACHPayment: true,
+    AllowOnlineCreditCardPayment: true,
+    EmailStatus: "NeedToSend",
+    domain: "QBO",
+    sparse: false
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/invoice/createInvoice.ts.html b/coverage/lcov-report/src/services/invoice/createInvoice.ts.html new file mode 100644 index 00000000..e5d28625 --- /dev/null +++ b/coverage/lcov-report/src/services/invoice/createInvoice.ts.html @@ -0,0 +1,343 @@ + + + + + + Code coverage report for src/services/invoice/createInvoice.ts + + + + + + + + + +
+
+

All files / src/services/invoice createInvoice.ts

+
+ +
+ 0% + Statements + 0/31 +
+ + +
+ 0% + Branches + 0/8 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/31 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/invoice/createInvoiceService.ts
+ 
+import { sendInvoiceEmailToCustomer } from '../../services/invoice/sendInvoiceEmail';
+import supabase from '../../supabase';
+import buildInvoicePayload from './buildInvoicePayload';
+import createInvoiceInQuickBooks from './createInvoiceInQuickBooks';
+import persistInvoiceToSupabase from './persistInvoiceToSupabase';
+ 
+export interface CreateInvoiceParams {
+  userId: string;
+  internalCustomerId: string;
+  lineItems: any[];
+  dueDate: string;
+  memo?: string;
+}
+ 
+/**
+ * Build, send, and persist a QuickBooks invoice, then email it to the customer
+ */
+export default async function createInvoiceService(
+  params: CreateInvoiceParams
+): Promise<any> {
+  const { userId, internalCustomerId, lineItems, dueDate, memo } = params;
+ 
+  Iif (!userId || !internalCustomerId) {
+    throw new Error('userId and internalCustomerId are required');
+  }
+ 
+  console.log('🚀 Invoice creation started for customer:', internalCustomerId);
+ 
+  // 1) Lookup the QBO customer ID AND customer info for email
+  const { data: cust, error: custErr } = await supabase
+    .from('customers')
+    .select('qbo_customer_id, name, email')
+    .eq('id', internalCustomerId)
+    .single();
+    
+  Iif (custErr || !cust?.qbo_customer_id) {
+    throw new Error(`No QuickBooks customer found for ${internalCustomerId}`);
+  }
+  
+  const { qbo_customer_id: qboCustomerId, name: customerName, email: customerEmail } = cust;
+  console.log('📋 Customer found:', { customerName, customerEmail });
+ 
+  // 2) Build the payload using the QBO ID 
+  console.log('🔧 Building invoice payload...');
+  const payload = buildInvoicePayload(qboCustomerId, {
+    lineItems,
+    dueDate,
+    memo,
+    customerEmail
+  });
+ 
+  // 3) Send it to QuickBooks
+  console.log('📤 Creating invoice in QuickBooks...');
+  const invoice = await createInvoiceInQuickBooks(payload);
+  
+  // 4) Persist the result to Supabase, storing your UUID in `customer_id`
+  console.log('💾 Saving invoice to Supabase...');
+  await persistInvoiceToSupabase(internalCustomerId, invoice);
+ 
+  // 5) 🎯 NEW: Send email to customer (only if email exists and invoice was successful)
+  if (customerEmail) {
+    try {
+      console.log('📧 Sending invoice email to customer...');
+      await sendInvoiceEmailToCustomer({
+        invoice,
+        customerName,
+        customerEmail,
+        lineItems,
+        dueDate,
+        memo
+      });
+      console.log('✅ Invoice email sent successfully to:', customerEmail);
+    } catch (emailError) {
+      console.error('❌ Failed to send invoice email:', emailError);
+      // Don't throw here - we want the invoice creation to succeed even if email fails
+      console.warn('⚠️ Invoice created successfully but email failed to send');
+    }
+  } else {
+    console.warn('⚠️ No email found for customer, skipping email notification');
+  }
+ 
+  console.log('✅ Invoice creation completed successfully!');
+  return invoice;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/invoice/createInvoiceInQuickBooks.ts.html b/coverage/lcov-report/src/services/invoice/createInvoiceInQuickBooks.ts.html new file mode 100644 index 00000000..06888e7f --- /dev/null +++ b/coverage/lcov-report/src/services/invoice/createInvoiceInQuickBooks.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/services/invoice/createInvoiceInQuickBooks.ts + + + + + + + + + +
+
+

All files / src/services/invoice createInvoiceInQuickBooks.ts

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/invoice/createInvoiceInQuickBooks.ts
+ 
+import { qboRequest } from '../../utils/qboClient';
+ 
+export default async function createInvoiceInQuickBooks(
+  payload: any
+): Promise<any> {
+  // Create invoice in QuickBooks
+  const { Invoice } = await qboRequest(
+    '/invoice?minorversion=65',
+    {
+      method: 'POST',
+      body: JSON.stringify(payload)
+    }
+  );
+ 
+  // Fetch the invoice again with the payment link
+  const { Invoice: InvoiceWithLink } = await qboRequest(
+    `/invoice/${Invoice.Id}?minorversion=65&include=invoiceLink`,
+    {
+      method: 'GET'
+    }
+  );
+ 
+  return InvoiceWithLink;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/invoice/index.html b/coverage/lcov-report/src/services/invoice/index.html new file mode 100644 index 00000000..783b9d86 --- /dev/null +++ b/coverage/lcov-report/src/services/invoice/index.html @@ -0,0 +1,176 @@ + + + + + + Code coverage report for src/services/invoice + + + + + + + + + +
+
+

All files src/services/invoice

+
+ +
+ 0% + Statements + 0/80 +
+ + +
+ 0% + Branches + 0/32 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/78 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
buildInvoicePayload.ts +
+
0%0/30%0/20%0/10%0/3
createInvoice.ts +
+
0%0/310%0/80%0/10%0/31
createInvoiceInQuickBooks.ts +
+
0%0/5100%0/00%0/10%0/5
persistInvoiceToSupabase.ts +
+
0%0/130%0/70%0/10%0/13
sendInvoiceEmail.ts +
+
0%0/280%0/150%0/30%0/26
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/invoice/persistInvoiceToSupabase.ts.html b/coverage/lcov-report/src/services/invoice/persistInvoiceToSupabase.ts.html new file mode 100644 index 00000000..687e27cf --- /dev/null +++ b/coverage/lcov-report/src/services/invoice/persistInvoiceToSupabase.ts.html @@ -0,0 +1,256 @@ + + + + + + Code coverage report for src/services/invoice/persistInvoiceToSupabase.ts + + + + + + + + + +
+
+

All files / src/services/invoice persistInvoiceToSupabase.ts

+
+ +
+ 0% + Statements + 0/13 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/13 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/invoice/persistInvoiceToSupabase.ts
+import supabase from '../../supabase';
+ 
+export default async function persistInvoiceToSupabase(
+  internalCustomerId: string,
+  invoice: any
+): Promise<void> {
+  console.log('💾 [Invoice] Persisting invoice data to Supabase...');
+  console.log('📋 [Invoice] QuickBooks invoice data:', JSON.stringify(invoice, null, 2));
+  
+  // Destructure the fields from QuickBooks invoice response
+  const {
+    DocNumber: doc_number,
+    TotalAmt: total_amount,
+    Balance: balance,
+    DueDate: due_date,
+    PrivateNote: memo,
+    Line: line_items,
+  } = invoice;
+ 
+  // Determine invoice status based on balance
+  const status = balance === 0 ? 'paid' : 'pending';
+ 
+  const now = new Date().toISOString();
+ 
+  console.log('📊 [Invoice] Saving invoice with fields:', {
+    customer_id: internalCustomerId,
+    doc_number,
+    total_amount,
+    balance,
+    due_date,
+    status,
+    line_items_count: line_items?.length || 0
+  });
+ 
+  const { error } = await supabase
+    .from('invoices')
+    .insert({
+      customer_id: internalCustomerId,
+      doc_number,                  // QuickBooks document number
+      total_amount,                // Total invoice amount
+      balance,                     // Outstanding balance
+      line_items,                  // JSONB array of line items
+      due_date,
+      memo: memo || null,
+      status,
+      created_at: now,
+      updated_at: now
+    });
+ 
+  Iif (error) {
+    console.error('❌ [Invoice] Supabase error:', error);
+    throw new Error(`Supabase error saving invoice: ${error.message}`);
+  }
+  
+  console.log('✅ [Invoice] Invoice saved successfully to Supabase');
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/invoice/sendInvoiceEmail.ts.html b/coverage/lcov-report/src/services/invoice/sendInvoiceEmail.ts.html new file mode 100644 index 00000000..52bbc2d5 --- /dev/null +++ b/coverage/lcov-report/src/services/invoice/sendInvoiceEmail.ts.html @@ -0,0 +1,520 @@ + + + + + + Code coverage report for src/services/invoice/sendInvoiceEmail.ts + + + + + + + + + +
+
+

All files / src/services/invoice sendInvoiceEmail.ts

+
+ +
+ 0% + Statements + 0/28 +
+ + +
+ 0% + Branches + 0/15 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/26 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// import { generateInvoicePDF, InvoiceData } from '../../utils/generateInvoicePdf';
+ 
+import { generateInvoicePDF, InvoiceData } from '../../utils/generateInvoicePdf';
+import { NodemailerService } from '../emailService';
+ 
+interface SendInvoiceEmailParams {
+  invoice: any;
+  customerName: string;
+  customerEmail: string;
+  lineItems: any[];
+  dueDate: string;
+  memo?: string;
+}
+ 
+/**
+ * Send invoice email with PDF attachment and payment link to customer
+ */
+export async function sendInvoiceEmailToCustomer(params: SendInvoiceEmailParams): Promise<void> {
+  const { invoice, customerName, customerEmail, lineItems, dueDate, memo } = params;
+  
+  console.log('📧 Preparing invoice email for:', customerEmail);
+  
+  const emailService = new NodemailerService();
+ 
+  // Get the payment link from QuickBooks response
+  const qboPaymentLink = invoice.invoiceLink;
+  Iif (!qboPaymentLink) {
+    console.warn('⚠️ No payment link available for invoice. Make sure "Accept Credit Cards" is enabled in QuickBooks and the invoice has an email address.');
+  }
+  console.log('🔗 QuickBooks payment link:', qboPaymentLink);
+ 
+  // Convert QuickBooks line items to our PDF format
+  const convertedLineItems = lineItems.map(item => ({
+    description: item.Description || 'Service',
+    quantity: item.SalesItemLineDetail?.Qty || 1,
+    rate: item.SalesItemLineDetail?.UnitPrice || 0,
+    amount: item.Amount || 0
+  }));
+ 
+  // Calculate totals
+  const subtotal = convertedLineItems.reduce((sum, item) => sum + item.amount, 0);
+  const total = subtotal;
+ 
+  // Get invoice number from QuickBooks response
+  const invoiceNumber = invoice.DocNumber || `INV-${Date.now()}`;
+  
+  console.log('📄 Generating PDF for invoice:', invoiceNumber);
+ 
+  // Prepare invoice data for PDF generation
+  const invoiceData: InvoiceData = {
+    invoiceNumber,
+    customerName,
+    customerEmail,
+    lineItems: convertedLineItems,
+    subtotal,
+    total,
+    dueDate,
+    issueDate: new Date().toISOString().split('T')[0],
+    memo
+  };
+ 
+  try {
+    // Generate PDF
+    const invoicePdfBuffer = await generateInvoicePDF(invoiceData);
+    console.log('📨 Sending email with PDF attachment and payment link...');
+ 
+    // Create HTML content with payment button (only if payment link is available)
+    const paymentSection = qboPaymentLink ? `
+      <div style="text-align: center; margin: 30px 0;">
+        <table role="presentation" style="margin: 0 auto;">
+          <tr>
+            <td style="background-color: #4CAF50; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
+              <a href="${qboPaymentLink}"
+                 style="background-color: #4CAF50; color: white; padding: 15px 30px; text-decoration: none; 
+                        border-radius: 5px; font-weight: bold; font-size: 16px; display: inline-block;">
+                Pay Invoice Now
+              </a>
+            </td>
+          </tr>
+        </table>
+      </div>
+      
+      <p style="color: #666; font-size: 14px;">You can also pay your invoice using this secure link: 
+        <a href="${qboPaymentLink}" style="color: #4CAF50; text-decoration: underline;">${qboPaymentLink}</a>
+      </p>
+    ` : '';
+ 
+    const html = `
+      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+        <h2 style="color: #333;">Invoice ${invoiceNumber}</h2>
+        <p>Dear ${customerName},</p>
+        <p>Please find attached your invoice for <strong>$${total.toFixed(2)}</strong>.</p>
+        
+        <div style="background-color: #f5f5f5; padding: 20px; border-radius: 5px; margin: 20px 0;">
+          <h3 style="margin-top: 0; color: #333;">Invoice Details:</h3>
+          <ul style="list-style: none; padding: 0; margin: 0;">
+            <li style="margin: 10px 0;"><strong>Invoice Number:</strong> ${invoiceNumber}</li>
+            <li style="margin: 10px 0;"><strong>Amount:</strong> $${total.toFixed(2)}</li>
+            <li style="margin: 10px 0;"><strong>Due Date:</strong> ${dueDate}</li>
+          </ul>
+        </div>
+ 
+        ${paymentSection}
+        
+        <p>Please remit payment by the due date. If you have any questions about this invoice, please contact us.</p>
+        <p>Thank you for your business!</p>
+        <p>Best regards,<br>The Sokana Team</p>
+      </div>
+    `;
+ 
+    // Create plain text content
+    const text = `Dear ${customerName},
+ 
+Please find attached invoice ${invoiceNumber} for $${total.toFixed(2)}.
+ 
+Invoice Details:
+- Invoice Number: ${invoiceNumber}
+- Amount: $${total.toFixed(2)}
+- Due Date: ${dueDate}
+${qboPaymentLink ? `\nYou can pay your invoice using this secure link:\n${qboPaymentLink}` : ''}
+ 
+Please remit payment by the due date. If you have any questions about this invoice, please contact us.
+ 
+Thank you for your business!
+ 
+Best regards,
+The Sokana Team`;
+ 
+    // Send email with both PDF attachment and payment link
+    await emailService.sendInvoiceEmail(
+      customerEmail,
+      customerName,
+      invoiceNumber,
+      `$${total.toFixed(2)}`,
+      dueDate,
+      invoicePdfBuffer,
+      html,
+      text
+    );
+    
+    console.log('✅ Invoice email sent successfully with payment link!');
+  } catch (error) {
+    console.error('❌ Error sending invoice email:', error);
+    throw error;
+  }
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/payments/buildChargePayload.ts.html b/coverage/lcov-report/src/services/payments/buildChargePayload.ts.html new file mode 100644 index 00000000..624806b5 --- /dev/null +++ b/coverage/lcov-report/src/services/payments/buildChargePayload.ts.html @@ -0,0 +1,169 @@ + + + + + + Code coverage report for src/services/payments/buildChargePayload.ts + + + + + + + + + +
+
+

All files / src/services/payments buildChargePayload.ts

+
+ +
+ 0% + Statements + 0/2 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/2 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export interface CardDetails {
+  number: string;
+  expMonth: string;
+  expYear: string;
+  cvc: string;
+}
+ 
+export interface ChargePayload {
+  amount: string;
+  currency: string;
+  card: CardDetails;
+  context: { isEcommerce: boolean };
+}
+ 
+export function buildChargePayload(amount: string, card: CardDetails): ChargePayload {
+  return {
+    amount: amount.toString(),
+    currency: 'USD',
+    card: {
+      number: card.number,
+      expMonth: card.expMonth,
+      expYear: card.expYear,
+      cvc: card.cvc
+    },
+    context: {
+      isEcommerce: true
+    }
+  };
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/payments/createCharge.ts.html b/coverage/lcov-report/src/services/payments/createCharge.ts.html new file mode 100644 index 00000000..687e259f --- /dev/null +++ b/coverage/lcov-report/src/services/payments/createCharge.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/services/payments/createCharge.ts + + + + + + + + + +
+
+

All files / src/services/payments createCharge.ts

+
+ +
+ 0% + Statements + 0/12 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { getValidAccessToken } from '../../utils/tokenUtils';
+import { buildChargePayload, CardDetails } from './buildChargePayload';
+ 
+export async function createCharge(amount: string, card: CardDetails) {
+  const accessToken = await getValidAccessToken();
+  Iif (!accessToken) {
+    throw new Error('Could not get QuickBooks access token');
+  }
+ 
+  const payload = buildChargePayload(amount, card);
+ 
+  const response = await fetch('https://sandbox.api.intuit.com/quickbooks/v4/payments/charges', {
+    method: 'POST',
+    headers: {
+      'Authorization': `Bearer ${accessToken}`,
+      'Content-Type': 'application/json',
+      'Accept': 'application/json'
+    },
+    body: JSON.stringify(payload)
+  });
+ 
+  const data = await response.json();
+  Iif (!response.ok) {
+    throw new Error(JSON.stringify(data));
+  }
+  return data;
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/payments/index.html b/coverage/lcov-report/src/services/payments/index.html new file mode 100644 index 00000000..ca58d30a --- /dev/null +++ b/coverage/lcov-report/src/services/payments/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/services/payments + + + + + + + + + +
+
+

All files src/services/payments

+
+ +
+ 0% + Statements + 0/151 +
+ + +
+ 0% + Branches + 0/30 +
+ + +
+ 0% + Functions + 0/11 +
+ + +
+ 0% + Lines + 0/147 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
buildChargePayload.ts +
+
0%0/2100%0/00%0/10%0/2
createCharge.ts +
+
0%0/120%0/20%0/10%0/12
paymentsController.ts +
+
0%0/140%0/30%0/10%0/12
stripePaymentService.ts +
+
0%0/1230%0/250%0/80%0/121
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/payments/paymentsController.ts.html b/coverage/lcov-report/src/services/payments/paymentsController.ts.html new file mode 100644 index 00000000..21844da7 --- /dev/null +++ b/coverage/lcov-report/src/services/payments/paymentsController.ts.html @@ -0,0 +1,136 @@ + + + + + + Code coverage report for src/services/payments/paymentsController.ts + + + + + + + + + +
+
+

All files / src/services/payments paymentsController.ts

+
+ +
+ 0% + Statements + 0/14 +
+ + +
+ 0% + Branches + 0/3 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { RequestHandler } from 'express';
+import { createCharge } from './createCharge';
+ 
+export const simulatePaymentController: RequestHandler = async (req, res) => {
+  try {
+    const { amount, card } = req.body;
+    Iif (!amount || !card) {
+      res.status(400).json({ error: 'Missing amount or card details' });
+      return;
+    }
+    const data = await createCharge(amount, card);
+    res.json(data);
+  } catch (error) {
+    let message = error.message;
+    try { message = JSON.parse(error.message); } catch {}
+    res.status(500).json({ error: message });
+  }
+}; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/payments/stripePaymentService.ts.html b/coverage/lcov-report/src/services/payments/stripePaymentService.ts.html new file mode 100644 index 00000000..2ca70bbe --- /dev/null +++ b/coverage/lcov-report/src/services/payments/stripePaymentService.ts.html @@ -0,0 +1,1237 @@ + + + + + + Code coverage report for src/services/payments/stripePaymentService.ts + + + + + + + + + +
+
+

All files / src/services/payments stripePaymentService.ts

+
+ +
+ 0% + Statements + 0/123 +
+ + +
+ 0% + Branches + 0/25 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/121 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { stripe } from '../../config/stripe';
+import supabase from '../../supabase';
+ 
+interface SaveCardParams {
+  customerId: string;
+  cardToken: string;
+}
+ 
+interface ChargeCardParams {
+  customerId: string;
+  amount: number; // Amount in cents
+  description?: string;
+}
+ 
+interface UpdateCardParams {
+  customerId: string;
+  cardToken: string;
+  paymentMethodId: string;
+}
+ 
+export class StripePaymentService {
+  private async ensureStripeCustomer(customerId: string): Promise<string> {
+    console.log(`Ensuring Stripe customer exists for customer ID: ${customerId}`);
+    
+    // Get customer info from database
+    const { data: customerData, error: customerError } = await supabase
+      .from('customers')
+      .select('email, name, stripe_customer_id')
+      .eq('id', customerId)
+      .single();
+ 
+    Iif (customerError || !customerData) {
+      console.error('Customer lookup error:', customerError);
+      throw new Error(`Customer not found: ${customerError?.message}`);
+    }
+ 
+    console.log('Found customer data:', { 
+      email: customerData.email, 
+      name: customerData.name, 
+      hasStripeId: !!customerData.stripe_customer_id 
+    });
+ 
+    // If customer already has Stripe ID, verify it exists in Stripe
+    Iif (customerData.stripe_customer_id) {
+      try {
+        await stripe.customers.retrieve(customerData.stripe_customer_id);
+        console.log('Verified existing Stripe customer:', customerData.stripe_customer_id);
+        return customerData.stripe_customer_id;
+      } catch (err) {
+        console.log('Stripe customer ID exists in DB but not in Stripe, creating new one');
+        // Continue to create new customer if retrieval fails
+      }
+    }
+ 
+    // Create new Stripe customer
+    try {
+      const stripeCustomer = await stripe.customers.create({
+        email: customerData.email,
+        name: customerData.name,
+        metadata: {
+          supabase_customer_id: customerId
+        }
+      });
+      
+      console.log('Created new Stripe customer:', stripeCustomer.id);
+ 
+      // Save Stripe customer ID
+      const { error: updateError } = await supabase
+        .from('customers')
+        .update({ stripe_customer_id: stripeCustomer.id })
+        .eq('id', customerId);
+ 
+      Iif (updateError) {
+        console.error('Failed to save Stripe customer ID:', updateError);
+        throw new Error(`Failed to save Stripe customer ID: ${updateError.message}`);
+      }
+ 
+      console.log('Successfully saved Stripe customer ID to database');
+      return stripeCustomer.id;
+    } catch (err) {
+      console.error('Error creating Stripe customer:', err);
+      throw new Error(`Failed to create Stripe customer: ${err.message}`);
+    }
+  }
+ 
+  async saveCard({ customerId, cardToken }: SaveCardParams) {
+    console.log('Starting saveCard process for customer:', customerId);
+    
+    // Ensure customer exists in Stripe
+    const stripeCustomerId = await this.ensureStripeCustomer(customerId);
+    
+    try {
+      // First, mark any existing payment methods as not default
+      console.log('Marking existing payment methods as not default');
+      await supabase
+        .from('payment_methods')
+        .update({ is_default: false })
+        .eq('customer_id', customerId);
+ 
+      // Create a payment method from the token and attach to customer in one step
+      console.log('Creating payment method from token and attaching to customer');
+      const paymentMethod = await stripe.paymentMethods.create({
+        type: 'card',
+        card: { token: cardToken },
+        metadata: {
+          customer_id: customerId
+        }
+      });
+ 
+      console.log('Created payment method:', paymentMethod.id);
+ 
+      // Attach payment method to the customer
+      console.log('Attaching payment method to customer');
+      await stripe.paymentMethods.attach(paymentMethod.id, {
+        customer: stripeCustomerId,
+      });
+ 
+      // Set as default payment method
+      console.log('Setting as default payment method');
+      await stripe.customers.update(stripeCustomerId, {
+        invoice_settings: {
+          default_payment_method: paymentMethod.id,
+        },
+      });
+ 
+      // Store the payment method in our database
+      console.log('Saving payment method to database');
+      const paymentMethodData = {
+        customer_id: customerId,
+        stripe_payment_method_id: paymentMethod.id,
+        card_last4: paymentMethod.card!.last4,
+        card_brand: paymentMethod.card!.brand,
+        card_exp_month: paymentMethod.card!.exp_month,
+        card_exp_year: paymentMethod.card!.exp_year,
+        is_default: true
+      };
+      
+      console.log('Payment method data to insert:', paymentMethodData);
+      
+      const { data: insertResult, error } = await supabase
+        .from('payment_methods')
+        .insert(paymentMethodData)
+        .select();
+ 
+      console.log('Insert result:', insertResult);
+      console.log('Insert error:', error);
+ 
+      Iif (error) {
+        console.error('Database error saving payment method:', error);
+        throw new Error(`Failed to save payment method: ${error.message}`);
+      }
+ 
+      console.log('Successfully saved card');
+      return {
+        id: paymentMethod.id,
+        last4: paymentMethod.card!.last4,
+        brand: paymentMethod.card!.brand,
+        expMonth: paymentMethod.card!.exp_month,
+        expYear: paymentMethod.card!.exp_year
+      };
+    } catch (err) {
+      console.error('Error in saveCard:', err);
+      throw err;
+    }
+  }
+ 
+  async chargeCard({ customerId, amount, description }: ChargeCardParams) {
+    console.log('Starting charge process for customer:', customerId);
+    
+    // Ensure customer exists in Stripe
+    const stripeCustomerId = await this.ensureStripeCustomer(customerId);
+ 
+    // Debug: Check all payment methods for this customer
+    console.log('Checking all payment methods for customer:', customerId);
+    const { data: allPaymentMethods, error: allError } = await supabase
+      .from('payment_methods')
+      .select('*')
+      .eq('customer_id', customerId);
+    
+    console.log('All payment methods for customer:', allPaymentMethods);
+    console.log('Payment methods query error:', allError);
+ 
+    // Get the payment method
+    console.log('Fetching default payment method');
+    const { data: paymentMethod, error } = await supabase
+      .from('payment_methods')
+      .select('id, stripe_payment_method_id')
+      .eq('customer_id', customerId)
+      .eq('is_default', true)
+      .single();
+ 
+    console.log('Default payment method query result:', paymentMethod);
+    console.log('Default payment method query error:', error);
+ 
+    Iif (error || !paymentMethod) {
+      console.error('Payment method lookup error:', error);
+      throw new Error('No payment method found for this customer');
+    }
+ 
+    try {
+      // Create and confirm the payment intent
+      console.log('Creating payment intent');
+      const paymentIntent = await stripe.paymentIntents.create({
+        amount,
+        currency: 'usd',
+        customer: stripeCustomerId,
+        payment_method: paymentMethod.stripe_payment_method_id,
+        confirm: true,
+        description,
+        off_session: true
+      });
+ 
+      console.log('Payment intent created:', paymentIntent.id);
+ 
+      // Save the charge
+      console.log('Saving charge to database');
+      const { error: chargeError } = await supabase.from('charges').insert({
+        customer_id: customerId,
+        payment_method_id: paymentMethod.id,
+        stripe_payment_intent_id: paymentIntent.id,
+        amount: paymentIntent.amount,
+        status: paymentIntent.status,
+        description: paymentIntent.description
+      });
+ 
+      Iif (chargeError) {
+        console.error('Failed to save charge to database:', chargeError);
+      }
+ 
+      console.log('Charge process completed successfully');
+      return paymentIntent;
+    } catch (err) {
+      console.error('Error in chargeCard:', err);
+      throw err;
+    }
+  }
+ 
+  async updateCard({ customerId, cardToken, paymentMethodId }: UpdateCardParams) {
+    console.log('Starting updateCard process for customer:', customerId);
+    
+    // Ensure customer exists in Stripe
+    const stripeCustomerId = await this.ensureStripeCustomer(customerId);
+ 
+    try {
+      // Verify the payment method belongs to this customer
+      const { data: existingPaymentMethod, error: lookupError } = await supabase
+        .from('payment_methods')
+        .select('stripe_payment_method_id, is_default')
+        .eq('id', paymentMethodId)
+        .eq('customer_id', customerId)
+        .single();
+ 
+      Iif (lookupError || !existingPaymentMethod) {
+        throw new Error('Payment method not found or does not belong to this customer');
+      }
+ 
+      // Create new payment method from token
+      console.log('Creating new payment method from token');
+      const newPaymentMethod = await stripe.paymentMethods.create({
+        type: 'card',
+        card: { token: cardToken }
+      });
+ 
+      console.log('Created new payment method:', newPaymentMethod.id);
+ 
+      // Attach new payment method to customer
+      console.log('Attaching new payment method to customer');
+      await stripe.paymentMethods.attach(newPaymentMethod.id, {
+        customer: stripeCustomerId,
+      });
+ 
+      // If this was the default payment method, update customer's default
+      Iif (existingPaymentMethod.is_default) {
+        console.log('Updating default payment method');
+        await stripe.customers.update(stripeCustomerId, {
+          invoice_settings: {
+            default_payment_method: newPaymentMethod.id,
+          },
+        });
+      }
+ 
+      // Detach old payment method from Stripe
+      console.log('Detaching old payment method');
+      await stripe.paymentMethods.detach(existingPaymentMethod.stripe_payment_method_id);
+ 
+      // Update payment method in database
+      console.log('Updating payment method in database');
+      const { error: updateError } = await supabase
+        .from('payment_methods')
+        .update({
+          stripe_payment_method_id: newPaymentMethod.id,
+          card_last4: newPaymentMethod.card!.last4,
+          card_brand: newPaymentMethod.card!.brand,
+          card_exp_month: newPaymentMethod.card!.exp_month,
+          card_exp_year: newPaymentMethod.card!.exp_year,
+          updated_at: new Date().toISOString()
+        })
+        .eq('id', paymentMethodId)
+        .eq('customer_id', customerId);
+ 
+      Iif (updateError) {
+        console.error('Database error updating payment method:', updateError);
+        throw new Error(`Failed to update payment method: ${updateError.message}`);
+      }
+ 
+      console.log('Successfully updated card');
+      return {
+        id: newPaymentMethod.id,
+        last4: newPaymentMethod.card!.last4,
+        brand: newPaymentMethod.card!.brand,
+        expMonth: newPaymentMethod.card!.exp_month,
+        expYear: newPaymentMethod.card!.exp_year
+      };
+    } catch (err) {
+      console.error('Error in updateCard:', err);
+      throw err;
+    }
+  }
+ 
+  async getPaymentMethods(customerId: string) {
+    console.log('Fetching payment methods for customer:', customerId);
+    
+    try {
+      // Get payment methods from database
+      const { data: paymentMethods, error } = await supabase
+        .from('payment_methods')
+        .select('id, stripe_payment_method_id, card_last4, card_brand, card_exp_month, card_exp_year, is_default, created_at')
+        .eq('customer_id', customerId)
+        .order('created_at', { ascending: false });
+ 
+      Iif (error) {
+        console.error('Database error fetching payment methods:', error);
+        throw new Error(`Failed to fetch payment methods: ${error.message}`);
+      }
+ 
+      console.log(`Found ${paymentMethods?.length || 0} payment methods for customer`);
+      
+      return (paymentMethods || []).map(pm => ({
+        id: pm.id,
+        stripePaymentMethodId: pm.stripe_payment_method_id,
+        last4: pm.card_last4,
+        brand: pm.card_brand,
+        expMonth: pm.card_exp_month,
+        expYear: pm.card_exp_year,
+        isDefault: pm.is_default,
+        createdAt: pm.created_at
+      }));
+    } catch (err) {
+      console.error('Error in getPaymentMethods:', err);
+      throw err;
+    }
+  }
+ 
+  async getCustomersWithStripeId() {
+    console.log('Fetching customers with Stripe IDs');
+    
+    try {
+      // Get customers from database that have a stripe_customer_id
+      const { data: customers, error } = await supabase
+        .from('customers')
+        .select('id, name, email, stripe_customer_id, created_at, updated_at')
+        .not('stripe_customer_id', 'is', null)
+        .order('created_at', { ascending: false });
+ 
+      Iif (error) {
+        console.error('Database error fetching customers:', error);
+        throw new Error(`Failed to fetch customers: ${error.message}`);
+      }
+ 
+      console.log(`Found ${customers?.length || 0} customers with Stripe IDs`);
+      
+      return (customers || []).map(customer => ({
+        id: customer.id,
+        name: customer.name,
+        email: customer.email,
+        stripeCustomerId: customer.stripe_customer_id,
+        createdAt: customer.created_at,
+        updatedAt: customer.updated_at
+      }));
+    } catch (err) {
+      console.error('Error in getCustomersWithStripeId:', err);
+      throw err;
+    }
+  }
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/supabaseAuthService.ts.html b/coverage/lcov-report/src/services/supabaseAuthService.ts.html new file mode 100644 index 00000000..8f1fabab --- /dev/null +++ b/coverage/lcov-report/src/services/supabaseAuthService.ts.html @@ -0,0 +1,784 @@ + + + + + + Code coverage report for src/services/supabaseAuthService.ts + + + + + + + + + +
+
+

All files / src/services supabaseAuthService.ts

+
+ +
+ 0% + Statements + 0/71 +
+ + +
+ 0% + Branches + 0/17 +
+ + +
+ 0% + Functions + 0/14 +
+ + +
+ 0% + Lines + 0/71 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { SupabaseClient } from '@supabase/supabase-js';
+import { User } from '../entities/User';
+import { UserRepository } from '../repositories/interface/userRepository';
+import { AuthService } from '../services/interface/authService';
+import {
+  AuthenticationError,
+  AuthorizationError
+} from './../domains/errors';
+ 
+export class SupabaseAuthService implements AuthService {
+  private supabaseClient: SupabaseClient;
+  
+  constructor(
+    supabaseClient: SupabaseClient,
+    private userRepository: UserRepository,
+  ) {
+    this.supabaseClient = supabaseClient;
+  }
+  
+  async signup(
+    email: string,
+    password: string,
+    firstname: string,
+    lastname: string
+  ): Promise<User> {
+    // Create the auth account in Supabase
+    const { data, error } = await this.supabaseClient.auth.signUp({
+      email,
+      password,
+    });
+ 
+    Iif (error) {
+      throw new AuthenticationError(`Authentication error: ${error.message}`);
+    }
+ 
+    Iif (!data.user) {
+      throw new AuthenticationError('User creation failed for unknown reasons');
+    }
+ 
+    const user = await this.userRepository.findByEmail(email);
+    Iif (!user) {
+      // This shouldn’t happen, but just in case
+      await this.supabaseClient.auth.admin.deleteUser(data.user.id);
+      throw new AuthorizationError("Signup not allowed — not approved.");
+    }
+ 
+    user.firstname = firstname || null;
+    user.lastname = lastname || null;
+ 
+    // update any details if needed
+    try {
+      await this.userRepository.save(user);
+      return user;
+    } catch (error) {
+      await this.supabaseClient.auth.admin.deleteUser(data.user.id);
+      throw new Error("Failed to update user profile during signup");
+    }
+  }
+  
+  async login(
+    email: string,
+    password: string
+  ): Promise<{user: User, token: string}> {
+ 
+    const { data, error } = await this.supabaseClient.auth.signInWithPassword({
+      email,
+      password: password
+    });
+ 
+    Iif (!data.session) {
+      throw new AuthenticationError("Invalid Credentials");
+    }
+ 
+    Iif (error) {
+      throw new AuthenticationError('Authentication error: Sign in failed from Supabase');
+    }
+ 
+    const token = data.session.access_token;
+ 
+    try {
+      const user = await this.userRepository.findByEmail(email);
+ 
+      return { user, token };
+    } catch (error) {
+      throw new Error('Authentication error: User could not be found from repository');
+    }
+  }
+ 
+  async getMe(
+    token: string
+  ): Promise<User> {
+ 
+    const { data: {user}, error } = await this.supabaseClient.auth.getUser(token);
+ 
+    try {
+      const user_profile = await this.userRepository.findByEmail(user.email);
+ 
+      return user_profile;
+ 
+    } catch (error) {
+      throw new Error('Authentication error: getMe could not be found from repository');
+    }
+  }
+ 
+  async logout(): Promise<void> {
+    // logout from supabase
+    await this.supabaseClient.auth.signOut();
+  }
+ 
+  async verifyEmail(
+    token_hash: string,
+    type: string
+  ): Promise<{ access_token: string, refresh_token: string, expires_in: number }> {
+ 
+    const { data, error } = await this.supabaseClient.auth.verifyOtp({
+      token_hash,
+      type: 'signup',
+    });
+ 
+    Iif (error) {
+      throw new AuthenticationError(error.message);
+    }
+ 
+    const access_token = data.session.access_token;
+    const refresh_token = data.session.refresh_token;
+    const expires_in = data.session.expires_in
+ 
+    return { access_token, refresh_token, expires_in };
+  }
+ 
+  async requestPasswordReset(
+    email: string,
+    redirectTo: string
+  ): Promise<void> {
+    const { error } = await this.supabaseClient.auth.resetPasswordForEmail(email, {
+      redirectTo
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+  }
+ 
+  async resetPassword(
+    token: string,
+    newPassword: string
+  ): Promise<void> {
+ 
+  }
+ 
+  async getUserFromToken(accessToken: string): Promise<any> {
+    const { data, error } = await this.supabaseClient.auth.getUser(accessToken);
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    // Fetch user from database
+    const user = await this.userRepository.findByEmail(data.user.email);
+ 
+    return user;
+  }
+ 
+  async getGoogleAuthUrl(
+    redirectTo: string
+  ): Promise<string> {
+ 
+ 
+    const { data, error } = await this.supabaseClient.auth.signInWithOAuth({
+      provider: 'google',
+      options: {
+        redirectTo,
+      },
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return data.url;
+  }
+ 
+  async setSession(token: string): Promise<any> {
+    const { data, error } = await this.supabaseClient.auth.setSession({
+      access_token: token,
+      refresh_token: token,
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return data.session;
+  }
+ 
+  async exchangeCodeForSession(code: string): Promise<{session: any, userData: any}> {
+ 
+    const { data, error } = await this.supabaseClient.auth.exchangeCodeForSession(code);
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return {
+      session: data.session,
+      userData: data.user
+    };
+  }
+ 
+  async verifyRecoveryToken(tokenHash: string): Promise<any> {
+    const { data, error } = await this.supabaseClient.auth.verifyOtp({
+      token_hash: tokenHash,
+      type: 'recovery',
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return data.session;
+  }
+ 
+  async updateUserPassword(password: string): Promise<any> {
+    const { data, error } = await this.supabaseClient.auth.updateUser({
+      password,
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return data.user;
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/services/supabaseContractService.ts.html b/coverage/lcov-report/src/services/supabaseContractService.ts.html new file mode 100644 index 00000000..5613ee70 --- /dev/null +++ b/coverage/lcov-report/src/services/supabaseContractService.ts.html @@ -0,0 +1,754 @@ + + + + + + Code coverage report for src/services/supabaseContractService.ts + + + + + + + + + +
+
+

All files / src/services supabaseContractService.ts

+
+ +
+ 0% + Statements + 0/76 +
+ + +
+ 0% + Branches + 0/30 +
+ + +
+ 0% + Functions + 0/9 +
+ + +
+ 0% + Lines + 0/67 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { SupabaseClient } from '@supabase/supabase-js';
+import Docxtemplater from 'docxtemplater';
+import { MulterFile as File } from 'multer';
+import PizZip from 'pizzip';
+import { v4 as uuidv4 } from 'uuid';
+import { NotFoundError } from '../domains/errors';
+import { Contract } from '../entities/Contract';
+import { Template } from '../entities/Template';
+import convertToPdf from '../utils/convertToPdf';
+import { ContractService } from '././interface/contractService';
+ 
+export class SupabaseContractService implements ContractService {
+  private supabaseClient: SupabaseClient;
+ 
+  constructor(supabaseClient: SupabaseClient) {
+    this.supabaseClient = supabaseClient;
+  }
+ 
+  async createContract(
+    templateId: string,
+    clientId: string,
+    fields: Record<string, string>,
+    note?: string,
+    fee?: string,
+    deposit?: string,
+    generatedBy?: string
+  ): Promise<Contract> {
+ 
+    const { data: templateUrl, error: urlError } = await this.supabaseClient
+      .from('contract_templates')
+      .select('storage_path')
+      .eq('id', templateId)
+      .single();
+ 
+    Iif (!templateUrl || urlError) {
+      throw new Error('Failed to retrieve template metadata');
+    }
+ 
+    console.log(templateUrl);
+ 
+    const { data: template, error } = await this.supabaseClient
+      .storage
+      .from('contract-templates')
+      .download(templateUrl.storage_path);
+ 
+    console.log('template is : ', template);
+ 
+    Iif (!template || error) {
+      throw new Error('Template download failed');
+    }
+ 
+    // generateTemplate expects a node.js Buffer
+    const arrayBuffer = await template.arrayBuffer();
+    const nodeBuffer = Buffer.from(arrayBuffer);
+    const pdf = await this.generateTemplate(nodeBuffer, fields);
+ 
+    const contractId = uuidv4();
+    const filePath = `contracts/client_${clientId}/contract_${contractId}.pdf`;
+ 
+    const upload = await this.supabaseClient.storage
+      .from('contracts')
+      .upload(filePath, pdf, { contentType: 'application/pdf' });
+ 
+    Iif (upload.error) throw new Error('Contract upload failed: ' + upload.error.message);
+ 
+ 
+    const { data, error: insertError } = await this.supabaseClient
+      .from('contracts')
+      .insert([{
+        id: contractId,
+        template_id: templateId,
+        template_name: fields.templateName || 'Untitled',
+        client_id: clientId,
+        note,
+        fee,
+        deposit,
+        status: 'created',
+        document_url: filePath,
+        generated_by: generatedBy,
+      }])
+      .select()
+      .single();
+ 
+    Iif (insertError) throw new Error('Failed to insert contract: ' + insertError.message);
+ 
+    return data as Contract;
+  }
+ 
+  async fetchContractPDF(contractId: string): Promise<{ buffer: Buffer; filename: string }> {
+ 
+    const { data, error } = await this.supabaseClient
+      .from('contracts')
+      .select('*')
+      .eq('id', contractId)
+      .single();
+ 
+    Iif (error || !data) throw new Error('Contract not found');
+ 
+    const { data: file, error: downloadError } = await this.supabaseClient
+      .storage
+      .from('contracts')
+      .download(data.document_url);
+ 
+    Iif (downloadError || !file) throw new Error('Failed to fetch PDF');
+ 
+    const buffer = Buffer.from(await file.arrayBuffer());
+    const filename = `contract_${contractId}.pdf`;
+ 
+    return { buffer, filename };
+  }
+  
+  async getAllTemplates(): Promise<Template[]> {
+    const { data, error } = await this.supabaseClient
+      .from('contract_templates')
+      .select('*')
+ 
+    Iif (error || !data) {
+      console.error('Error fetching templates:', error)
+      throw new Error('Could not fetch contract templates')
+    }
+ 
+    return data.map((row) => new Template(
+      row.id,
+      row.title,
+      parseFloat(row.deposit),
+      parseFloat(row.fee),
+      row.storagePath
+    ))
+  }
+ 
+  async deleteTemplate(templateName: string): Promise<boolean> {
+ 
+    const { error: tableError } = await this.supabaseClient
+      .from('contract_templates')
+      .delete()
+      .eq('title', templateName)
+      .select()
+      .single()
+ 
+    Iif (tableError) throw new Error(`Failed to delete template: ${tableError.message}`);
+ 
+    const { error: storageError } = await this.supabaseClient.storage
+      .from('contract-templates')
+      .remove([`${templateName}.docx`])
+ 
+    Iif (storageError) throw new Error(`Failed to delete template from stroage: ${storageError.message}`);
+ 
+    return true;
+  }
+ 
+  async uploadTemplate(file: File, name: string, deposit: number, fee: number): Promise<Boolean> {
+    const filePath = name.endsWith('.docx') ? name : `${name}.docx`;
+ 
+    Iif (file) {
+      const { error: uploadError } = await this.supabaseClient.storage
+        .from('contract-templates')
+        .upload(filePath, file.buffer, {
+          contentType: file.mimetype,
+          upsert: true,
+      });
+  
+      Iif (uploadError) {
+        throw new Error('failed to upload new template');
+      }
+    }
+ 
+    const { error: tableError } = await this.supabaseClient
+    .from('contract_templates')
+    .upsert([
+      {
+        title: name,
+        deposit: deposit,
+        fee: fee,
+        storage_path: filePath,
+      }
+    ]);
+ 
+    Iif (tableError) {
+      console.error('Table insert error:', tableError);
+      throw new Error('Failed to insert template metadata');
+    }
+ 
+    return true;
+  }
+ 
+  async getTemplate(templateName: string): Promise<Buffer> {
+    const filePath = templateName.endsWith('.docx') ? templateName : `${templateName}.docx`;
+ 
+    const { data } = this.supabaseClient
+      .storage
+      .from('contract-templates')
+      .getPublicUrl(filePath);
+ 
+    const publicUrl = data.publicUrl;
+    Iif (!publicUrl) throw new NotFoundError('Template public URL not generated');
+ 
+    const res = await fetch(publicUrl);
+    Iif (!res.ok) throw new NotFoundError(`Failed to fetch template: ${res.statusText}`);
+ 
+    const buffer = Buffer.from(await res.arrayBuffer());
+    return buffer;
+  }
+ 
+  async generateTemplate(buffer: Buffer, fields: Record<string, string>): Promise<Buffer> {
+ 
+    // Fill .docx with fields
+    const zip = new PizZip(buffer);
+ 
+    const doc = new Docxtemplater(zip, {
+      paragraphLoop: true,
+      linebreaks: true,
+    });
+ 
+    doc.render(fields);
+ 
+    const filled = doc.getZip().generate({
+      type: 'nodebuffer',
+      mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    });
+ 
+    const pdfBuffer = await convertToPdf(filled);
+    return pdfBuffer;
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/supabase.ts.html b/coverage/lcov-report/src/supabase.ts.html new file mode 100644 index 00000000..3136acfd --- /dev/null +++ b/coverage/lcov-report/src/supabase.ts.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for src/supabase.ts + + + + + + + + + +
+
+

All files / src supabase.ts

+
+ +
+ 0% + Statements + 0/9 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/9 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { createClient, SupabaseClient } from '@supabase/supabase-js';
+import dotenv from 'dotenv';
+ 
+dotenv.config();
+ 
+const supabaseUrl: string = process.env.SUPABASE_URL || '';
+const supabaseKey: string = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
+ 
+Iif (!supabaseUrl || !supabaseKey) {
+  throw new Error('Missing Supabase environment variables');
+}
+ 
+const supabase: SupabaseClient = createClient(supabaseUrl, supabaseKey, {
+  auth: {
+    persistSession: false,
+    autoRefreshToken: false,
+    detectSessionInUrl: false,
+  },
+});
+ 
+export default supabase;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/types.ts.html b/coverage/lcov-report/src/types.ts.html new file mode 100644 index 00000000..9b1c1eea --- /dev/null +++ b/coverage/lcov-report/src/types.ts.html @@ -0,0 +1,1126 @@ + + + + + + Code coverage report for src/types.ts + + + + + + + + + +
+
+

All files / src types.ts

+
+ +
+ 0% + Statements + 0/121 +
+ + +
+ 0% + Branches + 0/26 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 0% + Lines + 0/121 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request } from 'express';
+import type { File as MulterFile } from 'multer';
+import { User } from './entities/User';
+ 
+export enum ServiceTypes{
+  LABOR_SUPPORT = "Labor Support",
+  POSTPARTUM_SUPPORT= "Postpartum Support",
+  PERINATAL_EDUCATION= "Perinatal Education",
+  FIRST_NIGHT = "First Night Care",
+  LACTATION_SUPPORT = "Lactation Support",
+  PHOTOGRAPHY = "Photography",
+  OTHER = "Other"
+}
+ 
+export enum RequestStatus {
+  PENDING = "pending",
+  REVIEWING = "reviewing",
+  APPROVED = "approved",
+  REJECTED = "rejected",
+  COMPLETED = "completed"
+}
+ 
+export enum HomeType {
+  HOUSE = "House",
+  APARTMENT = "Apartment",
+  CONDO = "Condo",
+  TOWNHOUSE = "Townhouse",
+  OTHER = "Other"
+}
+ 
+export enum RelationshipStatus {
+  SINGLE = "Single",
+  MARRIED = "Married",
+  PARTNERED = "Partnered",
+  DIVORCED = "Divorced",
+  WIDOWED = "Widowed",
+  OTHER = "Other"
+}
+ 
+export enum ProviderType {
+  OB = "OB",
+  MIDWIFE = "Midwife",
+  FAMILY_PHYSICIAN = "Family Physician",
+  OTHER = "Other"
+}
+ 
+export enum ClientAgeRange {
+  UNDER_18 = "Under 18",
+  AGE_18_24 = "18-24",
+  AGE_25_34 = "25-34",
+  AGE_35_44 = "35-44",
+  AGE_45_54 = "45-54",
+  AGE_55_PLUS = "55+"
+}
+ 
+export enum Pronouns{
+  HE_HIM = "he/him",
+  SHE_HER = "she/her",
+  THEY_THEM = "they/them",
+  OTHER = "other",
+}
+ 
+export enum Sex{
+  MALE = "Male",
+  FEMALE = "Female"
+}
+ 
+export enum IncomeLevel{
+  FROM_0_TO_24999 = "$0 - $24,999",
+  FROM_25000_TO_44999 = "$25,000 - $44,999",
+  FROM_45000_TO_64999 = "$45,000 - $64,999",
+  FROM_65000_TO_84999 = "$65,000 - $84,999",
+  FROM_85000_TO_99999 = "$85,000 - $99,999",
+  ABOVE_100000 = "$100,000 and above"
+}
+ 
+export interface AuthRequest extends Request {
+  user?: User;
+}
+ 
+export interface UpdateRequest extends Request {
+  user?: User;
+  file?: MulterFile;
+}
+ 
+export interface UserData {
+  id?: string;
+  email?: string;
+  firstname?: string;
+  lastname?: string;
+  created_at?: Date;
+  updated_at?: Date;
+  role?: ROLE;
+  address?: string;
+  city?: string;
+  state?: STATE;
+  country?: string;
+  zip_code?: number;
+  profile_picture?: File;  
+  account_status?: ACCOUNT_STATUS;
+  business?: string;
+  bio?: string;  
+}
+ 
+export interface SignupBody {
+  email: string;
+  password: string;
+  firstname?: string;
+  lastname?: string;
+}
+ 
+export interface LoginBody {
+  email: string;
+  password: string;
+}
+ 
+export interface TokenBody {
+  access_token: string;
+}
+ 
+export interface PasswordResetBody {
+  email: string;
+}
+ 
+export interface UpdatePasswordBody {
+  password: string;
+}
+ 
+export interface RequestFormData {
+  // Step 1: Client Details
+  firstname: string;
+  lastname: string;
+  email: string;
+  phone_number: string;
+  pronouns?: Pronouns;
+  pronouns_other?: string;
+  children_expected?: string;
+  
+  // Step 2: Home Details
+  address: string;
+  city: string;
+  state: STATE;
+  zip_code: string;
+  home_phone?: string;
+  home_type?: HomeType;
+  home_access?: string;
+  pets?: string;
+  
+  // Step 3: Family Members
+  relationship_status?: RelationshipStatus;
+  first_name?: string;
+  last_name?: string;
+  middle_name?: string;
+  mobile_phone?: string;
+  work_phone?: string;
+  
+  // Step 4: Referral
+  referral_source?: string;
+  referral_name?: string;
+  referral_email?: string;
+  
+  // Step 5: Health History
+  health_history?: string;
+  allergies?: string;
+  health_notes?: string;
+  
+  // Step 6: Payment Info
+  annual_income?: IncomeLevel;
+  service_needed: ServiceTypes;
+  service_specifics?: string;
+  
+  // Step 7: Pregnancy/Baby
+  due_date?: Date;
+  birth_location?: string;
+  birth_hospital?: string;
+  number_of_babies?: number;
+  baby_name?: string;
+  provider_type?: ProviderType;
+  pregnancy_number?: number;
+  hospital?: string;
+  baby_sex?: string;
+  
+  // Step 8: Past Pregnancies
+  had_previous_pregnancies?: boolean;
+  previous_pregnancies_count?: number;
+  living_children_count?: number;
+  past_pregnancy_experience?: string;
+  
+  // Step 9: Services Interested
+  services_interested?: string[];
+  service_support_details?: string;
+  
+  // Step 10: Client Demographics (Optional)
+  race_ethnicity?: string;
+  primary_language?: string;
+  client_age_range?: ClientAgeRange;
+  insurance?: string;
+  demographics_multi?: string[];
+}
+ 
+export interface RequestFormResponse {
+  id: string;
+  status: RequestStatus;
+  requested?: string;
+  created_at: string;
+  updated_at: string;
+  user_id: string;
+  // Include all RequestFormData fields
+  firstname: string;
+  lastname: string;
+  email: string;
+  phone_number: string;
+  pronouns?: Pronouns;
+  pronouns_other?: string;
+  children_expected?: string;
+  address: string;
+  city: string;
+  state: STATE;
+  zip_code: string;
+  home_phone?: string;
+  home_type?: HomeType;
+  home_access?: string;
+  pets?: string;
+  relationship_status?: RelationshipStatus;
+  first_name?: string;
+  last_name?: string;
+  middle_name?: string;
+  mobile_phone?: string;
+  work_phone?: string;
+  referral_source?: string;
+  referral_name?: string;
+  referral_email?: string;
+  health_history?: string;
+  allergies?: string;
+  health_notes?: string;
+  annual_income?: IncomeLevel;
+  service_needed: ServiceTypes;
+  service_specifics?: string;
+  due_date?: string;
+  birth_location?: string;
+  birth_hospital?: string;
+  number_of_babies?: number;
+  baby_name?: string;
+  provider_type?: ProviderType;
+  pregnancy_number?: number;
+  hospital?: string;
+  baby_sex?: string;
+  had_previous_pregnancies?: boolean;
+  previous_pregnancies_count?: number;
+  living_children_count?: number;
+  past_pregnancy_experience?: string;
+  services_interested?: string[];
+  service_support_details?: string;
+  race_ethnicity?: string;
+  primary_language?: string;
+  client_age_range?: ClientAgeRange;
+  insurance?: string;
+  demographics_multi?: string[];
+}
+ 
+export interface DatabaseError {
+  code?: string;
+  message: string;
+  details?: string;
+  hint?: string;
+}
+ 
+export interface SupabaseUserMetadata {
+  given_name?: string;
+  family_name?: string;
+  name?: string;
+  [key: string]: unknown;
+}
+ 
+export enum CLIENT_STATUS {
+  LEAD = 'lead',
+  CONTACTED = 'contacted',
+  MATCHING = 'matching',
+  INTERVIEWING = 'interviewing',
+  'FOLLOW UP' = 'follow up',
+  CONTRACT = 'contract',
+  ACTIVE = 'active',
+  COMPLETE = 'complete',
+};
+ 
+export enum ACCOUNT_STATUS {
+  PENDING = "pending",
+  APPROVED = "approved"
+};
+ 
+export enum ROLE {
+  ADMIN = "admin",
+  DOULA = "doula",
+  CLIENT = "client"
+};
+ 
+export enum STATE {
+  AL = "AL",
+  AK = "AK",
+  AZ = "AZ",
+  AR = "AR",
+  CA = "CA",
+  CO = "CO",
+  CT = "CT",
+  DE = "DE",
+  FL = "FL",
+  GA = "GA",
+  HI = "HI",
+  ID = "ID",
+  IL = "IL",
+  IN = "IN",
+  IA = "IA",
+  KS = "KS",
+  KY = "KY",
+  LA = "LA",
+  ME = "ME",
+  MD = "MD",
+  MA = "MA",
+  MI = "MI",
+  MN = "MN",
+  MS = "MS",
+  MO = "MO",
+  MT = "MT",
+  NE = "NE",
+  NV = "NV",
+  NH = "NH",
+  NJ = "NJ",
+  NM = "NM",
+  NY = "NY",
+  NC = "NC",
+  ND = "ND",
+  OH = "OH",
+  OK = "OK",
+  OR = "OR",
+  PA = "PA",
+  RI = "RI",
+  SC = "SC",
+  SD = "SD",
+  TN = "TN",
+  TX = "TX",
+  UT = "UT",
+  VT = "VT",
+  VA = "VA",
+  WA = "WA",
+  WV = "WV",
+  WI = "WI",
+  WY = "WY"  
+};
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/usecase/authUseCase.ts.html b/coverage/lcov-report/src/usecase/authUseCase.ts.html new file mode 100644 index 00000000..2087df5f --- /dev/null +++ b/coverage/lcov-report/src/usecase/authUseCase.ts.html @@ -0,0 +1,1135 @@ + + + + + + Code coverage report for src/usecase/authUseCase.ts + + + + + + + + + +
+
+

All files / src/usecase authUseCase.ts

+
+ +
+ 0% + Statements + 0/90 +
+ + +
+ 0% + Branches + 0/32 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 0% + Lines + 0/90 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { AuthService } from '../services/interface/authService';
+ 
+import {
+  AuthenticationError,
+  AuthorizationError,
+  NotFoundError,
+  ValidationError
+} from '../domains/errors';
+import { User } from '../entities/User';
+import { UserRepository } from '../repositories/interface/userRepository';
+ 
+ 
+export class AuthUseCase {
+  private authService: AuthService;
+  private userRepository: UserRepository;
+ 
+  constructor(authService: AuthService, userRepository: UserRepository) {
+    this.authService = authService;
+    this.userRepository = userRepository;
+  }
+ 
+  //
+  // Sign up the user if they are already in the users table
+  //
+  // returns:
+  //    user
+  //
+  async signup(
+    email: string, 
+    password: string, 
+    firstname: string, 
+    lastname: string
+  ): Promise<User> {
+      
+    Iif (!email || !password) {
+      throw new ValidationError("Email and password are required");
+    }
+ 
+    Iif (password.length < 8) {
+      throw new ValidationError("Password must be at least 8 characters long");
+    }
+ 
+    // Check that the user is pre-approved by an admin
+    const existingUser = await this.userRepository.findByEmail(email);
+    Iif (!existingUser) {
+      throw new AuthorizationError("You are not authorized to sign up. Please email the office if the issue persists.");
+    }
+    Iif (existingUser.account_status !== 'pending') {
+      throw new AuthorizationError("This account already exists");
+    }
+ 
+    // Continue with signup
+    return await this.authService.signup(
+      email,
+      password,
+      firstname,
+      lastname
+    );
+  }
+ 
+  //
+  // login if valid credentials
+  //
+  // returns:
+  //    user
+  //
+  async login(
+    email: string,
+    password: string
+  ): Promise<{user: any, token: any}> {
+    
+    Iif (!email || !password) {
+      throw new ValidationError("Email and password are required");
+    }
+ 
+    try {
+      const existingUser = await this.userRepository.findByEmail(email);
+      Iif (!existingUser) {
+        throw new AuthorizationError("Invalid credentials. Please try again or contact the office.");
+      }
+      // let auth service return the user who just logged in alongside the session token
+      const { user, token } = await this.authService.login(
+        email,
+        password
+      );
+ 
+      return { user, token };
+    } catch (error) {
+      throw new AuthenticationError(error.message);
+    }
+  }
+ 
+  //
+  // forward to authService the token to retrieve user
+  //
+  // returns:
+  //    user
+  //
+  async getMe(
+      token: string
+  ): Promise<User> {
+    
+    Iif (!token) {
+      throw new AuthenticationError("Not authenticated");
+    }
+ 
+    try {
+      // let auth service return the user we requested
+      const user = await this.authService.getMe(token);
+ 
+      return user;
+    } catch (error) {
+      throw new AuthenticationError(error.message);
+    }
+  }
+ 
+  //
+  // signs out current user from the auth service
+  //
+  // returns:
+  //    none
+  //
+  async logout(): Promise<void> {
+    await this.authService.logout();
+  }
+ 
+  //
+  // redirect user to our custom verification page with a valid supabase otp
+  //
+  // returns:
+  //    user
+  //
+  async verifyEmail(
+    token_hash: string,
+    type: string
+  ): Promise<string> {
+ 
+    Iif (!token_hash || type != 'signup') {
+      throw new ValidationError("invalid_verification");
+    }
+ 
+    try {
+      const session = await this.authService.verifyEmail(token_hash, type);
+      const queryParams = new URLSearchParams({
+        access_token: session.access_token,
+        refresh_token: session.refresh_token,
+        expires_in: session.expires_in.toString(),
+        type: 'signup',
+      }).toString();
+ 
+      return queryParams;
+    } catch (error) {
+      throw new AuthenticationError(error.message);
+    }
+  }
+ 
+  //
+  // get all users
+  //
+  // returns:
+  //    user
+  //
+  async getAllUsers() {
+    try {
+      const users = await this.userRepository.findAll();
+      return users;
+    } catch (error) {
+      throw new AuthenticationError(`Error fetching users: ${error.message}`);
+    }
+  }
+ 
+  //
+  // redirect to google auth service
+  //
+  // returns:
+  //    user
+  //
+  async googleAuth(
+    redirectTo: string
+  ): Promise<string> {
+    try {
+      const url = await this.authService.getGoogleAuthUrl(redirectTo);
+      return url;
+    } catch (error) {
+      throw new AuthenticationError(`Failed to initialize Google auth: ${error.message}`);
+    }
+  }
+ 
+  //
+  // handle response from google oauth
+  //
+  // returns:
+  //    user
+  //
+  async handleOAuthCallback(
+    code: string,
+  ): Promise<{session: any, user: User}> {
+    
+    Iif (!code) {
+      throw new ValidationError('No code provided');
+    }
+ 
+    try {
+      // Exchange code for session
+      const { session, userData } = await this.authService.exchangeCodeForSession(code);
+      
+      // Check if user exists
+      let user = await this.userRepository.findByEmail(userData.email);
+      
+      // User should already exist in users table
+      Iif (!user) {
+        throw new AuthorizationError('You are not authorized to sign in. Please email the office if the issue persists.');
+      }
+      
+      return { session, user };
+    } catch (error) {
+      throw new AuthenticationError(`${error.message}`);
+    }
+  }
+ 
+  //
+  // forward to authService to authenticate and return our user
+  //
+  // returns:
+  //    user
+  //
+  async handleToken(
+    accessToken: string
+  ): Promise<User> {
+ 
+    Iif (!accessToken) {
+      throw new ValidationError('No access token provided');
+    }
+ 
+    try {
+      // Get user data from token
+      let user = await this.authService.getUserFromToken(accessToken);
+      
+      // Create user if doesn't exist
+      Iif (!user) {
+        const newUser = new User({
+          email: user.email,
+          firstname: user.user_metadata?.given_name || 
+                    user.user_metadata?.name?.split(' ')[0] || 
+                    null,
+          lastname: user.user_metadata?.family_name || 
+                   user.user_metadata?.name?.split(' ')[1] || 
+                   null,
+        });
+        
+        user = await this.userRepository.save(newUser);
+      }
+      
+      return user;
+    } catch (error) {
+      throw new AuthenticationError(`Token handling error: ${error.message}`);
+    }
+  }
+ 
+  //
+  // forward to authService to authenticate and return our user
+  //
+  // returns:
+  //    user
+  //
+  async requestPasswordReset(
+    email: string,
+    redirectTo: string
+  ): Promise<void> {
+ 
+    Iif (!email) {
+      throw new ValidationError('Email is required');
+    }
+ 
+    try {
+      await this.authService.requestPasswordReset(email, redirectTo);
+    } catch (error) {
+      throw new AuthenticationError(`Failed to process password reset request: ${error.message}`);
+    }
+  }
+ 
+  //
+  // forward to authService to authenticate and return our user
+  //
+  // returns:
+  //    user
+  //
+  async handlePasswordRecovery(
+    tokenHash: string,
+    type: string
+  ): Promise<string> {
+ 
+    Iif (!tokenHash || type !== 'recovery') {
+      throw new ValidationError('Invalid password recovery link');
+    }
+ 
+    try {
+      const session = await this.authService.verifyRecoveryToken(tokenHash);
+      
+      const queryParams = new URLSearchParams({
+        access_token: session.access_token,
+        refresh_token: session.refresh_token,
+        type: 'recovery',
+      }).toString();
+      
+      return queryParams;
+    } catch (error) {
+      throw new AuthenticationError(`Failed to process password recovery: ${error.message}`);
+    }
+  }
+ 
+  //
+  // forward to authService to authenticate and return our user
+  //
+  // returns:
+  //    user
+  //
+  async updatePassword (
+    password: string,
+    token: string
+  ): Promise<User> {
+    Iif (!password) {
+      throw new ValidationError('New password is required');
+    }
+ 
+    Iif (!token) {
+      throw new ValidationError('Authorization token is required');
+    }
+ 
+    try {
+      // Validate session
+      await this.authService.setSession(token);
+      
+      // Update password
+      const userData = await this.authService.updateUserPassword(password);
+      
+      // Get domain user
+      const user = await this.userRepository.findByEmail(userData.email);
+      Iif (!user) {
+        throw new NotFoundError('User not found');
+      }
+      
+      return user;
+    } catch (error) {
+      Iif (error instanceof NotFoundError) {
+        throw error;
+      }
+      throw new AuthenticationError(`Failed to update password: ${error.message}`);
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/usecase/clientUseCase.ts.html b/coverage/lcov-report/src/usecase/clientUseCase.ts.html new file mode 100644 index 00000000..2fb28ce7 --- /dev/null +++ b/coverage/lcov-report/src/usecase/clientUseCase.ts.html @@ -0,0 +1,358 @@ + + + + + + Code coverage report for src/usecase/clientUseCase.ts + + + + + + + + + +
+
+

All files / src/usecase clientUseCase.ts

+
+ +
+ 0% + Statements + 0/25 +
+ + +
+ 0% + Branches + 0/8 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/25 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Client } from '../entities/Client';
+import { ClientRepository } from '../repositories/interface/clientRepository';
+ 
+export class ClientUseCase {
+  private clientRepository: ClientRepository;
+ 
+  constructor (clientRepository: ClientRepository) {
+    this.clientRepository = clientRepository;
+  }
+ 
+  // Summary of clients for use in brief list of clients
+  async getClientsLite(id: string, role: string): Promise<Client[]> {
+    if (role === 'admin') {
+      return this.clientRepository.findClientsLiteAll();
+    } else {
+      // console.log("calling findClientsLiteByDoula in clientUseCase ");
+      return this.clientRepository.findClientsLiteByDoula(id);
+    }
+  }
+ 
+  // Detailed view of clients for profile
+  async getClientsDetailed(id: string, role: string): Promise<Client[]> {
+    if (role === 'admin') {
+      return this.clientRepository.findClientsDetailedAll();
+    } else {
+      return this.clientRepository.findClientsDetailedByDoula(id);
+    }
+  }
+ 
+ 
+    //
+  // // forward to repository to Fetch csv client data
+  // //
+  // // returns:
+  // //    CSV data of Client
+  // //
+  async exportCSV(role:string): Promise<string|null> {
+    try {
+      Iif (role == "admin"|| role == "client"){
+        const csvData = await this.clientRepository.exportCSV()
+        Iif (!csvData) {
+          throw new Error("No data available for CSV export");
+        }
+        return csvData;
+      }
+    } catch (error) {
+      throw new Error(`Failed to retrive CSV data ${error.message}`)
+    }
+  }
+ 
+  async getClientLite(clientId: string): Promise<Client> {
+    return this.clientRepository.findClientLiteById(clientId);
+  }
+ 
+  async getClientDetailed(clientId: string): Promise<Client> {
+    return this.clientRepository.findClientDetailedById(clientId);
+  }
+ 
+  // updates a client's status
+  async updateClientStatus(
+    clientId: string,
+    status: string
+  ): Promise<Client> {
+ 
+    try {
+      // Update the client status directly
+      const client = await this.clientRepository.updateStatus(clientId, status);
+ 
+      return client;
+    }
+    catch (error) {
+      throw new Error(`Could not update client: ${error.message}`);
+    }
+  }
+ 
+  // updates client profile fields
+  async updateClientProfile(
+    clientId: string,
+    fieldsToUpdate: Partial<Client>
+  ): Promise<Client> {
+ 
+    try {
+      // Update the client directly
+      const client = await this.clientRepository.updateClient(clientId, fieldsToUpdate);
+ 
+      return client;
+    }
+    catch (error) {
+      throw new Error(`Could not update client profile: ${error.message}`);
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/usecase/contractUseCase.ts.html b/coverage/lcov-report/src/usecase/contractUseCase.ts.html new file mode 100644 index 00000000..7155f6a0 --- /dev/null +++ b/coverage/lcov-report/src/usecase/contractUseCase.ts.html @@ -0,0 +1,244 @@ + + + + + + Code coverage report for src/usecase/contractUseCase.ts + + + + + + + + + +
+
+

All files / src/usecase contractUseCase.ts

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { MulterFile as File } from 'multer';
+import { Contract } from '../entities/Contract';
+import { Template } from '../entities/Template';
+import { ContractService } from '../services/interface/contractService';
+ 
+export class ContractUseCase {
+  constructor(private readonly contractService: ContractService) {}
+ 
+  async createContract(params: {
+    templateId: string;
+    clientId: string;
+    fields: Record<string, string>;
+    note?: string;
+    fee?: string;
+    deposit?: string;
+    generatedBy: string;
+  }): Promise<Contract> {
+    return await this.contractService.createContract(
+      params.templateId,
+      params.clientId,
+      params.fields,
+      params.note,
+      params.fee,
+      params.deposit,
+      params.generatedBy
+    );
+  }
+ 
+  async fetchContractPDF(contractId: string): Promise<{ buffer: Buffer; filename: string }> {
+    return await this.contractService.fetchContractPDF(contractId);
+  }
+ 
+  async getAllTemplates(): Promise<Template[]> {
+    return await this.contractService.getAllTemplates()
+  }
+ 
+  async deleteTemplate(templateName: string): Promise<boolean> {
+    return await this.contractService.deleteTemplate(templateName);
+  }
+ 
+  async updateTemplate(templateName: string, deposit: number, fee: number, template: File) {
+    return await this.contractService.uploadTemplate(template, templateName, deposit, fee);
+  }
+ 
+  async uploadTemplate(template: File, name: string, deposit: number, fee: number): Promise<Boolean> {
+    return await this.contractService.uploadTemplate(template, name, deposit, fee);
+  }
+ 
+  async generateTemplate(templateName: string, fields: Record<string, string>): Promise<Buffer> {
+    // grab the template from supabase
+    const buffer = await this.contractService.getTemplate(templateName);
+    return await this.contractService.generateTemplate(buffer, fields);
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/usecase/index.html b/coverage/lcov-report/src/usecase/index.html new file mode 100644 index 00000000..c20550cc --- /dev/null +++ b/coverage/lcov-report/src/usecase/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/usecase + + + + + + + + + +
+
+

All files src/usecase

+
+ +
+ 0% + Statements + 0/155 +
+ + +
+ 0% + Branches + 0/47 +
+ + +
+ 0% + Functions + 0/41 +
+ + +
+ 0% + Lines + 0/155 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
authUseCase.ts +
+
0%0/900%0/320%0/130%0/90
clientUseCase.ts +
+
0%0/250%0/80%0/80%0/25
contractUseCase.ts +
+
0%0/10100%0/00%0/80%0/10
userUseCase.ts +
+
0%0/300%0/70%0/120%0/30
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/usecase/userUseCase.ts.html b/coverage/lcov-report/src/usecase/userUseCase.ts.html new file mode 100644 index 00000000..68f79236 --- /dev/null +++ b/coverage/lcov-report/src/usecase/userUseCase.ts.html @@ -0,0 +1,352 @@ + + + + + + Code coverage report for src/usecase/userUseCase.ts + + + + + + + + + +
+
+

All files / src/usecase userUseCase.ts

+
+ +
+ 0% + Statements + 0/30 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 0% + Functions + 0/12 +
+ + +
+ 0% + Lines + 0/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { File as MulterFile } from 'multer';
+import { NotFoundError } from '../domains/errors';
+import { WORK_ENTRY } from '../entities/Hours';
+import { User } from '../entities/User';
+import { UserRepository } from '../repositories/interface/userRepository';
+ 
+export class UserUseCase {
+  private userRepository: UserRepository;
+ 
+  constructor(userRepository: UserRepository) {
+    this.userRepository = userRepository;
+  }
+ 
+  async getUserById(targetUserId: string): Promise<User> {
+    const user = await this.userRepository.findById(targetUserId);
+ 
+    Iif(!user) {
+      throw new NotFoundError("User not found");
+    }
+ 
+    return user;
+  }
+  
+  async getHoursById(targetUserId: string): Promise<WORK_ENTRY[]> {
+    const hours = await this.userRepository.getHoursById(targetUserId);
+    
+    Iif(!hours) {
+      throw new NotFoundError("Could not get hours based on Id");
+    }
+ 
+    return hours;
+  }
+ 
+  async getAllHours(): Promise<WORK_ENTRY[]> {
+    const hours = await this.userRepository.getAllHours();
+ 
+    Iif(!hours) {
+      throw new NotFoundError("Could not retrieve all work entries");
+    }
+ 
+    return hours;
+  }
+ 
+  async addNewHours(doula_id: string, client_id: string, start_time: Date, end_time: Date, note: string) {
+    const newWorkEntry = await this.userRepository.addNewHours(doula_id, client_id, start_time, end_time, note);
+ 
+    return newWorkEntry;
+  }
+ 
+  async uploadProfilePicture(user: User, profilePicture: MulterFile) {
+    const signedUrl = await this.userRepository.uploadProfilePicture(user, profilePicture);
+    return signedUrl;
+  }
+ 
+  async updateUser(user: User, updateData: Partial<User>) {
+ 
+    const fieldsToUpdate = Object.entries(updateData).reduce((acc, [key, value]) => {
+      Iif (value !== '' && user[key] !== value) {
+        acc[key] = value;
+      }
+      return acc;
+    }, {} as Partial<User>);
+ 
+    Iif (Object.keys(fieldsToUpdate).length === 0) {
+      return user; // Nothing to update
+    }
+ 
+    return this.userRepository.update(user.id, fieldsToUpdate);
+  }
+ 
+  async getAllUsers(): Promise<User[]> {
+    return this.userRepository.findAll();
+  }
+ 
+  async getAllTeamMembers(): Promise<User[]> {
+    return this.userRepository.findAllTeamMembers();
+  }
+ 
+  async deleteMember(userId: string): Promise<void> {
+    return this.userRepository.delete(userId);
+  }
+ 
+  async addMember(firstname: string, lastname: string, userEmail: string, userRole: string): Promise<User> {
+    return this.userRepository.addMember(firstname, lastname, userEmail, userRole);
+  }
+ 
+ 
+}
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/utils/convertToPdf.ts.html b/coverage/lcov-report/src/utils/convertToPdf.ts.html new file mode 100644 index 00000000..e32c71a2 --- /dev/null +++ b/coverage/lcov-report/src/utils/convertToPdf.ts.html @@ -0,0 +1,238 @@ + + + + + + Code coverage report for src/utils/convertToPdf.ts + + + + + + + + + +
+
+

All files / src/utils convertToPdf.ts

+
+ +
+ 0% + Statements + 0/22 +
+ + +
+ 0% + Branches + 0/6 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import CloudConvert from 'cloudconvert';
+ 
+export default async function convertToPdf(docxBuffer: Buffer): Promise<Buffer> {
+  const cloudConvert = new CloudConvert(process.env.CLOUDCONVERT_API_KEY);
+  try {
+    const job = await cloudConvert.jobs.create({
+      tasks: {
+        upload: {
+          operation: 'import/upload',
+        },
+        convert: {
+          operation: 'convert',
+          input: 'upload',
+          input_format: 'docx',
+          output_format: 'pdf',
+          engine: 'libreoffice',
+        },
+        export: {
+          operation: 'export/url',
+          input: 'convert',
+        },
+      },
+    });
+    
+      const uploadTask = job.tasks.find((t: any) => t.name === 'upload');
+      Iif (!uploadTask) throw new Error('Upload task not found');
+    
+      await cloudConvert.tasks.upload(uploadTask, docxBuffer, 'contract.docx', docxBuffer.length);
+    
+      const completedJob = await cloudConvert.jobs.wait(job.id);
+    
+      const exportTask = completedJob.tasks.find(
+        (t: any) => t.name === 'export' && t.status === 'finished'
+      );
+    
+      Iif (!exportTask?.result?.files?.[0]?.url) {
+        throw new Error('Export task failed or URL missing');
+      }
+    
+      const pdfUrl = exportTask.result.files[0].url;
+      const pdfRes = await fetch(pdfUrl);
+      const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());
+    
+      return pdfBuffer;
+  }
+  catch (err) {
+    console.error('Job creation error:', err.message);
+    console.error(err.response?.data || err);
+    throw err;
+  }
+  
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/utils/generateInvoicePdf.ts.html b/coverage/lcov-report/src/utils/generateInvoicePdf.ts.html new file mode 100644 index 00000000..d90e2dd6 --- /dev/null +++ b/coverage/lcov-report/src/utils/generateInvoicePdf.ts.html @@ -0,0 +1,448 @@ + + + + + + Code coverage report for src/utils/generateInvoicePdf.ts + + + + + + + + + +
+
+

All files / src/utils generateInvoicePdf.ts

+
+ +
+ 0% + Statements + 0/40 +
+ + +
+ 0% + Branches + 0/3 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 0% + Lines + 0/40 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import PDFDocument from 'pdfkit';
+ 
+export interface InvoiceData {
+  invoiceNumber: string;
+  customerName: string;
+  customerEmail: string;
+  customerAddress?: string;
+  lineItems: Array<{
+    description: string;
+    quantity: number;
+    rate: number;
+    amount: number;
+  }>;
+  subtotal: number;
+  tax?: number;
+  total: number;
+  dueDate: string;
+  issueDate: string;
+  memo?: string;
+}
+ 
+export function generateInvoicePDF(invoiceData: InvoiceData): Promise<Buffer> {
+  return new Promise((resolve, reject) => {
+    try {
+      const doc = new PDFDocument({ margin: 50 });
+      const buffers: Buffer[] = [];
+ 
+      doc.on('data', buffers.push.bind(buffers));
+      doc.on('end', () => {
+        const pdfBuffer = Buffer.concat(buffers);
+        resolve(pdfBuffer);
+      });
+ 
+      // Company Header
+      doc.fontSize(20)
+         .text('Sokana CRM', 50, 50);
+      
+      doc.fontSize(10)
+         .text('Professional Services', 50, 75)
+         .text('Contact: info@sokanacrm.org', 50, 90);
+ 
+      // Invoice Title
+      doc.fontSize(24)
+         .text('INVOICE', 400, 50);
+ 
+      // Invoice Details
+      doc.fontSize(12)
+         .text(`Invoice #: ${invoiceData.invoiceNumber}`, 400, 80)
+         .text(`Issue Date: ${invoiceData.issueDate}`, 400, 100)
+         .text(`Due Date: ${invoiceData.dueDate}`, 400, 120);
+ 
+      // Customer Information
+      doc.fontSize(14)
+         .text('Bill To:', 50, 150);
+      
+      doc.fontSize(12)
+         .text(invoiceData.customerName, 50, 170)
+         .text(invoiceData.customerEmail, 50, 185);
+      
+      Iif (invoiceData.customerAddress) {
+        doc.text(invoiceData.customerAddress, 50, 200);
+      }
+ 
+      // Line Items Table
+      const tableTop = 250;
+      doc.fontSize(12);
+ 
+      // Table Headers
+      doc.text('Description', 50, tableTop)
+         .text('Qty', 300, tableTop)
+         .text('Rate', 350, tableTop)
+         .text('Amount', 450, tableTop);
+ 
+      // Table line
+      doc.moveTo(50, tableTop + 15)
+         .lineTo(550, tableTop + 15)
+         .stroke();
+ 
+      // Line Items
+      let yPosition = tableTop + 30;
+      invoiceData.lineItems.forEach((item) => {
+        doc.text(item.description, 50, yPosition)
+           .text(item.quantity.toString(), 300, yPosition)
+           .text(`$${item.rate.toFixed(2)}`, 350, yPosition)
+           .text(`$${item.amount.toFixed(2)}`, 450, yPosition);
+        yPosition += 20;
+      });
+ 
+      // Totals
+      const totalsX = 400;
+      yPosition += 20;
+      
+      doc.text(`Subtotal: $${invoiceData.subtotal.toFixed(2)}`, totalsX, yPosition);
+      
+      Iif (invoiceData.tax) {
+        yPosition += 20;
+        doc.text(`Tax: $${invoiceData.tax.toFixed(2)}`, totalsX, yPosition);
+      }
+      
+      yPosition += 20;
+      doc.fontSize(14)
+         .text(`Total: $${invoiceData.total.toFixed(2)}`, totalsX, yPosition);
+ 
+      // Memo
+      Iif (invoiceData.memo) {
+        yPosition += 50;
+        doc.fontSize(12)
+           .text('Notes:', 50, yPosition)
+           .text(invoiceData.memo, 50, yPosition + 15);
+      }
+ 
+      // Footer
+      doc.fontSize(10)
+         .text('Thank you for your business!', 50, doc.page.height - 100)
+         .text('Please remit payment by the due date.', 50, doc.page.height - 85);
+ 
+      doc.end();
+    } catch (error) {
+      reject(error);
+    }
+  });
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/utils/index.html b/coverage/lcov-report/src/utils/index.html new file mode 100644 index 00000000..7b0959e4 --- /dev/null +++ b/coverage/lcov-report/src/utils/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/utils + + + + + + + + + +
+
+

All files src/utils

+
+ +
+ 0% + Statements + 0/162 +
+ + +
+ 0% + Branches + 0/30 +
+ + +
+ 0% + Functions + 0/16 +
+ + +
+ 0% + Lines + 0/158 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
convertToPdf.ts +
+
0%0/220%0/60%0/30%0/20
generateInvoicePdf.ts +
+
0%0/400%0/30%0/40%0/40
qboClient.ts +
+
0%0/260%0/110%0/40%0/24
tokenUtils.ts +
+
0%0/740%0/100%0/50%0/74
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/utils/qboClient.ts.html b/coverage/lcov-report/src/utils/qboClient.ts.html new file mode 100644 index 00000000..1301adaa --- /dev/null +++ b/coverage/lcov-report/src/utils/qboClient.ts.html @@ -0,0 +1,352 @@ + + + + + + Code coverage report for src/utils/qboClient.ts + + + + + + + + + +
+
+

All files / src/utils qboClient.ts

+
+ +
+ 0% + Statements + 0/26 +
+ + +
+ 0% + Branches + 0/11 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 0% + Lines + 0/24 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/utils/qboClient.ts
+ 
+import dotenv from 'dotenv';
+dotenv.config();
+ 
+import { RequestInit } from 'node-fetch';
+import { getTokenFromDatabase, refreshQuickBooksToken } from './tokenUtils';
+ 
+const {
+  QB_CLIENT_ID = '',
+  QB_CLIENT_SECRET = '',
+  QBO_ENV = 'production'
+} = process.env;
+ 
+interface AccessTokenResult {
+  accessToken: string;
+  realmId: string;
+}
+ 
+/**
+ * Retrieve (and refresh, if needed) the current OAuth tokens & realm ID.
+ */
+export async function getAccessToken(): Promise<AccessTokenResult> {
+  const tokens = await getTokenFromDatabase();
+  Iif (!tokens) {
+    throw new Error('No QuickBooks tokens found');
+  }
+ 
+  // Check if token is expired or will expire in the next minute
+  Iif (new Date(tokens.expiresAt) <= new Date(Date.now() + 60000)) {
+    const newTokens = await refreshQuickBooksToken();
+    return {
+      accessToken: newTokens.accessToken,
+      realmId: newTokens.realmId
+    };
+  }
+ 
+  return {
+    accessToken: tokens.accessToken,
+    realmId: tokens.realmId
+  };
+}
+ 
+/**
+ * Make a QuickBooks Online API request.
+ * @param path    e.g. '/customer?minorversion=65'
+ * @param options fetch options (method, body, headers, etc.)
+ */
+export async function qboRequest<T = any>(
+  path: string,
+  options: RequestInit = {}
+): Promise<T> {
+  const { accessToken, realmId } = await getAccessToken();
+ 
+  const host = QBO_ENV === 'sandbox'
+    ? 'https://sandbox-quickbooks.api.intuit.com'
+    : 'https://quickbooks.api.intuit.com';
+ 
+  const url = `${host}/v3/company/${realmId}${path}`;
+  console.log('QBO URL →', url);
+ 
+  // Use dynamic import for node-fetch
+  const fetch = (await import('node-fetch')).default;
+ 
+  const resp = await fetch(url, {
+    ...options,
+    headers: {
+      Authorization: `Bearer ${accessToken}`,
+      Accept: 'application/json',
+      'Content-Type': 'application/json',
+      ...(options.headers as Record<string, string>)
+    }
+  });
+ 
+  Iif (!resp.ok) {
+    // define the shape of a QuickBooks error
+    type QboError = {
+      Fault?: {
+        Error?: Array<{ Message: string }>;
+      };
+    };
+    // cast the parsed JSON to that type
+    const errBody = (await resp.json().catch(() => ({}))) as QboError;
+    const msg = errBody.Fault?.Error?.[0]?.Message ?? resp.statusText;
+    throw new Error(`QBO ${resp.status}: ${msg}`);
+  }
+ 
+  return resp.json() as Promise<T>;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/utils/tokenUtils.ts.html b/coverage/lcov-report/src/utils/tokenUtils.ts.html new file mode 100644 index 00000000..c209114c --- /dev/null +++ b/coverage/lcov-report/src/utils/tokenUtils.ts.html @@ -0,0 +1,625 @@ + + + + + + Code coverage report for src/utils/tokenUtils.ts + + + + + + + + + +
+
+

All files / src/utils tokenUtils.ts

+
+ +
+ 0% + Statements + 0/74 +
+ + +
+ 0% + Branches + 0/10 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/74 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/utils/tokenUtils.ts
+import supabase from '../supabase';
+ 
+export interface TokenStore {
+  realmId: string;
+  accessToken: string;
+  refreshToken: string;
+  expiresAt: string;
+}
+ 
+/**
+ * Load the QuickBooks OAuth tokens.
+ */
+export async function getTokenFromDatabase(): Promise<TokenStore | null> {
+  console.log('🔍 [QB] Loading tokens from database...');
+  
+  const { data, error } = await supabase
+    .from('quickbooks_tokens')
+    .select('realm_id, access_token, refresh_token, expires_at')
+    .single();
+ 
+  Iif (error) {
+    Iif (error.code === 'PGRST116') { // no rows found
+      console.log('❌ [QB] No tokens found in database');
+      return null;
+    }
+    console.error('❌ [QB] Database error loading tokens:', error.message);
+    throw new Error(`Could not load QuickBooks tokens: ${error.message}`);
+  }
+ 
+  const tokens = {
+    realmId: data.realm_id,
+    accessToken: data.access_token,
+    refreshToken: data.refresh_token,
+    expiresAt: data.expires_at,
+  };
+  
+  console.log('✅ [QB] Tokens loaded successfully');
+  console.log('📅 [QB] Token expires at:', tokens.expiresAt);
+  console.log('⏰ [QB] Current time:', new Date().toISOString());
+  console.log('🔍 [QB] Token expired?', new Date(tokens.expiresAt) <= new Date());
+  
+  return tokens;
+}
+ 
+/**
+ * Refresh QuickBooks access token using the refresh token.
+ * Returns the new access token or null if refresh fails.
+ */
+export async function refreshQuickBooksToken(): Promise<TokenStore | null> {
+  console.log('🔄 [QB] Starting token refresh...');
+  
+  const tokens = await getTokenFromDatabase();
+  Iif (!tokens) {
+    console.log('❌ [QB] No tokens to refresh');
+    return null;
+  }
+ 
+  const url = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
+  const auth = Buffer.from(`${process.env.QB_CLIENT_ID}:${process.env.QB_CLIENT_SECRET}`).toString('base64');
+  const body = new URLSearchParams({
+    grant_type: 'refresh_token',
+    refresh_token: tokens.refreshToken
+  });
+ 
+  console.log('📤 [QB] Making refresh request to:', url);
+ 
+  try {
+    const resp = await fetch(url, {
+      method: 'POST',
+      headers: {
+        Authorization: `Basic ${auth}`,
+        'Content-Type': 'application/x-www-form-urlencoded'
+      },
+      body: body.toString()
+    });
+ 
+    console.log('📥 [QB] Refresh response status:', resp.status);
+ 
+    Iif (!resp.ok) {
+      const errorText = await resp.text();
+      console.error('❌ [QB] Refresh failed:', resp.status, errorText);
+      throw new Error(`Failed to refresh token: ${resp.status}`);
+    }
+ 
+    const json = await resp.json();
+    console.log('✅ [QB] Refresh successful, expires in:', json.expires_in, 'seconds');
+    
+    const tokenData: TokenStore = {
+      realmId: tokens.realmId,
+      accessToken: json.access_token,
+      refreshToken: json.refresh_token,
+      expiresAt: new Date(Date.now() + json.expires_in * 1000).toISOString()
+    };
+ 
+    console.log('💾 [QB] Saving refreshed tokens...');
+    await saveTokensToDatabase(tokenData);
+    console.log('✅ [QB] Refreshed tokens saved successfully');
+    
+    return tokenData;
+  } catch (error) {
+    console.error('❌ [QB] Error refreshing token:', error);
+    return null;
+  }
+}
+ 
+/**
+ * Get a valid access token, refreshing if necessary.
+ * Returns null if no token exists or refresh fails.
+ */
+export async function getValidAccessToken(): Promise<string | null> {
+  console.log('🎯 [QB] Getting valid access token...');
+  
+  const tokens = await getTokenFromDatabase();
+  Iif (!tokens) {
+    console.log('❌ [QB] No tokens available');
+    return null;
+  }
+ 
+  const now = Date.now();
+  const expiresAt = new Date(tokens.expiresAt).getTime();
+  const timeUntilExpiry = expiresAt - now;
+  
+  console.log('⏱️ [QB] Time until expiry:', Math.round(timeUntilExpiry / 1000), 'seconds');
+ 
+  // Check if token is expired or will expire in the next minute
+  Iif (new Date(tokens.expiresAt) <= new Date(Date.now() + 60000)) {
+    console.log('🔄 [QB] Token expired or expiring soon, refreshing...');
+    const refreshed = await refreshQuickBooksToken();
+    return refreshed ? refreshed.accessToken : null;
+  }
+ 
+  console.log('✅ [QB] Using existing valid token');
+  return tokens.accessToken;
+}
+ 
+/**
+ * Save QuickBooks tokens to the database.
+ */
+export async function saveTokensToDatabase(tokens: TokenStore): Promise<void> {
+  console.log('💾 [QB] Saving tokens to database...');
+  
+  const { error } = await supabase
+    .from('quickbooks_tokens')
+    .upsert({
+      realm_id: tokens.realmId,
+      access_token: tokens.accessToken,
+      refresh_token: tokens.refreshToken,
+      expires_at: tokens.expiresAt,
+      updated_at: new Date().toISOString(),
+    });
+ 
+  Iif (error) {
+    console.error('❌ [QB] Failed to save tokens:', error.message);
+    throw new Error(`Failed to save QuickBooks tokens: ${error.message}`);
+  }
+  
+  console.log('✅ [QB] Tokens saved successfully');
+}
+ 
+/** Delete QuickBooks tokens */
+export async function deleteTokens(): Promise<void> {
+  console.log('🗑️ [QB] Deleting tokens...');
+  
+  const { error } = await supabase
+    .from('quickbooks_tokens')
+    .delete()
+    .gt('realm_id', ''); // Delete all rows where realm_id > '' (which means all rows)
+ 
+  Iif (error) {
+    console.error('❌ [QB] Failed to delete tokens:', error.message);
+    throw new Error(`Failed to delete QuickBooks tokens: ${error.message}`);
+  }
+  
+  console.log('✅ [QB] Tokens deleted successfully');
+}
+ 
+// Add these exports for the QuickBooks service
+export const getTokens = getTokenFromDatabase;
+export const saveTokens = saveTokensToDatabase;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 00000000..25938166 --- /dev/null +++ b/coverage/lcov.info @@ -0,0 +1,4564 @@ +TN: +SF:src/supabase.ts +FNF:0 +FNH:0 +DA:1,0 +DA:2,0 +DA:4,0 +DA:6,0 +DA:7,0 +DA:9,0 +DA:10,0 +DA:13,0 +DA:21,0 +LF:9 +LH:0 +BRDA:6,0,0,0 +BRDA:6,0,1,0 +BRDA:7,1,0,0 +BRDA:7,1,1,0 +BRDA:9,2,0,0 +BRDA:9,3,0,0 +BRDA:9,3,1,0 +BRF:7 +BRH:0 +end_of_record +TN: +SF:src/types.ts +FN:5,(anonymous_0) +FN:15,(anonymous_1) +FN:23,(anonymous_2) +FN:31,(anonymous_3) +FN:40,(anonymous_4) +FN:47,(anonymous_5) +FN:56,(anonymous_6) +FN:63,(anonymous_7) +FN:68,(anonymous_8) +FN:275,(anonymous_9) +FN:286,(anonymous_10) +FN:291,(anonymous_11) +FN:297,(anonymous_12) +FNF:13 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:275,0 +DA:276,0 +DA:277,0 +DA:278,0 +DA:279,0 +DA:280,0 +DA:281,0 +DA:282,0 +DA:283,0 +DA:286,0 +DA:287,0 +DA:288,0 +DA:291,0 +DA:292,0 +DA:293,0 +DA:294,0 +DA:297,0 +DA:298,0 +DA:299,0 +DA:300,0 +DA:301,0 +DA:302,0 +DA:303,0 +DA:304,0 +DA:305,0 +DA:306,0 +DA:307,0 +DA:308,0 +DA:309,0 +DA:310,0 +DA:311,0 +DA:312,0 +DA:313,0 +DA:314,0 +DA:315,0 +DA:316,0 +DA:317,0 +DA:318,0 +DA:319,0 +DA:320,0 +DA:321,0 +DA:322,0 +DA:323,0 +DA:324,0 +DA:325,0 +DA:326,0 +DA:327,0 +DA:328,0 +DA:329,0 +DA:330,0 +DA:331,0 +DA:332,0 +DA:333,0 +DA:334,0 +DA:335,0 +DA:336,0 +DA:337,0 +DA:338,0 +DA:339,0 +DA:340,0 +DA:341,0 +DA:342,0 +DA:343,0 +DA:344,0 +DA:345,0 +DA:346,0 +DA:347,0 +LF:121 +LH:0 +BRDA:5,0,0,0 +BRDA:5,0,1,0 +BRDA:15,1,0,0 +BRDA:15,1,1,0 +BRDA:23,2,0,0 +BRDA:23,2,1,0 +BRDA:31,3,0,0 +BRDA:31,3,1,0 +BRDA:40,4,0,0 +BRDA:40,4,1,0 +BRDA:47,5,0,0 +BRDA:47,5,1,0 +BRDA:56,6,0,0 +BRDA:56,6,1,0 +BRDA:63,7,0,0 +BRDA:63,7,1,0 +BRDA:68,8,0,0 +BRDA:68,8,1,0 +BRDA:275,9,0,0 +BRDA:275,9,1,0 +BRDA:286,10,0,0 +BRDA:286,10,1,0 +BRDA:291,11,0,0 +BRDA:291,11,1,0 +BRDA:297,12,0,0 +BRDA:297,12,1,0 +BRF:26 +BRH:0 +end_of_record +TN: +SF:src/api/index.ts +FNF:0 +FNH:0 +DA:1,0 +DA:2,0 +DA:4,0 +DA:6,0 +DA:8,0 +LF:5 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/api/simulate-payment.ts +FN:7,(anonymous_1) +FNF:1 +FNH:0 +FNDA:0,(anonymous_1) +DA:1,0 +DA:2,0 +DA:4,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:23,0 +DA:38,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:53,0 +DA:55,0 +DA:56,0 +DA:60,0 +LF:23 +LH:0 +BRDA:10,0,0,0 +BRDA:10,1,0,0 +BRDA:10,1,1,0 +BRDA:17,2,0,0 +BRDA:49,3,0,0 +BRDA:56,4,0,0 +BRDA:56,4,1,0 +BRF:7 +BRH:0 +end_of_record +TN: +SF:src/api/qbo/status.ts +FN:6,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:1,0 +DA:2,0 +DA:4,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:11,0 +DA:12,0 +DA:16,0 +LF:10 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/config/index.ts +FNF:0 +FNH:0 +DA:1,0 +LF:1 +LH:0 +BRDA:2,0,0,0 +BRDA:2,0,1,0 +BRDA:4,1,0,0 +BRDA:4,1,1,0 +BRDA:5,2,0,0 +BRDA:5,2,1,0 +BRF:6 +BRH:0 +end_of_record +TN: +SF:src/config/stripe.ts +FNF:0 +FNH:0 +DA:1,0 +DA:3,0 +DA:4,0 +DA:7,0 +LF:4 +LH:0 +BRDA:3,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/controllers/authController.ts +FN:25,(anonymous_1) +FN:38,(anonymous_2) +FN:63,(anonymous_3) +FN:87,(anonymous_4) +FN:136,(anonymous_5) +FN:154,(anonymous_6) +FN:180,(anonymous_7) +FN:186,(anonymous_8) +FN:202,(anonymous_9) +FN:226,(anonymous_10) +FN:262,(anonymous_11) +FN:299,(anonymous_12) +FN:325,(anonymous_13) +FN:355,(anonymous_14) +FN:376,(anonymous_15) +FNF:15 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +DA:3,0 +DA:10,0 +DA:22,0 +DA:26,0 +DA:27,0 +DA:42,0 +DA:43,0 +DA:45,0 +DA:46,0 +DA:49,0 +DA:50,0 +DA:67,0 +DA:68,0 +DA:70,0 +DA:71,0 +DA:74,0 +DA:75,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:101,0 +DA:104,0 +DA:105,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:115,0 +DA:120,0 +DA:121,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:162,0 +DA:165,0 +DA:168,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:189,0 +DA:190,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:213,0 +DA:214,0 +DA:230,0 +DA:231,0 +DA:232,0 +DA:235,0 +DA:237,0 +DA:238,0 +DA:246,0 +DA:248,0 +DA:266,0 +DA:267,0 +DA:269,0 +DA:270,0 +DA:273,0 +DA:275,0 +DA:283,0 +DA:285,0 +DA:303,0 +DA:304,0 +DA:305,0 +DA:308,0 +DA:310,0 +DA:312,0 +DA:313,0 +DA:329,0 +DA:330,0 +DA:331,0 +DA:334,0 +DA:336,0 +DA:337,0 +DA:339,0 +DA:359,0 +DA:360,0 +DA:361,0 +DA:363,0 +DA:365,0 +DA:370,0 +DA:371,0 +DA:380,0 +DA:382,0 +DA:383,0 +DA:384,0 +DA:385,0 +DA:386,0 +DA:387,0 +DA:388,0 +DA:389,0 +DA:390,0 +DA:391,0 +DA:393,0 +LF:107 +LH:0 +BRDA:89,0,0,0 +BRDA:89,0,1,0 +BRDA:90,1,0,0 +BRDA:97,2,0,0 +BRDA:107,3,0,0 +BRDA:107,4,0,0 +BRDA:107,4,1,0 +BRDA:108,5,0,0 +BRDA:108,5,1,0 +BRDA:109,6,0,0 +BRDA:269,7,0,0 +BRDA:382,8,0,0 +BRDA:382,8,1,0 +BRDA:384,9,0,0 +BRDA:384,9,1,0 +BRDA:386,10,0,0 +BRDA:386,10,1,0 +BRDA:388,11,0,0 +BRDA:388,11,1,0 +BRDA:390,12,0,0 +BRDA:390,12,1,0 +BRF:21 +BRH:0 +end_of_record +TN: +SF:src/controllers/clientController.ts +FN:17,(anonymous_0) +FN:29,(anonymous_1) +FN:40,(anonymous_2) +FN:56,(anonymous_3) +FN:85,(anonymous_4) +FN:118,(anonymous_5) +FN:163,(anonymous_6) +FN:232,(anonymous_7) +FN:254,(anonymous_8) +FNF:9 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +DA:2,0 +DA:14,0 +DA:18,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:34,0 +DA:38,0 +DA:40,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:66,0 +DA:69,0 +DA:71,0 +DA:72,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:95,0 +DA:99,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:122,0 +DA:123,0 +DA:125,0 +DA:126,0 +DA:127,0 +DA:130,0 +DA:132,0 +DA:134,0 +DA:150,0 +DA:151,0 +DA:167,0 +DA:168,0 +DA:170,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:193,0 +DA:200,0 +DA:201,0 +DA:206,0 +DA:208,0 +DA:225,0 +DA:226,0 +DA:227,0 +DA:236,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:249,0 +DA:255,0 +LF:74 +LH:0 +BRDA:34,0,0,0 +BRDA:34,0,1,0 +BRDA:43,1,0,0 +BRDA:71,2,0,0 +BRDA:90,3,0,0 +BRDA:95,4,0,0 +BRDA:95,4,1,0 +BRDA:102,5,0,0 +BRDA:125,6,0,0 +BRDA:125,7,0,0 +BRDA:125,7,1,0 +BRDA:180,8,0,0 +BRDA:187,9,0,0 +BRDA:238,10,0,0 +BRDA:238,10,1,0 +BRDA:240,11,0,0 +BRDA:240,11,1,0 +BRDA:242,12,0,0 +BRDA:242,12,1,0 +BRDA:244,13,0,0 +BRDA:244,13,1,0 +BRDA:246,14,0,0 +BRDA:246,14,1,0 +BRF:23 +BRH:0 +end_of_record +TN: +SF:src/controllers/contractController.ts +FN:17,(anonymous_0) +FN:24,(anonymous_1) +FN:58,(anonymous_2) +FN:84,(anonymous_3) +FN:90,(anonymous_4) +FN:109,(anonymous_5) +FN:136,(anonymous_6) +FN:165,(anonymous_7) +FN:196,(anonymous_8) +FN:230,(anonymous_9) +FN:252,(anonymous_10) +FNF:11 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +DA:2,0 +DA:14,0 +DA:18,0 +DA:28,0 +DA:29,0 +DA:31,0 +DA:32,0 +DA:36,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:63,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:93,0 +DA:95,0 +DA:96,0 +DA:113,0 +DA:115,0 +DA:116,0 +DA:117,0 +DA:120,0 +DA:122,0 +DA:123,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:149,0 +DA:151,0 +DA:152,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:173,0 +DA:174,0 +DA:176,0 +DA:178,0 +DA:181,0 +DA:183,0 +DA:184,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:204,0 +DA:207,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:214,0 +DA:215,0 +DA:218,0 +DA:221,0 +DA:223,0 +DA:224,0 +DA:234,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:247,0 +DA:253,0 +LF:81 +LH:0 +BRDA:31,0,0,0 +BRDA:31,1,0,0 +BRDA:31,1,1,0 +BRDA:31,1,2,0 +BRDA:49,2,0,0 +BRDA:61,3,0,0 +BRDA:70,4,0,0 +BRDA:95,5,0,0 +BRDA:122,6,0,0 +BRDA:151,7,0,0 +BRDA:173,8,0,0 +BRDA:174,9,0,0 +BRDA:183,10,0,0 +BRDA:204,11,0,0 +BRDA:207,12,0,0 +BRDA:207,12,1,0 +BRDA:209,13,0,0 +BRDA:209,13,1,0 +BRDA:223,14,0,0 +BRDA:236,15,0,0 +BRDA:236,15,1,0 +BRDA:238,16,0,0 +BRDA:238,16,1,0 +BRDA:240,17,0,0 +BRDA:240,17,1,0 +BRDA:242,18,0,0 +BRDA:242,18,1,0 +BRDA:244,19,0,0 +BRDA:244,19,1,0 +BRF:29 +BRH:0 +end_of_record +TN: +SF:src/controllers/emailController.ts +FN:7,(anonymous_0) +FN:11,(anonymous_1) +FN:42,(anonymous_2) +FNF:3 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +DA:2,0 +DA:4,0 +DA:8,0 +DA:12,0 +DA:13,0 +DA:15,0 +DA:16,0 +DA:20,0 +DA:23,0 +DA:29,0 +DA:34,0 +DA:35,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:52,0 +DA:55,0 +DA:62,0 +DA:67,0 +DA:68,0 +DA:73,0 +LF:23 +LH:0 +BRDA:15,0,0,0 +BRDA:15,1,0,0 +BRDA:15,1,1,0 +BRDA:15,1,2,0 +BRDA:37,2,0,0 +BRDA:37,2,1,0 +BRDA:46,3,0,0 +BRDA:46,4,0,0 +BRDA:46,4,1,0 +BRDA:46,4,2,0 +BRDA:46,4,3,0 +BRDA:75,5,0,0 +BRDA:75,5,1,0 +BRF:13 +BRH:0 +end_of_record +TN: +SF:src/controllers/paymentController.ts +FN:22,(anonymous_0) +FN:54,(anonymous_1) +FN:87,(anonymous_2) +FN:120,(anonymous_3) +FN:148,(anonymous_4) +FNF:5 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +DA:2,0 +DA:3,0 +DA:5,0 +DA:8,0 +DA:12,0 +DA:17,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:28,0 +DA:29,0 +DA:33,0 +DA:36,0 +DA:41,0 +DA:46,0 +DA:47,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:60,0 +DA:61,0 +DA:65,0 +DA:68,0 +DA:74,0 +DA:79,0 +DA:80,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:93,0 +DA:94,0 +DA:98,0 +DA:101,0 +DA:107,0 +DA:112,0 +DA:113,0 +DA:121,0 +DA:122,0 +DA:125,0 +DA:126,0 +DA:130,0 +DA:133,0 +DA:135,0 +DA:140,0 +DA:141,0 +DA:149,0 +DA:151,0 +DA:152,0 +DA:156,0 +DA:159,0 +DA:161,0 +DA:166,0 +DA:167,0 +DA:175,0 +LF:54 +LH:0 +BRDA:28,0,0,0 +BRDA:28,1,0,0 +BRDA:28,1,1,0 +BRDA:60,2,0,0 +BRDA:60,3,0,0 +BRDA:60,3,1,0 +BRDA:93,4,0,0 +BRDA:93,5,0,0 +BRDA:93,5,1,0 +BRDA:125,6,0,0 +BRDA:125,7,0,0 +BRDA:125,7,1,0 +BRDA:151,8,0,0 +BRF:13 +BRH:0 +end_of_record +TN: +SF:src/controllers/quickbooksController.ts +FN:20,(anonymous_1) +FN:33,(anonymous_2) +FN:46,(anonymous_3) +FN:66,(anonymous_4) +FN:78,(anonymous_5) +FN:90,(anonymous_6) +FN:103,(anonymous_7) +FN:118,(anonymous_8) +FN:131,(anonymous_9) +FNF:9 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +DA:3,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:13,0 +DA:15,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:26,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:39,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:59,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:71,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:83,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:96,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:110,0 +DA:111,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:123,0 +DA:131,0 +DA:132,0 +DA:133,0 +DA:138,0 +DA:139,0 +DA:141,0 +LF:59 +LH:0 +BRDA:138,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/controllers/requestFormController.ts +FN:12,(anonymous_0) +FN:16,(anonymous_1) +FN:41,(anonymous_2) +FN:59,(anonymous_3) +FN:88,(anonymous_4) +FN:112,(anonymous_5) +FN:147,(anonymous_6) +FN:194,(anonymous_7) +FNF:8 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +DA:2,0 +DA:4,0 +DA:6,0 +DA:7,0 +DA:9,0 +DA:13,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:28,0 +DA:29,0 +DA:31,0 +DA:36,0 +DA:37,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:48,0 +DA:49,0 +DA:54,0 +DA:55,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:78,0 +DA:83,0 +DA:84,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:101,0 +DA:102,0 +DA:107,0 +DA:108,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:125,0 +DA:126,0 +DA:127,0 +DA:128,0 +DA:131,0 +DA:132,0 +DA:133,0 +DA:134,0 +DA:137,0 +DA:142,0 +DA:143,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:160,0 +DA:161,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:179,0 +DA:182,0 +DA:183,0 +DA:188,0 +DA:189,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:200,0 +DA:201,0 +DA:204,0 +DA:205,0 +DA:208,0 +DA:282,0 +DA:404,0 +DA:406,0 +DA:411,0 +DA:412,0 +DA:414,0 +DA:421,0 +DA:440,0 +DA:442,0 +DA:446,0 +DA:448,0 +DA:449,0 +LF:114 +LH:0 +BRDA:18,0,0,0 +BRDA:23,1,0,0 +BRDA:43,2,0,0 +BRDA:61,3,0,0 +BRDA:67,4,0,0 +BRDA:73,5,0,0 +BRDA:90,6,0,0 +BRDA:96,7,0,0 +BRDA:114,8,0,0 +BRDA:120,9,0,0 +BRDA:126,10,0,0 +BRDA:132,11,0,0 +BRDA:149,12,0,0 +BRDA:155,13,0,0 +BRDA:163,14,0,0 +BRDA:168,15,0,0 +BRDA:174,16,0,0 +BRDA:196,17,0,0 +BRDA:214,18,0,0 +BRDA:214,18,1,0 +BRDA:214,19,0,0 +BRDA:214,19,1,0 +BRDA:215,20,0,0 +BRDA:215,20,1,0 +BRDA:222,21,0,0 +BRDA:222,21,1,0 +BRDA:223,22,0,0 +BRDA:223,22,1,0 +BRDA:224,23,0,0 +BRDA:224,23,1,0 +BRDA:225,24,0,0 +BRDA:225,24,1,0 +BRDA:228,25,0,0 +BRDA:228,25,1,0 +BRDA:229,26,0,0 +BRDA:229,26,1,0 +BRDA:229,27,0,0 +BRDA:229,27,1,0 +BRDA:229,28,0,0 +BRDA:229,28,1,0 +BRDA:230,29,0,0 +BRDA:230,29,1,0 +BRDA:231,30,0,0 +BRDA:231,30,1,0 +BRDA:234,31,0,0 +BRDA:234,31,1,0 +BRDA:235,32,0,0 +BRDA:235,32,1,0 +BRDA:236,33,0,0 +BRDA:236,33,1,0 +BRDA:239,34,0,0 +BRDA:239,34,1,0 +BRDA:240,35,0,0 +BRDA:240,35,1,0 +BRDA:241,36,0,0 +BRDA:241,36,1,0 +BRDA:244,37,0,0 +BRDA:244,37,1,0 +BRDA:246,38,0,0 +BRDA:246,38,1,0 +BRDA:249,39,0,0 +BRDA:249,39,1,0 +BRDA:250,40,0,0 +BRDA:250,40,1,0 +BRDA:251,41,0,0 +BRDA:251,41,1,0 +BRDA:252,42,0,0 +BRDA:252,42,1,0 +BRDA:253,43,0,0 +BRDA:253,43,1,0 +BRDA:254,44,0,0 +BRDA:254,44,1,0 +BRDA:255,45,0,0 +BRDA:255,45,1,0 +BRDA:256,46,0,0 +BRDA:256,46,1,0 +BRDA:259,47,0,0 +BRDA:259,47,1,0 +BRDA:260,48,0,0 +BRDA:260,48,1,0 +BRDA:261,49,0,0 +BRDA:261,49,1,0 +BRDA:262,50,0,0 +BRDA:262,50,1,0 +BRDA:265,51,0,0 +BRDA:265,51,1,0 +BRDA:265,52,0,0 +BRDA:265,52,1,0 +BRDA:266,53,0,0 +BRDA:266,53,1,0 +BRDA:269,54,0,0 +BRDA:269,54,1,0 +BRDA:270,55,0,0 +BRDA:270,55,1,0 +BRDA:271,56,0,0 +BRDA:271,56,1,0 +BRDA:272,57,0,0 +BRDA:272,57,1,0 +BRDA:273,58,0,0 +BRDA:273,58,1,0 +BRDA:273,59,0,0 +BRDA:273,59,1,0 +BRDA:293,60,0,0 +BRDA:293,60,1,0 +BRDA:293,61,0,0 +BRDA:293,61,1,0 +BRDA:294,62,0,0 +BRDA:294,62,1,0 +BRDA:303,63,0,0 +BRDA:303,63,1,0 +BRDA:304,64,0,0 +BRDA:304,64,1,0 +BRDA:305,65,0,0 +BRDA:305,65,1,0 +BRDA:306,66,0,0 +BRDA:306,66,1,0 +BRDA:313,67,0,0 +BRDA:313,67,1,0 +BRDA:314,68,0,0 +BRDA:314,68,1,0 +BRDA:314,69,0,0 +BRDA:314,69,1,0 +BRDA:314,70,0,0 +BRDA:314,70,1,0 +BRDA:315,71,0,0 +BRDA:315,71,1,0 +BRDA:316,72,0,0 +BRDA:316,72,1,0 +BRDA:323,73,0,0 +BRDA:323,73,1,0 +BRDA:324,74,0,0 +BRDA:324,74,1,0 +BRDA:325,75,0,0 +BRDA:325,75,1,0 +BRDA:332,76,0,0 +BRDA:332,76,1,0 +BRDA:333,77,0,0 +BRDA:333,77,1,0 +BRDA:334,78,0,0 +BRDA:334,78,1,0 +BRDA:341,79,0,0 +BRDA:341,79,1,0 +BRDA:343,80,0,0 +BRDA:343,80,1,0 +BRDA:350,81,0,0 +BRDA:350,81,1,0 +BRDA:351,82,0,0 +BRDA:351,82,1,0 +BRDA:352,83,0,0 +BRDA:352,83,1,0 +BRDA:353,84,0,0 +BRDA:353,84,1,0 +BRDA:354,85,0,0 +BRDA:354,85,1,0 +BRDA:355,86,0,0 +BRDA:355,86,1,0 +BRDA:356,87,0,0 +BRDA:356,87,1,0 +BRDA:357,88,0,0 +BRDA:357,88,1,0 +BRDA:364,89,0,0 +BRDA:364,89,1,0 +BRDA:365,90,0,0 +BRDA:365,90,1,0 +BRDA:366,91,0,0 +BRDA:366,91,1,0 +BRDA:367,92,0,0 +BRDA:367,92,1,0 +BRDA:374,93,0,0 +BRDA:374,93,1,0 +BRDA:374,94,0,0 +BRDA:374,94,1,0 +BRDA:375,95,0,0 +BRDA:375,95,1,0 +BRDA:382,96,0,0 +BRDA:382,96,1,0 +BRDA:383,97,0,0 +BRDA:383,97,1,0 +BRDA:384,98,0,0 +BRDA:384,98,1,0 +BRDA:385,99,0,0 +BRDA:385,99,1,0 +BRDA:386,100,0,0 +BRDA:386,100,1,0 +BRDA:386,101,0,0 +BRDA:386,101,1,0 +BRF:186 +BRH:0 +end_of_record +TN: +SF:src/controllers/userController.ts +FN:10,(anonymous_0) +FN:14,(anonymous_1) +FN:25,(anonymous_2) +FN:28,(anonymous_3) +FN:33,(anonymous_4) +FN:36,(anonymous_5) +FN:42,(anonymous_6) +FN:51,(anonymous_7) +FN:64,(anonymous_8) +FN:80,(anonymous_9) +FN:97,(anonymous_10) +FN:118,(anonymous_11) +FN:135,(anonymous_12) +FNF:13 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +DA:2,0 +DA:7,0 +DA:11,0 +DA:15,0 +DA:16,0 +DA:18,0 +DA:19,0 +DA:21,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:38,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:47,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:60,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:71,0 +DA:72,0 +DA:75,0 +DA:76,0 +DA:81,0 +DA:82,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:89,0 +DA:90,0 +DA:92,0 +DA:93,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:110,0 +DA:112,0 +DA:114,0 +DA:119,0 +DA:120,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:127,0 +DA:128,0 +DA:130,0 +DA:131,0 +DA:136,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:147,0 +DA:149,0 +LF:77 +LH:0 +BRDA:67,0,0,0 +BRDA:67,0,1,0 +BRDA:84,1,0,0 +BRDA:84,2,0,0 +BRDA:84,2,1,0 +BRDA:84,2,2,0 +BRDA:84,2,3,0 +BRDA:104,3,0,0 +BRDA:122,4,0,0 +BRDA:122,5,0,0 +BRDA:122,5,1,0 +BRDA:122,5,2,0 +BRDA:122,5,3,0 +BRDA:138,6,0,0 +BRDA:138,6,1,0 +BRDA:140,7,0,0 +BRDA:140,7,1,0 +BRDA:142,8,0,0 +BRDA:142,8,1,0 +BRDA:144,9,0,0 +BRDA:144,9,1,0 +BRDA:146,10,0,0 +BRDA:146,10,1,0 +BRF:23 +BRH:0 +end_of_record +TN: +SF:src/db/checkTables.ts +FN:3,checkTables +FNF:1 +FNH:0 +FNDA:0,checkTables +DA:1,0 +DA:4,0 +DA:7,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:16,0 +DA:20,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:29,0 +DA:34,0 +LF:13 +LH:0 +BRDA:13,0,0,0 +BRDA:13,0,1,0 +BRDA:26,1,0,0 +BRDA:26,1,1,0 +BRF:4 +BRH:0 +end_of_record +TN: +SF:src/db/setupStripeDb.ts +FN:3,setupStripeDb +FNF:1 +FNH:0 +FNDA:0,setupStripeDb +DA:1,0 +DA:4,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:69,0 +DA:70,0 +DA:75,0 +LF:9 +LH:0 +BRDA:66,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/domains/errors/AuthenticationError.ts +FN:4,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:1,0 +DA:3,0 +DA:5,0 +DA:6,0 +LF:4 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/domains/errors/AuthorizationError.ts +FN:4,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:1,0 +DA:3,0 +DA:5,0 +DA:6,0 +LF:4 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/domains/errors/ConflictError.ts +FN:4,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:1,0 +DA:3,0 +DA:5,0 +DA:6,0 +LF:4 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/domains/errors/DomainError.ts +FN:2,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:1,0 +DA:3,0 +DA:4,0 +DA:6,0 +LF:4 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/domains/errors/NotFoundError.ts +FN:4,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:1,0 +DA:3,0 +DA:5,0 +DA:6,0 +LF:4 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/domains/errors/ValidationError.ts +FN:4,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:1,0 +DA:3,0 +DA:5,0 +DA:6,0 +LF:4 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/domains/errors/index.ts +FNF:0 +FNH:0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +LF:6 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/entities/Activity.ts +FN:6,(anonymous_0) +FN:16,(anonymous_1) +FNF:2 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +DA:5,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:17,0 +LF:9 +LH:0 +BRDA:12,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/entities/Client.ts +FN:5,(anonymous_0) +FN:26,(anonymous_1) +FNF:2 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +DA:4,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:27,0 +LF:18 +LH:0 +BRDA:37,0,0,0 +BRDA:37,0,1,0 +BRDA:38,1,0,0 +BRDA:38,1,1,0 +BRDA:39,2,0,0 +BRDA:39,2,1,0 +BRDA:40,3,0,0 +BRDA:40,3,1,0 +BRDA:41,4,0,0 +BRDA:41,4,1,0 +BRDA:42,5,0,0 +BRDA:42,5,1,0 +BRDA:43,6,0,0 +BRDA:43,6,1,0 +BRDA:44,7,0,0 +BRDA:44,7,1,0 +BRDA:45,8,0,0 +BRDA:45,8,1,0 +BRDA:46,9,0,0 +BRDA:46,9,1,0 +BRF:20 +BRH:0 +end_of_record +TN: +SF:src/entities/Hours.ts +FNF:0 +FNH:0 +DA:1,0 +DA:17,0 +LF:2 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/entities/Note.ts +FN:1,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:1,0 +DA:2,0 +DA:3,0 +DA:6,0 +LF:4 +LH:0 +BRDA:1,0,0,0 +BRDA:1,0,1,0 +BRF:2 +BRH:0 +end_of_record +TN: +SF:src/entities/RequestForm.ts +FN:21,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:13,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:65,0 +DA:66,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:86,0 +DA:87,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +LF:51 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/entities/Template.ts +FN:2,(anonymous_0) +FN:10,(anonymous_1) +FNF:2 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +DA:1,0 +DA:3,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:11,0 +LF:7 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/entities/User.ts +FN:30,(anonymous_0) +FN:84,(anonymous_1) +FN:88,(anonymous_2) +FNF:3 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +DA:1,0 +DA:3,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:85,0 +DA:89,0 +LF:29 +LH:0 +BRDA:58,0,0,0 +BRDA:58,0,1,0 +BRDA:59,1,0,0 +BRDA:59,1,1,0 +BRDA:60,2,0,0 +BRDA:60,2,1,0 +BRDA:61,3,0,0 +BRDA:61,3,1,0 +BRDA:62,4,0,0 +BRDA:62,4,1,0 +BRDA:63,5,0,0 +BRDA:63,5,1,0 +BRDA:64,6,0,0 +BRDA:64,6,1,0 +BRDA:65,7,0,0 +BRDA:65,7,1,0 +BRDA:66,8,0,0 +BRDA:66,8,1,0 +BRDA:67,9,0,0 +BRDA:67,9,1,0 +BRDA:68,10,0,0 +BRDA:68,10,1,0 +BRDA:69,11,0,0 +BRDA:69,11,1,0 +BRDA:70,12,0,0 +BRDA:70,12,1,0 +BRDA:71,13,0,0 +BRDA:71,13,1,0 +BRDA:72,14,0,0 +BRDA:72,14,1,0 +BRDA:73,15,0,0 +BRDA:73,15,1,0 +BRDA:74,16,0,0 +BRDA:74,16,1,0 +BRDA:75,17,0,0 +BRDA:75,17,1,0 +BRDA:76,18,0,0 +BRDA:76,18,1,0 +BRDA:77,19,0,0 +BRDA:77,19,1,0 +BRDA:78,20,0,0 +BRDA:78,20,1,0 +BRDA:79,21,0,0 +BRDA:79,21,1,0 +BRDA:80,22,0,0 +BRDA:80,22,1,0 +BRDA:81,23,0,0 +BRDA:81,23,1,0 +BRF:48 +BRH:0 +end_of_record +TN: +SF:src/middleware/auth.ts +FN:5,(anonymous_1) +FN:29,(anonymous_2) +FN:30,(anonymous_3) +FNF:3 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +DA:2,0 +DA:3,0 +DA:5,0 +DA:10,0 +DA:11,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:18,0 +DA:20,0 +DA:26,0 +DA:29,0 +DA:30,0 +DA:32,0 +DA:34,0 +DA:35,0 +LF:16 +LH:0 +BRDA:13,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/middleware/authMiddleware.ts +FN:6,(anonymous_1) +FNF:1 +FNH:0 +FNDA:0,(anonymous_1) +DA:2,0 +DA:3,0 +DA:6,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:24,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:36,0 +DA:37,0 +DA:43,0 +LF:20 +LH:0 +BRDA:14,0,0,0 +BRDA:14,0,1,0 +BRDA:16,1,0,0 +BRDA:26,2,0,0 +BRDA:26,3,0,0 +BRDA:26,3,1,0 +BRF:6 +BRH:0 +end_of_record +TN: +SF:src/middleware/authorizeRoles.ts +FN:9,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:9,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:26,0 +DA:28,0 +DA:32,0 +LF:11 +LH:0 +BRDA:16,0,0,0 +BRDA:16,1,0,0 +BRDA:16,1,1,0 +BRDA:21,2,0,0 +BRF:4 +BRH:0 +end_of_record +TN: +SF:src/middleware/validateRequest.ts +FN:4,(anonymous_0) +FN:5,(anonymous_1) +FNF:2 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:10,0 +LF:6 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/repositories/requestFormRepository.ts +FN:7,(anonymous_0) +FN:11,(anonymous_1) +FN:105,(anonymous_2) +FN:125,(anonymous_3) +FN:149,(anonymous_4) +FN:168,(anonymous_5) +FN:191,(anonymous_6) +FNF:7 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +DA:4,0 +DA:8,0 +DA:12,0 +DA:13,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:96,0 +DA:97,0 +DA:100,0 +DA:101,0 +DA:106,0 +DA:107,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:118,0 +DA:120,0 +DA:121,0 +DA:126,0 +DA:127,0 +DA:134,0 +DA:135,0 +DA:136,0 +DA:138,0 +DA:139,0 +DA:142,0 +DA:144,0 +DA:145,0 +DA:150,0 +DA:151,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:161,0 +DA:163,0 +DA:164,0 +DA:169,0 +DA:170,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:180,0 +DA:181,0 +DA:184,0 +DA:186,0 +DA:187,0 +DA:192,0 +DA:193,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:205,0 +DA:207,0 +DA:208,0 +LF:55 +LH:0 +BRDA:91,0,0,0 +BRDA:113,1,0,0 +BRDA:134,2,0,0 +BRDA:135,3,0,0 +BRDA:156,4,0,0 +BRDA:176,5,0,0 +BRDA:177,6,0,0 +BRDA:200,7,0,0 +BRF:8 +BRH:0 +end_of_record +TN: +SF:src/repositories/supabaseActivityRepository.ts +FN:8,(anonymous_0) +FN:12,(anonymous_1) +FN:33,(anonymous_2) +FN:44,(anonymous_3) +FN:47,(anonymous_4) +FN:57,(anonymous_5) +FN:60,(anonymous_6) +FNF:7 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +DA:2,0 +DA:5,0 +DA:9,0 +DA:13,0 +DA:26,0 +DA:27,0 +DA:30,0 +DA:34,0 +DA:40,0 +DA:41,0 +DA:44,0 +DA:48,0 +DA:53,0 +DA:54,0 +DA:57,0 +DA:61,0 +LF:16 +LH:0 +BRDA:26,0,0,0 +BRDA:40,1,0,0 +BRDA:53,2,0,0 +BRF:3 +BRH:0 +end_of_record +TN: +SF:src/repositories/supabaseClientRepository.ts +FN:11,(anonymous_0) +FN:17,(anonymous_1) +FN:38,(anonymous_2) +FN:41,(anonymous_3) +FN:53,(anonymous_4) +FN:81,(anonymous_5) +FN:84,(anonymous_6) +FN:95,(anonymous_7) +FN:98,(anonymous_8) +FN:115,(anonymous_9) +FN:118,(anonymous_10) +FN:141,(anonymous_11) +FN:155,(anonymous_12) +FN:185,(anonymous_13) +FN:280,(anonymous_14) +FN:287,(anonymous_15) +FN:291,(anonymous_16) +FN:321,(anonymous_17) +FNF:18 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +FNDA:0,(anonymous_17) +DA:4,0 +DA:5,0 +DA:6,0 +DA:8,0 +DA:14,0 +DA:18,0 +DA:37,0 +DA:38,0 +DA:42,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:54,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:62,0 +DA:80,0 +DA:81,0 +DA:85,0 +DA:94,0 +DA:95,0 +DA:99,0 +DA:101,0 +DA:103,0 +DA:113,0 +DA:115,0 +DA:119,0 +DA:137,0 +DA:138,0 +DA:142,0 +DA:151,0 +DA:152,0 +DA:156,0 +DA:178,0 +DA:179,0 +DA:182,0 +DA:186,0 +DA:187,0 +DA:190,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:212,0 +DA:213,0 +DA:215,0 +DA:220,0 +DA:223,0 +DA:229,0 +DA:230,0 +DA:231,0 +DA:234,0 +DA:235,0 +DA:236,0 +DA:239,0 +DA:242,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:252,0 +DA:255,0 +DA:264,0 +DA:265,0 +DA:266,0 +DA:269,0 +DA:270,0 +DA:271,0 +DA:274,0 +DA:275,0 +DA:276,0 +DA:281,0 +DA:286,0 +DA:287,0 +DA:292,0 +DA:322,0 +DA:324,0 +DA:353,0 +LF:91 +LH:0 +BRDA:37,0,0,0 +BRDA:47,1,0,0 +BRDA:47,2,0,0 +BRDA:47,2,1,0 +BRDA:56,3,0,0 +BRDA:80,4,0,0 +BRDA:94,5,0,0 +BRDA:101,6,0,0 +BRDA:113,7,0,0 +BRDA:137,8,0,0 +BRDA:151,9,0,0 +BRDA:178,10,0,0 +BRDA:193,11,0,0 +BRDA:194,12,0,0 +BRDA:195,13,0,0 +BRDA:196,14,0,0 +BRDA:197,15,0,0 +BRDA:198,16,0,0 +BRDA:199,17,0,0 +BRDA:200,18,0,0 +BRDA:201,19,0,0 +BRDA:202,20,0,0 +BRDA:203,21,0,0 +BRDA:204,22,0,0 +BRDA:205,23,0,0 +BRDA:208,24,0,0 +BRDA:209,25,0,0 +BRDA:210,26,0,0 +BRDA:211,27,0,0 +BRDA:212,28,0,0 +BRDA:213,29,0,0 +BRDA:229,30,0,0 +BRDA:234,31,0,0 +BRDA:247,32,0,0 +BRDA:264,33,0,0 +BRDA:269,34,0,0 +BRDA:286,35,0,0 +BRDA:297,36,0,0 +BRDA:297,36,1,0 +BRDA:298,37,0,0 +BRDA:298,37,1,0 +BRDA:299,38,0,0 +BRDA:299,38,1,0 +BRDA:322,39,0,0 +BRDA:322,39,1,0 +BRDA:325,40,0,0 +BRDA:325,40,1,0 +BRDA:325,40,2,0 +BRDA:326,41,0,0 +BRDA:326,41,1,0 +BRDA:326,41,2,0 +BRDA:327,42,0,0 +BRDA:327,42,1,0 +BRDA:327,42,2,0 +BRDA:328,43,0,0 +BRDA:328,43,1,0 +BRDA:328,43,2,0 +BRDA:329,44,0,0 +BRDA:329,44,1,0 +BRDA:330,45,0,0 +BRDA:330,45,1,0 +BRDA:331,46,0,0 +BRDA:331,46,1,0 +BRDA:332,47,0,0 +BRDA:332,47,1,0 +BRDA:332,47,2,0 +BRDA:333,48,0,0 +BRDA:333,48,1,0 +BRDA:333,48,2,0 +BRDA:334,49,0,0 +BRDA:334,49,1,0 +BRDA:334,49,2,0 +BRDA:335,50,0,0 +BRDA:335,50,1,0 +BRDA:335,50,2,0 +BRDA:336,51,0,0 +BRDA:336,51,1,0 +BRDA:336,51,2,0 +BRDA:337,52,0,0 +BRDA:337,52,1,0 +BRDA:338,53,0,0 +BRDA:338,53,1,0 +BRDA:339,54,0,0 +BRDA:339,54,1,0 +BRDA:340,55,0,0 +BRDA:340,55,1,0 +BRDA:341,56,0,0 +BRDA:341,56,1,0 +BRDA:341,56,2,0 +BRDA:342,57,0,0 +BRDA:342,57,1,0 +BRDA:342,57,2,0 +BRDA:343,58,0,0 +BRDA:343,58,1,0 +BRDA:343,58,2,0 +BRDA:344,59,0,0 +BRDA:344,59,1,0 +BRDA:344,59,2,0 +BRDA:345,60,0,0 +BRDA:345,60,1,0 +BRDA:345,60,2,0 +BRDA:346,61,0,0 +BRDA:346,61,1,0 +BRDA:346,61,2,0 +BRDA:347,62,0,0 +BRDA:347,62,1,0 +BRDA:347,62,2,0 +BRDA:348,63,0,0 +BRDA:348,63,1,0 +BRDA:348,63,2,0 +BRDA:356,64,0,0 +BRDA:356,64,1,0 +BRDA:357,65,0,0 +BRDA:357,65,1,0 +BRDA:358,66,0,0 +BRDA:358,66,1,0 +BRDA:359,67,0,0 +BRDA:359,67,1,0 +BRDA:362,68,0,0 +BRDA:362,68,1,0 +BRDA:363,69,0,0 +BRDA:363,69,1,0 +BRDA:364,70,0,0 +BRDA:364,70,1,0 +BRDA:365,71,0,0 +BRDA:365,71,1,0 +BRDA:366,72,0,0 +BRDA:366,72,1,0 +BRDA:367,73,0,0 +BRDA:367,73,1,0 +BRDA:368,74,0,0 +BRDA:368,74,1,0 +BRDA:369,75,0,0 +BRDA:369,75,1,0 +BRDA:370,76,0,0 +BRDA:370,76,1,0 +BRDA:371,77,0,0 +BRDA:371,77,1,0 +BRF:138 +BRH:0 +end_of_record +TN: +SF:src/repositories/supabaseUserRepository.ts +FN:15,(anonymous_0) +FN:21,(anonymous_1) +FN:36,(anonymous_2) +FN:77,(anonymous_3) +FN:96,(anonymous_4) +FN:112,(anonymous_5) +FN:124,(anonymous_6) +FN:158,(anonymous_7) +FN:174,(anonymous_8) +FN:189,(anonymous_9) +FN:192,(anonymous_10) +FN:211,(anonymous_11) +FN:225,(anonymous_12) +FN:238,(anonymous_13) +FN:256,(anonymous_14) +FN:281,(anonymous_15) +FN:299,(anonymous_16) +FN:333,(anonymous_17) +FN:346,(anonymous_18) +FN:382,(anonymous_19) +FN:396,(anonymous_20) +FN:410,(anonymous_21) +FN:421,(anonymous_22) +FN:446,(anonymous_23) +FN:468,(anonymous_24) +FN:503,(anonymous_25) +FNF:26 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:0,(anonymous_18) +FNDA:0,(anonymous_19) +FNDA:0,(anonymous_20) +FNDA:0,(anonymous_21) +FNDA:0,(anonymous_22) +FNDA:0,(anonymous_23) +FNDA:0,(anonymous_24) +FNDA:0,(anonymous_25) +DA:5,0 +DA:8,0 +DA:10,0 +DA:12,0 +DA:18,0 +DA:22,0 +DA:28,0 +DA:29,0 +DA:32,0 +DA:37,0 +DA:43,0 +DA:44,0 +DA:47,0 +DA:78,0 +DA:92,0 +DA:93,0 +DA:96,0 +DA:113,0 +DA:115,0 +DA:120,0 +DA:121,0 +DA:125,0 +DA:145,0 +DA:146,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:154,0 +DA:159,0 +DA:164,0 +DA:165,0 +DA:169,0 +DA:170,0 +DA:174,0 +DA:179,0 +DA:184,0 +DA:185,0 +DA:189,0 +DA:193,0 +DA:204,0 +DA:205,0 +DA:208,0 +DA:213,0 +DA:221,0 +DA:222,0 +DA:226,0 +DA:231,0 +DA:232,0 +DA:235,0 +DA:239,0 +DA:240,0 +DA:245,0 +DA:246,0 +DA:249,0 +DA:250,0 +DA:252,0 +DA:257,0 +DA:258,0 +DA:271,0 +DA:272,0 +DA:275,0 +DA:277,0 +DA:282,0 +DA:284,0 +DA:289,0 +DA:290,0 +DA:291,0 +DA:295,0 +DA:296,0 +DA:299,0 +DA:300,0 +DA:301,0 +DA:302,0 +DA:305,0 +DA:309,0 +DA:327,0 +DA:329,0 +DA:334,0 +DA:336,0 +DA:340,0 +DA:341,0 +DA:342,0 +DA:346,0 +DA:348,0 +DA:349,0 +DA:350,0 +DA:351,0 +DA:353,0 +DA:354,0 +DA:358,0 +DA:376,0 +DA:378,0 +DA:383,0 +DA:389,0 +DA:390,0 +DA:393,0 +DA:398,0 +DA:403,0 +DA:404,0 +DA:407,0 +DA:411,0 +DA:416,0 +DA:417,0 +DA:422,0 +DA:425,0 +DA:432,0 +DA:433,0 +DA:434,0 +DA:438,0 +DA:442,0 +DA:447,0 +DA:471,0 +DA:485,0 +DA:493,0 +DA:504,0 +DA:516,0 +DA:517,0 +DA:523,0 +DA:525,0 +DA:537,0 +DA:538,0 +DA:542,0 +LF:122 +LH:0 +BRDA:28,0,0,0 +BRDA:28,1,0,0 +BRDA:28,1,1,0 +BRDA:43,2,0,0 +BRDA:92,3,0,0 +BRDA:120,4,0,0 +BRDA:145,5,0,0 +BRDA:149,6,0,0 +BRDA:149,7,0,0 +BRDA:149,7,1,0 +BRDA:164,8,0,0 +BRDA:169,9,0,0 +BRDA:169,10,0,0 +BRDA:169,10,1,0 +BRDA:184,11,0,0 +BRDA:204,12,0,0 +BRDA:221,13,0,0 +BRDA:231,14,0,0 +BRDA:245,15,0,0 +BRDA:271,16,0,0 +BRDA:289,17,0,0 +BRDA:290,18,0,0 +BRDA:296,19,0,0 +BRDA:301,20,0,0 +BRDA:318,21,0,0 +BRDA:318,21,1,0 +BRDA:323,22,0,0 +BRDA:323,22,1,0 +BRDA:340,23,0,0 +BRDA:341,24,0,0 +BRDA:351,25,0,0 +BRDA:353,26,0,0 +BRDA:367,27,0,0 +BRDA:367,27,1,0 +BRDA:372,28,0,0 +BRDA:372,28,1,0 +BRDA:389,29,0,0 +BRDA:389,30,0,0 +BRDA:389,30,1,0 +BRDA:403,31,0,0 +BRDA:416,32,0,0 +BRDA:432,33,0,0 +BRDA:452,34,0,0 +BRDA:452,34,1,0 +BRDA:453,35,0,0 +BRDA:453,35,1,0 +BRDA:454,36,0,0 +BRDA:454,36,1,0 +BRDA:471,37,0,0 +BRDA:471,37,1,0 +BRDA:486,38,0,0 +BRDA:486,38,1,0 +BRDA:516,39,0,0 +BRDA:523,40,0,0 +BRDA:537,41,0,0 +BRF:55 +BRH:0 +end_of_record +TN: +SF:src/routes/EmailRoutes.ts +FN:11,(anonymous_1) +FN:16,(anonymous_2) +FNF:2 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +DA:1,0 +DA:2,0 +DA:3,0 +DA:5,0 +DA:8,0 +DA:11,0 +DA:12,0 +DA:16,0 +DA:17,0 +DA:20,0 +LF:10 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/authRoutes.ts +FN:9,(anonymous_1) +FN:12,(anonymous_2) +FN:15,(anonymous_3) +FN:18,(anonymous_4) +FN:21,(anonymous_5) +FN:24,(anonymous_6) +FN:27,(anonymous_7) +FN:28,(anonymous_8) +FN:29,(anonymous_9) +FN:32,(anonymous_10) +FN:33,(anonymous_11) +FN:34,(anonymous_12) +FNF:12 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +DA:1,0 +DA:2,0 +DA:3,0 +DA:6,0 +DA:9,0 +DA:12,0 +DA:15,0 +DA:18,0 +DA:21,0 +DA:24,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:36,0 +LF:17 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/clientRoutes.ts +FN:11,(anonymous_1) +FN:12,(anonymous_2) +FN:17,(anonymous_3) +FN:18,(anonymous_4) +FN:23,(anonymous_5) +FN:24,(anonymous_6) +FN:30,(anonymous_7) +FN:31,(anonymous_8) +FN:36,(anonymous_9) +FN:37,(anonymous_10) +FN:43,(anonymous_11) +FN:44,(anonymous_12) +FN:50,(anonymous_13) +FN:51,(anonymous_14) +FN:56,(anonymous_15) +FN:57,(anonymous_16) +FNF:16 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:6,0 +DA:9,0 +DA:11,0 +DA:12,0 +DA:15,0 +DA:17,0 +DA:18,0 +DA:21,0 +DA:23,0 +DA:24,0 +DA:28,0 +DA:30,0 +DA:31,0 +DA:34,0 +DA:36,0 +DA:37,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:48,0 +DA:50,0 +DA:51,0 +DA:54,0 +DA:56,0 +DA:57,0 +DA:60,0 +LF:30 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/contractRoutes.ts +FN:18,(anonymous_1) +FN:19,(anonymous_2) +FN:25,(anonymous_3) +FN:26,(anonymous_4) +FN:32,(anonymous_5) +FN:33,(anonymous_6) +FN:39,(anonymous_7) +FN:40,(anonymous_8) +FN:46,(anonymous_9) +FN:48,(anonymous_10) +FN:54,(anonymous_11) +FN:56,(anonymous_12) +FN:62,(anonymous_13) +FN:63,(anonymous_14) +FNF:14 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:5,0 +DA:8,0 +DA:10,0 +DA:16,0 +DA:18,0 +DA:19,0 +DA:23,0 +DA:25,0 +DA:26,0 +DA:30,0 +DA:32,0 +DA:33,0 +DA:37,0 +DA:39,0 +DA:40,0 +DA:44,0 +DA:46,0 +DA:48,0 +DA:52,0 +DA:54,0 +DA:56,0 +DA:60,0 +DA:62,0 +DA:63,0 +DA:67,0 +LF:29 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/customersRoutes.ts +FNF:0 +FNH:0 +DA:2,0 +DA:4,0 +DA:5,0 +DA:8,0 +DA:12,0 +DA:14,0 +LF:6 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/doulaRoutes.ts +FN:10,(anonymous_1) +FN:11,(anonymous_2) +FN:15,(anonymous_3) +FN:16,(anonymous_4) +FNF:4 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:6,0 +DA:8,0 +DA:10,0 +DA:11,0 +DA:13,0 +DA:15,0 +DA:16,0 +DA:21,0 +LF:12 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/paymentRoutes.ts +FNF:0 +FNH:0 +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:5,0 +DA:7,0 +DA:10,0 +DA:14,0 +DA:19,0 +DA:24,0 +DA:27,0 +DA:34,0 +DA:41,0 +DA:48,0 +DA:54,0 +DA:59,0 +LF:16 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/quickbooksRoutes.ts +FNF:0 +FNH:0 +DA:2,0 +DA:3,0 +DA:12,0 +DA:13,0 +DA:15,0 +DA:18,0 +DA:19,0 +DA:22,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:32,0 +DA:34,0 +LF:15 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/requestRoute.ts +FN:8,(anonymous_1) +FNF:1 +FNH:0 +FNDA:0,(anonymous_1) +DA:1,0 +DA:2,0 +DA:4,0 +DA:7,0 +DA:8,0 +DA:10,0 +LF:6 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/routes/specificUserRoutes.ts +FN:9,(anonymous_1) +FN:11,(anonymous_2) +FN:13,(anonymous_3) +FN:20,(anonymous_4) +FNF:4 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:6,0 +DA:9,0 +DA:11,0 +DA:13,0 +DA:16,0 +DA:20,0 +DA:22,0 +LF:11 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/services/RequestFormService.ts +FN:13,(anonymous_0) +FN:17,(anonymous_1) +FN:61,(anonymous_2) +FN:65,(anonymous_3) +FN:69,(anonymous_4) +FN:73,(anonymous_5) +FN:77,(anonymous_6) +FN:88,(anonymous_7) +FNF:8 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +DA:1,0 +DA:2,0 +DA:4,0 +DA:10,0 +DA:14,0 +DA:19,0 +DA:20,0 +DA:23,0 +DA:24,0 +DA:27,0 +DA:28,0 +DA:31,0 +DA:32,0 +DA:35,0 +DA:36,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:58,0 +DA:62,0 +DA:66,0 +DA:70,0 +DA:74,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:84,0 +DA:89,0 +DA:91,0 +DA:92,0 +DA:95,0 +DA:96,0 +DA:99,0 +DA:100,0 +DA:103,0 +DA:104,0 +DA:107,0 +DA:108,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:130,0 +DA:200,0 +DA:203,0 +DA:256,0 +DA:257,0 +LF:58 +LH:0 +BRDA:19,0,0,0 +BRDA:19,1,0,0 +BRDA:19,1,1,0 +BRDA:23,2,0,0 +BRDA:27,3,0,0 +BRDA:27,4,0,0 +BRDA:27,4,1,0 +BRDA:31,5,0,0 +BRDA:35,6,0,0 +BRDA:35,7,0,0 +BRDA:35,7,1,0 +BRDA:35,7,2,0 +BRDA:35,7,3,0 +BRDA:41,8,0,0 +BRDA:47,9,0,0 +BRDA:53,10,0,0 +BRDA:80,11,0,0 +BRDA:91,12,0,0 +BRDA:91,13,0,0 +BRDA:91,13,1,0 +BRDA:95,14,0,0 +BRDA:99,15,0,0 +BRDA:99,16,0,0 +BRDA:99,16,1,0 +BRDA:103,17,0,0 +BRDA:107,18,0,0 +BRDA:107,19,0,0 +BRDA:107,19,1,0 +BRDA:107,19,2,0 +BRDA:107,19,3,0 +BRDA:113,20,0,0 +BRDA:119,21,0,0 +BRDA:125,22,0,0 +BRDA:234,23,0,0 +BRDA:234,23,1,0 +BRF:35 +BRH:0 +end_of_record +TN: +SF:src/services/emailService.ts +FN:7,(anonymous_1) +FN:19,(anonymous_2) +FN:48,(anonymous_3) +FN:150,(anonymous_4) +FN:171,(anonymous_5) +FNF:5 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +DA:1,0 +DA:4,0 +DA:8,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:29,0 +DA:32,0 +DA:33,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:58,0 +DA:61,0 +DA:78,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:123,0 +DA:126,0 +DA:127,0 +DA:142,0 +DA:143,0 +DA:145,0 +DA:146,0 +DA:151,0 +DA:152,0 +DA:153,0 +DA:168,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:190,0 +LF:34 +LH:0 +BRDA:10,0,0,0 +BRDA:10,0,1,0 +BRDA:21,1,0,0 +BRDA:27,2,0,0 +BRDA:27,2,1,0 +BRDA:34,3,0,0 +BRDA:34,3,1,0 +BRDA:38,4,0,0 +BRDA:38,4,1,0 +BRDA:61,5,0,0 +BRDA:61,5,1,0 +BRDA:78,6,0,0 +BRDA:78,6,1,0 +BRDA:109,7,0,0 +BRDA:128,8,0,0 +BRDA:128,8,1,0 +BRF:16 +BRH:0 +end_of_record +TN: +SF:src/services/supabaseAuthService.ts +FN:13,(anonymous_0) +FN:20,(anonymous_1) +FN:60,(anonymous_2) +FN:89,(anonymous_3) +FN:105,(anonymous_4) +FN:110,(anonymous_5) +FN:131,(anonymous_6) +FN:144,(anonymous_7) +FN:151,(anonymous_8) +FN:164,(anonymous_9) +FN:183,(anonymous_10) +FN:196,(anonymous_11) +FN:210,(anonymous_12) +FN:223,(anonymous_13) +FNF:14 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +DA:5,0 +DA:10,0 +DA:15,0 +DA:17,0 +DA:27,0 +DA:32,0 +DA:33,0 +DA:36,0 +DA:37,0 +DA:40,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:47,0 +DA:48,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:55,0 +DA:56,0 +DA:65,0 +DA:70,0 +DA:71,0 +DA:74,0 +DA:75,0 +DA:78,0 +DA:80,0 +DA:81,0 +DA:83,0 +DA:85,0 +DA:93,0 +DA:95,0 +DA:96,0 +DA:98,0 +DA:101,0 +DA:107,0 +DA:115,0 +DA:120,0 +DA:121,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:128,0 +DA:135,0 +DA:139,0 +DA:140,0 +DA:152,0 +DA:154,0 +DA:155,0 +DA:159,0 +DA:161,0 +DA:169,0 +DA:176,0 +DA:177,0 +DA:180,0 +DA:184,0 +DA:189,0 +DA:190,0 +DA:193,0 +DA:198,0 +DA:200,0 +DA:201,0 +DA:204,0 +DA:211,0 +DA:216,0 +DA:217,0 +DA:220,0 +DA:224,0 +DA:228,0 +DA:229,0 +DA:232,0 +LF:71 +LH:0 +BRDA:32,0,0,0 +BRDA:36,1,0,0 +BRDA:41,2,0,0 +BRDA:47,3,0,0 +BRDA:47,3,1,0 +BRDA:48,4,0,0 +BRDA:48,4,1,0 +BRDA:70,5,0,0 +BRDA:74,6,0,0 +BRDA:120,7,0,0 +BRDA:139,8,0,0 +BRDA:154,9,0,0 +BRDA:176,10,0,0 +BRDA:189,11,0,0 +BRDA:200,12,0,0 +BRDA:216,13,0,0 +BRDA:228,14,0,0 +BRF:17 +BRH:0 +end_of_record +TN: +SF:src/services/supabaseContractService.ts +FN:15,(anonymous_1) +FN:19,(anonymous_2) +FN:89,(anonymous_3) +FN:112,(anonymous_4) +FN:122,(anonymous_5) +FN:131,(anonymous_6) +FN:151,(anonymous_7) +FN:186,(anonymous_8) +FN:204,(anonymous_9) +FNF:9 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +DA:2,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:8,0 +DA:9,0 +DA:12,0 +DA:16,0 +DA:29,0 +DA:35,0 +DA:36,0 +DA:39,0 +DA:41,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:57,0 +DA:58,0 +DA:60,0 +DA:64,0 +DA:67,0 +DA:84,0 +DA:86,0 +DA:91,0 +DA:97,0 +DA:99,0 +DA:104,0 +DA:106,0 +DA:107,0 +DA:109,0 +DA:113,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:122,0 +DA:133,0 +DA:140,0 +DA:142,0 +DA:146,0 +DA:148,0 +DA:152,0 +DA:154,0 +DA:155,0 +DA:162,0 +DA:163,0 +DA:167,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:183,0 +DA:187,0 +DA:189,0 +DA:194,0 +DA:195,0 +DA:197,0 +DA:198,0 +DA:200,0 +DA:201,0 +DA:207,0 +DA:209,0 +DA:214,0 +DA:216,0 +DA:221,0 +DA:222,0 +LF:67 +LH:0 +BRDA:35,0,0,0 +BRDA:35,1,0,0 +BRDA:35,1,1,0 +BRDA:48,2,0,0 +BRDA:48,3,0,0 +BRDA:48,3,1,0 +BRDA:64,4,0,0 +BRDA:72,5,0,0 +BRDA:72,5,1,0 +BRDA:84,6,0,0 +BRDA:97,7,0,0 +BRDA:97,8,0,0 +BRDA:97,8,1,0 +BRDA:104,9,0,0 +BRDA:104,10,0,0 +BRDA:104,10,1,0 +BRDA:117,11,0,0 +BRDA:117,12,0,0 +BRDA:117,12,1,0 +BRDA:140,13,0,0 +BRDA:146,14,0,0 +BRDA:152,15,0,0 +BRDA:152,15,1,0 +BRDA:154,16,0,0 +BRDA:162,17,0,0 +BRDA:178,18,0,0 +BRDA:187,19,0,0 +BRDA:187,19,1,0 +BRDA:195,20,0,0 +BRDA:198,21,0,0 +BRF:30 +BRH:0 +end_of_record +TN: +SF:src/services/auth/quickbooksAuthService.ts +FN:29,generateConsentUrl +FN:43,handleAuthCallback +FN:79,isConnected +FN:99,(anonymous_13) +FN:113,disconnectQuickBooks +FNF:5 +FNH:0 +FNDA:0,generateConsentUrl +FNDA:0,handleAuthCallback +FNDA:0,isConnected +FNDA:0,(anonymous_13) +FNDA:0,disconnectQuickBooks +DA:3,0 +DA:4,0 +DA:5,0 +DA:17,0 +DA:19,0 +DA:29,0 +DA:30,0 +DA:43,0 +DA:47,0 +DA:48,0 +DA:56,0 +DA:58,0 +DA:59,0 +DA:63,0 +DA:71,0 +DA:72,0 +DA:79,0 +DA:80,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:96,0 +DA:97,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:106,0 +DA:107,0 +DA:113,0 +DA:114,0 +LF:39 +LH:0 +BRDA:13,0,0,0 +BRDA:14,1,0,0 +BRDA:15,2,0,0 +BRDA:16,3,0,0 +BRDA:22,4,0,0 +BRDA:22,4,1,0 +BRDA:56,5,0,0 +BRDA:56,5,1,0 +BRDA:58,6,0,0 +BRDA:83,7,0,0 +BRDA:96,8,0,0 +BRF:11 +BRH:0 +end_of_record +TN: +SF:src/services/customer/buildCustomerPayload.ts +FN:11,buildCustomerPayload +FNF:1 +FNH:0 +FNDA:0,buildCustomerPayload +DA:11,0 +DA:16,0 +DA:17,0 +LF:3 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/services/customer/createCustomer.ts +FN:24,createCustomer +FNF:1 +FNH:0 +FNDA:0,createCustomer +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:8,0 +DA:9,0 +DA:24,0 +DA:27,0 +DA:29,0 +DA:30,0 +DA:35,0 +DA:38,0 +DA:41,0 +DA:44,0 +DA:47,0 +DA:49,0 +LF:18 +LH:0 +BRDA:29,0,0,0 +BRDA:29,1,0,0 +BRDA:29,1,1,0 +BRDA:29,1,2,0 +BRDA:29,1,3,0 +BRF:5 +BRH:0 +end_of_record +TN: +SF:src/services/customer/createCustomerInQuickBooks.ts +FN:3,createCustomerInQuickBooks +FNF:1 +FNH:0 +FNDA:0,createCustomerInQuickBooks +DA:1,0 +DA:3,0 +DA:6,0 +DA:10,0 +LF:4 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/services/customer/getInvoiceableCustomers.ts +FN:11,getInvoiceableCustomers +FN:21,(anonymous_1) +FNF:2 +FNH:0 +FNDA:0,getInvoiceableCustomers +FNDA:0,(anonymous_1) +DA:11,0 +DA:14,0 +DA:19,0 +DA:21,0 +LF:4 +LH:0 +BRDA:19,0,0,0 +BRDA:21,1,0,0 +BRDA:21,1,1,0 +BRF:3 +BRH:0 +end_of_record +TN: +SF:src/services/customer/saveQboCustomerId.ts +FN:3,saveQboCustomerId +FNF:1 +FNH:0 +FNDA:0,saveQboCustomerId +DA:1,0 +DA:3,0 +DA:7,0 +DA:12,0 +DA:13,0 +LF:5 +LH:0 +BRDA:12,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/services/customer/upsertInternalCustomer.ts +FN:3,upsertInternalCustomer +FNF:1 +FNH:0 +FNDA:0,upsertInternalCustomer +DA:1,0 +DA:3,0 +DA:8,0 +DA:16,0 +DA:17,0 +DA:20,0 +LF:6 +LH:0 +BRDA:16,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/services/invoice/buildInvoicePayload.ts +FN:16,buildInvoicePayload +FNF:1 +FNH:0 +FNDA:0,buildInvoicePayload +DA:16,0 +DA:20,0 +DA:21,0 +LF:3 +LH:0 +BRDA:26,0,0,0 +BRDA:26,0,1,0 +BRF:2 +BRH:0 +end_of_record +TN: +SF:src/services/invoice/createInvoice.ts +FN:20,createInvoiceService +FNF:1 +FNH:0 +FNDA:0,createInvoiceService +DA:3,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:20,0 +DA:23,0 +DA:25,0 +DA:26,0 +DA:29,0 +DA:32,0 +DA:38,0 +DA:39,0 +DA:42,0 +DA:43,0 +DA:46,0 +DA:47,0 +DA:55,0 +DA:56,0 +DA:59,0 +DA:60,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:74,0 +DA:76,0 +DA:78,0 +DA:81,0 +DA:84,0 +DA:85,0 +LF:31 +LH:0 +BRDA:25,0,0,0 +BRDA:25,1,0,0 +BRDA:25,1,1,0 +BRDA:38,2,0,0 +BRDA:38,3,0,0 +BRDA:38,3,1,0 +BRDA:63,4,0,0 +BRDA:63,4,1,0 +BRF:8 +BRH:0 +end_of_record +TN: +SF:src/services/invoice/createInvoiceInQuickBooks.ts +FN:5,createInvoiceInQuickBooks +FNF:1 +FNH:0 +FNDA:0,createInvoiceInQuickBooks +DA:3,0 +DA:5,0 +DA:9,0 +DA:18,0 +DA:25,0 +LF:5 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/services/invoice/persistInvoiceToSupabase.ts +FN:4,persistInvoiceToSupabase +FNF:1 +FNH:0 +FNDA:0,persistInvoiceToSupabase +DA:2,0 +DA:4,0 +DA:8,0 +DA:9,0 +DA:19,0 +DA:22,0 +DA:24,0 +DA:26,0 +DA:36,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:56,0 +LF:13 +LH:0 +BRDA:22,0,0,0 +BRDA:22,0,1,0 +BRDA:33,1,0,0 +BRDA:33,1,1,0 +BRDA:45,2,0,0 +BRDA:45,2,1,0 +BRDA:51,3,0,0 +BRF:7 +BRH:0 +end_of_record +TN: +SF:src/services/invoice/sendInvoiceEmail.ts +FN:18,sendInvoiceEmailToCustomer +FN:33,(anonymous_1) +FN:41,(anonymous_2) +FNF:3 +FNH:0 +FNDA:0,sendInvoiceEmailToCustomer +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +DA:3,0 +DA:4,0 +DA:18,0 +DA:19,0 +DA:21,0 +DA:23,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:33,0 +DA:41,0 +DA:42,0 +DA:45,0 +DA:47,0 +DA:50,0 +DA:62,0 +DA:64,0 +DA:65,0 +DA:68,0 +DA:88,0 +DA:112,0 +DA:130,0 +DA:141,0 +DA:143,0 +DA:144,0 +LF:26 +LH:0 +BRDA:27,0,0,0 +BRDA:34,1,0,0 +BRDA:34,1,1,0 +BRDA:35,2,0,0 +BRDA:35,2,1,0 +BRDA:36,3,0,0 +BRDA:36,3,1,0 +BRDA:37,4,0,0 +BRDA:37,4,1,0 +BRDA:45,5,0,0 +BRDA:45,5,1,0 +BRDA:68,6,0,0 +BRDA:68,6,1,0 +BRDA:120,7,0,0 +BRDA:120,7,1,0 +BRF:15 +BRH:0 +end_of_record +TN: +SF:src/services/payments/buildChargePayload.ts +FN:15,buildChargePayload +FNF:1 +FNH:0 +FNDA:0,buildChargePayload +DA:15,0 +DA:16,0 +LF:2 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/services/payments/createCharge.ts +FN:4,createCharge +FNF:1 +FNH:0 +FNDA:0,createCharge +DA:1,0 +DA:2,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:10,0 +DA:12,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:26,0 +LF:12 +LH:0 +BRDA:6,0,0,0 +BRDA:23,1,0,0 +BRF:2 +BRH:0 +end_of_record +TN: +SF:src/services/payments/paymentsController.ts +FN:4,(anonymous_0) +FNF:1 +FNH:0 +FNDA:0,(anonymous_0) +DA:2,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:11,0 +DA:12,0 +DA:14,0 +DA:15,0 +DA:16,0 +LF:12 +LH:0 +BRDA:7,0,0,0 +BRDA:7,1,0,0 +BRDA:7,1,1,0 +BRF:3 +BRH:0 +end_of_record +TN: +SF:src/services/payments/stripePaymentService.ts +FN:22,(anonymous_1) +FN:86,(anonymous_2) +FN:167,(anonymous_3) +FN:238,(anonymous_4) +FN:320,(anonymous_5) +FN:338,(anonymous_6) +FN:354,(anonymous_7) +FN:372,(anonymous_8) +FNF:8 +FNH:0 +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +DA:1,0 +DA:2,0 +DA:21,0 +DA:23,0 +DA:26,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:37,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:56,0 +DA:57,0 +DA:65,0 +DA:68,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:78,0 +DA:79,0 +DA:81,0 +DA:82,0 +DA:87,0 +DA:90,0 +DA:92,0 +DA:94,0 +DA:95,0 +DA:101,0 +DA:102,0 +DA:110,0 +DA:113,0 +DA:114,0 +DA:119,0 +DA:120,0 +DA:127,0 +DA:128,0 +DA:138,0 +DA:140,0 +DA:145,0 +DA:146,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:153,0 +DA:154,0 +DA:162,0 +DA:163,0 +DA:168,0 +DA:171,0 +DA:174,0 +DA:175,0 +DA:180,0 +DA:181,0 +DA:184,0 +DA:185,0 +DA:192,0 +DA:193,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:200,0 +DA:202,0 +DA:203,0 +DA:213,0 +DA:216,0 +DA:217,0 +DA:226,0 +DA:227,0 +DA:230,0 +DA:231,0 +DA:233,0 +DA:234,0 +DA:239,0 +DA:242,0 +DA:244,0 +DA:246,0 +DA:253,0 +DA:254,0 +DA:258,0 +DA:259,0 +DA:264,0 +DA:267,0 +DA:268,0 +DA:273,0 +DA:274,0 +DA:275,0 +DA:283,0 +DA:284,0 +DA:287,0 +DA:288,0 +DA:301,0 +DA:302,0 +DA:303,0 +DA:306,0 +DA:307,0 +DA:315,0 +DA:316,0 +DA:321,0 +DA:323,0 +DA:325,0 +DA:331,0 +DA:332,0 +DA:333,0 +DA:336,0 +DA:338,0 +DA:349,0 +DA:350,0 +DA:355,0 +DA:357,0 +DA:359,0 +DA:365,0 +DA:366,0 +DA:367,0 +DA:370,0 +DA:372,0 +DA:381,0 +DA:382,0 +LF:121 +LH:0 +BRDA:32,0,0,0 +BRDA:32,1,0,0 +BRDA:32,1,1,0 +BRDA:44,2,0,0 +BRDA:73,3,0,0 +BRDA:148,4,0,0 +BRDA:195,5,0,0 +BRDA:195,6,0,0 +BRDA:195,6,1,0 +BRDA:226,7,0,0 +BRDA:253,8,0,0 +BRDA:253,9,0,0 +BRDA:253,9,1,0 +BRDA:273,10,0,0 +BRDA:301,11,0,0 +BRDA:331,12,0,0 +BRDA:336,13,0,0 +BRDA:336,13,1,0 +BRDA:338,14,0,0 +BRDA:338,14,1,0 +BRDA:365,15,0,0 +BRDA:370,16,0,0 +BRDA:370,16,1,0 +BRDA:372,17,0,0 +BRDA:372,17,1,0 +BRF:25 +BRH:0 +end_of_record +TN: +SF:src/usecase/authUseCase.ts +FN:17,(anonymous_0) +FN:28,(anonymous_1) +FN:67,(anonymous_2) +FN:99,(anonymous_3) +FN:123,(anonymous_4) +FN:133,(anonymous_5) +FN:163,(anonymous_6) +FN:178,(anonymous_7) +FN:195,(anonymous_8) +FN:227,(anonymous_9) +FN:266,(anonymous_10) +FN:288,(anonymous_11) +FN:318,(anonymous_12) +FNF:13 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +DA:3,0 +DA:9,0 +DA:13,0 +DA:18,0 +DA:19,0 +DA:35,0 +DA:36,0 +DA:39,0 +DA:40,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:53,0 +DA:72,0 +DA:73,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:82,0 +DA:87,0 +DA:89,0 +DA:103,0 +DA:104,0 +DA:107,0 +DA:109,0 +DA:111,0 +DA:113,0 +DA:124,0 +DA:138,0 +DA:139,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:151,0 +DA:153,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:168,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:185,0 +DA:199,0 +DA:200,0 +DA:203,0 +DA:205,0 +DA:208,0 +DA:211,0 +DA:212,0 +DA:215,0 +DA:217,0 +DA:231,0 +DA:232,0 +DA:235,0 +DA:237,0 +DA:240,0 +DA:241,0 +DA:251,0 +DA:254,0 +DA:256,0 +DA:271,0 +DA:272,0 +DA:275,0 +DA:276,0 +DA:278,0 +DA:293,0 +DA:294,0 +DA:297,0 +DA:298,0 +DA:300,0 +DA:306,0 +DA:308,0 +DA:322,0 +DA:323,0 +DA:326,0 +DA:327,0 +DA:330,0 +DA:332,0 +DA:335,0 +DA:338,0 +DA:339,0 +DA:340,0 +DA:343,0 +DA:345,0 +DA:346,0 +DA:348,0 +LF:90 +LH:0 +BRDA:35,0,0,0 +BRDA:35,1,0,0 +BRDA:35,1,1,0 +BRDA:39,2,0,0 +BRDA:45,3,0,0 +BRDA:48,4,0,0 +BRDA:72,5,0,0 +BRDA:72,6,0,0 +BRDA:72,6,1,0 +BRDA:78,7,0,0 +BRDA:103,8,0,0 +BRDA:138,9,0,0 +BRDA:138,10,0,0 +BRDA:138,10,1,0 +BRDA:199,11,0,0 +BRDA:211,12,0,0 +BRDA:231,13,0,0 +BRDA:240,14,0,0 +BRDA:243,15,0,0 +BRDA:243,15,1,0 +BRDA:243,15,2,0 +BRDA:246,16,0,0 +BRDA:246,16,1,0 +BRDA:246,16,2,0 +BRDA:271,17,0,0 +BRDA:293,18,0,0 +BRDA:293,19,0,0 +BRDA:293,19,1,0 +BRDA:322,20,0,0 +BRDA:326,21,0,0 +BRDA:339,22,0,0 +BRDA:345,23,0,0 +BRF:32 +BRH:0 +end_of_record +TN: +SF:src/usecase/clientUseCase.ts +FN:7,(anonymous_0) +FN:12,(anonymous_1) +FN:22,(anonymous_2) +FN:37,(anonymous_3) +FN:51,(anonymous_4) +FN:55,(anonymous_5) +FN:60,(anonymous_6) +FN:77,(anonymous_7) +FNF:8 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +DA:4,0 +DA:8,0 +DA:13,0 +DA:14,0 +DA:17,0 +DA:23,0 +DA:24,0 +DA:26,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:44,0 +DA:47,0 +DA:52,0 +DA:56,0 +DA:65,0 +DA:67,0 +DA:69,0 +DA:72,0 +DA:82,0 +DA:84,0 +DA:86,0 +DA:89,0 +LF:25 +LH:0 +BRDA:13,0,0,0 +BRDA:13,0,1,0 +BRDA:23,1,0,0 +BRDA:23,1,1,0 +BRDA:39,2,0,0 +BRDA:39,3,0,0 +BRDA:39,3,1,0 +BRDA:41,4,0,0 +BRF:8 +BRH:0 +end_of_record +TN: +SF:src/usecase/contractUseCase.ts +FN:7,(anonymous_0) +FN:9,(anonymous_1) +FN:29,(anonymous_2) +FN:33,(anonymous_3) +FN:37,(anonymous_4) +FN:41,(anonymous_5) +FN:45,(anonymous_6) +FN:49,(anonymous_7) +FNF:8 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +DA:6,0 +DA:7,0 +DA:18,0 +DA:30,0 +DA:34,0 +DA:38,0 +DA:42,0 +DA:46,0 +DA:51,0 +DA:52,0 +LF:10 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/usecase/userUseCase.ts +FN:10,(anonymous_0) +FN:14,(anonymous_1) +FN:24,(anonymous_2) +FN:34,(anonymous_3) +FN:44,(anonymous_4) +FN:50,(anonymous_5) +FN:55,(anonymous_6) +FN:57,(anonymous_7) +FN:71,(anonymous_8) +FN:75,(anonymous_9) +FN:79,(anonymous_10) +FN:83,(anonymous_11) +FNF:12 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +DA:2,0 +DA:7,0 +DA:11,0 +DA:15,0 +DA:17,0 +DA:18,0 +DA:21,0 +DA:25,0 +DA:27,0 +DA:28,0 +DA:31,0 +DA:35,0 +DA:37,0 +DA:38,0 +DA:41,0 +DA:45,0 +DA:47,0 +DA:51,0 +DA:52,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:61,0 +DA:64,0 +DA:65,0 +DA:68,0 +DA:72,0 +DA:76,0 +DA:80,0 +DA:84,0 +LF:30 +LH:0 +BRDA:17,0,0,0 +BRDA:27,1,0,0 +BRDA:37,2,0,0 +BRDA:58,3,0,0 +BRDA:58,4,0,0 +BRDA:58,4,1,0 +BRDA:64,5,0,0 +BRF:7 +BRH:0 +end_of_record +TN: +SF:src/utils/convertToPdf.ts +FN:3,convertToPdf +FN:25,(anonymous_2) +FN:33,(anonymous_3) +FNF:3 +FNH:0 +FNDA:0,convertToPdf +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +DA:1,0 +DA:3,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:25,0 +DA:26,0 +DA:28,0 +DA:30,0 +DA:32,0 +DA:33,0 +DA:36,0 +DA:37,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:44,0 +DA:47,0 +DA:48,0 +DA:49,0 +LF:20 +LH:0 +BRDA:26,0,0,0 +BRDA:33,1,0,0 +BRDA:33,1,1,0 +BRDA:36,2,0,0 +BRDA:48,3,0,0 +BRDA:48,3,1,0 +BRF:6 +BRH:0 +end_of_record +TN: +SF:src/utils/generateInvoicePdf.ts +FN:22,generateInvoicePDF +FN:23,(anonymous_2) +FN:29,(anonymous_3) +FN:81,(anonymous_4) +FNF:4 +FNH:0 +FNDA:0,generateInvoicePDF +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +DA:1,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:35,0 +DA:38,0 +DA:43,0 +DA:47,0 +DA:53,0 +DA:56,0 +DA:60,0 +DA:61,0 +DA:65,0 +DA:66,0 +DA:69,0 +DA:75,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:86,0 +DA:90,0 +DA:91,0 +DA:93,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:100,0 +DA:101,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:113,0 +DA:117,0 +DA:119,0 +LF:40 +LH:0 +BRDA:60,0,0,0 +BRDA:95,1,0,0 +BRDA:105,2,0,0 +BRF:3 +BRH:0 +end_of_record +TN: +SF:src/utils/qboClient.ts +FN:23,getAccessToken +FN:49,qboRequest +FN:63,(anonymous_12) +FN:83,(anonymous_13) +FNF:4 +FNH:0 +FNDA:0,getAccessToken +FNDA:0,qboRequest +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +DA:3,0 +DA:4,0 +DA:7,0 +DA:13,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:38,0 +DA:49,0 +DA:53,0 +DA:55,0 +DA:59,0 +DA:60,0 +DA:63,0 +DA:65,0 +DA:75,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:88,0 +LF:24 +LH:0 +BRDA:10,0,0,0 +BRDA:11,1,0,0 +BRDA:12,2,0,0 +BRDA:25,3,0,0 +BRDA:30,4,0,0 +BRDA:51,5,0,0 +BRDA:55,6,0,0 +BRDA:55,6,1,0 +BRDA:75,7,0,0 +BRDA:84,8,0,0 +BRDA:84,8,1,0 +BRF:11 +BRH:0 +end_of_record +TN: +SF:src/utils/tokenUtils.ts +FN:14,getTokenFromDatabase +FN:50,refreshQuickBooksToken +FN:111,getValidAccessToken +FN:140,saveTokensToDatabase +FN:162,deleteTokens +FNF:5 +FNH:0 +FNDA:0,getTokenFromDatabase +FNDA:0,refreshQuickBooksToken +FNDA:0,getValidAccessToken +FNDA:0,saveTokensToDatabase +FNDA:0,deleteTokens +DA:2,0 +DA:14,0 +DA:15,0 +DA:17,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:27,0 +DA:28,0 +DA:31,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:43,0 +DA:50,0 +DA:51,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:66,0 +DA:68,0 +DA:69,0 +DA:78,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:86,0 +DA:87,0 +DA:89,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:100,0 +DA:102,0 +DA:103,0 +DA:111,0 +DA:112,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:117,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:124,0 +DA:127,0 +DA:128,0 +DA:129,0 +DA:130,0 +DA:133,0 +DA:134,0 +DA:140,0 +DA:141,0 +DA:143,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:158,0 +DA:162,0 +DA:163,0 +DA:165,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:175,0 +DA:179,0 +DA:180,0 +LF:74 +LH:0 +BRDA:22,0,0,0 +BRDA:23,1,0,0 +BRDA:54,2,0,0 +BRDA:80,3,0,0 +BRDA:115,4,0,0 +BRDA:127,5,0,0 +BRDA:130,6,0,0 +BRDA:130,6,1,0 +BRDA:153,7,0,0 +BRDA:170,8,0,0 +BRF:10 +BRH:0 +end_of_record diff --git a/coverage/prettify.css b/coverage/prettify.css new file mode 100644 index 00000000..b317a7cd --- /dev/null +++ b/coverage/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/prettify.js b/coverage/prettify.js new file mode 100644 index 00000000..b3225238 --- /dev/null +++ b/coverage/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png new file mode 100644 index 00000000..6ed68316 Binary files /dev/null and b/coverage/sort-arrow-sprite.png differ diff --git a/coverage/sorter.js b/coverage/sorter.js new file mode 100644 index 00000000..2bb296a8 --- /dev/null +++ b/coverage/sorter.js @@ -0,0 +1,196 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if ( + row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()) + ) { + row.style.display = ''; + } else { + row.style.display = 'none'; + } + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/coverage/src/api/index.html b/coverage/src/api/index.html new file mode 100644 index 00000000..259b0a72 --- /dev/null +++ b/coverage/src/api/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/api + + + + + + + + + +
+
+

All files src/api

+
+ +
+ 0% + Statements + 0/28 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/28 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
index.ts +
+
0%0/5100%0/0100%0/00%0/5
simulate-payment.ts +
+
0%0/230%0/70%0/10%0/23
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/api/index.ts.html b/coverage/src/api/index.ts.html new file mode 100644 index 00000000..e79ec3ea --- /dev/null +++ b/coverage/src/api/index.ts.html @@ -0,0 +1,106 @@ + + + + + + Code coverage report for src/api/index.ts + + + + + + + + + +
+
+

All files / src/api index.ts

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8  +  +  +  +  +  +  + 
import { Router } from 'express';
+import qboStatusRouter from './qbo/status';
+ 
+const router = Router();
+ 
+router.use('/qbo', qboStatusRouter);
+ 
+export default router; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/api/qbo/index.html b/coverage/src/api/qbo/index.html new file mode 100644 index 00000000..1fd2b6b5 --- /dev/null +++ b/coverage/src/api/qbo/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/api/qbo + + + + + + + + + +
+
+

All files src/api/qbo

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
status.ts +
+
0%0/10100%0/00%0/10%0/10
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/api/qbo/status.ts.html b/coverage/src/api/qbo/status.ts.html new file mode 100644 index 00000000..407f5b43 --- /dev/null +++ b/coverage/src/api/qbo/status.ts.html @@ -0,0 +1,130 @@ + + + + + + Code coverage report for src/api/qbo/status.ts + + + + + + + + + +
+
+

All files / src/api/qbo status.ts

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Router } from 'express';
+import { getValidAccessToken } from '../../utils/tokenUtils';
+ 
+const router = Router();
+ 
+router.get('/status', async (req, res) => {
+  try {
+    const accessToken = await getValidAccessToken();
+    res.json({ connected: !!accessToken });
+  } catch (error) {
+    console.error('Error checking QBO status:', error);
+    res.json({ connected: false });
+  }
+});
+ 
+export default router; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/api/simulate-payment.ts.html b/coverage/src/api/simulate-payment.ts.html new file mode 100644 index 00000000..2627015c --- /dev/null +++ b/coverage/src/api/simulate-payment.ts.html @@ -0,0 +1,262 @@ + + + + + + Code coverage report for src/api/simulate-payment.ts + + + + + + + + + +
+
+

All files / src/api simulate-payment.ts

+
+ +
+ 0% + Statements + 0/23 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/23 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express from 'express';
+import { getValidAccessToken } from '../utils/tokenUtils';
+ 
+const router = express.Router();
+ 
+// Change the route path to match the router mount in server.ts
+router.post('/simulate-payment', async (req, res) => {
+  try {
+    const { amount, card } = req.body;
+    Iif (!amount || !card) {
+      res.status(400).json({ error: 'Missing amount or card details' });
+      return;
+    }
+ 
+    // Get access token (hardcoded or from user context)
+    const accessToken = await getValidAccessToken();
+    Iif (!accessToken) {
+      res.status(401).json({ error: 'Could not get QuickBooks access token' });
+      return;
+    }
+ 
+    // Prepare payload for QuickBooks Payments API
+    const payload = {
+      amount: amount.toString(),
+      currency: 'USD',
+      card: {
+        number: card.number,
+        expMonth: card.expMonth,
+        expYear: card.expYear,
+        cvc: card.cvc
+      },
+      context: {
+        isEcommerce: true
+      }
+    };
+ 
+    // Call QuickBooks Payments API
+    const response = await fetch('https://sandbox.api.intuit.com/quickbooks/v4/payments/charges', {
+      method: 'POST',
+      headers: {
+        'Authorization': `Bearer ${accessToken}`,
+        'Content-Type': 'application/json',
+        'Accept': 'application/json'
+      },
+      body: JSON.stringify(payload)
+    });
+ 
+    const data = await response.json();
+    Iif (!response.ok) {
+      res.status(response.status).json({ error: data });
+      return;
+    }
+    res.json(data);
+  } catch (error) {
+    console.error('Simulate payment error:', error);
+    res.status(500).json({ error: error.message || 'Internal server error' });
+  }
+});
+ 
+export default router; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/config/index.html b/coverage/src/config/index.html new file mode 100644 index 00000000..8ccb5f70 --- /dev/null +++ b/coverage/src/config/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/config + + + + + + + + + +
+
+

All files src/config

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
index.ts +
+
0%0/10%0/6100%0/00%0/1
stripe.ts +
+
0%0/40%0/1100%0/00%0/4
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/config/index.ts.html b/coverage/src/config/index.ts.html new file mode 100644 index 00000000..557410cc --- /dev/null +++ b/coverage/src/config/index.ts.html @@ -0,0 +1,103 @@ + + + + + + Code coverage report for src/config/index.ts + + + + + + + + + +
+
+

All files / src/config index.ts

+
+ +
+ 0% + Statements + 0/1 +
+ + +
+ 0% + Branches + 0/6 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/1 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7  +  +  +  +  +  + 
export const config = {
+  jwtSecret: process.env.JWT_SECRET || 'your-default-secret-key',
+  stripe: {
+    secretKey: process.env.STRIPE_SECRET_KEY || '',
+    publicKey: process.env.STRIPE_PUBLIC_KEY || '',
+  }
+}; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/config/stripe.ts.html b/coverage/src/config/stripe.ts.html new file mode 100644 index 00000000..0536dd42 --- /dev/null +++ b/coverage/src/config/stripe.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/config/stripe.ts + + + + + + + + + +
+
+

All files / src/config stripe.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import Stripe from 'stripe';
+ 
+Iif (!process.env.STRIPE_SECRET_KEY) {
+  throw new Error('STRIPE_SECRET_KEY environment variable is required');
+}
+ 
+export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
+  apiVersion: '2023-10-16', // Use the latest API version
+}); 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/authController.ts.html b/coverage/src/controllers/authController.ts.html new file mode 100644 index 00000000..bda101eb --- /dev/null +++ b/coverage/src/controllers/authController.ts.html @@ -0,0 +1,1270 @@ + + + + + + Code coverage report for src/controllers/authController.ts + + + + + + + + + +
+
+

All files / src/controllers authController.ts

+
+ +
+ 0% + Statements + 0/108 +
+ + +
+ 0% + Branches + 0/21 +
+ + +
+ 0% + Functions + 0/15 +
+ + +
+ 0% + Lines + 0/107 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from 'express';
+ 
+import {
+  AuthenticationError,
+  AuthorizationError,
+  ConflictError,
+  NotFoundError,
+  ValidationError
+} from '../domains/errors';
+import supabase from '../supabase';
+import {
+  AuthRequest,
+  LoginBody,
+  PasswordResetBody,
+  SignupBody,
+  TokenBody,
+  UpdatePasswordBody,
+} from '../types';
+import { AuthUseCase } from '../usecase/authUseCase.js';
+ 
+ 
+export class AuthController {
+  private authUseCase: AuthUseCase;
+ 
+  constructor(authUseCase: AuthUseCase) {
+    this.authUseCase = authUseCase;
+    this.handleError = this.handleError.bind(this);
+  }
+ 
+  //
+  // signup()
+  //
+  // Handles user sign up after being approved by admin (by invite from Admin)
+  //
+  // returns:
+  //    User
+  //
+  async signup(
+    req: Request<object, object, SignupBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { email, password, firstname, lastname } = req.body;
+      // call useCase to grab newly created user
+      const user = await this.authUseCase.signup(email, password, firstname, lastname);
+      res.status(201).json({ message: 'User created successfully', user: user.toJSON() })
+    } 
+    catch (signUpError) {
+      const error = this.handleError(signUpError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+  
+  //
+  // login()
+  //
+  // Handles user login using email and password for authentication.
+  //
+  // returns:
+  //    User
+  //    Token
+  //
+  async login(
+    req: Request<object, object, LoginBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { email, password } = req.body;
+      // call useCase to grab the user and token
+      const result = await this.authUseCase.login(email, password);
+      res.status(200).json({ message: 'Login successful', user: result.user.toJSON() , token: result.token });
+    } 
+    catch (loginError) {
+      const error = this.handleError(loginError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+ 
+  //
+  // getMe()
+  //
+  // Grabs the current user from a token session
+  //
+  // returns:
+  //    User
+  //
+  async getMe(req: Request, res: Response): Promise<void> {
+    try {
+      const token = req.cookies?.session || req.headers.authorization?.split(' ')[1]
+      Iif (!token) {
+        res.status(401).json({ error: 'No session token provided' })
+        return
+      }
+  
+      // 1) Get your app user
+      const appUser = await this.authUseCase.getMe(token)
+      Iif (!appUser) {
+        res.status(404).json({ error: 'User not found' })
+        return
+      }
+      const base = appUser.toJSON()
+  
+      // 2) Fetch Supabase user metadata
+      const { data: sbUser, error } = await supabase.auth.getUser(token)
+      let finalRole = (base as any).role  // fallback to the DB role
+  
+      Iif (!error && sbUser.user) {
+        const meta = (sbUser.user.user_metadata as any) || {}
+        Iif (typeof meta.role === 'string') {
+          finalRole = meta.role
+        }
+      }
+  
+      // 3) Merge and return
+      res.json({
+        ...(base as any),
+        role: finalRole
+      })
+    } catch (err: any) {
+      const errorInfo = this.handleError(err, res)
+      res.status(errorInfo.status).json({ error: errorInfo.message })
+    }
+  }
+  
+  
+ 
+  
+  //
+  // logout()
+  //
+  // Signs out of current user and releases session cookie
+  //
+  // returns:
+  //    None
+  //
+  async logout(
+    _req: Request, 
+    res: Response
+  ): Promise<void> {
+    res.clearCookie('session');
+    await this.authUseCase.logout();
+    console.log('logged out')
+    res.json({ message: 'Logged out successfully' });
+  }
+  
+  //
+  // verifyEmail()
+  //
+  // Verifies the email after user signs up and redirects to success page
+  //
+  // returns:
+  //    None
+  //
+  async verifyEmail(
+    req: Request, 
+    res: Response
+  ): Promise<void> {
+    try {
+      const token_hash = req.query.token_hash as string;
+      const type = req.query.type as string;
+      // call useCase to return success, query params, and error message
+      const queryParams = await this.authUseCase.verifyEmail(token_hash, type);
+        
+      // Redirect with tokens if verification is successful
+      return res.redirect(`${process.env.FRONTEND_URL}/auth/callback?${queryParams}`);
+    } 
+    catch (error) {
+      res.redirect(`${process.env.FRONTEND_URL}/auth/callback?error=${error.message}`);
+    }
+  }
+  
+  //
+  // getAllUsers()
+  //
+  // Retrieves all users from the users table
+  //
+  // returns:
+  //    users => user.toJSON()
+  //
+  async getAllUsers(
+    _req: AuthRequest, 
+    res: Response
+  ): Promise<void> {
+    try {
+      const users = await this.authUseCase.getAllUsers();
+      res.status(200).json(users.map(user => user.toJSON()));
+    }
+    catch (getAllUsersError) {
+      const error = this.handleError(getAllUsersError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+  
+  //
+  // googleAuth()
+  //
+  // Initiates google oath
+  //
+  // returns:
+  //    url - OAuth URL
+  //
+  async googleAuth(
+    _req: Request, 
+    res: Response
+  ): Promise<void> {
+    try {
+      console.log('starting google auth');
+      const redirectTo = `${process.env.FRONTEND_URL}/auth/callback`;
+      const url = await this.authUseCase.googleAuth(redirectTo);
+      res.json({ url });
+    } 
+    catch (googleAuthError) {
+      const error = this.handleError(googleAuthError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+  
+  //
+  // handleOAuthCallback()
+  //
+  // Handles OAuth initiatiation with a cookie and user (new if not existing)
+  //
+  // returns:
+  //    none
+  //
+  async handleOAuthCallback(
+    req: Request, 
+    res: Response
+  ): Promise<void> {
+    try {
+      console.log('OAuth callback received:', req.query);
+      const code = req.query.code as string;
+ 
+      // call useCase to retrieve current session and user
+      const data = await this.authUseCase.handleOAuthCallback(code);
+      // create our cookie
+      console.log("creating cookie");
+      res.cookie('session', data.session.access_token, {
+        httpOnly: true,
+        secure: process.env.NODE_ENV === 'production',
+        sameSite: 'lax',
+        maxAge: 3600 * 1000,
+        path: '/',
+      });
+      // Redirect to home page
+      res.redirect(`${process.env.FRONTEND_URL}`);
+    } catch (error) {
+      res.redirect(
+        `${process.env.FRONTEND_URL}/login?error=` + encodeURIComponent(error.message)
+      );
+    }
+  }
+  
+  //
+  // handleToken()
+  //
+  // Checks that the token is valid and is associated with a user
+  //
+  // returns:
+  //    users => user.toJSON()
+  //
+  async handleToken(
+    req: Request<object, object, TokenBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { access_token } = req.body;
+ 
+      Iif (!access_token) {
+        res.status(401).json({ error: 'No access token provided' });
+      }
+      
+      const user = await this.authUseCase.handleToken(access_token);
+  
+      res.cookie('session', access_token, {
+        httpOnly: true,
+        secure: process.env.NODE_ENV === 'production',
+        sameSite: 'lax',
+        maxAge: 3600 * 1000,
+        path: '/',
+      });
+ 
+      res.json({ success: true , user: user.toJSON()});
+    } catch (handleTokenError) {
+      console.log(handleTokenError);
+      // const error = this.handleError(handleTokenError, res);
+      // res.status(error.status).json({ error: error.message})
+    }
+  }
+  
+  //
+  // requestPasswordReset()
+  //
+  // Request password reset and sends link to user
+  //
+  // returns:
+  //    None
+  //
+  async requestPasswordReset(
+    req: Request<object, object, PasswordResetBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { email } = req.body;
+      const redirectTo = `${process.env.FRONTEND_URL}/auth/reset-password`;
+      
+      // call useCase to redirect user to reset password and check for errors
+      await this.authUseCase.requestPasswordReset(email, redirectTo);
+      
+      res.status(200).json({ message: 'Password reset instructions sent to email'});
+    } catch (requestPasswordError) {
+      const error = this.handleError(requestPasswordError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+ 
+  //
+  // handlePasswordRecovery()
+  //
+  // Verify session and directs user to password recovery
+  //
+  // returns:
+  //    None
+  //
+  async handlePasswordRecovery(
+    req: Request, 
+    res: Response
+  ): Promise<void> {
+    try {
+      const token_hash = req.query.token_hash as string;
+      const type = req.query.type as string;
+      
+      // call useCase to retrieve access and refresh tokens.
+      const queryParams = await this.authUseCase.handlePasswordRecovery(token_hash, type);
+  
+      const redirectUrl = `${process.env.FRONTEND_URL}/auth/reset-password?${queryParams.toString()}`;
+      res.redirect(redirectUrl);
+    } catch {
+      res.redirect(
+        `${process.env.FRONTEND_URL}/auth/reset-password?error=${encodeURIComponent(
+          'Failed to process password recovery'
+        )}`
+      );
+    }
+  }
+ 
+  //
+  // updatePassword()
+  //
+  // After being verified, allows user to update password
+  //
+  // returns:
+  //    user
+  //
+  async updatePassword(
+    req: Request<object, object, UpdatePasswordBody>,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { password } = req.body;
+      const token = req.headers.authorization?.split(' ')[1];
+      
+      const user = await this.authUseCase.updatePassword(password, token);
+  
+      res.status(200).json({
+        message: 'Password updated successfully',
+        user: user.toJSON(),
+      });
+    } catch (updatePasswordError) {
+      const error = this.handleError(updatePasswordError, res);
+      res.status(error.status).json({ error: error.message})
+    }
+  }
+ 
+  // Helper method to handle errors
+  private handleError(
+    error: Error, 
+    res: Response
+  ): { status: number, message: string } {
+    console.error('Error:', error.message);
+    
+    if (error instanceof ValidationError) {
+      return { status: 400, message: error.message};
+    } else if (error instanceof ConflictError) {
+      return { status: 409, message: error.message};
+    } else if (error instanceof AuthenticationError) {
+      return { status: 401, message: error.message};
+    } else if (error instanceof NotFoundError) {
+      return { status: 404, message: error.message};
+    } else if (error instanceof AuthorizationError) {
+      return { status: 403, message: error.message};
+    } else {
+      return { status: 500, message: error.message};
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/clientController.ts.html b/coverage/src/controllers/clientController.ts.html new file mode 100644 index 00000000..10d06cef --- /dev/null +++ b/coverage/src/controllers/clientController.ts.html @@ -0,0 +1,877 @@ + + + + + + Code coverage report for src/controllers/clientController.ts + + + + + + + + + +
+
+

All files / src/controllers clientController.ts

+
+ +
+ 0% + Statements + 0/75 +
+ + +
+ 0% + Branches + 0/23 +
+ + +
+ 0% + Functions + 0/9 +
+ + +
+ 0% + Lines + 0/74 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Response } from 'express';
+import {
+    AuthenticationError,
+    AuthorizationError,
+    ConflictError,
+    NotFoundError,
+    ValidationError
+} from '../domains/errors';
+import { Client } from '../entities/Client';
+ 
+import { AuthRequest } from '../types';
+import { ClientUseCase } from '../usecase/clientUseCase';
+ 
+export class ClientController {
+  private clientUseCase: ClientUseCase;
+ 
+  constructor (clientUseCase: ClientUseCase) {
+    this.clientUseCase = clientUseCase;
+  };
+ 
+  //
+  // getClients()
+  //
+  // Grabs all clients (lite or detailed) based on role or query param
+  //
+  // returns:
+  //    Clients[]
+  //
+  async getClients(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const { id, role } = req.user;
+      const { detailed } = req.query;
+ 
+      const clients = detailed === 'true'
+        ? await this.clientUseCase.getClientsDetailed(id, role)
+        : await this.clientUseCase.getClientsLite(id, role);
+ 
+      console.log("clients:", clients);
+ 
+      res.json(clients.map(client => client.toJson()));
+    } catch (getError) {
+      const error = this.handleError(getError, res);
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message });
+      }
+    }
+  }
+//
+  // getCSVClients()
+  //
+  // Grabs all client data in CSV form
+  //
+  // returns:
+  //    CSV of users
+  //
+  async exportCSV(
+    req: AuthRequest,
+    res: Response,
+  ): Promise<void> {
+    try {
+      const {role} = req.user;
+      const clientsCSV = await this.clientUseCase.exportCSV(role);
+      res.header("Content-Type", "text/csv");
+      res.attachment("clients.csv");
+ 
+      res.send(clientsCSV);
+    } 
+    catch (getError) {
+      const error = this.handleError(getError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+  //
+  // getClientById()
+  //
+  // Grab a specific client with detailed information
+  //
+  // returns:
+  //    Client
+  //
+  async getClientById(req: AuthRequest, res: Response): Promise<void> {
+  try {
+    const { id } = req.params;
+    const { detailed } = req.query;
+ 
+    Iif (!id) {
+      res.status(400).json({ error: 'Missing client ID' });
+      return;
+    }
+ 
+    const client = detailed === 'true'
+      ? await this.clientUseCase.getClientDetailed(id)
+      : await this.clientUseCase.getClientLite(id);
+ 
+    res.json(client.toJson());
+  } catch (error) {
+    const err = this.handleError(error, res);
+    Iif (!res.headersSent) {
+      res.status(err.status).json({ error: err.message });
+    }
+  }
+}
+ 
+ 
+ 
+  //
+  // updateClientStatus
+  //
+  // Updates client status in client_info table by grabbing the client to update in the request body
+  //
+  // returns:
+  //    Client with updatedAt timestamp
+  //
+  async updateClientStatus(
+    req: AuthRequest,
+    res: Response,
+  ): Promise<void> {
+    const { clientId, status } = req.body;
+    console.log(clientId, status);
+ 
+    Iif (!clientId || !status) {
+      res.status(400).json({ message: 'Missing client ID or status' });
+      return;
+    }
+ 
+    try {
+      // Update client status directly in client_info table
+      const client = await this.clientUseCase.updateClientStatus(clientId, status);
+      
+      res.json({
+        success: true,
+        client: {
+          id: client.id,
+          status: client.status,
+          updatedAt: client.updatedAt,
+          firstname: client.user.firstname,
+          lastname: client.user.lastname,
+          email: client.user.email,
+          role: client.user.role,
+          serviceNeeded: client.serviceNeeded,
+          requestedAt: client.requestedAt
+        }
+      });
+    }
+    catch (statusError) {
+      const error = this.handleError(statusError, res);
+      res.status(error.status).json({ error: error.message });
+    }
+  }
+ 
+  //
+  // updateClient
+  //
+  // Updates client profile fields
+  //
+  // returns:
+  //    Client with updatedAt timestamp
+  //
+  async updateClient(
+    req: AuthRequest,
+    res: Response,
+  ): Promise<void> {
+    const { id } = req.params;
+    const updateData = req.body;
+ 
+    console.log('Controller: Request details:', {
+      method: req.method,
+      url: req.url,
+      originalUrl: req.originalUrl,
+      path: req.path,
+      params: req.params,
+      id,
+      idType: typeof id
+    });
+ 
+    Iif (!id) {
+      res.status(400).json({ error: 'Missing client ID' });
+      return;
+    }
+ 
+    // Validate that id looks like a UUID
+    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+    Iif (!uuidRegex.test(id)) {
+      console.error('Controller: Invalid client ID format:', id);
+      res.status(400).json({ error: `Invalid client ID format: ${id}. Expected UUID format.` });
+      return;
+    }
+ 
+    console.log('Controller: Updating client:', { 
+      id, 
+      idType: typeof id,
+      updateData,
+      updateDataKeys: Object.keys(updateData)
+    });
+ 
+    try {
+      const client = await this.clientUseCase.updateClientProfile(
+        id,
+        updateData
+      );
+      
+      console.log('Controller: Client updated successfully:', client.id);
+      
+      res.json({
+        success: true,
+        client: {
+          id: client.id,
+          updatedAt: client.updatedAt,
+          firstname: client.user.firstname,
+          lastname: client.user.lastname,
+          email: client.user.email,
+          phoneNumber: client.phoneNumber, // Get from Client entity
+          role: client.user.role,
+          status: client.status,
+          serviceNeeded: client.serviceNeeded,
+          requestedAt: client.requestedAt
+        }
+      });
+    }
+    catch (error) {
+      console.error('Controller: Error updating client:', error);
+      const err = this.handleError(error, res);
+      res.status(err.status).json({ error: err.message });
+    }
+  }
+ 
+  // Helper method to handle errors
+  private handleError(
+    error: Error, 
+    res: Response
+  ): { status: number, message: string } {
+    console.error('Error:', error.message);
+    
+    if (error instanceof ValidationError) {
+      return { status: 400, message: error.message};
+    } else if (error instanceof ConflictError) {
+      return { status: 409, message: error.message};
+    } else if (error instanceof AuthenticationError) {
+      return { status: 401, message: error.message};
+    } else if (error instanceof NotFoundError) {
+      return { status: 404, message: error.message};
+    } else if (error instanceof AuthorizationError) {
+      return { status: 403, message: error.message};
+    } else {
+      return { status: 500, message: error.message};
+    }
+  }
+ 
+  // Helper for returning basic summary of a client
+  private mapToClientSummary(client: Client) {
+    return {
+      id: client.user.id.toString(),
+      firstname: client.user.firstname,
+      lastname: client.user.lastname,
+      serviceNeeded: client.serviceNeeded,
+      requestedAt: client.requestedAt,
+      updatedAt: client.updatedAt,
+      status: client.status,
+    };
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/contractController.ts.html b/coverage/src/controllers/contractController.ts.html new file mode 100644 index 00000000..bc911a08 --- /dev/null +++ b/coverage/src/controllers/contractController.ts.html @@ -0,0 +1,871 @@ + + + + + + Code coverage report for src/controllers/contractController.ts + + + + + + + + + +
+
+

All files / src/controllers contractController.ts

+
+ +
+ 0% + Statements + 0/86 +
+ + +
+ 0% + Branches + 0/29 +
+ + +
+ 0% + Functions + 0/11 +
+ + +
+ 0% + Lines + 0/81 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from 'express';
+import {
+  AuthenticationError,
+  AuthorizationError,
+  ConflictError,
+  NotFoundError,
+  ValidationError
+} from '../domains/errors';
+import { Client } from '../entities/Client';
+ 
+import { UpdateRequest } from '../types';
+import { ContractUseCase } from '../usecase/contractUseCase';
+ 
+export class ContractController {
+  private contractUseCase: ContractUseCase;
+ 
+  constructor (contractUseCase: ContractUseCase) {
+    this.contractUseCase = contractUseCase;
+  };
+ 
+  //
+  // Generate and save a contract (finalized)
+  //
+  async generateContract(
+    req: UpdateRequest, 
+    res: Response
+  ): Promise<void> {
+    try {
+      const { templateId, clientId, fields, note, fee, deposit } = req.body;
+ 
+      Iif (!templateId || !clientId || !fields) {
+        throw new ValidationError('Missing required fields.');
+      }
+ 
+      // Delegate to use case for PDF generation + upload + DB write
+      const contract = await this.contractUseCase.createContract({
+        templateId,
+        clientId,
+        fields,
+        note,
+        fee,
+        deposit,
+        generatedBy: req.user.id,
+      });
+ 
+      res.status(201).json(contract);
+    } catch (err) {
+      const error = this.handleError(err, res);
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message });
+      }
+    }
+  }
+ 
+  //
+  // Preview a generated contract PDF
+  //
+  async previewContract(req: Request, res: Response): Promise<void> {
+    try {
+      const contractId = req.params.id;
+      Iif (!contractId) throw new ValidationError('Missing contract ID');
+ 
+      const { buffer, filename } = await this.contractUseCase.fetchContractPDF(contractId);
+ 
+      res.setHeader('Content-Type', 'application/pdf');
+      res.setHeader('Content-Disposition', `inline; filename=${filename}`);
+      res.send(buffer);
+    } catch (err) {
+      const error = this.handleError(err, res);
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message });
+      }
+    }
+  }
+ 
+  //
+  // getTemplates
+  //
+  // Get a list of all templates
+  //
+  // returns:
+  //    Templates
+  //
+  async getAllTemplates(
+    req: Request,
+    res: Response,
+  ): Promise<void> {
+    try {
+      const templates = await this.contractUseCase.getAllTemplates();
+      res.status(200).json(templates.map((template) => template.toJson()));
+    }
+    catch (getError) {
+      const error = this.handleError(getError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+  //
+  // deleteTemplate
+  //
+  // Delete a template
+  //
+  // returns:
+  //    None
+  //
+  async deleteTemplate(
+    req: Request,
+    res: Response
+  ): Promise<void> {
+    const name = req.params.name;
+ 
+    try {
+      const result = await this.contractUseCase.deleteTemplate(name);
+      res.status(204).send();
+    }
+    catch (delError) {
+      const error = this.handleError(delError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+    //
+  // deleteTemplate
+  //
+  // Delete a template
+  //
+  // returns:
+  //    None
+  //
+  async updateTemplate(
+    req: UpdateRequest,
+    res: Response
+  ): Promise<void> {
+    const name = req.params.name;
+    const file = req.file;
+    const { deposit, fee } = req.body;
+ 
+    try {
+      const result = await this.contractUseCase.updateTemplate(name, deposit, fee, file);
+      res.status(204).send();
+    }
+    catch (delError) {
+      const error = this.handleError(delError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+  //
+  // uploadTemplate()
+  //
+  // Upload template to storage
+  //
+  // returns:
+  //    none
+  //
+  async uploadTemplate(
+    req: UpdateRequest,
+    res: Response,
+  ): Promise<void> {
+    try {
+      const file = req.file;
+      const { name, deposit, fee } = req.body;
+  
+      Iif (!file) throw new ValidationError('No file uploaded');
+      Iif (!name) throw new ValidationError('No contract name specified');
+  
+      await this.contractUseCase.uploadTemplate(file, name, deposit, fee);
+  
+      res.status(201).json({ success: true });
+    } 
+    catch (getError) {
+      const error = this.handleError(getError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message})
+      }
+    }
+  }
+ 
+ 
+  //
+  // Generate a filled template
+  //
+  // returns:
+  //    none
+  //
+  async generateTemplate(
+    req: Request,
+    res: Response
+  ): Promise<void> {
+    try {
+      const { name, fields } = req.body;
+      const download = req.query.download === 'true';
+ 
+      Iif (!name) throw new ValidationError('No template name provided');
+ 
+      // generate the template as pdf
+      const pdfBuffer = await this.contractUseCase.generateTemplate(name, fields ?? {});
+      
+      if (download) {
+        res.setHeader('Content-Disposition', `attachment; filename=${fields.clientname}-${name}.pdf`);
+        res.setHeader('Content-Type', 'application/pdf');
+      }
+      else {
+        res.setHeader('Content-Type', 'application/pdf');
+        res.setHeader('Content-Disposition', `inline; filename=${fields.clientname}-${name}-preview.pdf`);
+      }
+ 
+      res.send(pdfBuffer);
+    }
+    catch (genError) {
+      const error = this.handleError(genError, res);
+ 
+      Iif (!res.headersSent) {
+        res.status(error.status).json({ error: error.message });
+      }
+    }
+  }
+ 
+  // Helper method to handle errors
+  private handleError(
+    error: Error, 
+    res: Response
+  ): { status: number, message: string } {
+    console.error('Error:', error.message);
+    
+    if (error instanceof ValidationError) {
+      return { status: 400, message: error.message};
+    } else if (error instanceof ConflictError) {
+      return { status: 409, message: error.message};
+    } else if (error instanceof AuthenticationError) {
+      return { status: 401, message: error.message};
+    } else if (error instanceof NotFoundError) {
+      return { status: 404, message: error.message};
+    } else if (error instanceof AuthorizationError) {
+      return { status: 403, message: error.message};
+    } else {
+      return { status: 500, message: error.message};
+    }
+  }
+ 
+  // Helper for returning basic summary of a client
+  private mapToClientSummary(client: Client) {
+    return {
+      id: client.user.id.toString(),
+      firstname: client.user.firstname,
+      lastname: client.user.lastname,
+      serviceNeeded: client.serviceNeeded,
+      requestedAt: client.requestedAt,
+      updatedAt: client.updatedAt,
+      status: client.status,
+    };
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/emailController.ts.html b/coverage/src/controllers/emailController.ts.html new file mode 100644 index 00000000..5041aaf2 --- /dev/null +++ b/coverage/src/controllers/emailController.ts.html @@ -0,0 +1,319 @@ + + + + + + Code coverage report for src/controllers/emailController.ts + + + + + + + + + +
+
+

All files / src/controllers emailController.ts

+
+ +
+ 0% + Statements + 0/23 +
+ + +
+ 0% + Branches + 0/13 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/23 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from 'express';
+import { NodemailerService } from '../services/emailService';
+ 
+export class EmailController {
+  private emailService: NodemailerService;
+ 
+  constructor() {
+    this.emailService = new NodemailerService();
+  }
+ 
+  async sendClientApproval(req: Request, res: Response): Promise<void> {
+    try {
+      const { email, name, signupUrl } = req.body;
+ 
+      Iif (!email || !name || !signupUrl) {
+        res.status(400).json({ 
+          success: false, 
+          error: 'Missing required fields: email, name, or signupUrl' 
+        });
+        return;
+      }
+ 
+      await this.emailService.sendClientApprovalEmail(
+        email,
+        name,
+        signupUrl
+      );
+ 
+      res.status(200).json({ 
+        success: true, 
+        message: `Approval email sent to ${email}` 
+      });
+    } catch (error) {
+      console.error('Error sending approval email:', error);
+      res.status(500).json({ 
+        success: false, 
+        error: error.message || 'Failed to send email' 
+      });
+    }
+  }
+ 
+  async sendTeamInvite(req: Request, res: Response): Promise<void> {
+    try {
+      const { email, firstname, lastname, role } = req.body;
+ 
+      Iif (!email || !firstname || !lastname || !role) {
+        console.log('Missing required fields:', { email, firstname, lastname, role });
+        res.status(400).json({ 
+          success: false, 
+          error: 'Missing required fields: email, firstname, lastname, or role' 
+        });
+        return;
+      }
+ 
+      await this.emailService.sendTeamInviteEmail(
+        email,
+        firstname,
+        lastname,
+        role
+      );
+ 
+      res.status(200).json({ 
+        success: true, 
+        message: `Invite email sent to ${email}` 
+      });
+    } catch (error) {
+      console.error('Error sending team invite email:', error);
+      console.error('Error details:', {
+        name: error.name,
+        message: error.message,
+        stack: error.stack
+      });
+      res.status(500).json({ 
+        success: false, 
+        error: error.message || 'Failed to send email' 
+      });
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/index.html b/coverage/src/controllers/index.html new file mode 100644 index 00000000..3e342f88 --- /dev/null +++ b/coverage/src/controllers/index.html @@ -0,0 +1,221 @@ + + + + + + Code coverage report for src/controllers + + + + + + + + + +
+
+

All files src/controllers

+
+ +
+ 0% + Statements + 0/608 +
+ + +
+ 0% + Branches + 0/309 +
+ + +
+ 0% + Functions + 0/73 +
+ + +
+ 0% + Lines + 0/589 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
authController.ts +
+
0%0/1080%0/210%0/150%0/107
clientController.ts +
+
0%0/750%0/230%0/90%0/74
contractController.ts +
+
0%0/860%0/290%0/110%0/81
emailController.ts +
+
0%0/230%0/130%0/30%0/23
paymentController.ts +
+
0%0/540%0/130%0/50%0/54
quickbooksController.ts +
+
0%0/690%0/10%0/90%0/59
requestFormController.ts +
+
0%0/1140%0/1860%0/80%0/114
userController.ts +
+
0%0/790%0/230%0/130%0/77
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/paymentController.ts.html b/coverage/src/controllers/paymentController.ts.html new file mode 100644 index 00000000..97bf43a7 --- /dev/null +++ b/coverage/src/controllers/paymentController.ts.html @@ -0,0 +1,607 @@ + + + + + + Code coverage report for src/controllers/paymentController.ts + + + + + + + + + +
+
+

All files / src/controllers paymentController.ts

+
+ +
+ 0% + Statements + 0/54 +
+ + +
+ 0% + Branches + 0/13 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/54 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from 'express';
+import { z } from 'zod';
+import { StripePaymentService } from '../services/payments/stripePaymentService';
+ 
+const paymentService = new StripePaymentService();
+ 
+// Validation schemas
+const saveCardSchema = z.object({
+  cardToken: z.string(),
+});
+ 
+const chargeCardSchema = z.object({
+  amount: z.number().positive(),
+  description: z.string().optional(),
+});
+ 
+const updateCardSchema = z.object({
+  cardToken: z.string(),
+});
+ 
+class PaymentController {
+  async saveCard(req: Request, res: Response): Promise<void> {
+    try {
+      const { customerId } = req.params;
+      const { cardToken } = req.body;
+ 
+      // Verify the authenticated user has permission for this customer
+      Iif (req.user.id !== customerId && req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Not authorized to perform this action'
+        });
+        return;
+      }
+ 
+      const card = await paymentService.saveCard({
+        customerId,
+        cardToken,
+      });
+ 
+      res.json({
+        success: true,
+        data: card,
+      });
+    } catch (error) {
+      console.error('Error saving card:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+ 
+  async processCharge(req: Request, res: Response): Promise<void> {
+    try {
+      const { customerId } = req.params;
+      const { amount, description } = req.body;
+ 
+      // Verify the authenticated user has permission for this customer
+      Iif (req.user.id !== customerId && req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Not authorized to perform this action'
+        });
+        return;
+      }
+ 
+      const charge = await paymentService.chargeCard({
+        customerId,
+        amount,
+        description,
+      });
+ 
+      res.json({
+        success: true,
+        data: charge,
+      });
+    } catch (error) {
+      console.error('Error processing charge:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+ 
+  async updatePaymentMethod(req: Request, res: Response): Promise<void> {
+    try {
+      const { customerId, paymentMethodId } = req.params;
+      const { cardToken } = req.body;
+ 
+      // Verify the authenticated user has permission for this customer
+      Iif (req.user.id !== customerId && req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Not authorized to perform this action'
+        });
+        return;
+      }
+ 
+      const updatedCard = await paymentService.updateCard({
+        customerId,
+        cardToken,
+        paymentMethodId,
+      });
+ 
+      res.json({
+        success: true,
+        data: updatedCard,
+      });
+    } catch (error) {
+      console.error('Error updating payment method:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+ 
+  async getPaymentMethods(req: Request, res: Response): Promise<void> {
+    try {
+      const { customerId } = req.params;
+ 
+      // Verify the authenticated user has permission for this customer
+      Iif (req.user.id !== customerId && req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Not authorized to perform this action'
+        });
+        return;
+      }
+ 
+      const paymentMethods = await paymentService.getPaymentMethods(customerId);
+ 
+      res.json({
+        success: true,
+        data: paymentMethods,
+      });
+    } catch (error) {
+      console.error('Error fetching payment methods:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+ 
+  async getCustomersWithStripeId(req: Request, res: Response): Promise<void> {
+    try {
+      // Only allow admins to fetch all customers
+      Iif (req.user.role !== 'admin') {
+        res.status(403).json({
+          success: false,
+          error: 'Admin access required'
+        });
+        return;
+      }
+ 
+      const customers = await paymentService.getCustomersWithStripeId();
+ 
+      res.json({
+        success: true,
+        data: customers,
+      });
+    } catch (error) {
+      console.error('Error fetching customers with Stripe ID:', error);
+      res.status(400).json({
+        success: false,
+        error: error.message,
+      });
+    }
+  }
+}
+ 
+export const paymentController = new PaymentController(); 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/quickbooksController.ts.html b/coverage/src/controllers/quickbooksController.ts.html new file mode 100644 index 00000000..79ceddeb --- /dev/null +++ b/coverage/src/controllers/quickbooksController.ts.html @@ -0,0 +1,511 @@ + + + + + + Code coverage report for src/controllers/quickbooksController.ts + + + + + + + + + +
+
+

All files / src/controllers quickbooksController.ts

+
+ +
+ 0% + Statements + 0/69 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/9 +
+ + +
+ 0% + Lines + 0/59 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/controller/quickbooksController.ts
+import { RequestHandler } from 'express';
+import {
+    disconnectQuickBooks,
+    generateConsentUrl,
+    handleAuthCallback,
+    isConnected
+} from '../services/auth/quickbooksAuthService';
+import createCustomerService, { CreateCustomerParams } from '../services/customer/createCustomer';
+import createInvoiceService from '../services/invoice/createInvoice';
+import supabase from '../supabase';
+// ← 1) Import your invoiceable-customers logic
+import getInvoiceableCustomers from '../services/customer/getInvoiceableCustomers';
+// Ensure you have SUPABASE_JWT_SECRET in your env
+const JWT_SECRET = process.env.SUPABASE_JWT_SECRET!
+ 
+/**
+ * JSON endpoint: return the Intuit consent URL for AJAX calls.
+ */
+export const quickBooksAuthUrl: RequestHandler = (_req, res, next) => {
+  try {
+    const state = Math.random().toString(36).substring(2)
+    const url   = generateConsentUrl(state)
+    res.json({ url })
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * Redirect endpoint: used by window.open to start OAuth directly.
+ */
+export const connectQuickBooks: RequestHandler = (req, res, next) => {
+  try {
+    const state = Math.random().toString(36).substring(2)
+    const url   = generateConsentUrl(state)
+    res.redirect(url)
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * OAuth callback: exchange code for tokens, persist them, then notify the opener.
+ */
+export const handleQuickBooksCallback: RequestHandler = async (req, res, next) => {
+  try {
+    const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
+    await handleAuthCallback(fullUrl)
+    res.send(`
+      <html><body>
+        <script>
+           window.opener.postMessage({ success: true }, 'http://localhost:3001')
+          window.close()
+        </script>
+      </body></html>
+    `)
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * Create an invoice
+ */
+export const createInvoice: RequestHandler = async (req, res, next) => {
+  try {
+    const invoice = await createInvoiceService(req.body);
+    res.status(201).json(invoice);
+  } catch (err) {
+    next(err);
+  }
+}
+ 
+/**
+ * Get invoiceable customers
+ */
+export const getInvoiceableCustomersController: RequestHandler = async (_req, res, next) => {
+  try {
+    const customers = await getInvoiceableCustomers(supabase);
+    res.json(customers);
+  } catch (err: any) {
+    next(err);
+  }
+}
+ 
+/**
+ * Create a customer
+ */
+export const createCustomer: RequestHandler = async (req, res, next) => {
+  try {
+    const params: CreateCustomerParams = req.body;
+    const result = await createCustomerService(params)
+    res.status(201).json(result)
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * Get QuickBooks connection status
+ */
+export const quickBooksStatus: RequestHandler = async (req, res, next) => {
+  try {
+    console.log('🔍 [QB Status] Checking connection status...');
+    const connected = await isConnected();
+    console.log('📊 [QB Status] Connection result:', connected);
+    res.json({ connected });
+  } catch (err) {
+    console.error('❌ [QB Status] Error checking status:', err);
+    next(err);
+  }
+}
+ 
+/**
+ * Disconnect QuickBooks
+ */
+export const quickBooksDisconnect: RequestHandler = async (req, res, next) => {
+  try {
+    await disconnectQuickBooks()
+    res.json({ disconnected: true })
+  } catch (err) {
+    next(err)
+  }
+}
+ 
+/**
+ * GET /quickbooks/invoices
+ * Returns all invoices you've saved in Supabase
+ */
+export const getInvoices: RequestHandler = async (_req, res, next) => {
+  try {
+    const { data, error } = await supabase
+      .from('invoices')
+      .select('*')
+      .order('created_at', { ascending: false })
+ 
+    Iif (error) throw error
+    res.json(data)
+  } catch (err) {
+    next(err)
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/requestFormController.ts.html b/coverage/src/controllers/requestFormController.ts.html new file mode 100644 index 00000000..6e1b9406 --- /dev/null +++ b/coverage/src/controllers/requestFormController.ts.html @@ -0,0 +1,1438 @@ + + + + + + Code coverage report for src/controllers/requestFormController.ts + + + + + + + + + +
+
+

All files / src/controllers requestFormController.ts

+
+ +
+ 0% + Statements + 0/114 +
+ + +
+ 0% + Branches + 0/186 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/114 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request, Response } from "express";
+import { NodemailerService } from '../services/emailService';
+import { RequestFormService } from "../services/RequestFormService";
+import { AuthRequest, RequestFormData, RequestStatus } from "../types";
+ 
+const notificationEmail = 'jerrybony5@gmail.com';
+const emailService = new NodemailerService();
+ 
+export class RequestFormController {
+    private service: RequestFormService;
+ 
+    constructor(requestFormService: RequestFormService) {
+        this.service = requestFormService;
+    }
+ 
+    async createRequest(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.body) {
+                res.status(400).json({ error: 'No body found in request' });
+                return;
+            }
+ 
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            const formData: RequestFormData = req.body;
+            const result = await this.service.createRequest(formData);
+            
+            res.status(201).json({
+                message: "Request form submitted successfully",
+                data: result
+            });
+        } catch (error) {
+            console.error("Error creating request:", error);
+            res.status(400).json({ error: error.message });
+        }
+    }
+ 
+    async getUserRequests(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            const requests = await this.service.getUserRequests(req.user.id);
+            res.status(200).json({
+                message: "User requests retrieved successfully",
+                data: requests
+            });
+        } catch (error) {
+            console.error("Error getting user requests:", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    async getRequestById(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            const { id } = req.params;
+            Iif (!id) {
+                res.status(400).json({ error: 'Request ID is required' });
+                return;
+            }
+ 
+            const request = await this.service.getRequestById(id, req.user.id);
+            Iif (!request) {
+                res.status(404).json({ error: 'Request not found' });
+                return;
+            }
+ 
+            res.status(200).json({
+                message: "Request retrieved successfully",
+                data: request
+            });
+        } catch (error) {
+            console.error("Error getting request by ID:", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    async getAllRequests(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            // Check if user is admin
+            Iif (req.user.role !== 'admin') {
+                res.status(403).json({ error: 'Admin access required' });
+                return;
+            }
+ 
+            const requests = await this.service.getAllRequests();
+            res.status(200).json({
+                message: "All requests retrieved successfully",
+                data: requests
+            });
+        } catch (error) {
+            console.error("Error getting all requests:", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    async getRequestByIdAdmin(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            // Check if user is admin
+            Iif (req.user.role !== 'admin') {
+                res.status(403).json({ error: 'Admin access required' });
+                return;
+            }
+ 
+            const { id } = req.params;
+            Iif (!id) {
+                res.status(400).json({ error: 'Request ID is required' });
+                return;
+            }
+ 
+            const request = await this.service.getRequestByIdAdmin(id);
+            Iif (!request) {
+                res.status(404).json({ error: 'Request not found' });
+                return;
+            }
+ 
+            res.status(200).json({
+                message: "Request retrieved successfully",
+                data: request
+            });
+        } catch (error) {
+            console.error("Error getting request by ID (admin):", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    async updateRequestStatus(req: AuthRequest, res: Response): Promise<void> {
+        try {
+            Iif (!req.user?.id) {
+                res.status(401).json({ error: 'User not authenticated' });
+                return;
+            }
+ 
+            // Check if user is admin
+            Iif (req.user.role !== 'admin') {
+                res.status(403).json({ error: 'Admin access required' });
+                return;
+            }
+ 
+            const { id } = req.params;
+            const { status } = req.body;
+ 
+            Iif (!id) {
+                res.status(400).json({ error: 'Request ID is required' });
+                return;
+            }
+ 
+            Iif (!status) {
+                res.status(400).json({ error: 'Status is required' });
+                return;
+            }
+ 
+            const validStatuses = Object.values(RequestStatus);
+            Iif (!validStatuses.includes(status)) {
+                res.status(400).json({ 
+                    error: 'Invalid status value',
+                    validStatuses: validStatuses
+                });
+                return;
+            }
+ 
+            const updatedRequest = await this.service.updateRequestStatus(id, status);
+            res.status(200).json({
+                message: "Request status updated successfully",
+                data: updatedRequest
+            });
+        } catch (error) {
+            console.error("Error updating request status:", error);
+            res.status(500).json({ error: error.message });
+        }
+    }
+ 
+    // Updated method to handle all 10-step form fields
+    async createForm(req: Request, res: Response): Promise<void> {
+        try {
+            Iif (!req.body) {
+                res.status(400).json({ error: 'No body found in request' });
+                return;
+            }
+            const formData = req.body;
+            const savedForm = await this.service.newForm(formData);
+ 
+            // Send notification email
+            try {
+                const subject = 'New Lead Submitted via Request Form';
+                
+                // Create comprehensive text version
+                const text = `A new lead has been submitted via the request form.
+ 
+CLIENT DETAILS:
+Name: ${savedForm.firstname} ${savedForm.lastname}
+Email: ${savedForm.email}
+Phone: ${savedForm.phone_number}
+Pronouns: ${savedForm.pronouns || 'Not specified'}${savedForm.pronouns_other ? ` (${savedForm.pronouns_other})` : ''}
+Children Expected: ${savedForm.children_expected || 'Not specified'}
+ 
+HOME DETAILS:
+Address: ${savedForm.address}
+City: ${savedForm.city}
+State: ${savedForm.state}
+Zip Code: ${savedForm.zip_code}
+Home Phone: ${savedForm.home_phone || 'Not provided'}
+Home Type: ${savedForm.home_type || 'Not specified'}
+Home Access: ${savedForm.home_access || 'Not specified'}
+Pets: ${savedForm.pets || 'None'}
+ 
+FAMILY MEMBERS:
+Relationship Status: ${savedForm.relationship_status || 'Not specified'}
+Partner Name: ${savedForm.first_name || 'Not provided'} ${savedForm.last_name || ''} ${savedForm.middle_name ? `(${savedForm.middle_name})` : ''}
+Partner Mobile: ${savedForm.mobile_phone || 'Not provided'}
+Partner Work Phone: ${savedForm.work_phone || 'Not provided'}
+ 
+REFERRAL:
+Source: ${savedForm.referral_source || 'Not specified'}
+Referral Name: ${savedForm.referral_name || 'Not provided'}
+Referral Email: ${savedForm.referral_email || 'Not provided'}
+ 
+HEALTH HISTORY:
+Health History: ${savedForm.health_history || 'None reported'}
+Allergies: ${savedForm.allergies || 'None reported'}
+Health Notes: ${savedForm.health_notes || 'None'}
+ 
+PAYMENT INFO:
+Annual Income: ${savedForm.annual_income || 'Not specified'}
+Service Needed: ${savedForm.service_needed}
+Service Specifics: ${savedForm.service_specifics || 'Not provided'}
+ 
+PREGNANCY/BABY:
+Due Date: ${savedForm.due_date ? new Date(savedForm.due_date).toLocaleDateString() : 'Not specified'}
+Birth Location: ${savedForm.birth_location || 'Not specified'}
+Birth Hospital: ${savedForm.birth_hospital || 'Not specified'}
+Number of Babies: ${savedForm.number_of_babies || 'Not specified'}
+Baby Name: ${savedForm.baby_name || 'Not specified'}
+Provider Type: ${savedForm.provider_type || 'Not specified'}
+Pregnancy Number: ${savedForm.pregnancy_number || 'Not specified'}
+Hospital: ${savedForm.hospital || 'Not specified'}
+ 
+PAST PREGNANCIES:
+Had Previous Pregnancies: ${savedForm.had_previous_pregnancies ? 'Yes' : 'No'}
+Previous Pregnancies Count: ${savedForm.previous_pregnancies_count || '0'}
+Living Children Count: ${savedForm.living_children_count || '0'}
+Past Pregnancy Experience: ${savedForm.past_pregnancy_experience || 'None'}
+ 
+SERVICES INTERESTED:
+Services: ${Array.isArray(savedForm.services_interested) ? savedForm.services_interested.join(', ') : savedForm.services_interested || 'Not specified'}
+Service Support Details: ${savedForm.service_support_details || 'Not provided'}
+ 
+DEMOGRAPHICS:
+Race/Ethnicity: ${savedForm.race_ethnicity || 'Not specified'}
+Primary Language: ${savedForm.primary_language || 'Not specified'}
+Client Age Range: ${savedForm.client_age_range || 'Not specified'}
+Insurance: ${savedForm.insurance || 'Not specified'}
+Demographics: ${Array.isArray(savedForm.demographics_multi) ? savedForm.demographics_multi.join(', ') : savedForm.demographics_multi || 'None'}
+ 
+FORM SUBMISSION DETAILS:
+Submission Date: ${new Date().toLocaleString()}
+Status: lead`;
+ 
+ 
+ 
+                // Create comprehensive HTML version
+                const html = `
+                  <div style="font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; background-color: #f9f9f9; padding: 20px;">
+                    <div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
+                      <h1 style="color: #4CAF50; text-align: center; margin-bottom: 30px; border-bottom: 3px solid #4CAF50; padding-bottom: 10px;">New Lead Submitted</h1>
+                      
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">👤 Client Details</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Name:</td><td style="padding: 8px;">${savedForm.firstname} ${savedForm.lastname}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Email:</td><td style="padding: 8px;"><a href="mailto:${savedForm.email}">${savedForm.email}</a></td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Phone:</td><td style="padding: 8px;"><a href="tel:${savedForm.phone_number}">${savedForm.phone_number}</a></td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Pronouns:</td><td style="padding: 8px;">${savedForm.pronouns || 'Not specified'}${savedForm.pronouns_other ? ` (${savedForm.pronouns_other})` : ''}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Children Expected:</td><td style="padding: 8px;">${savedForm.children_expected || 'Not specified'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">🏠 Home Details</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Address:</td><td style="padding: 8px;">${savedForm.address}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">City/State/Zip:</td><td style="padding: 8px;">${savedForm.city}, ${savedForm.state} ${savedForm.zip_code}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Home Phone:</td><td style="padding: 8px;">${savedForm.home_phone || 'Not provided'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Home Type:</td><td style="padding: 8px;">${savedForm.home_type || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Home Access:</td><td style="padding: 8px;">${savedForm.home_access || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Pets:</td><td style="padding: 8px;">${savedForm.pets || 'None'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">👨‍👩‍👧‍👦 Family Members</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Relationship Status:</td><td style="padding: 8px;">${savedForm.relationship_status || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Partner Name:</td><td style="padding: 8px;">${savedForm.first_name || 'Not provided'} ${savedForm.last_name || ''} ${savedForm.middle_name ? `(${savedForm.middle_name})` : ''}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Partner Mobile:</td><td style="padding: 8px;">${savedForm.mobile_phone || 'Not provided'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Partner Work Phone:</td><td style="padding: 8px;">${savedForm.work_phone || 'Not provided'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">📞 Referral</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Source:</td><td style="padding: 8px;">${savedForm.referral_source || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Referral Name:</td><td style="padding: 8px;">${savedForm.referral_name || 'Not provided'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Referral Email:</td><td style="padding: 8px;">${savedForm.referral_email || 'Not provided'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">🏥 Health History</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Health History:</td><td style="padding: 8px;">${savedForm.health_history || 'None reported'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Allergies:</td><td style="padding: 8px;">${savedForm.allergies || 'None reported'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Health Notes:</td><td style="padding: 8px;">${savedForm.health_notes || 'None'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">💰 Payment Info</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Annual Income:</td><td style="padding: 8px;">${savedForm.annual_income || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Service Needed:</td><td style="padding: 8px; font-weight: bold; color: #4CAF50;">${savedForm.service_needed}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Service Specifics:</td><td style="padding: 8px;">${savedForm.service_specifics || 'Not provided'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">👶 Pregnancy/Baby</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Due Date:</td><td style="padding: 8px;">${savedForm.due_date ? new Date(savedForm.due_date).toLocaleDateString() : 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Birth Location:</td><td style="padding: 8px;">${savedForm.birth_location || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Birth Hospital:</td><td style="padding: 8px;">${savedForm.birth_hospital || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Number of Babies:</td><td style="padding: 8px;">${savedForm.number_of_babies || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Baby Name:</td><td style="padding: 8px;">${savedForm.baby_name || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Provider Type:</td><td style="padding: 8px;">${savedForm.provider_type || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Pregnancy Number:</td><td style="padding: 8px;">${savedForm.pregnancy_number || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Hospital:</td><td style="padding: 8px;">${savedForm.hospital || 'Not specified'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">📋 Past Pregnancies</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Had Previous Pregnancies:</td><td style="padding: 8px;">${savedForm.had_previous_pregnancies ? 'Yes' : 'No'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Previous Pregnancies Count:</td><td style="padding: 8px;">${savedForm.previous_pregnancies_count || '0'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Living Children Count:</td><td style="padding: 8px;">${savedForm.living_children_count || '0'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Past Pregnancy Experience:</td><td style="padding: 8px;">${savedForm.past_pregnancy_experience || 'None'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">🎯 Services Interested</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Services:</td><td style="padding: 8px;">${Array.isArray(savedForm.services_interested) ? savedForm.services_interested.join(', ') : savedForm.services_interested || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Service Support Details:</td><td style="padding: 8px;">${savedForm.service_support_details || 'Not provided'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">📊 Demographics</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Race/Ethnicity:</td><td style="padding: 8px;">${savedForm.race_ethnicity || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Primary Language:</td><td style="padding: 8px;">${savedForm.primary_language || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Client Age Range:</td><td style="padding: 8px;">${savedForm.client_age_range || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Insurance:</td><td style="padding: 8px;">${savedForm.insurance || 'Not specified'}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Demographics:</td><td style="padding: 8px;">${Array.isArray(savedForm.demographics_multi) ? savedForm.demographics_multi.join(', ') : savedForm.demographics_multi || 'None'}</td></tr>
+                        </table>
+                      </div>
+ 
+                      <div style="margin-bottom: 25px;">
+                        <h2 style="color: #333; background-color: #e8f5e8; padding: 10px; border-radius: 5px;">📋 Form Submission Details</h2>
+                        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+                          <tr><td style="font-weight: bold; padding: 8px; width: 30%; background-color: #f5f5f5;">Submission Date:</td><td style="padding: 8px;">${new Date().toLocaleString()}</td></tr>
+                          <tr><td style="font-weight: bold; padding: 8px; background-color: #f5f5f5;">Status:</td><td style="padding: 8px;">lead</td></tr>
+                        </table>
+                      </div>
+ 
+                    </div>
+                  </div>
+                `;
+ 
+ 
+                
+                await emailService.sendEmail(notificationEmail, subject, text, html);
+            } catch (emailError) {
+                console.error('Failed to send notification email:', emailError);
+                // Do not block form submission if email fails
+            }
+ 
+            // Send confirmation email to the person who submitted the request
+            try {
+                const confirmationSubject = 'Request Received - We\'re Working on Your Match';
+                
+                const confirmationText = `Dear ${savedForm.firstname} ${savedForm.lastname},
+ 
+Thank you for submitting your request for doula services. We have received your information and are working on finding the perfect match for you.
+ 
+Best regards,
+The Sokana Collective Team`;
+ 
+                const confirmationHtml = `
+                  <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background-color: #f9f9f9; padding: 20px;">
+                    <div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
+                      <h1 style="color: #4CAF50; text-align: center; margin-bottom: 30px; border-bottom: 3px solid #4CAF50; padding-bottom: 10px;">Request Received</h1>
+                      
+                      <p style="font-size: 18px; color: #333; margin-bottom: 20px;">Dear ${savedForm.firstname} ${savedForm.lastname},</p>
+                      
+                      <p style="font-size: 16px; color: #555; line-height: 1.6; margin-bottom: 20px;">
+                        Thank you for submitting your request for doula services. We have received your information and are working on finding the perfect match for you.
+                      </p>
+                      
+                      <div style="text-align: center; margin-top: 30px; padding: 20px; background-color: #f5f5f5; border-radius: 5px;">
+                        <p style="margin: 0; font-weight: bold; color: #333;">Best regards,</p>
+                        <p style="margin: 5px 0 0 0; color: #4CAF50; font-weight: bold;">The Sokana Collective Team</p>
+                      </div>
+                    </div>
+                  </div>
+                `;
+                
+                await emailService.sendEmail(savedForm.email, confirmationSubject, confirmationText, confirmationHtml);
+            } catch (confirmationEmailError) {
+                console.error('Failed to send confirmation email:', confirmationEmailError);
+                // Do not block form submission if confirmation email fails
+            }
+ 
+            res.status(200).json({ message: "Form data received, onto processing" });
+        } catch (error) {
+            console.error("Error processing form data:", error);
+            res.status(400).json({ error: error.message });
+        }
+    }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/controllers/userController.ts.html b/coverage/src/controllers/userController.ts.html new file mode 100644 index 00000000..559dbb6a --- /dev/null +++ b/coverage/src/controllers/userController.ts.html @@ -0,0 +1,538 @@ + + + + + + Code coverage report for src/controllers/userController.ts + + + + + + + + + +
+
+

All files / src/controllers userController.ts

+
+ +
+ 0% + Statements + 0/79 +
+ + +
+ 0% + Branches + 0/23 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 0% + Lines + 0/77 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Response } from 'express';
+import { AuthenticationError, AuthorizationError, ConflictError, NotFoundError, ValidationError } from '../domains/errors';
+import { AuthRequest, UpdateRequest } from '../types';
+import { UserUseCase } from "../usecase/userUseCase";
+ 
+ 
+export class UserController {
+  private userUseCase: UserUseCase;
+ 
+  constructor(userUseCase: UserUseCase) {
+    this.userUseCase = userUseCase;
+  }
+ 
+  async getUserById(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const targetUserId = req.params.id;
+ 
+      const user = await this.userUseCase.getUserById(targetUserId);
+      res.status(200).json(user.toJSON());
+    } catch (error) {
+      this.handleError(error, res);
+    }
+  }
+ 
+  async getAllUsers(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const users = await this.userUseCase.getAllUsers();
+      res.status(200).json(users.map(user => user.toJSON()));
+    } catch (error) {
+      this.handleError(error, res);
+    }
+  }
+  async getAllTeamMembers(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const users = await this.userUseCase.getAllTeamMembers();
+      res.status(200).json(users.map(user => user.toJSON()));
+    } catch (error) {
+      this.handleError(error, res);
+    }
+  }
+ 
+  async deleteMember(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const userId = req.params.id;
+      await this.userUseCase.deleteMember(userId);
+    } catch (error) {
+      this.handleError(error, res);
+    }
+  }
+ 
+  async addMember(req: AuthRequest, res: Response): Promise<void>{
+    try{
+      const userName = req.params.firstname
+      const userEmail = req.params.email
+      const userRole = req.params.role
+      const userBio = req.params.bio
+      const user = await this.userUseCase.addMember(userName, userEmail, userRole, userBio)
+      res.status(200).json(user.toJSON())
+    } catch (error) {
+      this.handleError(error, res)
+    }
+  }
+ 
+  async getHours(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const { id, role } = req.user;
+      if(role === "admin") {
+        const allHoursData = await this.userUseCase.getAllHours();
+        res.status(200).json(allHoursData);
+      } else {
+        const specificHoursData = await this.userUseCase.getHoursById(id);
+        res.status(200).json(specificHoursData);
+      }
+    } catch (error) {
+      console.log("Error when retrieving user's work data");
+      this.handleError(error, res);
+    }
+  }
+ 
+  async addNewHours(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const { doula_id, client_id, start_time, end_time, note } = req.body;
+ 
+      Iif(!doula_id || !client_id || !start_time|| !end_time) {
+        console.log(`${doula_id}, ${client_id}, ${start_time}, ${end_time}`);
+        throw new Error(`Error: missing doula_id, client_id, start_time, or end_time`);
+      }
+ 
+      const newWorkEntry = await this.userUseCase.addNewHours(doula_id, client_id, new Date(start_time), new Date(end_time), note);
+      res.status(200).json(newWorkEntry);
+    } catch (error) {
+      console.log("Error trying to add new work entry");
+      this.handleError(error, res);
+    }
+  }
+ 
+  async updateUser(req: UpdateRequest, res: Response): Promise<void> {
+    try {
+      const user = req.user
+      const updateData = req.body;
+      const profilePicture = req.file;
+      
+      // upload profile picture to supabase storage so we can grab it later
+      Iif (profilePicture) {
+        const imageUrl = await this.userUseCase.uploadProfilePicture(user, profilePicture);
+        updateData.profile_picture = imageUrl;
+      }
+      
+      // Here we will handle which fields to update
+      const updatedUser = await this.userUseCase.updateUser(user, updateData);
+  
+      res.status(200).json(updatedUser.toJSON());
+    } catch(error) {
+      res.status(400).json({ error: error.message});
+    }
+  }
+ 
+  async addTeamMember(req: AuthRequest, res: Response): Promise<void> {
+    try {
+      const { firstname, lastname, email, role } = req.body;
+ 
+      Iif (!firstname || !lastname || !email || !role) {
+        res.status(400).json({ error: 'Missing required fields' });
+        return;
+      }
+ 
+      const newMember = await this.userUseCase.addMember(firstname, lastname, email, role);
+      res.status(201).json(newMember);
+    } catch (error) {
+      console.error('Error adding team member:', error);
+      res.status(500).json({ error: error.message });
+    }
+  }
+ 
+  private handleError(error: Error, res: Response): void {
+    console.error('Error:', error.message);
+    
+    if (error instanceof ValidationError) {
+      res.status(400).json({ error: error.message });
+    } else if (error instanceof ConflictError) {
+      res.status(409).json({ error: error.message });
+    } else if (error instanceof AuthenticationError) {
+      res.status(401).json({ error: error.message });
+    } else if (error instanceof NotFoundError) {
+      res.status(404).json({ error: error.message });
+    } else if (error instanceof AuthorizationError) {
+      res.status(403).json({ error: error.message });
+    } else {
+      res.status(500).json({ error: error.message });
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/db/checkTables.ts.html b/coverage/src/db/checkTables.ts.html new file mode 100644 index 00000000..8ef33145 --- /dev/null +++ b/coverage/src/db/checkTables.ts.html @@ -0,0 +1,184 @@ + + + + + + Code coverage report for src/db/checkTables.ts + + + + + + + + + +
+
+

All files / src/db checkTables.ts

+
+ +
+ 0% + Statements + 0/13 +
+ + +
+ 0% + Branches + 0/4 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/13 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import supabase from '../supabase';
+ 
+async function checkTables() {
+  console.log('Checking database tables...');
+ 
+  // Check payment_methods table
+  const { data: paymentMethodsData, error: paymentMethodsError } = await supabase
+    .from('payment_methods')
+    .select('*')
+    .limit(1);
+ 
+  console.log('\nPayment Methods Table:');
+  if (paymentMethodsError) {
+    console.error('Error:', paymentMethodsError.message);
+  } else {
+    console.log('✅ Table exists');
+  }
+ 
+  // Check charges table
+  const { data: chargesData, error: chargesError } = await supabase
+    .from('charges')
+    .select('*')
+    .limit(1);
+ 
+  console.log('\nCharges Table:');
+  if (chargesError) {
+    console.error('Error:', chargesError.message);
+  } else {
+    console.log('✅ Table exists');
+  }
+}
+ 
+// Run the check
+checkTables().catch(console.error); 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/db/index.html b/coverage/src/db/index.html new file mode 100644 index 00000000..3a7746aa --- /dev/null +++ b/coverage/src/db/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/db + + + + + + + + + +
+
+

All files src/db

+
+ +
+ 0% + Statements + 0/23 +
+ + +
+ 0% + Branches + 0/5 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/22 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
checkTables.ts +
+
0%0/130%0/40%0/10%0/13
setupStripeDb.ts +
+
0%0/100%0/10%0/10%0/9
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/db/setupStripeDb.ts.html b/coverage/src/db/setupStripeDb.ts.html new file mode 100644 index 00000000..7f0841a6 --- /dev/null +++ b/coverage/src/db/setupStripeDb.ts.html @@ -0,0 +1,307 @@ + + + + + + Code coverage report for src/db/setupStripeDb.ts + + + + + + + + + +
+
+

All files / src/db setupStripeDb.ts

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/9 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import supabase from '../supabase';
+ 
+async function setupStripeDb() {
+  const sql = `
+    -- First, create the update_updated_at_column function if it doesn't exist
+    create or replace function update_updated_at_column()
+    returns trigger as $$
+    begin
+        new.updated_at = now();
+        return new;
+    end;
+    $$ language 'plpgsql';
+ 
+    -- Drop existing objects if they exist
+    drop trigger if exists update_payment_methods_updated_at on payment_methods;
+    drop trigger if exists update_charges_updated_at on charges;
+    drop table if exists charges;
+    drop table if exists payment_methods;
+ 
+    -- Create payment_methods table
+    create table payment_methods (
+      id uuid default uuid_generate_v4() primary key,
+      customer_id uuid references customers(id) not null,
+      stripe_payment_method_id text not null,
+      card_last4 text not null,
+      card_brand text not null,
+      card_exp_month integer not null,
+      card_exp_year integer not null,
+      is_default boolean default false,
+      created_at timestamp with time zone default now(),
+      updated_at timestamp with time zone default now()
+    );
+ 
+    -- Create charges table
+    create table charges (
+      id uuid default uuid_generate_v4() primary key,
+      customer_id uuid references customers(id) not null,
+      payment_method_id uuid references payment_methods(id) not null,
+      stripe_payment_intent_id text not null,
+      amount integer not null,  -- Amount in cents
+      status text not null,    -- 'succeeded', 'failed', etc.
+      description text,
+      created_at timestamp with time zone default now(),
+      updated_at timestamp with time zone default now()
+    );
+ 
+    -- Create indexes
+    create index if not exists payment_methods_customer_id_idx on payment_methods(customer_id);
+    create index if not exists charges_customer_id_idx on charges(customer_id);
+    create index if not exists charges_payment_method_id_idx on charges(payment_method_id);
+ 
+    -- Create triggers
+    create trigger update_payment_methods_updated_at
+        before update on payment_methods
+        for each row
+        execute procedure update_updated_at_column();
+ 
+    create trigger update_charges_updated_at
+        before update on charges
+        for each row
+        execute procedure update_updated_at_column();
+  `;
+ 
+  try {
+    const { error } = await supabase.rpc('exec_sql', { sql });
+    Iif (error) throw error;
+    console.log('Successfully set up Stripe database tables');
+  } catch (error) {
+    console.error('Error setting up Stripe database:', error);
+    throw error;
+  }
+}
+ 
+// Run the setup
+setupStripeDb().catch(console.error); 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/domains/errors/AuthenticationError.ts.html b/coverage/src/domains/errors/AuthenticationError.ts.html new file mode 100644 index 00000000..820828c4 --- /dev/null +++ b/coverage/src/domains/errors/AuthenticationError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/AuthenticationError.ts + + + + + + + + + +
+
+

All files / src/domains/errors AuthenticationError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from '././DomainError';
+ 
+export class AuthenticationError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, AuthenticationError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/domains/errors/AuthorizationError.ts.html b/coverage/src/domains/errors/AuthorizationError.ts.html new file mode 100644 index 00000000..89651847 --- /dev/null +++ b/coverage/src/domains/errors/AuthorizationError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/AuthorizationError.ts + + + + + + + + + +
+
+

All files / src/domains/errors AuthorizationError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from '././DomainError';
+ 
+export class AuthorizationError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, AuthorizationError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/domains/errors/ConflictError.ts.html b/coverage/src/domains/errors/ConflictError.ts.html new file mode 100644 index 00000000..95af068b --- /dev/null +++ b/coverage/src/domains/errors/ConflictError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/ConflictError.ts + + + + + + + + + +
+
+

All files / src/domains/errors ConflictError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from '././DomainError';
+ 
+export class ConflictError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, ConflictError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/domains/errors/DomainError.ts.html b/coverage/src/domains/errors/DomainError.ts.html new file mode 100644 index 00000000..8fce0093 --- /dev/null +++ b/coverage/src/domains/errors/DomainError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/DomainError.ts + + + + + + + + + +
+
+

All files / src/domains/errors DomainError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
export class DomainError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = this.constructor.name;
+    // This is necessary to make instanceof work properly in TypeScript
+    Object.setPrototypeOf(this, DomainError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/domains/errors/NotFoundError.ts.html b/coverage/src/domains/errors/NotFoundError.ts.html new file mode 100644 index 00000000..6ead2295 --- /dev/null +++ b/coverage/src/domains/errors/NotFoundError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/NotFoundError.ts + + + + + + + + + +
+
+

All files / src/domains/errors NotFoundError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from './DomainError';
+ 
+export class NotFoundError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, NotFoundError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/domains/errors/ValidationError.ts.html b/coverage/src/domains/errors/ValidationError.ts.html new file mode 100644 index 00000000..03250e67 --- /dev/null +++ b/coverage/src/domains/errors/ValidationError.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/ValidationError.ts + + + + + + + + + +
+
+

All files / src/domains/errors ValidationError.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
import { DomainError } from './DomainError';
+ 
+export class ValidationError extends DomainError {
+  constructor(message: string) {
+    super(message);
+    Object.setPrototypeOf(this, ValidationError.prototype);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/domains/errors/index.html b/coverage/src/domains/errors/index.html new file mode 100644 index 00000000..e62ec5aa --- /dev/null +++ b/coverage/src/domains/errors/index.html @@ -0,0 +1,206 @@ + + + + + + Code coverage report for src/domains/errors + + + + + + + + + +
+
+

All files src/domains/errors

+
+ +
+ 0% + Statements + 0/30 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/6 +
+ + +
+ 0% + Lines + 0/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
AuthenticationError.ts +
+
0%0/4100%0/00%0/10%0/4
AuthorizationError.ts +
+
0%0/4100%0/00%0/10%0/4
ConflictError.ts +
+
0%0/4100%0/00%0/10%0/4
DomainError.ts +
+
0%0/4100%0/00%0/10%0/4
NotFoundError.ts +
+
0%0/4100%0/00%0/10%0/4
ValidationError.ts +
+
0%0/4100%0/00%0/10%0/4
index.ts +
+
0%0/6100%0/0100%0/00%0/6
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/domains/errors/index.ts.html b/coverage/src/domains/errors/index.ts.html new file mode 100644 index 00000000..4ba09442 --- /dev/null +++ b/coverage/src/domains/errors/index.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/domains/errors/index.ts + + + + + + + + + +
+
+

All files / src/domains/errors index.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +  + 
// src/domain/errors/index.ts
+export * from './AuthenticationError';
+export * from './AuthorizationError';
+export * from './ConflictError';
+export * from './DomainError';
+export * from './NotFoundError';
+export * from './ValidationError';
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/entities/Activity.ts.html b/coverage/src/entities/Activity.ts.html new file mode 100644 index 00000000..3c93606f --- /dev/null +++ b/coverage/src/entities/Activity.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/entities/Activity.ts + + + + + + + + + +
+
+

All files / src/entities Activity.ts

+
+ +
+ 0% + Statements + 0/9 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/9 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export interface ActivityMetadata {
+  [key: string]: any;
+}
+ 
+export class Activity {
+  constructor(
+    public id: string,
+    public clientId: string,
+    public type: string,
+    public description?: string,
+    public metadata?: ActivityMetadata,
+    public timestamp: Date = new Date(),
+    public createdBy?: string
+  ) {}
+ 
+  toJson(): Object {
+    return {
+      id: this.id,
+      clientId: this.clientId,
+      type: this.type,
+      description: this.description,
+      metadata: this.metadata,
+      timestamp: this.timestamp,
+      createdBy: this.createdBy
+    };
+  }
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/entities/Client.ts.html b/coverage/src/entities/Client.ts.html new file mode 100644 index 00000000..caff7de8 --- /dev/null +++ b/coverage/src/entities/Client.ts.html @@ -0,0 +1,232 @@ + + + + + + Code coverage report for src/entities/Client.ts + + + + + + + + + +
+
+

All files / src/entities Client.ts

+
+ +
+ 0% + Statements + 0/18 +
+ + +
+ 0% + Branches + 0/20 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { CLIENT_STATUS, ServiceTypes } from '../types';
+import { User } from './User';
+ 
+export class Client {
+  constructor(
+    public id: string,
+    public user: User,
+    public serviceNeeded: ServiceTypes,
+    public requestedAt: Date,
+    public updatedAt: Date,
+    public status: CLIENT_STATUS,
+ 
+    // Optional detailed fields from client_info
+    public childrenExpected?: string,
+    public pronouns?: string,
+    public health_history?: string,
+    public allergies?: string,
+    public due_date?: Date,
+    public hospital?: string,
+    public baby_sex?: string,
+    public annual_income?: string,
+    public service_specifics?: string,
+    public phoneNumber?: string, // Add phone number field
+  ) {}
+ 
+  toJson(): Object {
+    return (
+      {
+        id: this.id,
+        user: this.user,
+        serviceNeeded: this.serviceNeeded,
+        requestedAt: this.requestedAt,
+        updatedAt: this.updatedAt,
+        status: this.status,
+ 
+        // Optional detailed fields from client_info
+        ...(this.childrenExpected && { childrenExpected: this.childrenExpected }),
+        ...(this.pronouns && { pronouns: this.pronouns }),
+        ...(this.health_history && { health_history: this.health_history }),
+        ...(this.allergies && { allergies: this.allergies }),
+        ...(this.due_date && { due_date: this.due_date }),
+        ...(this.hospital && { hospital: this.hospital }),
+        ...(this.baby_sex && { baby_sex: this.baby_sex }),
+        ...(this.annual_income && { annual_income: this.annual_income }),
+        ...(this.service_specifics && { service_specifics: this.service_specifics }),
+        ...(this.phoneNumber && { phoneNumber: this.phoneNumber }) // Include phone number in JSON
+      }
+    );
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/entities/Hours.ts.html b/coverage/src/entities/Hours.ts.html new file mode 100644 index 00000000..bfa53eb3 --- /dev/null +++ b/coverage/src/entities/Hours.ts.html @@ -0,0 +1,151 @@ + + + + + + Code coverage report for src/entities/Hours.ts + + + + + + + + + +
+
+

All files / src/entities Hours.ts

+
+ +
+ 0% + Statements + 0/2 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/2 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export class WORK_ENTRY {
+  id: string;
+  start_time: Date;
+  end_time: Date;
+  doula: {
+      id: string;
+      firstname: string;
+      lastname: string;
+  };
+  client: {
+      id: string;
+      firstname: string;
+      lastname: string;
+  };
+};
+ 
+export class WORK_ENTRY_ROW {
+  id: string;
+  doula_id: string;
+  client_id: string;
+  start_time: Date;
+  end_time: Date;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/entities/Note.ts.html b/coverage/src/entities/Note.ts.html new file mode 100644 index 00000000..70ad75d0 --- /dev/null +++ b/coverage/src/entities/Note.ts.html @@ -0,0 +1,118 @@ + + + + + + Code coverage report for src/entities/Note.ts + + + + + + + + + +
+
+

All files / src/entities Note.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12  +  +  +  +  +  +  +  +  +  +  + 
export enum VISIBILITY {
+  PUBLIC = "public",
+  PRIVATE = "private"
+}
+ 
+export class NOTE {
+  id: string;
+  content: string;
+  created_by: string;
+  work_log_id: string;
+  visibility: VISIBILITY;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/entities/RequestForm.ts.html b/coverage/src/entities/RequestForm.ts.html new file mode 100644 index 00000000..736c0d12 --- /dev/null +++ b/coverage/src/entities/RequestForm.ts.html @@ -0,0 +1,370 @@ + + + + + + Code coverage report for src/entities/RequestForm.ts + + + + + + + + + +
+
+

All files / src/entities RequestForm.ts

+
+ +
+ 0% + Statements + 0/51 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/51 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import {
+    ClientAgeRange,
+    HomeType,
+    IncomeLevel,
+    Pronouns,
+    ProviderType,
+    RelationshipStatus,
+    RequestStatus,
+    ServiceTypes,
+    STATE
+} from '../types';
+ 
+export class RequestForm {
+  public id?: string;
+  public status?: RequestStatus; // Remove default value
+  public user_id?: string;
+  public created_at?: Date;
+  public updated_at?: Date;
+  public requested?: string;
+  
+  constructor(
+    // Step 1: Client Details (Required)
+    public firstname: string,
+    public lastname: string,
+    public email: string,
+    public phone_number: string,
+    public service_needed: ServiceTypes,
+    
+    // Step 2: Home Details (Required)
+    public address: string,
+    public city: string,
+    public state: STATE,
+    public zip_code: string,
+    
+    // Step 1: Client Details (Optional)
+    public pronouns?: Pronouns,
+    public pronouns_other?: string,
+    public children_expected?: string,
+    
+    // Step 2: Home Details (Optional)
+    public home_phone?: string,
+    public home_type?: HomeType,
+    public home_access?: string,
+    public pets?: string,
+    
+    // Step 3: Family Members
+    public relationship_status?: RelationshipStatus,
+    public first_name?: string,
+    public last_name?: string,
+    public middle_name?: string,
+    public mobile_phone?: string,
+    public work_phone?: string,
+    
+    // Step 4: Referral
+    public referral_source?: string,
+    public referral_name?: string,
+    public referral_email?: string,
+    
+    // Step 5: Health History
+    public health_history?: string,
+    public allergies?: string,
+    public health_notes?: string,
+    
+    // Step 6: Payment Info (Optional)
+    public annual_income?: IncomeLevel,
+    public service_specifics?: string,
+    
+    // Step 7: Pregnancy/Baby
+    public due_date?: Date,
+    public birth_location?: string,
+    public birth_hospital?: string,
+    public number_of_babies?: number,
+    public baby_name?: string,
+    public provider_type?: ProviderType,
+    public pregnancy_number?: number,
+    public hospital?: string,
+    public baby_sex?: string,
+    
+    // Step 8: Past Pregnancies
+    public had_previous_pregnancies?: boolean,
+    public previous_pregnancies_count?: number,
+    public living_children_count?: number,
+    public past_pregnancy_experience?: string,
+    
+    // Step 9: Services Interested
+    public services_interested?: string[],
+    public service_support_details?: string,
+    
+    // Step 10: Client Demographics (Optional)
+    public race_ethnicity?: string,
+    public primary_language?: string,
+    public client_age_range?: ClientAgeRange,
+    public insurance?: string,
+    public demographics_multi?: string[]
+  ) {}
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/entities/Template.ts.html b/coverage/src/entities/Template.ts.html new file mode 100644 index 00000000..9569856d --- /dev/null +++ b/coverage/src/entities/Template.ts.html @@ -0,0 +1,139 @@ + + + + + + Code coverage report for src/entities/Template.ts + + + + + + + + + +
+
+

All files / src/entities Template.ts

+
+ +
+ 0% + Statements + 0/7 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/7 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export class Template {
+  constructor(
+    public id: string,
+    public name: string,
+    public depositFee: number,
+    public serviceFee: number,
+    public storagePath: string,
+  ) {}
+ 
+  toJson() {
+    return {
+      id: this.id,
+      name: this.name,
+      depositFee: this.depositFee,
+      serviceFee: this.serviceFee,
+      storagePath: this.storagePath,
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/entities/User.ts.html b/coverage/src/entities/User.ts.html new file mode 100644 index 00000000..ddae27e9 --- /dev/null +++ b/coverage/src/entities/User.ts.html @@ -0,0 +1,433 @@ + + + + + + Code coverage report for src/entities/User.ts + + + + + + + + + +
+
+

All files / src/entities User.ts

+
+ +
+ 0% + Statements + 0/29 +
+ + +
+ 0% + Branches + 0/48 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/29 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { ACCOUNT_STATUS, ROLE, STATE } from '../types';
+ 
+export class User {
+  id: string;
+  email?: string;
+  firstname?: string;
+  lastname?: string;
+  created_at?: Date;
+  updated_at?: Date;
+  role?: ROLE;
+  address?: string;
+  city?: string;
+  state?: STATE;
+  country?: string;
+  zip_code?: number;
+  children_expected?:string;
+  pronouns?:string;
+  health_history?:string;
+  allergies?:string;
+  due_date?:string;
+  annual_income?:string;
+  status?:string;
+  hospital?:string;
+  service_needed?:string;
+  profile_picture?: File;  
+  account_status?: ACCOUNT_STATUS;
+  business?: string;
+  bio?: string;
+ 
+  constructor(data: {
+    id?: string;
+    email?: string;
+    firstname?: string;
+    lastname?: string;
+    created_at?: Date;
+    updated_at?: Date;
+    role?: ROLE;
+    address?: string;
+    children_expected?:string;
+    service_needed?:string;
+    pronouns?:string;
+    health_history?:string;
+    allergies?:string;
+    due_date?:string;
+    annual_income?:string;
+    status?:string;
+    hospital?:string;
+    city?: string;
+    state?: STATE;
+    country?: string;
+    zip_code?: number;
+    profile_picture?: File;
+    account_status?: ACCOUNT_STATUS;
+    business?: string;
+    bio?: string;  
+    }) {
+      this.id = data.id;
+      this.email = data.email || "";
+      this.firstname = data.firstname || '';
+      this.lastname = data.lastname || '';
+      this.created_at = data.created_at || new Date();
+      this.updated_at = data.updated_at || new Date();
+      this.role = data.role || ROLE.CLIENT;
+      this.children_expected = data.children_expected || "";
+      this.service_needed = data.service_needed ||"";
+      this.health_history = data.health_history || "";
+      this.allergies = data.allergies || "";
+      this.due_date = data.due_date || "";
+      this.annual_income = data.annual_income || "";
+      this.status = data.status || "";
+      this.hospital = data.hospital || "";
+      this.address = data.address || "";
+      this.city = data.city || "";
+      this.state = data.state || STATE.IL;
+      this.country = data.country || "";
+      this.zip_code = data.zip_code || -1;
+      this.profile_picture = data.profile_picture || null;
+      this.account_status = data.account_status || ACCOUNT_STATUS.PENDING; 
+      this.business = data.business || "";
+      this.bio = data.bio || "";    
+      this.service_needed = data.service_needed || "";
+  }
+ 
+  getFullName(): string {
+    return `${this.firstname} ${this.lastname}`.trim();
+  }
+ 
+  toJSON(): object {
+    return {
+      id: this.id,
+      email: this.email,
+      firstname: this.firstname,
+      lastname: this.lastname,
+      fullName: this.getFullName(),
+      children_expected: this.children_expected,
+      service_needed: this.service_needed,
+      health_history: this.health_history,
+      allergies: this.allergies,
+      due_date:this.due_date,
+      annual_income:this.annual_income,
+      status:this.status,
+      hospital:this.hospital,
+      created_at: this.created_at,
+      updatedAt: this.updated_at,
+      role: this.role,
+      address: this.address,
+      city: this.city,
+      state: this.state,
+      country: this.country,
+      zip_code: this.zip_code,
+      profile_picture: this.profile_picture,
+      account_status: this.account_status,
+      business: this.business,
+      bio: this.bio
+    };
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/entities/index.html b/coverage/src/entities/index.html new file mode 100644 index 00000000..f008185e --- /dev/null +++ b/coverage/src/entities/index.html @@ -0,0 +1,206 @@ + + + + + + Code coverage report for src/entities + + + + + + + + + +
+
+

All files src/entities

+
+ +
+ 0% + Statements + 0/120 +
+ + +
+ 0% + Branches + 0/71 +
+ + +
+ 0% + Functions + 0/11 +
+ + +
+ 0% + Lines + 0/120 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
Activity.ts +
+
0%0/90%0/10%0/20%0/9
Client.ts +
+
0%0/180%0/200%0/20%0/18
Hours.ts +
+
0%0/2100%0/0100%0/00%0/2
Note.ts +
+
0%0/40%0/20%0/10%0/4
RequestForm.ts +
+
0%0/51100%0/00%0/10%0/51
Template.ts +
+
0%0/7100%0/00%0/20%0/7
User.ts +
+
0%0/290%0/480%0/30%0/29
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/index.html b/coverage/src/index.html new file mode 100644 index 00000000..5e5f690b --- /dev/null +++ b/coverage/src/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+ +
+ 0% + Statements + 0/130 +
+ + +
+ 0% + Branches + 0/33 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 0% + Lines + 0/130 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
supabase.ts +
+
0%0/90%0/7100%0/00%0/9
types.ts +
+
0%0/1210%0/260%0/130%0/121
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/middleware/auth.ts.html b/coverage/src/middleware/auth.ts.html new file mode 100644 index 00000000..f7430a30 --- /dev/null +++ b/coverage/src/middleware/auth.ts.html @@ -0,0 +1,193 @@ + + + + + + Code coverage report for src/middleware/auth.ts + + + + + + + + + +
+
+

All files / src/middleware auth.ts

+
+ +
+ 0% + Statements + 0/17 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/16 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NextFunction, Request, Response } from 'express';
+import jwt from 'jsonwebtoken';
+import { config } from '../config';
+ 
+export const authenticateUser = async (
+  req: Request,
+  res: Response,
+  next: NextFunction
+): Promise<void> => {
+  try {
+    const authHeader = req.headers.authorization;
+    
+    Iif (!authHeader?.startsWith('Bearer ')) {
+      res.status(401).json({ error: 'No token provided' });
+      return;
+    }
+ 
+    const token = authHeader.split(' ')[1];
+    
+    const decoded = jwt.verify(token, config.jwtSecret) as {
+      id: string;
+      role?: string;
+    };
+ 
+    // Create a User instance from the decoded token data
+    req.user = {
+      id: decoded.id,
+      role: decoded.role,
+      getFullName: () => '',
+      toJSON: () => ({ id: decoded.id, role: decoded.role })
+    } as any;
+    next();
+  } catch (error) {
+    console.error('Authentication error:', error);
+    res.status(401).json({ error: 'Invalid token' });
+  }
+}; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/middleware/authMiddleware.ts.html b/coverage/src/middleware/authMiddleware.ts.html new file mode 100644 index 00000000..5ccb89ee --- /dev/null +++ b/coverage/src/middleware/authMiddleware.ts.html @@ -0,0 +1,214 @@ + + + + + + Code coverage report for src/middleware/authMiddleware.ts + + + + + + + + + +
+
+

All files / src/middleware authMiddleware.ts

+
+ +
+ 0% + Statements + 0/20 +
+ + +
+ 0% + Branches + 0/6 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NextFunction, Response } from 'express';
+import { authService } from '../index';
+import supabase from '../supabase';
+import type { AuthRequest } from '../types';
+ 
+const authMiddleware = async (
+  req: AuthRequest,
+  res: Response,
+  next: NextFunction
+): Promise<void> => {
+  try {
+    const authHeader = req.headers.authorization
+    const cookieToken = req.cookies?.session
+    const token = authHeader ? authHeader.split(' ')[1] : cookieToken
+ 
+    Iif (!token) {
+      res.status(401).json({ error: 'No session token provided' })
+      return
+    }
+ 
+    const {
+      data: { user },
+      error
+    } = await supabase.auth.getUser(token)
+ 
+    Iif (error || !user) {
+      res.status(401).json({ error: 'Invalid or expired session token' })
+      return
+    }
+ 
+    // Your app’s user object
+    const user_entity = await authService.getUserFromToken(token)
+    req.user = user_entity;
+    next();
+  } catch {
+    console.error('Auth middleware error:');
+    res.status(500).json({ error: 'Internal server error' });
+  }
+};
+ 
+ 
+ 
+export default authMiddleware
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/middleware/authorizeRoles.ts.html b/coverage/src/middleware/authorizeRoles.ts.html new file mode 100644 index 00000000..319d079c --- /dev/null +++ b/coverage/src/middleware/authorizeRoles.ts.html @@ -0,0 +1,181 @@ + + + + + + Code coverage report for src/middleware/authorizeRoles.ts + + + + + + + + + +
+
+

All files / src/middleware authorizeRoles.ts

+
+ +
+ 0% + Statements + 0/11 +
+ + +
+ 0% + Branches + 0/4 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NextFunction, Response } from 'express';
+import type { AuthRequest } from '../types';
+ 
+// authorizeRoles
+//
+// Takes in an array of authorized roles (in lowercase) of 'patient', 'doula', 'admin'.
+//
+ 
+const authorizeRoles = async (
+  req: AuthRequest,
+  res: Response,
+  next: NextFunction,
+  allowedRoles: string[]
+): Promise<void> => {
+  try {
+    Iif (!req.user || !req.user.email) {
+      res.status(401).json({ error: 'Unauthorized: No user found' })
+      return   // ← stop here!
+    }
+ 
+    Iif (!allowedRoles.includes(req.user.role)) {
+      res.status(403).json({ error: 'Forbidden: Insufficient permissions' })
+      return   // ← and stop here!
+    }
+ 
+    next()
+  } catch {
+    res.status(500).json({ error: 'Internal server error' })
+  }
+}
+ 
+export default authorizeRoles
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/middleware/index.html b/coverage/src/middleware/index.html new file mode 100644 index 00000000..ad08e9b3 --- /dev/null +++ b/coverage/src/middleware/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/middleware + + + + + + + + + +
+
+

All files src/middleware

+
+ +
+ 0% + Statements + 0/55 +
+ + +
+ 0% + Branches + 0/11 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/53 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
auth.ts +
+
0%0/170%0/10%0/30%0/16
authMiddleware.ts +
+
0%0/200%0/60%0/10%0/20
authorizeRoles.ts +
+
0%0/110%0/40%0/10%0/11
validateRequest.ts +
+
0%0/7100%0/00%0/20%0/6
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/middleware/validateRequest.ts.html b/coverage/src/middleware/validateRequest.ts.html new file mode 100644 index 00000000..be8c38cb --- /dev/null +++ b/coverage/src/middleware/validateRequest.ts.html @@ -0,0 +1,133 @@ + + + + + + Code coverage report for src/middleware/validateRequest.ts + + + + + + + + + +
+
+

All files / src/middleware validateRequest.ts

+
+ +
+ 0% + Statements + 0/7 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NextFunction, Request, Response } from 'express';
+import { AnyZodObject } from 'zod';
+ 
+export const validateRequest = (schema: AnyZodObject) => {
+  return async (req: Request, res: Response, next: NextFunction) => {
+    try {
+      await schema.parseAsync(req.body);
+      next();
+    } catch (error) {
+      res.status(400).json({
+        success: false,
+        error: 'Invalid request data',
+        details: error.errors
+      });
+    }
+  };
+}; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/repositories/index.html b/coverage/src/repositories/index.html new file mode 100644 index 00000000..5d93973d --- /dev/null +++ b/coverage/src/repositories/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/repositories + + + + + + + + + +
+
+

All files src/repositories

+
+ +
+ 0% + Statements + 0/326 +
+ + +
+ 0% + Branches + 0/204 +
+ + +
+ 0% + Functions + 0/58 +
+ + +
+ 0% + Lines + 0/284 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
requestFormRepository.ts +
+
0%0/550%0/80%0/70%0/55
supabaseActivityRepository.ts +
+
0%0/180%0/30%0/70%0/16
supabaseClientRepository.ts +
+
0%0/1230%0/1380%0/180%0/91
supabaseUserRepository.ts +
+
0%0/1300%0/550%0/260%0/122
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/repositories/requestFormRepository.ts.html b/coverage/src/repositories/requestFormRepository.ts.html new file mode 100644 index 00000000..dd87a0a6 --- /dev/null +++ b/coverage/src/repositories/requestFormRepository.ts.html @@ -0,0 +1,715 @@ + + + + + + Code coverage report for src/repositories/requestFormRepository.ts + + + + + + + + + +
+
+

All files / src/repositories requestFormRepository.ts

+
+ +
+ 0% + Statements + 0/55 +
+ + +
+ 0% + Branches + 0/8 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/55 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { SupabaseClient } from "@supabase/supabase-js";
+import { RequestFormData, RequestFormResponse, RequestStatus } from "../types";
+ 
+export class RequestFormRepository {
+    private supabaseClient: SupabaseClient;
+ 
+    constructor(supabaseClient: SupabaseClient) {
+        this.supabaseClient = supabaseClient;
+    }
+ 
+    async saveData(formData: RequestFormData): Promise<RequestFormResponse> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('client_info')
+                .insert([
+                    {
+                        // Step 1: Client Details
+                        firstname: formData.firstname,
+                        lastname: formData.lastname,
+                        email: formData.email,
+                        phone_number: formData.phone_number,
+                        pronouns: formData.pronouns,
+                        pronouns_other: formData.pronouns_other,
+                        
+                        // Step 2: Home Details
+                        address: formData.address,
+                        city: formData.city,
+                        state: formData.state,
+                        zip_code: formData.zip_code,
+                        home_phone: formData.home_phone,
+                        home_type: formData.home_type,
+                        home_access: formData.home_access,
+                        pets: formData.pets,
+                        
+                        // Step 3: Family Members
+                        relationship_status: formData.relationship_status,
+                        first_name: formData.first_name,
+                        last_name: formData.last_name,
+                        middle_name: formData.middle_name,
+                        mobile_phone: formData.mobile_phone,
+                        work_phone: formData.work_phone,
+                        
+                        // Step 4: Referral
+                        referral_source: formData.referral_source,
+                        referral_name: formData.referral_name,
+                        referral_email: formData.referral_email,
+                        
+                        // Step 5: Health History
+                        health_history: formData.health_history,
+                        allergies: formData.allergies,
+                        health_notes: formData.health_notes,
+                        
+                        // Step 6: Payment Info
+                        annual_income: formData.annual_income,
+                        service_needed: formData.service_needed,
+                        service_specifics: formData.service_specifics,
+                        
+                        // Step 7: Pregnancy/Baby
+                        due_date: formData.due_date,
+                        birth_location: formData.birth_location,
+                        birth_hospital: formData.birth_hospital,
+                        number_of_babies: formData.number_of_babies,
+                        baby_name: formData.baby_name,
+                        provider_type: formData.provider_type,
+                        pregnancy_number: formData.pregnancy_number,
+                        
+                        // Step 8: Past Pregnancies
+                        had_previous_pregnancies: formData.had_previous_pregnancies,
+                        previous_pregnancies_count: formData.previous_pregnancies_count,
+                        living_children_count: formData.living_children_count,
+                        past_pregnancy_experience: formData.past_pregnancy_experience,
+                        
+                        // Step 9: Services Interested
+                        services_interested: formData.services_interested,
+                        service_support_details: formData.service_support_details,
+                        
+                        // Step 10: Client Demographics
+                        race_ethnicity: formData.race_ethnicity,
+                        primary_language: formData.primary_language,
+                        client_age_range: formData.client_age_range,
+                        insurance: formData.insurance,
+                        demographics_multi: formData.demographics_multi,
+                        
+                        // System fields
+                        status: 'lead'
+                    }
+                ])
+                .select()
+                .single();
+ 
+            Iif (error) {
+                console.error("Supabase insert error:", error);
+                throw new Error("Database insertion failed: " + error.message);
+            }
+ 
+            console.log('Request form saved successfully:', data);
+            return data as RequestFormResponse;
+ 
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async getUserRequests(userId: string): Promise<RequestFormResponse[]> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .select('*')
+                .eq('user_id', userId)
+                .order('created_at', { ascending: false });
+ 
+            Iif (error) {
+                console.error("Supabase select error:", error);
+                throw new Error("Database query failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse[];
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async getRequestById(requestId: string, userId: string): Promise<RequestFormResponse | null> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .select('*')
+                .eq('id', requestId)
+                .eq('user_id', userId)
+                .single();
+ 
+            Iif (error) {
+                Iif (error.code === 'PGRST116') {
+                    return null; // No rows returned
+                }
+                console.error("Supabase select error:", error);
+                throw new Error("Database query failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse;
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async getAllRequests(): Promise<RequestFormResponse[]> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .select('*')
+                .order('created_at', { ascending: false });
+ 
+            Iif (error) {
+                console.error("Supabase select error:", error);
+                throw new Error("Database query failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse[];
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async getRequestByIdAdmin(requestId: string): Promise<RequestFormResponse | null> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .select('*')
+                .eq('id', requestId)
+                .single();
+ 
+            Iif (error) {
+                Iif (error.code === 'PGRST116') {
+                    return null; // No rows returned
+                }
+                console.error("Supabase select error:", error);
+                throw new Error("Database query failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse;
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+ 
+    async updateRequestStatus(requestId: string, status: RequestStatus): Promise<RequestFormResponse> {
+        try {
+            const { data, error } = await this.supabaseClient
+                .from('requests')
+                .update({ status })
+                .eq('id', requestId)
+                .select()
+                .single();
+ 
+            Iif (error) {
+                console.error("Supabase update error:", error);
+                throw new Error("Database update failed: " + error.message);
+            }
+ 
+            return data as RequestFormResponse;
+        } catch (error) {
+            console.error(error);
+            throw error;
+        }
+    }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/repositories/supabaseActivityRepository.ts.html b/coverage/src/repositories/supabaseActivityRepository.ts.html new file mode 100644 index 00000000..12d52c97 --- /dev/null +++ b/coverage/src/repositories/supabaseActivityRepository.ts.html @@ -0,0 +1,295 @@ + + + + + + Code coverage report for src/repositories/supabaseActivityRepository.ts + + + + + + + + + +
+
+

All files / src/repositories supabaseActivityRepository.ts

+
+ +
+ 0% + Statements + 0/18 +
+ + +
+ 0% + Branches + 0/3 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/16 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { SupabaseClient } from '@supabase/supabase-js';
+import { Activity } from '../entities/Activity';
+import { ActivityRepository } from './interface/activityRepository';
+ 
+export class SupabaseActivityRepository implements ActivityRepository {
+  private supabaseClient: SupabaseClient;
+ 
+  constructor(supabaseClient: SupabaseClient) {
+    this.supabaseClient = supabaseClient;
+  }
+ 
+  async createActivity(activityData: Omit<Activity, 'id'>): Promise<Activity> {
+    const { data, error } = await this.supabaseClient
+      .from('client_activities')
+      .insert({
+        client_id: activityData.clientId,
+        type: activityData.type,
+        description: activityData.description,
+        metadata: activityData.metadata,
+        timestamp: activityData.timestamp,
+        created_by: activityData.createdBy
+      })
+      .select()
+      .single();
+ 
+    Iif (error) {
+      throw new Error(`Failed to create activity: ${error.message}`);
+    }
+ 
+    return this.mapToActivity(data);
+  }
+ 
+  async getActivitiesByClientId(clientId: string): Promise<Activity[]> {
+    const { data, error } = await this.supabaseClient
+      .from('client_activities')
+      .select('*')
+      .eq('client_id', clientId)
+      .order('timestamp', { ascending: false });
+ 
+    Iif (error) {
+      throw new Error(`Failed to fetch activities: ${error.message}`);
+    }
+ 
+    return data.map(row => this.mapToActivity(row));
+  }
+ 
+  async getAllActivities(): Promise<Activity[]> {
+    const { data, error } = await this.supabaseClient
+      .from('client_activities')
+      .select('*')
+      .order('timestamp', { ascending: false });
+ 
+    Iif (error) {
+      throw new Error(`Failed to fetch all activities: ${error.message}`);
+    }
+ 
+    return data.map(row => this.mapToActivity(row));
+  }
+ 
+  private mapToActivity(data: any): Activity {
+    return new Activity(
+      data.id,
+      data.client_id,
+      data.type,
+      data.description,
+      data.metadata,
+      new Date(data.timestamp),
+      data.created_by
+    );
+  }
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/repositories/supabaseClientRepository.ts.html b/coverage/src/repositories/supabaseClientRepository.ts.html new file mode 100644 index 00000000..0f459af6 --- /dev/null +++ b/coverage/src/repositories/supabaseClientRepository.ts.html @@ -0,0 +1,1204 @@ + + + + + + Code coverage report for src/repositories/supabaseClientRepository.ts + + + + + + + + + +
+
+

All files / src/repositories supabaseClientRepository.ts

+
+ +
+ 0% + Statements + 0/123 +
+ + +
+ 0% + Branches + 0/138 +
+ + +
+ 0% + Functions + 0/18 +
+ + +
+ 0% + Lines + 0/91 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+import { SupabaseClient } from '@supabase/supabase-js';
+import { Client } from '../entities/Client';
+import { User } from '../entities/User';
+import { ROLE } from '../types';
+ 
+export class SupabaseClientRepository  {
+  private supabaseClient: SupabaseClient;
+  
+  constructor(
+    supabaseClient: SupabaseClient
+  ) {
+    this.supabaseClient = supabaseClient;
+  }
+ 
+  async findClientsLiteAll(): Promise<Client[]> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        id,
+        firstname,
+        lastname,
+        email,
+        phone_number,
+        status,
+        service_needed,
+        requested,
+        updated_at,
+        users (
+          firstname,
+          lastname,
+          profile_picture
+        )
+      `);
+ 
+    Iif (error) throw new Error(error.message);
+    return data.map(row => this.mapToClient(row));
+  }
+ 
+  async exportCSV():Promise<string | null>{
+    const {data,error} = await this.supabaseClient
+    .from('client_info')
+    .select('firstname,lastname,zip_code,annual_income,pronouns')
+    .csv()
+ 
+    Iif(error || !data){
+      throw new Error(`Failed to fetch CSV Data ${error.message}`);
+    }
+    return data;
+  }
+ 
+  async findClientsLiteByDoula(userId: string): Promise<Client[]> {
+    const clientIds = await this.getClientIdsAssignedToDoula(userId);
+    
+    Iif (clientIds.length === 0) {
+      console.log("clientIDs.length is 0");
+      return [];
+    }
+    // console.log("clientIds is ", clientIds);
+ 
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        id,
+        firstname,
+        lastname,
+        email,
+        phone_number,
+        status,
+        users (
+          firstname,
+          lastname,
+          profile_picture
+        )
+      `)
+      .in('id', clientIds);
+ 
+ 
+    Iif (error) throw new Error(error.message);
+    return data.map(user => this.mapToClient(user));
+  }
+ 
+  async findClientsDetailedAll(): Promise<Client[]> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        *,
+        users (
+          *
+        )
+        `);
+        
+        Iif (error) throw new Error(error.message);
+        return data.map(user => this.mapToClient(user));
+      }
+      
+      async findClientsDetailedByDoula(userId: string): Promise<Client[]> {
+        const clientIds = await this.getClientIdsAssignedToDoula(userId);
+        
+        Iif (clientIds.length === 0) return [];
+        
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        *,
+        users (
+          *
+        )
+      `)
+      .in('id', clientIds);
+ 
+    Iif (error) throw new Error(error.message);
+    // return data.map(this.mapToClient);
+    return data.map(user => this.mapToClient(user));
+  }
+  
+  async findClientLiteById(clientId: string): Promise<Client> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        id,
+        firstname,
+        lastname,
+        email,
+        phone_number,
+        status,
+        users (
+          firstname,
+          lastname,
+          profile_picture
+        )
+      `)
+      .eq('id', clientId)
+      .single();
+ 
+    Iif (error) throw new Error(error.message);
+    return this.mapToClient(data);
+  }
+ 
+  async findClientDetailedById(clientId: string): Promise<Client> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        *,
+        users (*)
+      `)
+      .eq('id', clientId)
+      .single();
+ 
+    Iif (error) throw new Error(error.message);
+    return this.mapToClient(data);
+  }
+ 
+  async updateStatus(clientId: string, status: string): Promise<Client> {
+    const { data, error } = await this.supabaseClient
+      .from('client_info')
+      .update({ status })
+      .eq('id', clientId)
+      .select(`
+        id,
+        firstname,
+        lastname,
+        phone_number,
+        service_needed,
+        requested,
+        updated_at,
+        status,
+        user_id,
+        users (
+          profile_picture,
+          firstname,
+          lastname
+        )
+      `)
+      .single()
+ 
+    Iif (error) {
+      throw new Error(`${error.message}`);
+    }
+ 
+    return this.mapToClient(data);
+  }
+ 
+  async updateClient(clientId: string, fieldsToUpdate: any): Promise<Client> {
+    console.log('Repository: Updating client with ID:', clientId);
+    console.log('Repository: Fields to update:', JSON.stringify(fieldsToUpdate, null, 2));
+    
+    // Map request body fields to database column names
+    const updateData: any = {};
+    
+    // Map the fields from the request body to database columns
+    Iif (fieldsToUpdate.user?.firstname !== undefined) updateData.firstname = fieldsToUpdate.user.firstname;
+    Iif (fieldsToUpdate.user?.lastname !== undefined) updateData.lastname = fieldsToUpdate.user.lastname;
+    Iif (fieldsToUpdate.user?.email !== undefined) updateData.email = fieldsToUpdate.user.email;
+    Iif (fieldsToUpdate.user?.role !== undefined) updateData.role = fieldsToUpdate.user.role;
+    Iif (fieldsToUpdate.serviceNeeded !== undefined) updateData.service_needed = fieldsToUpdate.serviceNeeded;
+    Iif (fieldsToUpdate.childrenExpected !== undefined) updateData.children_expected = fieldsToUpdate.childrenExpected;
+    Iif (fieldsToUpdate.pronouns !== undefined) updateData.pronouns = fieldsToUpdate.pronouns;
+    Iif (fieldsToUpdate.health_history !== undefined) updateData.health_history = fieldsToUpdate.health_history;
+    Iif (fieldsToUpdate.allergies !== undefined) updateData.allergies = fieldsToUpdate.allergies;
+    Iif (fieldsToUpdate.due_date !== undefined) updateData.due_date = fieldsToUpdate.due_date;
+    Iif (fieldsToUpdate.hospital !== undefined) updateData.hospital = fieldsToUpdate.hospital;
+    Iif (fieldsToUpdate.annual_income !== undefined) updateData.annual_income = fieldsToUpdate.annual_income;
+    Iif (fieldsToUpdate.service_specifics !== undefined) updateData.service_specifics = fieldsToUpdate.service_specifics;
+ 
+    // Handle direct field mappings from request body
+    Iif (fieldsToUpdate.firstname !== undefined) updateData.firstname = fieldsToUpdate.firstname;
+    Iif (fieldsToUpdate.lastname !== undefined) updateData.lastname = fieldsToUpdate.lastname;
+    Iif (fieldsToUpdate.email !== undefined) updateData.email = fieldsToUpdate.email;
+    Iif (fieldsToUpdate.phoneNumber !== undefined) updateData.phone_number = fieldsToUpdate.phoneNumber;
+    Iif (fieldsToUpdate.phone_number !== undefined) updateData.phone_number = fieldsToUpdate.phone_number;
+    Iif (fieldsToUpdate.status !== undefined) updateData.status = fieldsToUpdate.status;
+ 
+    console.log('Repository: phoneNumber field check:', {
+      hasPhoneNumber: 'phoneNumber' in fieldsToUpdate,
+      phoneNumberValue: fieldsToUpdate.phoneNumber,
+      phoneNumberType: typeof fieldsToUpdate.phoneNumber
+    });
+    console.log('Repository: Mapped update data:', updateData);
+ 
+    // Check if client exists first
+    const { data: existingClient, error: checkError } = await this.supabaseClient
+      .from('client_info')
+      .select('id, firstname, lastname, phone_number')
+      .eq('id', clientId)
+      .maybeSingle();
+ 
+    Iif (checkError) {
+      console.error('Repository: Error checking client existence:', checkError);
+      throw new Error(`Error checking client existence: ${checkError.message}`);
+    }
+ 
+    Iif (!existingClient) {
+      console.error('Repository: Client not found with ID:', clientId);
+      throw new Error(`Client not found with ID: ${clientId}`);
+    }
+ 
+    console.log('Repository: Found existing client:', existingClient);
+ 
+    // Perform the update
+    const { data: updateResult, error: updateError } = await this.supabaseClient
+      .from('client_info')
+      .update(updateData)
+      .eq('id', clientId);
+ 
+    Iif (updateError) {
+      console.error('Repository: Update error:', updateError);
+      throw new Error(`Failed to update client: ${updateError.message}`);
+    }
+ 
+    console.log('Repository: Update completed, fetching updated data');
+ 
+    // Fetch the updated client data
+    const { data, error: fetchError } = await this.supabaseClient
+      .from('client_info')
+      .select(`
+        *,
+        users (*)
+      `)
+      .eq('id', clientId)
+      .single();
+ 
+    Iif (fetchError) {
+      console.error('Repository: Error fetching updated client:', fetchError);
+      throw new Error(`Failed to fetch updated client: ${fetchError.message}`);
+    }
+ 
+    Iif (!data) {
+      console.error('Repository: No data returned after update');
+      throw new Error(`No data returned after update for client ID: ${clientId}`);
+    }
+ 
+    console.log('Repository: Raw database response after update:', data);
+    console.log('Repository: Update successful, mapping data');
+    return this.mapToClient(data);
+  }
+ 
+  // Helper to find client id's for a given doula
+  private async getClientIdsAssignedToDoula(doulaId: string): Promise<string[]> {
+    const { data, error } = await this.supabaseClient
+      .from('assignments')
+      .select('client_id')
+      .eq('doula_id', doulaId);
+ 
+    Iif (error) throw new Error(error.message);
+    return data.map(entry => entry.client_id);
+  }
+ 
+  // Helper to map database user to domain User
+  private mapToUser(data: any): User {
+    return new User({
+      id: data.id,
+      email: data.email,
+      firstname: data.firstname,
+      lastname: data.lastname,
+      created_at: new Date(data.created_at || Date.now()),
+      updated_at: new Date(data.updated_at || Date.now()),
+      role: data.role || ROLE.CLIENT,
+      address: data.address,
+      city: data.city,
+      state: data.state,
+      country: data.country,
+      zip_code: data.zip_code,
+      profile_picture: data.profile_picture,
+      account_status: data.account_status,
+      business: data.business,
+      bio: data.bio,
+      children_expected: data.children_expected,
+      service_needed: data.service_needed,
+      health_history: data.health_history,
+      allergies: data.allergies,
+      due_date: data.due_date,
+      annual_income:data.annual_income,
+      status: data.status,
+      hospital:data.hospital,
+ 
+    });
+  }
+ 
+  private mapToClient(data: any): Client {
+    const userRecord = data.users ?? {};
+ 
+    const user = this.mapToUser({
+      id: userRecord.id || data.user_id || data.id,
+      email: userRecord.email || data.email || '',
+      firstname: userRecord.firstname || data.firstname || '',
+      lastname: userRecord.lastname || data.lastname || '',
+      created_at: userRecord.created_at || data.created_at,
+      updated_at: userRecord.updated_at || data.updated_at,
+      role: userRecord.role || 'client',
+      address: userRecord.address || data.address || '',
+      city: userRecord.city || data.city || '',
+      state: userRecord.state || data.state || '',
+      country: userRecord.country || data.country || '',
+      zip_code: userRecord.zip_code || data.zip_code || '',
+      profile_picture: userRecord.profile_picture || '',
+      account_status: userRecord.account_status || null,
+      business: userRecord.business || null,
+      bio: userRecord.bio || '',
+      children_expected: userRecord.children_expected || data.children_expected || '',
+      service_needed: userRecord.service_needed || data.service_needed || '',
+      health_history: userRecord.health_history || data.health_history || '',
+      allergies: userRecord.allergies || data.allergies || '',
+      due_date: userRecord.due_date || data.due_date || '',
+      annual_income: userRecord.annual_income || data.annual_income || '',
+      status: userRecord.status || data.status || '',
+      hospital: userRecord.hospital || data.hospital|| ''
+ 
+ 
+    });
+ 
+    return new Client(
+      data.id,
+      user,
+      data.service_needed ?? null,
+      data.requested ? new Date(data.requested) : null,
+      data.updated_at ? new Date(data.updated_at) : new Date(),
+      data.status ?? 'lead',
+ 
+      // Optional detailed fields
+      data.children_expected ?? undefined,
+      data.pronouns ?? undefined,
+      data.health_history ?? undefined,
+      data.allergies ?? undefined,
+      data.due_date ? new Date(data.due_date) : undefined,
+      data.hospital ?? undefined,
+      data.baby_sex ?? undefined,
+      data.annual_income ?? undefined,
+      data.service_specifics ?? undefined,
+      data.phone_number ?? undefined // Add phone number mapping
+    );
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/repositories/supabaseUserRepository.ts.html b/coverage/src/repositories/supabaseUserRepository.ts.html new file mode 100644 index 00000000..b422a74f --- /dev/null +++ b/coverage/src/repositories/supabaseUserRepository.ts.html @@ -0,0 +1,1714 @@ + + + + + + Code coverage report for src/repositories/supabaseUserRepository.ts + + + + + + + + + +
+
+

All files / src/repositories supabaseUserRepository.ts

+
+ +
+ 0% + Statements + 0/130 +
+ + +
+ 0% + Branches + 0/55 +
+ + +
+ 0% + Functions + 0/26 +
+ + +
+ 0% + Lines + 0/122 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+import { SupabaseClient } from '@supabase/supabase-js';
+import { File as MulterFile } from 'multer';
+import { Client } from '../entities/Client';
+import { WORK_ENTRY_ROW } from '../entities/Hours';
+import { NOTE } from '../entities/Note';
+import { User } from '../entities/User';
+import { UserRepository } from '../repositories/interface/userRepository';
+import { ROLE } from '../types';
+ 
+export class SupabaseUserRepository implements UserRepository {
+  private supabaseClient: SupabaseClient;
+  
+  constructor(
+    supabaseClient: SupabaseClient
+  ) {
+    this.supabaseClient = supabaseClient;
+  }
+  
+  async findByEmail(email: string): Promise<User | null> {
+    const { data, error } = await this.supabaseClient
+      .from('users')
+      .select('*')
+      .eq('email', email)
+      .single();
+      
+    Iif (error || !data) {
+      return null;
+    }
+    
+    return this.mapToUser(data);
+  }
+ 
+ 
+  async findByRole(role: string): Promise<User[]> {
+    const { data, error } = await this.supabaseClient
+      .from('users')
+      .select('*')
+      .eq('role', role)
+      .order('first_name', { ascending: true });
+ 
+    Iif (error) {
+      throw new Error(`Failed to fetch ${role} users: ${error.message}`);
+    }
+ 
+    return data.map(this.mapToUser);
+  }
+ 
+  // async findClientsAll(): Promise<any> {
+  //   const { data, error } = await this.supabaseClient
+  //     .from('client_info')
+  //     .select('first_name, last_name, service_needed, requested, updated_at, status');
+ 
+  //   if (error) {
+  //     throw new Error(`Failed to fetch clients: ${error.message}`);
+  //   }
+ 
+  //   return data.map((client) => ({
+  //     firstName: client.first_name,
+  //     lastName: client.last_name,
+  //     serviceNeeded: client.service_needed,
+  //     requestedAt: new Date(client.requested), // Ensure it's a Date object
+  //     updatedAt: new Date(client.updated_at), // Ensure it's a Date object
+  //     status: client.status,
+  //   }));
+  // }
+ 
+// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+// infrastructure/repositories/SupabaseUserRepository.ts
+ 
+async findClientsAll(): Promise<any[]> {
+  const { data, error } = await this.supabaseClient
+    .from('client_info')
+    .select(`
+      id,
+      user_id,           
+      firstname,
+      lastname,
+      email,
+      service_needed,
+      requested,
+      updated_at,
+      status
+    `)
+ 
+  Iif (error) {
+    throw new Error(`Failed to fetch clients: ${error.message}`)
+  }
+ 
+  return (data as any[]).map(client => ({
+    id:            client.id,
+    userId:        client.user_id,        // expose the real UUID
+    firstName:     client.firstname,
+    lastName:      client.lastname,
+    email:         client.email,
+    serviceNeeded: client.service_needed,
+    requestedAt:   new Date(client.requested),
+    updatedAt:     new Date(client.updated_at),
+    status:        client.status,
+  }))
+}
+ 
+ 
+// Add this method inside the SupabaseUserRepository class
+ 
+async updateClientStatusToCustomer(userId: string): Promise<void> {
+  console.log('Updating client_info where user_id =', userId);
+ 
+  const { error } = await this.supabaseClient
+    .from('client_info')
+    .update({ status: 'customer' })      // set the new status
+    .eq('user_id', userId);              // match by user_id (UUID)
+ 
+  Iif (error) {
+    throw new Error(`Failed to update client status: ${error.message}`);
+  }
+}
+async findClientsById(id: string): Promise<any> {
+  const { data, error } = await this.supabaseClient
+    .from('client_info')
+    .select(`
+      id,
+      firstname,
+      lastname,
+      email,
+      service_needed,
+      requested,
+      updated_at,
+      status,
+      user_id,
+      users (
+        profile_picture,
+        firstname,
+        lastname
+      )
+    `)
+    .eq('id', id);
+ 
+  Iif (error) {
+    throw new Error(`${error.message}`);
+  }
+ 
+  Iif (!data || data.length === 0) {
+    console.log("GOING TO EERROR: NO DATA, client id is", id);
+    return null;
+  }
+  
+  return this.mapToClient(data[0]); 
+}
+ 
+ 
+  async findClientsByDoula(doulaId: string): Promise<Client[]> {
+    const { data: assignments, error: assignmentsError } = await this.supabaseClient
+      .from('assignments')
+      .select('client_id')
+      .eq('doula_id', doulaId)
+ 
+    Iif (assignmentsError) {
+      throw new Error(`Failed to fetch assignments: ${assignmentsError.message}`);
+    }
+ 
+    // Return if there are no assigned clients
+    Iif (!assignments || assignments.length === 0) {
+      return [];
+    }
+ 
+    // store out client ids into an array
+    const clientIds = assignments.map(assignment => assignment.client_id);
+ 
+    // console.log("clientIds are ", clientIds);
+ 
+    // grab our users
+    const { data: users, error: getUsersError } = await this.supabaseClient
+      .from('client_info')
+      .select('*')
+      .in('id', clientIds);
+ 
+    Iif (getUsersError) {
+      throw new Error(`${getUsersError.message}`);
+    }
+    // console.log("after call to client_info");
+ 
+    return users.map(user => this.mapToClient(user));
+  }
+  
+  async save(user: User): Promise<User> {
+    const { data, error } = await this.supabaseClient
+      .from('users')
+      .upsert({
+        id: user.id,
+        email: user.email,
+        firstname: user.firstname,
+        lastname: user.lastname,
+      }, { onConflict: 'email' })
+      .select()
+      .single();
+      
+    Iif (error) {
+      throw new Error(error.message);
+    }
+    
+    return this.mapToUser(data);
+  }
+ 
+  async update(userId: string, fieldsToUpdate: Partial<User>): Promise<User> {
+ 
+    const { data: updatedUser, error: updatedUserError } = await this.supabaseClient
+      .from('users')
+      .update(fieldsToUpdate)
+      .eq('id', userId)
+      .select()
+      .single()
+ 
+ 
+    Iif (updatedUserError) throw new Error(updatedUserError.message);
+    return this.mapToUser(updatedUser);
+  }
+  
+  async findAll(): Promise<User[]> {
+    const { data, error } = await this.supabaseClient
+    .from('users')
+    .select('email, firstname, lastname')
+    .order('firstname', { ascending: true });
+    
+    Iif (error) {
+      throw new Error(`Failed to fetch users: ${error.message}`);
+    }
+    
+    return data.map(this.mapToUser);
+  }
+ 
+  async findAllTeamMembers(): Promise<User[]> {
+    try {
+      const { data, error } = await this.supabaseClient
+      .from('users')
+      .select('id, firstname, lastname, email, role, bio')
+      .in('role', ['doula','admin'])
+ 
+      Iif (error) {
+        throw new Error(`Failed to retrieve team members: ${error.message}`);
+      }
+ 
+      const mappedUsers = data.map(this.mapToUser);
+      return mappedUsers;
+    } catch (err) {
+      throw new Error(`Failed to fetch team members: ${err.message}`);
+    }
+  }
+ 
+  async addMember(firstname: string, lastname: string, userEmail: string, userRole: string): Promise<User> {
+    try {
+      const { data, error } = await this.supabaseClient
+        .from('users')
+        .insert([
+          { 
+            firstname:firstname,
+            lastname:lastname,
+            email: userEmail, 
+            role: userRole
+          },
+        ])
+        .select()
+        .single()
+ 
+      Iif (error) {
+        throw new Error(`Failed to add member: ${error.message}`);
+      }
+ 
+      return this.mapToUser(data);
+    } catch (err) {
+      throw new Error(`Failed to add member: ${err.message}`);
+    }
+  }
+ 
+  async getHoursById(id: string): Promise<any> {
+    try {
+      // Get all hours entries for this doula
+      const { data: hoursData, error: hoursError } = await this.supabaseClient
+        .from('hours')
+        .select('*')
+        .eq('doula_id', id);
+      
+      Iif (hoursError) throw new Error(hoursError.message);
+      Iif (!hoursData) {
+        return []
+      };
+      
+      // Get doula data once (since it's the same for all entries)
+      const doulaData = await this.findById(id);
+      Iif (!doulaData) throw new Error(`Doula with ID ${id} not found`);
+      
+      // Process each hour entry to include client data
+      const result = await Promise.all(hoursData.map(async (entry) => {
+        const clientData = await this.findClientsById(entry.client_id);
+        Iif(!clientData) {
+          console.log("clientData is null, entry is", entry);
+        }
+        // console.log("in getHoursById in supabaseUsersRepository, clientData (to which we are accessing clientData.firstname) is ", clientData);
+        const noteData = await this.findNoteByWorkLogId(entry.id);
+ 
+        
+ 
+        return {
+          id: entry.id,
+          start_time: entry.start_time,
+          end_time: entry.end_time,
+          doula: {
+            id: doulaData.id,
+            firstname: doulaData.firstname,
+            lastname: doulaData.lastname
+          },
+          client: clientData ? {
+            id: clientData.user.id,
+            firstname: clientData.user.firstname,
+            lastname: clientData.user.lastname
+          } : null,
+          note: noteData ? noteData : null
+        };
+      }));
+      
+      return result;
+    } catch (error) {
+      throw new Error(`Failed to get user's hours: ${error.message}`);
+    }
+  }
+ 
+  async getAllHours(): Promise<any> {
+    try {
+      // Get all hours entries for this doula
+      const { data: hoursData, error: hoursError } = await this.supabaseClient
+        .from('hours')
+        .select('*')
+      
+      Iif (hoursError) throw new Error(hoursError.message);
+      Iif (!hoursData) {
+        return []
+      };
+      
+      // Process each hour entry to include client data
+      const result = await Promise.all(hoursData.map(async (entry) => {
+        // console.log("entry is", entry);
+        const clientData = await this.findClientsById(entry.client_id);
+        const noteData = await this.findNoteByWorkLogId(entry.id);
+        const doulaData = await this.findById(entry.doula_id);
+        Iif (!doulaData) throw new Error(`Doula with the ID ${entry.doula_id} not found, inside getAllHours()`);
+ 
+        Iif(!clientData) {
+          console.log("clientData is null in getAllHours, entry is", entry);
+        }
+ 
+        
+        return {
+          id: entry.id,
+          start_time: entry.start_time,
+          end_time: entry.end_time,
+          doula: {
+            id: doulaData.id,
+            firstname: doulaData.firstname,
+            lastname: doulaData.lastname
+          },
+          client: clientData ? {
+            id: clientData.id,
+            firstname: clientData.user.firstname,
+            lastname: clientData.user.lastname
+          } : null,
+          note: noteData ? noteData : null
+        };
+      }));
+      
+      return result;
+    } catch (error) {
+      throw new Error(`Failed to get all hours: ${error.message}`);
+    }
+  }
+  
+  async findById(id: string): Promise<User | null> {
+    const { data, error } = await this.supabaseClient
+      .from('users')
+      .select('*')
+      .eq('id', id)
+      .single();
+      
+    Iif (error || !data) {
+      return null;
+    }
+    
+    return this.mapToUser(data);
+  }
+ 
+  async findNoteByWorkLogId(id: string): Promise<NOTE | null> {
+    
+    const { data, error } = await this.supabaseClient
+    .from('notes')
+    .select('*')
+    .eq('work_log_id', id)
+ 
+    Iif(error) {
+      console.log(`Given this work_log_id: ${id} error finding note correspimonding to it: ${error.message}`);
+    }
+ 
+    return data[0];
+  }
+  
+  async delete(id: string): Promise<void> {
+    const { error } = await this.supabaseClient
+      .from('users')
+      .delete()
+      .eq('id', id);
+      
+    Iif (error) {
+      throw new Error(`Failed to delete user: ${error.message}`);
+    }
+  }
+  
+  async uploadProfilePicture(user: User, profilePicture: MulterFile) {
+    const filePath = `${user.id}/${Date.now()}_${profilePicture.originalname}`;
+ 
+    // upload to supabase
+    const { data, error: uploadError } = await this.supabaseClient.storage
+    .from('profile-pictures')
+    .upload(filePath, profilePicture.buffer, {
+      contentType: profilePicture.mimetype,
+      upsert: true,
+    });
+ 
+    Iif (uploadError) {
+      console.log('Upload error', uploadError);
+      throw new Error('failed to stash profile picture');
+    }
+ 
+    // grab the link to it
+    const { data: { publicUrl }} = await this.supabaseClient.storage
+      .from('profile-pictures')
+      .getPublicUrl(filePath);
+ 
+    return publicUrl;
+  }
+ 
+  // Helper to map database user to domain User
+  private mapToUser(data: any): User {
+    return new User({
+      id: data.id,
+      email: data.email,
+      firstname: data.firstname,
+      lastname: data.lastname,
+      created_at: new Date(data.created_at || Date.now()),
+      updated_at: new Date(data.updated_at || Date.now()),
+      role: data.role || ROLE.CLIENT,
+      address: data.address,
+      city: data.city,
+      state: data.state,
+      country: data.country,
+      zip_code: data.zip_code,
+      profile_picture: data.profile_picture,
+      account_status: data.account_status,
+      business: data.business,
+      bio: data.bio
+    });
+  }
+ 
+  // Helper to map to client entity
+  private mapToClient(data: any): Client {
+    // If the user has created a profile, grab user data from users table. If not, grab details
+    // from the request form (client_info table).
+    const userData = data.users ? {
+      id: data.users.user_id,
+      firstname: data.users.firstname,
+      lastname: data.users.lastname,
+      profile_picture: data.users,
+    } :
+    {
+      id: data.id,
+      firstname: data.firstname,
+      lastname: data.lastname,
+      profile_picture: ''
+    };
+ 
+    // if user doesn't exist (not approved), we fill fields from client_info table
+    const user = this.mapToUser({
+      id: userData.id ?? data.id,
+      firstname: userData.firstname,
+      lastname: userData.lastname,
+      profile_picture: userData.profile_picture,
+      role: 'client',
+    })
+ 
+    return new Client(
+      data.id,
+      user,
+      data.service_needed,
+      new Date(data.requested),
+      new Date(data.updated_at),
+      data.status
+    )
+  }
+ 
+  async addNewHours(doula_id: string, client_id: string, start_time: Date, end_time: Date, note: string): Promise<WORK_ENTRY_ROW> {
+    const { data: hoursData, error: hoursError } = await this.supabaseClient
+      .from('hours')
+      .insert([
+        {
+          doula_id: doula_id, 
+          client_id: client_id, 
+          start_time: start_time, 
+          end_time: end_time
+        }
+      ])
+      .select();
+      
+      Iif (hoursError) {
+        throw new Error(`Failed to post new user: ${hoursError.message}`);
+      }
+ 
+      // console.log("hoursData is" , hoursData);
+      // console.log("the id contained in hoursData is", hoursData[0].id);
+ 
+    Iif(note != "") {
+      // console.log("note is not empty and about to call https call, note is", note);
+      const { data: noteData, error: noteError } = await this.supabaseClient
+      .from('notes')
+      .insert([
+        {
+          content: note,
+          created_by: doula_id,
+          work_log_id: hoursData[0].id,
+          visibility: "public"
+        }
+      ])
+      .select();
+      
+      Iif(noteError) {
+        throw new Error(`The note field is nonempty but failed to add note, ${noteError.message}`);
+      }
+    }
+    
+    return hoursData[0];
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/EmailRoutes.ts.html b/coverage/src/routes/EmailRoutes.ts.html new file mode 100644 index 00000000..87dec43d --- /dev/null +++ b/coverage/src/routes/EmailRoutes.ts.html @@ -0,0 +1,142 @@ + + + + + + Code coverage report for src/routes/EmailRoutes.ts + + + + + + + + + +
+
+

All files / src/routes EmailRoutes.ts

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { emailController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+ 
+const emailRoutes: Router = express.Router();
+ 
+// Protect all email routes with authentication
+emailRoutes.use(authMiddleware);
+ 
+// Route for sending client approval emails
+emailRoutes.post('/client-approval', (req, res) => 
+  emailController.sendClientApproval(req, res)
+);
+ 
+// Route for sending team invite emails
+emailRoutes.post('/team-invite', (req, res) => 
+  emailController.sendTeamInvite(req, res)
+);
+ 
+export default emailRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/authRoutes.ts.html b/coverage/src/routes/authRoutes.ts.html new file mode 100644 index 00000000..e2df8b43 --- /dev/null +++ b/coverage/src/routes/authRoutes.ts.html @@ -0,0 +1,193 @@ + + + + + + Code coverage report for src/routes/authRoutes.ts + + + + + + + + + +
+
+

All files / src/routes authRoutes.ts

+
+ +
+ 0% + Statements + 0/29 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/12 +
+ + +
+ 0% + Lines + 0/17 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { authController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+ 
+ 
+const authRoutes: Router = express.Router();
+ 
+// Signup route
+authRoutes.post('/signup', (req, res) => authController.signup(req, res));
+ 
+// Login route
+authRoutes.post('/login', (req, res) => authController.login(req, res));
+ 
+// Get current user route
+authRoutes.get('/me', (req, res) => authController.getMe(req, res));
+ 
+// Get all users route
+authRoutes.get('/users', authMiddleware, (req, res) => authController.getAllUsers(req, res));
+ 
+// Logout route
+authRoutes.post('/logout', (req, res) => authController.logout(req, res));
+ 
+// Email verification route
+authRoutes.get('/verify', (req, res) => authController.verifyEmail(req, res));
+ 
+// Google OAuth routes
+authRoutes.get('/google', (req, res) => authController.googleAuth(req, res));
+authRoutes.get('/callback', (req, res) => authController.handleOAuthCallback(req, res));
+authRoutes.post('/callback', (req, res) => authController.handleToken(req, res));
+ 
+// Password reset routes
+authRoutes.post('/reset-password', (req, res) => authController.requestPasswordReset(req, res));
+authRoutes.get('/password-recovery', (req, res) => authController.handlePasswordRecovery(req, res));
+authRoutes.put('/reset-password', (req, res) => authController.updatePassword(req, res));
+ 
+export default authRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/clientRoutes.ts.html b/coverage/src/routes/clientRoutes.ts.html new file mode 100644 index 00000000..41457959 --- /dev/null +++ b/coverage/src/routes/clientRoutes.ts.html @@ -0,0 +1,265 @@ + + + + + + Code coverage report for src/routes/clientRoutes.ts + + + + + + + + + +
+
+

All files / src/routes clientRoutes.ts

+
+ +
+ 0% + Statements + 0/30 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/16 +
+ + +
+ 0% + Lines + 0/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { clientController, userController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+import authorizeRoles from '../middleware/authorizeRoles';
+ 
+const clientRoutes: Router = express.Router();
+ 
+// Team specific routes
+clientRoutes.get('/team/all',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']),
+  (req, res) => userController.getAllTeamMembers(req, res)
+);
+ 
+clientRoutes.delete('/team/:id',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => userController.deleteMember(req, res)
+);
+ 
+clientRoutes.post("/team/add",
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => userController.addTeamMember(req, res)
+);
+ 
+// Client specific routes - ORDER MATTERS! Specific routes first
+clientRoutes.get('/fetchCSV', 
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin','client']), 
+  (req, res) => clientController.exportCSV(req, res)
+);
+ 
+clientRoutes.get('/', 
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']), 
+  (req, res) => clientController.getClients(req, res)
+);
+ 
+// Specific routes must come before generic /:id route
+clientRoutes.put('/status',
+  authMiddleware, 
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']), 
+  (req, res) => clientController.updateClientStatus(req, res)
+);
+ 
+// Generic routes last
+clientRoutes.get('/:id',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula', 'client']),
+  (req, res) => clientController.getClientById(req, res)
+);
+ 
+clientRoutes.put('/:id',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']),
+  (req, res) => clientController.updateClient(req, res)
+);
+ 
+export default clientRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/contractRoutes.ts.html b/coverage/src/routes/contractRoutes.ts.html new file mode 100644 index 00000000..afca6832 --- /dev/null +++ b/coverage/src/routes/contractRoutes.ts.html @@ -0,0 +1,286 @@ + + + + + + Code coverage report for src/routes/contractRoutes.ts + + + + + + + + + +
+
+

All files / src/routes contractRoutes.ts

+
+ +
+ 0% + Statements + 0/29 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/14 +
+ + +
+ 0% + Lines + 0/29 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import multer from 'multer';
+import { contractController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+import authorizeRoles from '../middleware/authorizeRoles';
+ 
+ 
+const clientRoutes: Router = express.Router();
+ 
+const upload = multer({ 
+  storage: multer.memoryStorage(),
+  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
+ });
+ 
+// generate a contract for a client given a template
+clientRoutes.post('/',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => contractController.generateContract(req, res)
+)
+ 
+// get a preview of an already generated contract
+clientRoutes.get('/:id/preview',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula', 'client']),
+  (req, res) => contractController.previewContract(req, res)
+)
+ 
+// get the list of templates
+clientRoutes.get('/templates',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['doula', 'admin']),
+  (req, res) => contractController.getAllTemplates(req, res),
+)
+ 
+// delete a template
+clientRoutes.delete('/templates/:name',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => contractController.deleteTemplate(req, res),
+)
+ 
+// update a template
+clientRoutes.put('/templates/:name',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  upload.single('contract'),
+  (req, res) => contractController.updateTemplate(req, res),
+)
+ 
+// upload a template
+clientRoutes.post('/templates', 
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']), 
+  upload.single('contract'),
+  (req, res) => contractController.uploadTemplate(req, res)
+);
+ 
+// request a filled template
+clientRoutes.post('/templates/generate',
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin']),
+  (req, res) => contractController.generateTemplate(req, res),
+)
+ 
+ 
+export default clientRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/customersRoutes.ts.html b/coverage/src/routes/customersRoutes.ts.html new file mode 100644 index 00000000..0558bb7d --- /dev/null +++ b/coverage/src/routes/customersRoutes.ts.html @@ -0,0 +1,127 @@ + + + + + + Code coverage report for src/routes/customersRoutes.ts + + + + + + + + + +
+
+

All files / src/routes customersRoutes.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/routes/customersRoutes.js
+import { Router } from 'express';
+ 
+import { createCustomer, getInvoiceableCustomersController } from '../controllers/quickbooksController';
+const router = Router();
+ 
+// POST /quickbooks/customers
+router.post('/', createCustomer);
+ 
+ 
+// GET /quickbooks/customers/invoiceable
+router.get('/invoiceable', getInvoiceableCustomersController);
+ 
+export default router;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/doulaRoutes.ts.html b/coverage/src/routes/doulaRoutes.ts.html new file mode 100644 index 00000000..a44f1645 --- /dev/null +++ b/coverage/src/routes/doulaRoutes.ts.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for src/routes/doulaRoutes.ts + + + + + + + + + +
+
+

All files / src/routes doulaRoutes.ts

+
+ +
+ 0% + Statements + 0/12 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 0% + Lines + 0/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { clientController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+import authorizeRoles from '../middleware/authorizeRoles';
+ 
+const doulaRoutes: Router = express.Router();
+ 
+doulaRoutes.get('/', 
+  authMiddleware,
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']), 
+  (req, res) => clientController.getClients(req, res)
+);
+doulaRoutes.put('/status', 
+  authMiddleware, 
+  (req, res, next) => authorizeRoles(req, res, next, ['admin', 'doula']), 
+  (req, res) => clientController.updateClientStatus(req, res)
+);
+ 
+ 
+ 
+export default doulaRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/index.html b/coverage/src/routes/index.html new file mode 100644 index 00000000..e644bc77 --- /dev/null +++ b/coverage/src/routes/index.html @@ -0,0 +1,251 @@ + + + + + + Code coverage report for src/routes + + + + + + + + + +
+
+

All files src/routes

+
+ +
+ 0% + Statements + 0/168 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/53 +
+ + +
+ 0% + Lines + 0/152 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
EmailRoutes.ts +
+
0%0/10100%0/00%0/20%0/10
authRoutes.ts +
+
0%0/29100%0/00%0/120%0/17
clientRoutes.ts +
+
0%0/30100%0/00%0/160%0/30
contractRoutes.ts +
+
0%0/29100%0/00%0/140%0/29
customersRoutes.ts +
+
0%0/6100%0/0100%0/00%0/6
doulaRoutes.ts +
+
0%0/12100%0/00%0/40%0/12
paymentRoutes.ts +
+
0%0/16100%0/0100%0/00%0/16
quickbooksRoutes.ts +
+
0%0/15100%0/0100%0/00%0/15
requestRoute.ts +
+
0%0/6100%0/00%0/10%0/6
specificUserRoutes.ts +
+
0%0/15100%0/00%0/40%0/11
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/paymentRoutes.ts.html b/coverage/src/routes/paymentRoutes.ts.html new file mode 100644 index 00000000..17b8e4b2 --- /dev/null +++ b/coverage/src/routes/paymentRoutes.ts.html @@ -0,0 +1,259 @@ + + + + + + Code coverage report for src/routes/paymentRoutes.ts + + + + + + + + + +
+
+

All files / src/routes paymentRoutes.ts

+
+ +
+ 0% + Statements + 0/16 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/16 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Router } from 'express';
+import { z } from 'zod';
+import { paymentController } from '../controllers/paymentController';
+import authMiddleware from '../middleware/authMiddleware';
+import { validateRequest } from '../middleware/validateRequest';
+ 
+const router = Router();
+ 
+// Validation schemas
+const saveCardSchema = z.object({
+  cardToken: z.string(),
+});
+ 
+const chargeCardSchema = z.object({
+  amount: z.number().positive(),
+  description: z.string().optional(),
+});
+ 
+const updateCardSchema = z.object({
+  cardToken: z.string(),
+});
+ 
+// All payment routes should be authenticated
+router.use(authMiddleware);
+ 
+// Save a new card
+router.post(
+  '/customers/:customerId/cards',
+  validateRequest(saveCardSchema),
+  paymentController.saveCard.bind(paymentController)
+);
+ 
+// Update an existing card
+router.put(
+  '/customers/:customerId/cards/:paymentMethodId',
+  validateRequest(updateCardSchema),
+  paymentController.updatePaymentMethod.bind(paymentController)
+);
+ 
+// Process a charge
+router.post(
+  '/customers/:customerId/charge',
+  validateRequest(chargeCardSchema),
+  paymentController.processCharge.bind(paymentController)
+);
+ 
+// Get stored payment methods for a customer
+router.get(
+  '/customers/:customerId/cards',
+  paymentController.getPaymentMethods.bind(paymentController)
+);
+ 
+// Get all customers with Stripe IDs
+router.get(
+  '/customers',
+  paymentController.getCustomersWithStripeId.bind(paymentController)
+);
+ 
+export default router; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/quickbooksRoutes.ts.html b/coverage/src/routes/quickbooksRoutes.ts.html new file mode 100644 index 00000000..c4c69743 --- /dev/null +++ b/coverage/src/routes/quickbooksRoutes.ts.html @@ -0,0 +1,187 @@ + + + + + + Code coverage report for src/routes/quickbooksRoutes.ts + + + + + + + + + +
+
+

All files / src/routes quickbooksRoutes.ts

+
+ +
+ 0% + Statements + 0/15 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/15 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/routes/quickbooksRoutes.ts
+import { Router } from 'express'
+import {
+  connectQuickBooks,
+  createInvoice,
+  getInvoices,
+  handleQuickBooksCallback,
+  quickBooksAuthUrl,
+  quickBooksDisconnect,
+  quickBooksStatus
+} from '../controllers/quickbooksController'
+import authMiddleware from '../middleware/authMiddleware'
+import { simulatePaymentController } from '../services/payments/paymentsController'
+ 
+const router = Router()
+ 
+// 1️⃣ Public OAuth endpoints (no auth required for redirect/callback)
+router.get('/auth', connectQuickBooks)
+router.get('/callback', handleQuickBooksCallback)
+ 
+// 2️⃣ Now apply auth + admin guard to the rest
+router.use(authMiddleware)
+ 
+// 3️⃣ Protected AJAX endpoints
+router.get('/auth/url', quickBooksAuthUrl)
+router.get('/status', quickBooksStatus)
+router.get('/invoices', getInvoices)
+router.post('/disconnect', quickBooksDisconnect)
+router.post('/invoice', createInvoice)
+ 
+// Simulate payment endpoint
+router.post('/simulate-payment', simulatePaymentController)
+ 
+export default router
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/requestRoute.ts.html b/coverage/src/routes/requestRoute.ts.html new file mode 100644 index 00000000..0f0642d3 --- /dev/null +++ b/coverage/src/routes/requestRoute.ts.html @@ -0,0 +1,115 @@ + + + + + + Code coverage report for src/routes/requestRoute.ts + + + + + + + + + +
+
+

All files / src/routes requestRoute.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import { requestFormController } from '../index';
+ 
+const requestRouter: Router = express.Router();
+ 
+// Updated endpoint to handle all 10-step form fields
+requestRouter.post('/requestSubmission', 
+  (req, res) => requestFormController.createForm(req, res));
+ 
+export default requestRouter;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/specificUserRoutes.ts.html b/coverage/src/routes/specificUserRoutes.ts.html new file mode 100644 index 00000000..b1aa56f4 --- /dev/null +++ b/coverage/src/routes/specificUserRoutes.ts.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for src/routes/specificUserRoutes.ts + + + + + + + + + +
+
+

All files / src/routes specificUserRoutes.ts

+
+ +
+ 0% + Statements + 0/15 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 0% + Lines + 0/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import express, { Router } from 'express';
+import multer from 'multer';
+import { userController } from '../index';
+import authMiddleware from '../middleware/authMiddleware';
+ 
+const userRoutes: Router = express.Router();
+ 
+// route for retrieving specific user's information
+userRoutes.get('/:id', authMiddleware, (req, res) => userController.getUserById(req, res));
+ 
+userRoutes.get('/:id/hours', authMiddleware, (req, res) => userController.getHours(req, res));
+ 
+userRoutes.post('/:id/addhours', authMiddleware, (req, res) => userController.addNewHours(req, res));
+ 
+// uploading a profile picture requires multer
+const upload = multer({ 
+  storage: multer.memoryStorage(),
+  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
+ });
+userRoutes.put('/update', authMiddleware, upload.single('profile_picture'), (req, res) => userController.updateUser(req, res));
+ 
+export default userRoutes;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/RequestFormService.ts.html b/coverage/src/services/RequestFormService.ts.html new file mode 100644 index 00000000..0e6ae86f --- /dev/null +++ b/coverage/src/services/RequestFormService.ts.html @@ -0,0 +1,862 @@ + + + + + + Code coverage report for src/services/RequestFormService.ts + + + + + + + + + +
+
+

All files / src/services RequestFormService.ts

+
+ +
+ 0% + Statements + 0/58 +
+ + +
+ 0% + Branches + 0/35 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/58 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { ValidationError } from "../domains/errors";
+import { RequestForm } from '../entities/RequestForm';
+import { RequestFormRepository } from "../repositories/requestFormRepository";
+import {
+    RequestFormData,
+    RequestFormResponse,
+    RequestStatus
+} from "../types";
+ 
+export class RequestFormService {
+  private repository: RequestFormRepository;
+ 
+  constructor(requestFormRepository: RequestFormRepository) {
+    this.repository = requestFormRepository;
+  }
+ 
+  async createRequest(formData: RequestFormData): Promise<RequestFormResponse> {
+    // Validate required fields
+    Iif (!formData.firstname || !formData.lastname) {
+      throw new ValidationError("Missing required fields: first name and last name");
+    }
+ 
+    Iif (!formData.service_needed) {
+      throw new ValidationError("Missing required field: service_needed");
+    }
+    
+    Iif (!formData.email || !formData.email.includes('@')) {
+      throw new ValidationError("Valid email is required");
+    }
+    
+    Iif (!formData.phone_number) {
+      throw new ValidationError("Phone number is required");
+    }
+ 
+    Iif (!formData.address || !formData.city || !formData.state || !formData.zip_code) {
+      throw new ValidationError("Complete address is required");
+    }
+ 
+    // Validate email format
+    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+    Iif (!emailRegex.test(formData.email)) {
+      throw new ValidationError("Invalid email format");
+    }
+ 
+    // Validate phone number format (basic validation)
+    const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/;
+    Iif (!phoneRegex.test(formData.phone_number.replace(/[\s\-\(\)]/g, ''))) {
+      throw new ValidationError("Invalid phone number format");
+    }
+ 
+    // Validate zip code format
+    const zipRegex = /^\d{5}(-\d{4})?$/;
+    Iif (!zipRegex.test(formData.zip_code)) {
+      throw new ValidationError("Invalid zip code format");
+    }
+ 
+    // Save to repository (no userId)
+    return await this.repository.saveData(formData);
+  }
+ 
+  async getUserRequests(userId: string): Promise<RequestFormResponse[]> {
+    return await this.repository.getUserRequests(userId);
+  }
+ 
+  async getRequestById(requestId: string, userId: string): Promise<RequestFormResponse | null> {
+    return await this.repository.getRequestById(requestId, userId);
+  }
+ 
+  async getAllRequests(): Promise<RequestFormResponse[]> {
+    return await this.repository.getAllRequests();
+  }
+ 
+  async getRequestByIdAdmin(requestId: string): Promise<RequestFormResponse | null> {
+    return await this.repository.getRequestByIdAdmin(requestId);
+  }
+ 
+  async updateRequestStatus(requestId: string, status: RequestStatus): Promise<RequestFormResponse> {
+    // Validate status
+    const validStatuses = Object.values(RequestStatus);
+    Iif (!validStatuses.includes(status)) {
+      throw new ValidationError("Invalid status value");
+    }
+ 
+    return await this.repository.updateRequestStatus(requestId, status);
+  }
+ 
+  // Updated method to handle all 10-step form fields
+  async newForm(formData: any): Promise<RequestForm> {
+    try {
+      // Validate required fields
+      Iif (!formData.firstname || !formData.lastname) {
+        throw new ValidationError("Missing required fields: first name and last name");
+      }
+ 
+      Iif (!formData.service_needed) {
+        throw new ValidationError("Missing required field: service_needed");
+      }
+      
+      Iif (!formData.email || !formData.email.includes('@')) {
+        throw new ValidationError("Valid email is required");
+      }
+      
+      Iif (!formData.phone_number) {
+        throw new ValidationError("Phone number is required");
+      }
+ 
+      Iif (!formData.address || !formData.city || !formData.state || !formData.zip_code) {
+        throw new ValidationError("Complete address is required");
+      }
+ 
+      // Validate email format
+      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+      Iif (!emailRegex.test(formData.email)) {
+        throw new ValidationError("Invalid email format");
+      }
+ 
+      // Validate phone number format (basic validation)
+      const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/;
+      Iif (!phoneRegex.test(formData.phone_number.replace(/[\s\-\(\)]/g, ''))) {
+        throw new ValidationError("Invalid phone number format");
+      }
+ 
+      // Validate zip code format
+      const zipRegex = /^\d{5}(-\d{4})?$/;
+      Iif (!zipRegex.test(formData.zip_code)) {
+        throw new ValidationError("Invalid zip code format");
+      }
+ 
+      // Convert to RequestFormData format
+      const newFormData: RequestFormData = {
+        // Step 1: Client Details
+        firstname: formData.firstname,
+        lastname: formData.lastname,
+        email: formData.email,
+        phone_number: formData.phone_number,
+        pronouns: formData.pronouns,
+        pronouns_other: formData.pronouns_other,
+        
+        // Step 2: Home Details
+        address: formData.address,
+        city: formData.city,
+        state: formData.state,
+        zip_code: formData.zip_code,
+        home_phone: formData.home_phone,
+        home_type: formData.home_type,
+        home_access: formData.home_access,
+        pets: formData.pets,
+        
+        // Step 3: Family Members
+        relationship_status: formData.relationship_status,
+        first_name: formData.first_name,
+        last_name: formData.last_name,
+        middle_name: formData.middle_name,
+        mobile_phone: formData.mobile_phone,
+        work_phone: formData.work_phone,
+        
+        // Step 4: Referral
+        referral_source: formData.referral_source,
+        referral_name: formData.referral_name,
+        referral_email: formData.referral_email,
+        
+        // Step 5: Health History
+        health_history: formData.health_history,
+        allergies: formData.allergies,
+        health_notes: formData.health_notes,
+        
+        // Step 6: Payment Info
+        annual_income: formData.annual_income,
+        service_needed: formData.service_needed,
+        service_specifics: formData.service_specifics,
+        
+        // Step 7: Pregnancy/Baby
+        due_date: formData.due_date,
+        birth_location: formData.birth_location,
+        birth_hospital: formData.birth_hospital,
+        number_of_babies: formData.number_of_babies,
+        baby_name: formData.baby_name,
+        provider_type: formData.provider_type,
+        pregnancy_number: formData.pregnancy_number,
+        
+        // Step 8: Past Pregnancies
+        had_previous_pregnancies: formData.had_previous_pregnancies,
+        previous_pregnancies_count: formData.previous_pregnancies_count,
+        living_children_count: formData.living_children_count,
+        past_pregnancy_experience: formData.past_pregnancy_experience,
+        
+        // Step 9: Services Interested
+        services_interested: formData.services_interested,
+        service_support_details: formData.service_support_details,
+        
+        // Step 10: Client Demographics
+        race_ethnicity: formData.race_ethnicity,
+        primary_language: formData.primary_language,
+        client_age_range: formData.client_age_range,
+        insurance: formData.insurance,
+        demographics_multi: formData.demographics_multi
+      };
+ 
+      // Save to repository (no userId)
+      const response = await this.repository.saveData(newFormData);
+      
+      // Return the complete RequestForm with all fields
+      return new RequestForm(
+        response.firstname,
+        response.lastname,
+        response.email,
+        response.phone_number,
+        response.service_needed,
+        response.address,
+        response.city,
+        response.state,
+        response.zip_code,
+        response.pronouns,
+        response.pronouns_other,
+        response.children_expected,
+        response.home_phone,
+        response.home_type,
+        response.home_access,
+        response.pets,
+        response.relationship_status,
+        response.first_name,
+        response.last_name,
+        response.middle_name,
+        response.mobile_phone,
+        response.work_phone,
+        response.referral_source,
+        response.referral_name,
+        response.referral_email,
+        response.health_history,
+        response.allergies,
+        response.health_notes,
+        response.annual_income,
+        response.service_specifics,
+        response.due_date ? new Date(response.due_date) : undefined,
+        response.birth_location,
+        response.birth_hospital,
+        response.number_of_babies,
+        response.baby_name,
+        response.provider_type,
+        response.pregnancy_number,
+        response.hospital,
+        response.baby_sex,
+        response.had_previous_pregnancies,
+        response.previous_pregnancies_count,
+        response.living_children_count,
+        response.past_pregnancy_experience,
+        response.services_interested,
+        response.service_support_details,
+        response.race_ethnicity,
+        response.primary_language,
+        response.client_age_range,
+        response.insurance,
+        response.demographics_multi
+      );
+    } catch (error) {
+      console.error("Error in newForm:", error);
+      throw error;
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/auth/index.html b/coverage/src/services/auth/index.html new file mode 100644 index 00000000..2a31f985 --- /dev/null +++ b/coverage/src/services/auth/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/services/auth + + + + + + + + + +
+
+

All files src/services/auth

+
+ +
+ 0% + Statements + 0/39 +
+ + +
+ 0% + Branches + 0/11 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/39 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
quickbooksAuthService.ts +
+
0%0/390%0/110%0/50%0/39
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/auth/quickbooksAuthService.ts.html b/coverage/src/services/auth/quickbooksAuthService.ts.html new file mode 100644 index 00000000..6725c993 --- /dev/null +++ b/coverage/src/services/auth/quickbooksAuthService.ts.html @@ -0,0 +1,433 @@ + + + + + + Code coverage report for src/services/auth/quickbooksAuthService.ts + + + + + + + + + +
+
+

All files / src/services/auth quickbooksAuthService.ts

+
+ +
+ 0% + Statements + 0/39 +
+ + +
+ 0% + Branches + 0/11 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/39 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/auth/quickbooksAuthService.ts
+ 
+import OAuthClient from 'intuit-oauth';
+import { URL } from 'url';
+import {
+    deleteTokens,
+    getTokens,
+    saveTokens,
+    TokenStore
+} from '../../utils/tokenUtils';
+ 
+const {
+  QB_CLIENT_ID     = '',
+  QB_CLIENT_SECRET = '',
+  QB_REDIRECT_URI  = '',
+  QBO_ENV          = 'production'
+} = process.env;
+ 
+const oauthClient = new OAuthClient({
+  clientId:     QB_CLIENT_ID,
+  clientSecret: QB_CLIENT_SECRET,
+  environment:  QBO_ENV === 'sandbox' ? 'sandbox' : 'production',
+  redirectUri:  QB_REDIRECT_URI
+});
+ 
+/**
+ * Build the Intuit consent URL.
+ */
+export function generateConsentUrl(state: string): string {
+  return oauthClient.authorizeUri({
+    scope: [ OAuthClient.scopes.Accounting ],
+    state
+  });
+}
+ 
+/**
+ * Handle Intuit's callback:
+ *   1) Exchange the code for tokens
+ *   2) Extract realmId (from the JSON or the URL query)
+ *   3) Persist tokens
+ *   4) Return them
+ */
+export async function handleAuthCallback(
+  callbackUrl: string
+): Promise<Omit<TokenStore, 'userId'>> {
+  // Exchange code for tokens
+  const authResponse = await oauthClient.createToken(callbackUrl);
+  const json = authResponse.getJson() as {
+    access_token:  string;
+    refresh_token: string;
+    expires_in:    number;
+    realmId?:      string;
+  };
+ 
+  // Intuit sometimes returns realmId in JSON or URL query
+  const realmId = json.realmId ?? new URL(callbackUrl).searchParams.get('realmId');
+ 
+  Iif (!realmId) {
+    throw new Error('Missing realmId in QuickBooks callback');
+  }
+ 
+  // Build TokenStore
+  const tokens: TokenStore = {
+    realmId,
+    accessToken:  json.access_token,
+    refreshToken: json.refresh_token,
+    expiresAt:    new Date(Date.now() + json.expires_in * 1000).toISOString()
+  };
+ 
+  // Persist tokens
+  await saveTokens(tokens);
+  return tokens;
+}
+ 
+/**
+ * Check if connected (tokens exist & are not expired).
+ * If tokens are expired, attempt to refresh them.
+ */
+export async function isConnected(): Promise<boolean> {
+  console.log('🔍 [QB Auth] Checking if QuickBooks is connected...');
+  
+  const tokens = await getTokens();
+  Iif (!tokens) {
+    console.log('❌ [QB Auth] No tokens found - not connected');
+    return false;
+  }
+  
+  const now = new Date();
+  const expiresAt = new Date(tokens.expiresAt);
+  const isExpired = expiresAt <= now;
+  
+  console.log('⏰ [QB Auth] Current time:', now.toISOString());
+  console.log('📅 [QB Auth] Token expires at:', expiresAt.toISOString());
+  console.log('🔍 [QB Auth] Token expired?', isExpired);
+  
+  Iif (isExpired) {
+    console.log('🔄 [QB Auth] Token expired, attempting refresh...');
+    // Import and use getValidAccessToken which handles refresh
+    const { getValidAccessToken } = await import('../../utils/tokenUtils');
+    const validToken = await getValidAccessToken();
+    const refreshSuccessful = !!validToken;
+    console.log('📊 [QB Auth] Refresh successful?', refreshSuccessful);
+    return refreshSuccessful;
+  }
+  
+  console.log('📊 [QB Auth] Connected? true (token valid)');
+  return true;
+}
+ 
+/**
+ * Disconnect QuickBooks by deleting stored tokens.
+ */
+export async function disconnectQuickBooks(): Promise<void> {
+  await deleteTokens();
+}
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/customer/buildCustomerPayload.ts.html b/coverage/src/services/customer/buildCustomerPayload.ts.html new file mode 100644 index 00000000..c7f55b91 --- /dev/null +++ b/coverage/src/services/customer/buildCustomerPayload.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/services/customer/buildCustomerPayload.ts + + + + + + + + + +
+
+

All files / src/services/customer buildCustomerPayload.ts

+
+ +
+ 0% + Statements + 0/3 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/3 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export interface BuildCustomerPayloadResult {
+  fullName: string;
+  payload: {
+    GivenName: string;
+    FamilyName: string;
+    DisplayName: string;
+    PrimaryEmailAddr: { Address: string };
+  };
+}
+ 
+export default function buildCustomerPayload(
+  firstName: string,
+  lastName: string,
+  email: string
+): BuildCustomerPayloadResult {
+  const fullName = `${firstName} ${lastName}`;
+  return {
+    fullName,
+    payload: {
+      GivenName: firstName,
+      FamilyName: lastName,
+      DisplayName: fullName,
+      PrimaryEmailAddr: { Address: email }
+    }
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/customer/createCustomer.ts.html b/coverage/src/services/customer/createCustomer.ts.html new file mode 100644 index 00000000..a303918f --- /dev/null +++ b/coverage/src/services/customer/createCustomer.ts.html @@ -0,0 +1,235 @@ + + + + + + Code coverage report for src/services/customer/createCustomer.ts + + + + + + + + + +
+
+

All files / src/services/customer createCustomer.ts

+
+ +
+ 0% + Statements + 0/18 +
+ + +
+ 0% + Branches + 0/5 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { createClient } from '@supabase/supabase-js';
+import { SupabaseUserRepository } from '../../repositories/supabaseUserRepository';
+import buildCustomerPayload, { BuildCustomerPayloadResult } from './buildCustomerPayload';
+import createCustomerInQuickBooks from './createCustomerInQuickBooks';
+import saveQboCustomerId from './saveQboCustomerId';
+import upsertInternalCustomer from './upsertInternalCustomer';
+ 
+const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)
+const userRepository = new SupabaseUserRepository(supabase)
+ 
+export interface CreateCustomerParams {
+  internalCustomerId: string;
+  firstName: string;
+  lastName: string;
+  email: string;
+}
+ 
+export interface CreateCustomerResult {
+  internalCustomerId: string;
+  qboCustomerId: string;
+  fullName: string;
+}
+ 
+export default async function createCustomer(
+  params: CreateCustomerParams
+): Promise<CreateCustomerResult> {
+  const { internalCustomerId, firstName, lastName, email } = params;
+ 
+  Iif (!internalCustomerId || !firstName || !lastName || !email) {
+    throw new Error('Missing required fields to create customer.');
+  }
+ 
+  // 1) Build payload
+  const { fullName, payload }: BuildCustomerPayloadResult =
+    buildCustomerPayload(firstName, lastName, email);
+ 
+  // 2) Upsert internal record
+  await upsertInternalCustomer(internalCustomerId, fullName, email);
+ 
+  // 3) Create in QuickBooks
+  const qboCustomer = await createCustomerInQuickBooks(payload);
+ 
+  // 4) Save QBO customer ID back internally
+  await saveQboCustomerId(internalCustomerId, qboCustomer.Id);
+ 
+  // 5) Update client_info status to 'customer'
+  await userRepository.updateClientStatusToCustomer(internalCustomerId);
+ 
+  return { internalCustomerId, qboCustomerId: qboCustomer.Id, fullName };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/customer/createCustomerInQuickBooks.ts.html b/coverage/src/services/customer/createCustomerInQuickBooks.ts.html new file mode 100644 index 00000000..3d6cc0d1 --- /dev/null +++ b/coverage/src/services/customer/createCustomerInQuickBooks.ts.html @@ -0,0 +1,118 @@ + + + + + + Code coverage report for src/services/customer/createCustomerInQuickBooks.ts + + + + + + + + + +
+
+

All files / src/services/customer createCustomerInQuickBooks.ts

+
+ +
+ 0% + Statements + 0/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12  +  +  +  +  +  +  +  +  +  +  + 
import { qboRequest } from '../../utils/qboClient';
+ 
+export default async function createCustomerInQuickBooks(
+  qboPayload: any
+): Promise<any> {
+  const { Customer } = await qboRequest(
+    '/customer?minorversion=65',
+    { method: 'POST', body: JSON.stringify(qboPayload) }
+  );
+  return Customer;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/customer/getInvoiceableCustomers.ts.html b/coverage/src/services/customer/getInvoiceableCustomers.ts.html new file mode 100644 index 00000000..19102b0e --- /dev/null +++ b/coverage/src/services/customer/getInvoiceableCustomers.ts.html @@ -0,0 +1,166 @@ + + + + + + Code coverage report for src/services/customer/getInvoiceableCustomers.ts + + + + + + + + + +
+
+

All files / src/services/customer getInvoiceableCustomers.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 0% + Branches + 0/3 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/customer/getInvoiceableCustomers.ts
+import { SupabaseClient } from '@supabase/supabase-js';
+ 
+export interface InvoiceableCustomer {
+  id: string;               // UUID PK
+  name: string;             // full name
+  email: string;
+  qboCustomerId: string | null;
+}
+ 
+export default async function getInvoiceableCustomers(
+  supabase: SupabaseClient
+): Promise<InvoiceableCustomer[]> {
+  const { data, error } = await supabase
+    .from('customers')
+    .select('id, name, email, qbo_customer_id')
+    .order('name', { ascending: true });
+ 
+  Iif (error) throw new Error(`Error fetching customers: ${error.message}`);
+ 
+  return (data || []).map((row: any) => ({
+    id: row.id,
+    name: row.name,
+    email: row.email,
+    qboCustomerId: row.qbo_customer_id,
+  }));
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/customer/index.html b/coverage/src/services/customer/index.html new file mode 100644 index 00000000..98bf76d3 --- /dev/null +++ b/coverage/src/services/customer/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for src/services/customer + + + + + + + + + +
+
+

All files src/services/customer

+
+ +
+ 0% + Statements + 0/42 +
+ + +
+ 0% + Branches + 0/10 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/40 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
buildCustomerPayload.ts +
+
0%0/3100%0/00%0/10%0/3
createCustomer.ts +
+
0%0/180%0/50%0/10%0/18
createCustomerInQuickBooks.ts +
+
0%0/4100%0/00%0/10%0/4
getInvoiceableCustomers.ts +
+
0%0/60%0/30%0/20%0/4
saveQboCustomerId.ts +
+
0%0/50%0/10%0/10%0/5
upsertInternalCustomer.ts +
+
0%0/60%0/10%0/10%0/6
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/customer/saveQboCustomerId.ts.html b/coverage/src/services/customer/saveQboCustomerId.ts.html new file mode 100644 index 00000000..61d73a1b --- /dev/null +++ b/coverage/src/services/customer/saveQboCustomerId.ts.html @@ -0,0 +1,130 @@ + + + + + + Code coverage report for src/services/customer/saveQboCustomerId.ts + + + + + + + + + +
+
+

All files / src/services/customer saveQboCustomerId.ts

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import supabase from '../../supabase';
+ 
+export default async function saveQboCustomerId(
+  internalCustomerId: string,
+  qboCustomerId: string
+): Promise<void> {
+  const { error } = await supabase
+    .from('customers')
+    .update({ qbo_customer_id: qboCustomerId })
+    .eq('id', internalCustomerId);
+ 
+  Iif (error) {
+    throw new Error(`Supabase error saving qbo_customer_id: ${error.message}`);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/customer/upsertInternalCustomer.ts.html b/coverage/src/services/customer/upsertInternalCustomer.ts.html new file mode 100644 index 00000000..cb16736a --- /dev/null +++ b/coverage/src/services/customer/upsertInternalCustomer.ts.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for src/services/customer/upsertInternalCustomer.ts + + + + + + + + + +
+
+

All files / src/services/customer upsertInternalCustomer.ts

+
+ +
+ 0% + Statements + 0/6 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import supabase from '../../supabase';
+ 
+export default async function upsertInternalCustomer(
+  internalCustomerId: string,
+  fullName: string,
+  email: string
+): Promise<any> {
+  const { data, error } = await supabase
+    .from('customers')
+    .upsert(
+      { id: internalCustomerId, name: fullName, email },
+      { onConflict: 'id' }
+    )
+    .single();
+ 
+  Iif (error) {
+    throw new Error(`Supabase error upserting internal customer: ${error.message}`);
+  }
+ 
+  return data;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/emailService.ts.html b/coverage/src/services/emailService.ts.html new file mode 100644 index 00000000..697ab2f0 --- /dev/null +++ b/coverage/src/services/emailService.ts.html @@ -0,0 +1,658 @@ + + + + + + Code coverage report for src/services/emailService.ts + + + + + + + + + +
+
+

All files / src/services emailService.ts

+
+ +
+ 0% + Statements + 0/34 +
+ + +
+ 0% + Branches + 0/16 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/34 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import nodemailer from 'nodemailer';
+import { EmailService } from './interface/emailServiceInterface';
+ 
+export class NodemailerService implements EmailService {
+  private transporter: nodemailer.Transporter;
+ 
+  constructor() {
+    this.transporter = nodemailer.createTransport({
+      host: process.env.EMAIL_HOST,
+      port: parseInt(process.env.EMAIL_PORT || '587'),
+      secure: process.env.EMAIL_SECURE === 'true',
+      auth: {
+        user: process.env.EMAIL_USER,
+        pass: process.env.EMAIL_PASSWORD,
+      },
+    });
+  }
+ 
+  async sendEmail(to: string, subject: string, text: string, html?: string): Promise<void> {
+    // Check if we're in test mode
+    Iif (process.env.USE_TEST_EMAIL === 'true') {
+      console.log('Test email mode enabled - email not sent');
+      console.log({
+        to,
+        subject,
+        text,
+        html: html ? 'HTML content available' : 'No HTML content'
+      });
+      return;
+    }
+ 
+    try {
+      const mailOptions = {
+        from: process.env.EMAIL_FROM || 'Sokana CRM <noreply@sokanacrm.org>',
+        to,
+        subject,
+        text,
+        html: html || undefined,
+      };
+ 
+      const info = await this.transporter.sendMail(mailOptions);
+    } catch (error) {
+      console.error('Failed to send email:', error);
+      throw new Error(`Failed to send email: ${error.message}`);
+    }
+  }
+ 
+  async sendInvoiceEmail(
+    to: string,
+    customerName: string,
+    invoiceNumber: string,
+    amount: string,
+    dueDate: string,
+    invoicePdfBuffer: Buffer,
+    customHtml?: string,
+    customText?: string
+  ): Promise<void> {
+    const subject = `Invoice ${invoiceNumber} from Sokana CRM`;
+    
+    // Use custom text content if provided, otherwise use default
+    const text = customText || `Dear ${customerName},
+ 
+Please find attached invoice ${invoiceNumber} for ${amount}.
+ 
+Invoice Details:
+- Invoice Number: ${invoiceNumber}
+- Amount: ${amount}
+- Due Date: ${dueDate}
+ 
+Please remit payment by the due date. If you have any questions about this invoice, please contact us.
+ 
+Thank you for your business!
+ 
+Best regards,
+The Sokana Team`;
+ 
+    // Use custom HTML content if provided, otherwise use default
+    const html = customHtml || `
+      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+        <h2 style="color: #333;">Invoice ${invoiceNumber}</h2>
+        <p>Dear ${customerName},</p>
+        <p>Please find attached your invoice for <strong>${amount}</strong>.</p>
+        
+        <div style="background-color: #f5f5f5; padding: 20px; border-radius: 5px; margin: 20px 0;">
+          <h3 style="margin-top: 0; color: #333;">Invoice Details:</h3>
+          <ul style="list-style: none; padding: 0; margin: 0;">
+            <li style="margin: 10px 0;"><strong>Invoice Number:</strong> ${invoiceNumber}</li>
+            <li style="margin: 10px 0;"><strong>Amount:</strong> ${amount}</li>
+            <li style="margin: 10px 0;"><strong>Due Date:</strong> ${dueDate}</li>
+          </ul>
+        </div>
+ 
+        <div style="text-align: center; margin: 30px 0;">
+          <a href="https://app.sandbox.qbo.intuit.com/app/invoice?txnId=\${invoice.Id}"
+             style="background-color: #4CAF50; color: white; padding: 15px 30px; text-decoration: none; 
+                    border-radius: 5px; font-weight: bold; font-size: 16px; display: inline-block;
+                    box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
+            Pay Invoice Now
+          </a>
+        </div>
+        
+        <p>Please remit payment by the due date. If you have any questions about this invoice, please contact us.</p>
+        <p>Thank you for your business!</p>
+        <p>Best regards,<br>The Sokana Team</p>
+      </div>
+    `;
+ 
+    // Check if we're in test mode
+    Iif (process.env.USE_TEST_EMAIL === 'true') {
+      console.log('Test email mode enabled - email with attachment not sent');
+      console.log({
+        to,
+        subject,
+        text,
+        html: 'HTML content available',
+        attachments: [
+          {
+            filename: `invoice-${invoiceNumber}.pdf`,
+            content: `Buffer with ${invoicePdfBuffer.length} bytes`
+          }
+        ]
+      });
+      return;
+    }
+ 
+    try {
+      const mailOptions = {
+        from: process.env.EMAIL_FROM || 'Sokana CRM <noreply@sokanacrm.org>',
+        to,
+        subject,
+        text,
+        html,
+        attachments: [
+          {
+            filename: `invoice-${invoiceNumber}.pdf`,
+            content: invoicePdfBuffer,
+            contentType: 'application/pdf'
+          }
+        ]
+      };
+ 
+      const info = await this.transporter.sendMail(mailOptions);
+      console.log('Invoice email sent successfully:', info.messageId);
+    } catch (error) {
+      console.error('Failed to send invoice email:', error);
+      throw new Error(`Failed to send invoice email: ${error.message}`);
+    }
+  }
+ 
+  async sendClientApprovalEmail(to: string, name: string, signupUrl: string): Promise<void> {
+    const subject = 'Your Sokana CRM Account Request Has Been Approved';
+    const text = `Dear ${name},\n\nYour request for Sokana services has been approved! You can now create an account using the following link: ${signupUrl}\n\nBest regards,\nThe Sokana Team`;
+    const html = `
+      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+        <h2>Welcome to Sokana!</h2>
+        <p>Dear ${name},</p>
+        <p>We're pleased to inform you that your service request has been approved!</p>
+        <p>You can now create your account by clicking the button below:</p>
+        <div style="text-align: center; margin: 25px 0;">
+          <a href="${signupUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Create Account</a>
+        </div>
+        <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
+        <p>${signupUrl}</p>
+        <p>Best regards,<br>The Sokana Team</p>
+      </div>
+    `;
+    
+    await this.sendEmail(to, subject, text, html);
+  }
+ 
+  async sendTeamInviteEmail(to: string, firstname: string, lastname: string, role: string): Promise<void> {
+    const signupUrl = `${process.env.FRONTEND_URL}/signup`;
+    const subject = 'Welcome to the Sokana CRM Team!';
+    const text = `Dear ${firstname} ${lastname},\n\nYou have been invited to join the Sokana CRM team as a ${role}. Please fill out the sign up form to create an account and make sure to use this same email address.${signupUrl}\n\nBest regards,\nThe Sokana Team`;
+    const html = `
+      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+        <h2>Welcome to the Sokana Team!</h2>
+        <p>Dear ${firstname} ${lastname},</p>
+        <p>We're excited to have you join our team as a ${role}!</p>
+        <div>    
+        <p>Please fill out the</p>
+        <a href="${signupUrl}" style="font-weight: bold;">Sign Up Form</a>
+        <p> to create a new account and make sure to use this same email address.</p>
+        </div>
+        <p>If you have any questions, please don't hesitate to reach out.</p>
+        <p>Best regards,<br>The Sokana Team</p>
+      </div>
+    `;
+    
+    await this.sendEmail(to, subject, text, html);
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/index.html b/coverage/src/services/index.html new file mode 100644 index 00000000..2e5198fc --- /dev/null +++ b/coverage/src/services/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/services + + + + + + + + + +
+
+

All files src/services

+
+ +
+ 0% + Statements + 0/239 +
+ + +
+ 0% + Branches + 0/98 +
+ + +
+ 0% + Functions + 0/36 +
+ + +
+ 0% + Lines + 0/230 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
RequestFormService.ts +
+
0%0/580%0/350%0/80%0/58
emailService.ts +
+
0%0/340%0/160%0/50%0/34
supabaseAuthService.ts +
+
0%0/710%0/170%0/140%0/71
supabaseContractService.ts +
+
0%0/760%0/300%0/90%0/67
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/invoice/buildInvoicePayload.ts.html b/coverage/src/services/invoice/buildInvoicePayload.ts.html new file mode 100644 index 00000000..432ebf5c --- /dev/null +++ b/coverage/src/services/invoice/buildInvoicePayload.ts.html @@ -0,0 +1,187 @@ + + + + + + Code coverage report for src/services/invoice/buildInvoicePayload.ts + + + + + + + + + +
+
+

All files / src/services/invoice buildInvoicePayload.ts

+
+ +
+ 0% + Statements + 0/3 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/3 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/invoice/buildInvoicePayload.ts
+export interface RawLineItem {
+  DetailType: string;
+  Amount: number;
+  Description?: string;
+  SalesItemLineDetail: {
+    ItemRef: { value: string };
+    UnitPrice: number;
+    Qty: number;
+  };
+}
+ 
+/**
+ * Construct a QuickBooks Invoice payload with the correct QBO customer reference
+ */
+export default function buildInvoicePayload(
+  qboCustomerId: string,
+  opts: { lineItems: RawLineItem[]; dueDate: string; memo?: string; customerEmail: string }
+) {
+  const { lineItems, dueDate, memo, customerEmail } = opts;
+  return {
+    CustomerRef: { value: qboCustomerId },
+    Line: lineItems,
+    TxnDate: dueDate,
+    DueDate: dueDate,
+    PrivateNote: memo || "",
+    BillEmail: { Address: customerEmail },
+    AllowOnlineACHPayment: true,
+    AllowOnlineCreditCardPayment: true,
+    EmailStatus: "NeedToSend",
+    domain: "QBO",
+    sparse: false
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/invoice/createInvoice.ts.html b/coverage/src/services/invoice/createInvoice.ts.html new file mode 100644 index 00000000..6622f7d5 --- /dev/null +++ b/coverage/src/services/invoice/createInvoice.ts.html @@ -0,0 +1,343 @@ + + + + + + Code coverage report for src/services/invoice/createInvoice.ts + + + + + + + + + +
+
+

All files / src/services/invoice createInvoice.ts

+
+ +
+ 0% + Statements + 0/31 +
+ + +
+ 0% + Branches + 0/8 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/31 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/invoice/createInvoiceService.ts
+ 
+import { sendInvoiceEmailToCustomer } from '../../services/invoice/sendInvoiceEmail';
+import supabase from '../../supabase';
+import buildInvoicePayload from './buildInvoicePayload';
+import createInvoiceInQuickBooks from './createInvoiceInQuickBooks';
+import persistInvoiceToSupabase from './persistInvoiceToSupabase';
+ 
+export interface CreateInvoiceParams {
+  userId: string;
+  internalCustomerId: string;
+  lineItems: any[];
+  dueDate: string;
+  memo?: string;
+}
+ 
+/**
+ * Build, send, and persist a QuickBooks invoice, then email it to the customer
+ */
+export default async function createInvoiceService(
+  params: CreateInvoiceParams
+): Promise<any> {
+  const { userId, internalCustomerId, lineItems, dueDate, memo } = params;
+ 
+  Iif (!userId || !internalCustomerId) {
+    throw new Error('userId and internalCustomerId are required');
+  }
+ 
+  console.log('🚀 Invoice creation started for customer:', internalCustomerId);
+ 
+  // 1) Lookup the QBO customer ID AND customer info for email
+  const { data: cust, error: custErr } = await supabase
+    .from('customers')
+    .select('qbo_customer_id, name, email')
+    .eq('id', internalCustomerId)
+    .single();
+    
+  Iif (custErr || !cust?.qbo_customer_id) {
+    throw new Error(`No QuickBooks customer found for ${internalCustomerId}`);
+  }
+  
+  const { qbo_customer_id: qboCustomerId, name: customerName, email: customerEmail } = cust;
+  console.log('📋 Customer found:', { customerName, customerEmail });
+ 
+  // 2) Build the payload using the QBO ID 
+  console.log('🔧 Building invoice payload...');
+  const payload = buildInvoicePayload(qboCustomerId, {
+    lineItems,
+    dueDate,
+    memo,
+    customerEmail
+  });
+ 
+  // 3) Send it to QuickBooks
+  console.log('📤 Creating invoice in QuickBooks...');
+  const invoice = await createInvoiceInQuickBooks(payload);
+  
+  // 4) Persist the result to Supabase, storing your UUID in `customer_id`
+  console.log('💾 Saving invoice to Supabase...');
+  await persistInvoiceToSupabase(internalCustomerId, invoice);
+ 
+  // 5) 🎯 NEW: Send email to customer (only if email exists and invoice was successful)
+  if (customerEmail) {
+    try {
+      console.log('📧 Sending invoice email to customer...');
+      await sendInvoiceEmailToCustomer({
+        invoice,
+        customerName,
+        customerEmail,
+        lineItems,
+        dueDate,
+        memo
+      });
+      console.log('✅ Invoice email sent successfully to:', customerEmail);
+    } catch (emailError) {
+      console.error('❌ Failed to send invoice email:', emailError);
+      // Don't throw here - we want the invoice creation to succeed even if email fails
+      console.warn('⚠️ Invoice created successfully but email failed to send');
+    }
+  } else {
+    console.warn('⚠️ No email found for customer, skipping email notification');
+  }
+ 
+  console.log('✅ Invoice creation completed successfully!');
+  return invoice;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/invoice/createInvoiceInQuickBooks.ts.html b/coverage/src/services/invoice/createInvoiceInQuickBooks.ts.html new file mode 100644 index 00000000..08b51dd3 --- /dev/null +++ b/coverage/src/services/invoice/createInvoiceInQuickBooks.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/services/invoice/createInvoiceInQuickBooks.ts + + + + + + + + + +
+
+

All files / src/services/invoice createInvoiceInQuickBooks.ts

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/invoice/createInvoiceInQuickBooks.ts
+ 
+import { qboRequest } from '../../utils/qboClient';
+ 
+export default async function createInvoiceInQuickBooks(
+  payload: any
+): Promise<any> {
+  // Create invoice in QuickBooks
+  const { Invoice } = await qboRequest(
+    '/invoice?minorversion=65',
+    {
+      method: 'POST',
+      body: JSON.stringify(payload)
+    }
+  );
+ 
+  // Fetch the invoice again with the payment link
+  const { Invoice: InvoiceWithLink } = await qboRequest(
+    `/invoice/${Invoice.Id}?minorversion=65&include=invoiceLink`,
+    {
+      method: 'GET'
+    }
+  );
+ 
+  return InvoiceWithLink;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/invoice/index.html b/coverage/src/services/invoice/index.html new file mode 100644 index 00000000..632c8fda --- /dev/null +++ b/coverage/src/services/invoice/index.html @@ -0,0 +1,176 @@ + + + + + + Code coverage report for src/services/invoice + + + + + + + + + +
+
+

All files src/services/invoice

+
+ +
+ 0% + Statements + 0/80 +
+ + +
+ 0% + Branches + 0/32 +
+ + +
+ 0% + Functions + 0/7 +
+ + +
+ 0% + Lines + 0/78 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
buildInvoicePayload.ts +
+
0%0/30%0/20%0/10%0/3
createInvoice.ts +
+
0%0/310%0/80%0/10%0/31
createInvoiceInQuickBooks.ts +
+
0%0/5100%0/00%0/10%0/5
persistInvoiceToSupabase.ts +
+
0%0/130%0/70%0/10%0/13
sendInvoiceEmail.ts +
+
0%0/280%0/150%0/30%0/26
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/invoice/persistInvoiceToSupabase.ts.html b/coverage/src/services/invoice/persistInvoiceToSupabase.ts.html new file mode 100644 index 00000000..7123e6dd --- /dev/null +++ b/coverage/src/services/invoice/persistInvoiceToSupabase.ts.html @@ -0,0 +1,256 @@ + + + + + + Code coverage report for src/services/invoice/persistInvoiceToSupabase.ts + + + + + + + + + +
+
+

All files / src/services/invoice persistInvoiceToSupabase.ts

+
+ +
+ 0% + Statements + 0/13 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/13 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/services/invoice/persistInvoiceToSupabase.ts
+import supabase from '../../supabase';
+ 
+export default async function persistInvoiceToSupabase(
+  internalCustomerId: string,
+  invoice: any
+): Promise<void> {
+  console.log('💾 [Invoice] Persisting invoice data to Supabase...');
+  console.log('📋 [Invoice] QuickBooks invoice data:', JSON.stringify(invoice, null, 2));
+  
+  // Destructure the fields from QuickBooks invoice response
+  const {
+    DocNumber: doc_number,
+    TotalAmt: total_amount,
+    Balance: balance,
+    DueDate: due_date,
+    PrivateNote: memo,
+    Line: line_items,
+  } = invoice;
+ 
+  // Determine invoice status based on balance
+  const status = balance === 0 ? 'paid' : 'pending';
+ 
+  const now = new Date().toISOString();
+ 
+  console.log('📊 [Invoice] Saving invoice with fields:', {
+    customer_id: internalCustomerId,
+    doc_number,
+    total_amount,
+    balance,
+    due_date,
+    status,
+    line_items_count: line_items?.length || 0
+  });
+ 
+  const { error } = await supabase
+    .from('invoices')
+    .insert({
+      customer_id: internalCustomerId,
+      doc_number,                  // QuickBooks document number
+      total_amount,                // Total invoice amount
+      balance,                     // Outstanding balance
+      line_items,                  // JSONB array of line items
+      due_date,
+      memo: memo || null,
+      status,
+      created_at: now,
+      updated_at: now
+    });
+ 
+  Iif (error) {
+    console.error('❌ [Invoice] Supabase error:', error);
+    throw new Error(`Supabase error saving invoice: ${error.message}`);
+  }
+  
+  console.log('✅ [Invoice] Invoice saved successfully to Supabase');
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/invoice/sendInvoiceEmail.ts.html b/coverage/src/services/invoice/sendInvoiceEmail.ts.html new file mode 100644 index 00000000..670931f2 --- /dev/null +++ b/coverage/src/services/invoice/sendInvoiceEmail.ts.html @@ -0,0 +1,520 @@ + + + + + + Code coverage report for src/services/invoice/sendInvoiceEmail.ts + + + + + + + + + +
+
+

All files / src/services/invoice sendInvoiceEmail.ts

+
+ +
+ 0% + Statements + 0/28 +
+ + +
+ 0% + Branches + 0/15 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/26 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// import { generateInvoicePDF, InvoiceData } from '../../utils/generateInvoicePdf';
+ 
+import { generateInvoicePDF, InvoiceData } from '../../utils/generateInvoicePdf';
+import { NodemailerService } from '../emailService';
+ 
+interface SendInvoiceEmailParams {
+  invoice: any;
+  customerName: string;
+  customerEmail: string;
+  lineItems: any[];
+  dueDate: string;
+  memo?: string;
+}
+ 
+/**
+ * Send invoice email with PDF attachment and payment link to customer
+ */
+export async function sendInvoiceEmailToCustomer(params: SendInvoiceEmailParams): Promise<void> {
+  const { invoice, customerName, customerEmail, lineItems, dueDate, memo } = params;
+  
+  console.log('📧 Preparing invoice email for:', customerEmail);
+  
+  const emailService = new NodemailerService();
+ 
+  // Get the payment link from QuickBooks response
+  const qboPaymentLink = invoice.invoiceLink;
+  Iif (!qboPaymentLink) {
+    console.warn('⚠️ No payment link available for invoice. Make sure "Accept Credit Cards" is enabled in QuickBooks and the invoice has an email address.');
+  }
+  console.log('🔗 QuickBooks payment link:', qboPaymentLink);
+ 
+  // Convert QuickBooks line items to our PDF format
+  const convertedLineItems = lineItems.map(item => ({
+    description: item.Description || 'Service',
+    quantity: item.SalesItemLineDetail?.Qty || 1,
+    rate: item.SalesItemLineDetail?.UnitPrice || 0,
+    amount: item.Amount || 0
+  }));
+ 
+  // Calculate totals
+  const subtotal = convertedLineItems.reduce((sum, item) => sum + item.amount, 0);
+  const total = subtotal;
+ 
+  // Get invoice number from QuickBooks response
+  const invoiceNumber = invoice.DocNumber || `INV-${Date.now()}`;
+  
+  console.log('📄 Generating PDF for invoice:', invoiceNumber);
+ 
+  // Prepare invoice data for PDF generation
+  const invoiceData: InvoiceData = {
+    invoiceNumber,
+    customerName,
+    customerEmail,
+    lineItems: convertedLineItems,
+    subtotal,
+    total,
+    dueDate,
+    issueDate: new Date().toISOString().split('T')[0],
+    memo
+  };
+ 
+  try {
+    // Generate PDF
+    const invoicePdfBuffer = await generateInvoicePDF(invoiceData);
+    console.log('📨 Sending email with PDF attachment and payment link...');
+ 
+    // Create HTML content with payment button (only if payment link is available)
+    const paymentSection = qboPaymentLink ? `
+      <div style="text-align: center; margin: 30px 0;">
+        <table role="presentation" style="margin: 0 auto;">
+          <tr>
+            <td style="background-color: #4CAF50; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
+              <a href="${qboPaymentLink}"
+                 style="background-color: #4CAF50; color: white; padding: 15px 30px; text-decoration: none; 
+                        border-radius: 5px; font-weight: bold; font-size: 16px; display: inline-block;">
+                Pay Invoice Now
+              </a>
+            </td>
+          </tr>
+        </table>
+      </div>
+      
+      <p style="color: #666; font-size: 14px;">You can also pay your invoice using this secure link: 
+        <a href="${qboPaymentLink}" style="color: #4CAF50; text-decoration: underline;">${qboPaymentLink}</a>
+      </p>
+    ` : '';
+ 
+    const html = `
+      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+        <h2 style="color: #333;">Invoice ${invoiceNumber}</h2>
+        <p>Dear ${customerName},</p>
+        <p>Please find attached your invoice for <strong>$${total.toFixed(2)}</strong>.</p>
+        
+        <div style="background-color: #f5f5f5; padding: 20px; border-radius: 5px; margin: 20px 0;">
+          <h3 style="margin-top: 0; color: #333;">Invoice Details:</h3>
+          <ul style="list-style: none; padding: 0; margin: 0;">
+            <li style="margin: 10px 0;"><strong>Invoice Number:</strong> ${invoiceNumber}</li>
+            <li style="margin: 10px 0;"><strong>Amount:</strong> $${total.toFixed(2)}</li>
+            <li style="margin: 10px 0;"><strong>Due Date:</strong> ${dueDate}</li>
+          </ul>
+        </div>
+ 
+        ${paymentSection}
+        
+        <p>Please remit payment by the due date. If you have any questions about this invoice, please contact us.</p>
+        <p>Thank you for your business!</p>
+        <p>Best regards,<br>The Sokana Team</p>
+      </div>
+    `;
+ 
+    // Create plain text content
+    const text = `Dear ${customerName},
+ 
+Please find attached invoice ${invoiceNumber} for $${total.toFixed(2)}.
+ 
+Invoice Details:
+- Invoice Number: ${invoiceNumber}
+- Amount: $${total.toFixed(2)}
+- Due Date: ${dueDate}
+${qboPaymentLink ? `\nYou can pay your invoice using this secure link:\n${qboPaymentLink}` : ''}
+ 
+Please remit payment by the due date. If you have any questions about this invoice, please contact us.
+ 
+Thank you for your business!
+ 
+Best regards,
+The Sokana Team`;
+ 
+    // Send email with both PDF attachment and payment link
+    await emailService.sendInvoiceEmail(
+      customerEmail,
+      customerName,
+      invoiceNumber,
+      `$${total.toFixed(2)}`,
+      dueDate,
+      invoicePdfBuffer,
+      html,
+      text
+    );
+    
+    console.log('✅ Invoice email sent successfully with payment link!');
+  } catch (error) {
+    console.error('❌ Error sending invoice email:', error);
+    throw error;
+  }
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/payments/buildChargePayload.ts.html b/coverage/src/services/payments/buildChargePayload.ts.html new file mode 100644 index 00000000..2f1e4f59 --- /dev/null +++ b/coverage/src/services/payments/buildChargePayload.ts.html @@ -0,0 +1,169 @@ + + + + + + Code coverage report for src/services/payments/buildChargePayload.ts + + + + + + + + + +
+
+

All files / src/services/payments buildChargePayload.ts

+
+ +
+ 0% + Statements + 0/2 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/2 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export interface CardDetails {
+  number: string;
+  expMonth: string;
+  expYear: string;
+  cvc: string;
+}
+ 
+export interface ChargePayload {
+  amount: string;
+  currency: string;
+  card: CardDetails;
+  context: { isEcommerce: boolean };
+}
+ 
+export function buildChargePayload(amount: string, card: CardDetails): ChargePayload {
+  return {
+    amount: amount.toString(),
+    currency: 'USD',
+    card: {
+      number: card.number,
+      expMonth: card.expMonth,
+      expYear: card.expYear,
+      cvc: card.cvc
+    },
+    context: {
+      isEcommerce: true
+    }
+  };
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/payments/createCharge.ts.html b/coverage/src/services/payments/createCharge.ts.html new file mode 100644 index 00000000..1d4678a2 --- /dev/null +++ b/coverage/src/services/payments/createCharge.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/services/payments/createCharge.ts + + + + + + + + + +
+
+

All files / src/services/payments createCharge.ts

+
+ +
+ 0% + Statements + 0/12 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { getValidAccessToken } from '../../utils/tokenUtils';
+import { buildChargePayload, CardDetails } from './buildChargePayload';
+ 
+export async function createCharge(amount: string, card: CardDetails) {
+  const accessToken = await getValidAccessToken();
+  Iif (!accessToken) {
+    throw new Error('Could not get QuickBooks access token');
+  }
+ 
+  const payload = buildChargePayload(amount, card);
+ 
+  const response = await fetch('https://sandbox.api.intuit.com/quickbooks/v4/payments/charges', {
+    method: 'POST',
+    headers: {
+      'Authorization': `Bearer ${accessToken}`,
+      'Content-Type': 'application/json',
+      'Accept': 'application/json'
+    },
+    body: JSON.stringify(payload)
+  });
+ 
+  const data = await response.json();
+  Iif (!response.ok) {
+    throw new Error(JSON.stringify(data));
+  }
+  return data;
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/payments/index.html b/coverage/src/services/payments/index.html new file mode 100644 index 00000000..442db793 --- /dev/null +++ b/coverage/src/services/payments/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/services/payments + + + + + + + + + +
+
+

All files src/services/payments

+
+ +
+ 0% + Statements + 0/151 +
+ + +
+ 0% + Branches + 0/30 +
+ + +
+ 0% + Functions + 0/11 +
+ + +
+ 0% + Lines + 0/147 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
buildChargePayload.ts +
+
0%0/2100%0/00%0/10%0/2
createCharge.ts +
+
0%0/120%0/20%0/10%0/12
paymentsController.ts +
+
0%0/140%0/30%0/10%0/12
stripePaymentService.ts +
+
0%0/1230%0/250%0/80%0/121
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/payments/paymentsController.ts.html b/coverage/src/services/payments/paymentsController.ts.html new file mode 100644 index 00000000..cb42713b --- /dev/null +++ b/coverage/src/services/payments/paymentsController.ts.html @@ -0,0 +1,136 @@ + + + + + + Code coverage report for src/services/payments/paymentsController.ts + + + + + + + + + +
+
+

All files / src/services/payments paymentsController.ts

+
+ +
+ 0% + Statements + 0/14 +
+ + +
+ 0% + Branches + 0/3 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { RequestHandler } from 'express';
+import { createCharge } from './createCharge';
+ 
+export const simulatePaymentController: RequestHandler = async (req, res) => {
+  try {
+    const { amount, card } = req.body;
+    Iif (!amount || !card) {
+      res.status(400).json({ error: 'Missing amount or card details' });
+      return;
+    }
+    const data = await createCharge(amount, card);
+    res.json(data);
+  } catch (error) {
+    let message = error.message;
+    try { message = JSON.parse(error.message); } catch {}
+    res.status(500).json({ error: message });
+  }
+}; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/payments/stripePaymentService.ts.html b/coverage/src/services/payments/stripePaymentService.ts.html new file mode 100644 index 00000000..a5d4b9c5 --- /dev/null +++ b/coverage/src/services/payments/stripePaymentService.ts.html @@ -0,0 +1,1237 @@ + + + + + + Code coverage report for src/services/payments/stripePaymentService.ts + + + + + + + + + +
+
+

All files / src/services/payments stripePaymentService.ts

+
+ +
+ 0% + Statements + 0/123 +
+ + +
+ 0% + Branches + 0/25 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/121 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { stripe } from '../../config/stripe';
+import supabase from '../../supabase';
+ 
+interface SaveCardParams {
+  customerId: string;
+  cardToken: string;
+}
+ 
+interface ChargeCardParams {
+  customerId: string;
+  amount: number; // Amount in cents
+  description?: string;
+}
+ 
+interface UpdateCardParams {
+  customerId: string;
+  cardToken: string;
+  paymentMethodId: string;
+}
+ 
+export class StripePaymentService {
+  private async ensureStripeCustomer(customerId: string): Promise<string> {
+    console.log(`Ensuring Stripe customer exists for customer ID: ${customerId}`);
+    
+    // Get customer info from database
+    const { data: customerData, error: customerError } = await supabase
+      .from('customers')
+      .select('email, name, stripe_customer_id')
+      .eq('id', customerId)
+      .single();
+ 
+    Iif (customerError || !customerData) {
+      console.error('Customer lookup error:', customerError);
+      throw new Error(`Customer not found: ${customerError?.message}`);
+    }
+ 
+    console.log('Found customer data:', { 
+      email: customerData.email, 
+      name: customerData.name, 
+      hasStripeId: !!customerData.stripe_customer_id 
+    });
+ 
+    // If customer already has Stripe ID, verify it exists in Stripe
+    Iif (customerData.stripe_customer_id) {
+      try {
+        await stripe.customers.retrieve(customerData.stripe_customer_id);
+        console.log('Verified existing Stripe customer:', customerData.stripe_customer_id);
+        return customerData.stripe_customer_id;
+      } catch (err) {
+        console.log('Stripe customer ID exists in DB but not in Stripe, creating new one');
+        // Continue to create new customer if retrieval fails
+      }
+    }
+ 
+    // Create new Stripe customer
+    try {
+      const stripeCustomer = await stripe.customers.create({
+        email: customerData.email,
+        name: customerData.name,
+        metadata: {
+          supabase_customer_id: customerId
+        }
+      });
+      
+      console.log('Created new Stripe customer:', stripeCustomer.id);
+ 
+      // Save Stripe customer ID
+      const { error: updateError } = await supabase
+        .from('customers')
+        .update({ stripe_customer_id: stripeCustomer.id })
+        .eq('id', customerId);
+ 
+      Iif (updateError) {
+        console.error('Failed to save Stripe customer ID:', updateError);
+        throw new Error(`Failed to save Stripe customer ID: ${updateError.message}`);
+      }
+ 
+      console.log('Successfully saved Stripe customer ID to database');
+      return stripeCustomer.id;
+    } catch (err) {
+      console.error('Error creating Stripe customer:', err);
+      throw new Error(`Failed to create Stripe customer: ${err.message}`);
+    }
+  }
+ 
+  async saveCard({ customerId, cardToken }: SaveCardParams) {
+    console.log('Starting saveCard process for customer:', customerId);
+    
+    // Ensure customer exists in Stripe
+    const stripeCustomerId = await this.ensureStripeCustomer(customerId);
+    
+    try {
+      // First, mark any existing payment methods as not default
+      console.log('Marking existing payment methods as not default');
+      await supabase
+        .from('payment_methods')
+        .update({ is_default: false })
+        .eq('customer_id', customerId);
+ 
+      // Create a payment method from the token and attach to customer in one step
+      console.log('Creating payment method from token and attaching to customer');
+      const paymentMethod = await stripe.paymentMethods.create({
+        type: 'card',
+        card: { token: cardToken },
+        metadata: {
+          customer_id: customerId
+        }
+      });
+ 
+      console.log('Created payment method:', paymentMethod.id);
+ 
+      // Attach payment method to the customer
+      console.log('Attaching payment method to customer');
+      await stripe.paymentMethods.attach(paymentMethod.id, {
+        customer: stripeCustomerId,
+      });
+ 
+      // Set as default payment method
+      console.log('Setting as default payment method');
+      await stripe.customers.update(stripeCustomerId, {
+        invoice_settings: {
+          default_payment_method: paymentMethod.id,
+        },
+      });
+ 
+      // Store the payment method in our database
+      console.log('Saving payment method to database');
+      const paymentMethodData = {
+        customer_id: customerId,
+        stripe_payment_method_id: paymentMethod.id,
+        card_last4: paymentMethod.card!.last4,
+        card_brand: paymentMethod.card!.brand,
+        card_exp_month: paymentMethod.card!.exp_month,
+        card_exp_year: paymentMethod.card!.exp_year,
+        is_default: true
+      };
+      
+      console.log('Payment method data to insert:', paymentMethodData);
+      
+      const { data: insertResult, error } = await supabase
+        .from('payment_methods')
+        .insert(paymentMethodData)
+        .select();
+ 
+      console.log('Insert result:', insertResult);
+      console.log('Insert error:', error);
+ 
+      Iif (error) {
+        console.error('Database error saving payment method:', error);
+        throw new Error(`Failed to save payment method: ${error.message}`);
+      }
+ 
+      console.log('Successfully saved card');
+      return {
+        id: paymentMethod.id,
+        last4: paymentMethod.card!.last4,
+        brand: paymentMethod.card!.brand,
+        expMonth: paymentMethod.card!.exp_month,
+        expYear: paymentMethod.card!.exp_year
+      };
+    } catch (err) {
+      console.error('Error in saveCard:', err);
+      throw err;
+    }
+  }
+ 
+  async chargeCard({ customerId, amount, description }: ChargeCardParams) {
+    console.log('Starting charge process for customer:', customerId);
+    
+    // Ensure customer exists in Stripe
+    const stripeCustomerId = await this.ensureStripeCustomer(customerId);
+ 
+    // Debug: Check all payment methods for this customer
+    console.log('Checking all payment methods for customer:', customerId);
+    const { data: allPaymentMethods, error: allError } = await supabase
+      .from('payment_methods')
+      .select('*')
+      .eq('customer_id', customerId);
+    
+    console.log('All payment methods for customer:', allPaymentMethods);
+    console.log('Payment methods query error:', allError);
+ 
+    // Get the payment method
+    console.log('Fetching default payment method');
+    const { data: paymentMethod, error } = await supabase
+      .from('payment_methods')
+      .select('id, stripe_payment_method_id')
+      .eq('customer_id', customerId)
+      .eq('is_default', true)
+      .single();
+ 
+    console.log('Default payment method query result:', paymentMethod);
+    console.log('Default payment method query error:', error);
+ 
+    Iif (error || !paymentMethod) {
+      console.error('Payment method lookup error:', error);
+      throw new Error('No payment method found for this customer');
+    }
+ 
+    try {
+      // Create and confirm the payment intent
+      console.log('Creating payment intent');
+      const paymentIntent = await stripe.paymentIntents.create({
+        amount,
+        currency: 'usd',
+        customer: stripeCustomerId,
+        payment_method: paymentMethod.stripe_payment_method_id,
+        confirm: true,
+        description,
+        off_session: true
+      });
+ 
+      console.log('Payment intent created:', paymentIntent.id);
+ 
+      // Save the charge
+      console.log('Saving charge to database');
+      const { error: chargeError } = await supabase.from('charges').insert({
+        customer_id: customerId,
+        payment_method_id: paymentMethod.id,
+        stripe_payment_intent_id: paymentIntent.id,
+        amount: paymentIntent.amount,
+        status: paymentIntent.status,
+        description: paymentIntent.description
+      });
+ 
+      Iif (chargeError) {
+        console.error('Failed to save charge to database:', chargeError);
+      }
+ 
+      console.log('Charge process completed successfully');
+      return paymentIntent;
+    } catch (err) {
+      console.error('Error in chargeCard:', err);
+      throw err;
+    }
+  }
+ 
+  async updateCard({ customerId, cardToken, paymentMethodId }: UpdateCardParams) {
+    console.log('Starting updateCard process for customer:', customerId);
+    
+    // Ensure customer exists in Stripe
+    const stripeCustomerId = await this.ensureStripeCustomer(customerId);
+ 
+    try {
+      // Verify the payment method belongs to this customer
+      const { data: existingPaymentMethod, error: lookupError } = await supabase
+        .from('payment_methods')
+        .select('stripe_payment_method_id, is_default')
+        .eq('id', paymentMethodId)
+        .eq('customer_id', customerId)
+        .single();
+ 
+      Iif (lookupError || !existingPaymentMethod) {
+        throw new Error('Payment method not found or does not belong to this customer');
+      }
+ 
+      // Create new payment method from token
+      console.log('Creating new payment method from token');
+      const newPaymentMethod = await stripe.paymentMethods.create({
+        type: 'card',
+        card: { token: cardToken }
+      });
+ 
+      console.log('Created new payment method:', newPaymentMethod.id);
+ 
+      // Attach new payment method to customer
+      console.log('Attaching new payment method to customer');
+      await stripe.paymentMethods.attach(newPaymentMethod.id, {
+        customer: stripeCustomerId,
+      });
+ 
+      // If this was the default payment method, update customer's default
+      Iif (existingPaymentMethod.is_default) {
+        console.log('Updating default payment method');
+        await stripe.customers.update(stripeCustomerId, {
+          invoice_settings: {
+            default_payment_method: newPaymentMethod.id,
+          },
+        });
+      }
+ 
+      // Detach old payment method from Stripe
+      console.log('Detaching old payment method');
+      await stripe.paymentMethods.detach(existingPaymentMethod.stripe_payment_method_id);
+ 
+      // Update payment method in database
+      console.log('Updating payment method in database');
+      const { error: updateError } = await supabase
+        .from('payment_methods')
+        .update({
+          stripe_payment_method_id: newPaymentMethod.id,
+          card_last4: newPaymentMethod.card!.last4,
+          card_brand: newPaymentMethod.card!.brand,
+          card_exp_month: newPaymentMethod.card!.exp_month,
+          card_exp_year: newPaymentMethod.card!.exp_year,
+          updated_at: new Date().toISOString()
+        })
+        .eq('id', paymentMethodId)
+        .eq('customer_id', customerId);
+ 
+      Iif (updateError) {
+        console.error('Database error updating payment method:', updateError);
+        throw new Error(`Failed to update payment method: ${updateError.message}`);
+      }
+ 
+      console.log('Successfully updated card');
+      return {
+        id: newPaymentMethod.id,
+        last4: newPaymentMethod.card!.last4,
+        brand: newPaymentMethod.card!.brand,
+        expMonth: newPaymentMethod.card!.exp_month,
+        expYear: newPaymentMethod.card!.exp_year
+      };
+    } catch (err) {
+      console.error('Error in updateCard:', err);
+      throw err;
+    }
+  }
+ 
+  async getPaymentMethods(customerId: string) {
+    console.log('Fetching payment methods for customer:', customerId);
+    
+    try {
+      // Get payment methods from database
+      const { data: paymentMethods, error } = await supabase
+        .from('payment_methods')
+        .select('id, stripe_payment_method_id, card_last4, card_brand, card_exp_month, card_exp_year, is_default, created_at')
+        .eq('customer_id', customerId)
+        .order('created_at', { ascending: false });
+ 
+      Iif (error) {
+        console.error('Database error fetching payment methods:', error);
+        throw new Error(`Failed to fetch payment methods: ${error.message}`);
+      }
+ 
+      console.log(`Found ${paymentMethods?.length || 0} payment methods for customer`);
+      
+      return (paymentMethods || []).map(pm => ({
+        id: pm.id,
+        stripePaymentMethodId: pm.stripe_payment_method_id,
+        last4: pm.card_last4,
+        brand: pm.card_brand,
+        expMonth: pm.card_exp_month,
+        expYear: pm.card_exp_year,
+        isDefault: pm.is_default,
+        createdAt: pm.created_at
+      }));
+    } catch (err) {
+      console.error('Error in getPaymentMethods:', err);
+      throw err;
+    }
+  }
+ 
+  async getCustomersWithStripeId() {
+    console.log('Fetching customers with Stripe IDs');
+    
+    try {
+      // Get customers from database that have a stripe_customer_id
+      const { data: customers, error } = await supabase
+        .from('customers')
+        .select('id, name, email, stripe_customer_id, created_at, updated_at')
+        .not('stripe_customer_id', 'is', null)
+        .order('created_at', { ascending: false });
+ 
+      Iif (error) {
+        console.error('Database error fetching customers:', error);
+        throw new Error(`Failed to fetch customers: ${error.message}`);
+      }
+ 
+      console.log(`Found ${customers?.length || 0} customers with Stripe IDs`);
+      
+      return (customers || []).map(customer => ({
+        id: customer.id,
+        name: customer.name,
+        email: customer.email,
+        stripeCustomerId: customer.stripe_customer_id,
+        createdAt: customer.created_at,
+        updatedAt: customer.updated_at
+      }));
+    } catch (err) {
+      console.error('Error in getCustomersWithStripeId:', err);
+      throw err;
+    }
+  }
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/supabaseAuthService.ts.html b/coverage/src/services/supabaseAuthService.ts.html new file mode 100644 index 00000000..d1e122af --- /dev/null +++ b/coverage/src/services/supabaseAuthService.ts.html @@ -0,0 +1,784 @@ + + + + + + Code coverage report for src/services/supabaseAuthService.ts + + + + + + + + + +
+
+

All files / src/services supabaseAuthService.ts

+
+ +
+ 0% + Statements + 0/71 +
+ + +
+ 0% + Branches + 0/17 +
+ + +
+ 0% + Functions + 0/14 +
+ + +
+ 0% + Lines + 0/71 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { SupabaseClient } from '@supabase/supabase-js';
+import { User } from '../entities/User';
+import { UserRepository } from '../repositories/interface/userRepository';
+import { AuthService } from '../services/interface/authService';
+import {
+  AuthenticationError,
+  AuthorizationError
+} from './../domains/errors';
+ 
+export class SupabaseAuthService implements AuthService {
+  private supabaseClient: SupabaseClient;
+  
+  constructor(
+    supabaseClient: SupabaseClient,
+    private userRepository: UserRepository,
+  ) {
+    this.supabaseClient = supabaseClient;
+  }
+  
+  async signup(
+    email: string,
+    password: string,
+    firstname: string,
+    lastname: string
+  ): Promise<User> {
+    // Create the auth account in Supabase
+    const { data, error } = await this.supabaseClient.auth.signUp({
+      email,
+      password,
+    });
+ 
+    Iif (error) {
+      throw new AuthenticationError(`Authentication error: ${error.message}`);
+    }
+ 
+    Iif (!data.user) {
+      throw new AuthenticationError('User creation failed for unknown reasons');
+    }
+ 
+    const user = await this.userRepository.findByEmail(email);
+    Iif (!user) {
+      // This shouldn’t happen, but just in case
+      await this.supabaseClient.auth.admin.deleteUser(data.user.id);
+      throw new AuthorizationError("Signup not allowed — not approved.");
+    }
+ 
+    user.firstname = firstname || null;
+    user.lastname = lastname || null;
+ 
+    // update any details if needed
+    try {
+      await this.userRepository.save(user);
+      return user;
+    } catch (error) {
+      await this.supabaseClient.auth.admin.deleteUser(data.user.id);
+      throw new Error("Failed to update user profile during signup");
+    }
+  }
+  
+  async login(
+    email: string,
+    password: string
+  ): Promise<{user: User, token: string}> {
+ 
+    const { data, error } = await this.supabaseClient.auth.signInWithPassword({
+      email,
+      password: password
+    });
+ 
+    Iif (!data.session) {
+      throw new AuthenticationError("Invalid Credentials");
+    }
+ 
+    Iif (error) {
+      throw new AuthenticationError('Authentication error: Sign in failed from Supabase');
+    }
+ 
+    const token = data.session.access_token;
+ 
+    try {
+      const user = await this.userRepository.findByEmail(email);
+ 
+      return { user, token };
+    } catch (error) {
+      throw new Error('Authentication error: User could not be found from repository');
+    }
+  }
+ 
+  async getMe(
+    token: string
+  ): Promise<User> {
+ 
+    const { data: {user}, error } = await this.supabaseClient.auth.getUser(token);
+ 
+    try {
+      const user_profile = await this.userRepository.findByEmail(user.email);
+ 
+      return user_profile;
+ 
+    } catch (error) {
+      throw new Error('Authentication error: getMe could not be found from repository');
+    }
+  }
+ 
+  async logout(): Promise<void> {
+    // logout from supabase
+    await this.supabaseClient.auth.signOut();
+  }
+ 
+  async verifyEmail(
+    token_hash: string,
+    type: string
+  ): Promise<{ access_token: string, refresh_token: string, expires_in: number }> {
+ 
+    const { data, error } = await this.supabaseClient.auth.verifyOtp({
+      token_hash,
+      type: 'signup',
+    });
+ 
+    Iif (error) {
+      throw new AuthenticationError(error.message);
+    }
+ 
+    const access_token = data.session.access_token;
+    const refresh_token = data.session.refresh_token;
+    const expires_in = data.session.expires_in
+ 
+    return { access_token, refresh_token, expires_in };
+  }
+ 
+  async requestPasswordReset(
+    email: string,
+    redirectTo: string
+  ): Promise<void> {
+    const { error } = await this.supabaseClient.auth.resetPasswordForEmail(email, {
+      redirectTo
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+  }
+ 
+  async resetPassword(
+    token: string,
+    newPassword: string
+  ): Promise<void> {
+ 
+  }
+ 
+  async getUserFromToken(accessToken: string): Promise<any> {
+    const { data, error } = await this.supabaseClient.auth.getUser(accessToken);
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    // Fetch user from database
+    const user = await this.userRepository.findByEmail(data.user.email);
+ 
+    return user;
+  }
+ 
+  async getGoogleAuthUrl(
+    redirectTo: string
+  ): Promise<string> {
+ 
+ 
+    const { data, error } = await this.supabaseClient.auth.signInWithOAuth({
+      provider: 'google',
+      options: {
+        redirectTo,
+      },
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return data.url;
+  }
+ 
+  async setSession(token: string): Promise<any> {
+    const { data, error } = await this.supabaseClient.auth.setSession({
+      access_token: token,
+      refresh_token: token,
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return data.session;
+  }
+ 
+  async exchangeCodeForSession(code: string): Promise<{session: any, userData: any}> {
+ 
+    const { data, error } = await this.supabaseClient.auth.exchangeCodeForSession(code);
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return {
+      session: data.session,
+      userData: data.user
+    };
+  }
+ 
+  async verifyRecoveryToken(tokenHash: string): Promise<any> {
+    const { data, error } = await this.supabaseClient.auth.verifyOtp({
+      token_hash: tokenHash,
+      type: 'recovery',
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return data.session;
+  }
+ 
+  async updateUserPassword(password: string): Promise<any> {
+    const { data, error } = await this.supabaseClient.auth.updateUser({
+      password,
+    });
+ 
+    Iif (error) {
+      throw new Error(error.message);
+    }
+ 
+    return data.user;
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/services/supabaseContractService.ts.html b/coverage/src/services/supabaseContractService.ts.html new file mode 100644 index 00000000..7f8897c6 --- /dev/null +++ b/coverage/src/services/supabaseContractService.ts.html @@ -0,0 +1,754 @@ + + + + + + Code coverage report for src/services/supabaseContractService.ts + + + + + + + + + +
+
+

All files / src/services supabaseContractService.ts

+
+ +
+ 0% + Statements + 0/76 +
+ + +
+ 0% + Branches + 0/30 +
+ + +
+ 0% + Functions + 0/9 +
+ + +
+ 0% + Lines + 0/67 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { SupabaseClient } from '@supabase/supabase-js';
+import Docxtemplater from 'docxtemplater';
+import { MulterFile as File } from 'multer';
+import PizZip from 'pizzip';
+import { v4 as uuidv4 } from 'uuid';
+import { NotFoundError } from '../domains/errors';
+import { Contract } from '../entities/Contract';
+import { Template } from '../entities/Template';
+import convertToPdf from '../utils/convertToPdf';
+import { ContractService } from '././interface/contractService';
+ 
+export class SupabaseContractService implements ContractService {
+  private supabaseClient: SupabaseClient;
+ 
+  constructor(supabaseClient: SupabaseClient) {
+    this.supabaseClient = supabaseClient;
+  }
+ 
+  async createContract(
+    templateId: string,
+    clientId: string,
+    fields: Record<string, string>,
+    note?: string,
+    fee?: string,
+    deposit?: string,
+    generatedBy?: string
+  ): Promise<Contract> {
+ 
+    const { data: templateUrl, error: urlError } = await this.supabaseClient
+      .from('contract_templates')
+      .select('storage_path')
+      .eq('id', templateId)
+      .single();
+ 
+    Iif (!templateUrl || urlError) {
+      throw new Error('Failed to retrieve template metadata');
+    }
+ 
+    console.log(templateUrl);
+ 
+    const { data: template, error } = await this.supabaseClient
+      .storage
+      .from('contract-templates')
+      .download(templateUrl.storage_path);
+ 
+    console.log('template is : ', template);
+ 
+    Iif (!template || error) {
+      throw new Error('Template download failed');
+    }
+ 
+    // generateTemplate expects a node.js Buffer
+    const arrayBuffer = await template.arrayBuffer();
+    const nodeBuffer = Buffer.from(arrayBuffer);
+    const pdf = await this.generateTemplate(nodeBuffer, fields);
+ 
+    const contractId = uuidv4();
+    const filePath = `contracts/client_${clientId}/contract_${contractId}.pdf`;
+ 
+    const upload = await this.supabaseClient.storage
+      .from('contracts')
+      .upload(filePath, pdf, { contentType: 'application/pdf' });
+ 
+    Iif (upload.error) throw new Error('Contract upload failed: ' + upload.error.message);
+ 
+ 
+    const { data, error: insertError } = await this.supabaseClient
+      .from('contracts')
+      .insert([{
+        id: contractId,
+        template_id: templateId,
+        template_name: fields.templateName || 'Untitled',
+        client_id: clientId,
+        note,
+        fee,
+        deposit,
+        status: 'created',
+        document_url: filePath,
+        generated_by: generatedBy,
+      }])
+      .select()
+      .single();
+ 
+    Iif (insertError) throw new Error('Failed to insert contract: ' + insertError.message);
+ 
+    return data as Contract;
+  }
+ 
+  async fetchContractPDF(contractId: string): Promise<{ buffer: Buffer; filename: string }> {
+ 
+    const { data, error } = await this.supabaseClient
+      .from('contracts')
+      .select('*')
+      .eq('id', contractId)
+      .single();
+ 
+    Iif (error || !data) throw new Error('Contract not found');
+ 
+    const { data: file, error: downloadError } = await this.supabaseClient
+      .storage
+      .from('contracts')
+      .download(data.document_url);
+ 
+    Iif (downloadError || !file) throw new Error('Failed to fetch PDF');
+ 
+    const buffer = Buffer.from(await file.arrayBuffer());
+    const filename = `contract_${contractId}.pdf`;
+ 
+    return { buffer, filename };
+  }
+  
+  async getAllTemplates(): Promise<Template[]> {
+    const { data, error } = await this.supabaseClient
+      .from('contract_templates')
+      .select('*')
+ 
+    Iif (error || !data) {
+      console.error('Error fetching templates:', error)
+      throw new Error('Could not fetch contract templates')
+    }
+ 
+    return data.map((row) => new Template(
+      row.id,
+      row.title,
+      parseFloat(row.deposit),
+      parseFloat(row.fee),
+      row.storagePath
+    ))
+  }
+ 
+  async deleteTemplate(templateName: string): Promise<boolean> {
+ 
+    const { error: tableError } = await this.supabaseClient
+      .from('contract_templates')
+      .delete()
+      .eq('title', templateName)
+      .select()
+      .single()
+ 
+    Iif (tableError) throw new Error(`Failed to delete template: ${tableError.message}`);
+ 
+    const { error: storageError } = await this.supabaseClient.storage
+      .from('contract-templates')
+      .remove([`${templateName}.docx`])
+ 
+    Iif (storageError) throw new Error(`Failed to delete template from stroage: ${storageError.message}`);
+ 
+    return true;
+  }
+ 
+  async uploadTemplate(file: File, name: string, deposit: number, fee: number): Promise<Boolean> {
+    const filePath = name.endsWith('.docx') ? name : `${name}.docx`;
+ 
+    Iif (file) {
+      const { error: uploadError } = await this.supabaseClient.storage
+        .from('contract-templates')
+        .upload(filePath, file.buffer, {
+          contentType: file.mimetype,
+          upsert: true,
+      });
+  
+      Iif (uploadError) {
+        throw new Error('failed to upload new template');
+      }
+    }
+ 
+    const { error: tableError } = await this.supabaseClient
+    .from('contract_templates')
+    .upsert([
+      {
+        title: name,
+        deposit: deposit,
+        fee: fee,
+        storage_path: filePath,
+      }
+    ]);
+ 
+    Iif (tableError) {
+      console.error('Table insert error:', tableError);
+      throw new Error('Failed to insert template metadata');
+    }
+ 
+    return true;
+  }
+ 
+  async getTemplate(templateName: string): Promise<Buffer> {
+    const filePath = templateName.endsWith('.docx') ? templateName : `${templateName}.docx`;
+ 
+    const { data } = this.supabaseClient
+      .storage
+      .from('contract-templates')
+      .getPublicUrl(filePath);
+ 
+    const publicUrl = data.publicUrl;
+    Iif (!publicUrl) throw new NotFoundError('Template public URL not generated');
+ 
+    const res = await fetch(publicUrl);
+    Iif (!res.ok) throw new NotFoundError(`Failed to fetch template: ${res.statusText}`);
+ 
+    const buffer = Buffer.from(await res.arrayBuffer());
+    return buffer;
+  }
+ 
+  async generateTemplate(buffer: Buffer, fields: Record<string, string>): Promise<Buffer> {
+ 
+    // Fill .docx with fields
+    const zip = new PizZip(buffer);
+ 
+    const doc = new Docxtemplater(zip, {
+      paragraphLoop: true,
+      linebreaks: true,
+    });
+ 
+    doc.render(fields);
+ 
+    const filled = doc.getZip().generate({
+      type: 'nodebuffer',
+      mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    });
+ 
+    const pdfBuffer = await convertToPdf(filled);
+    return pdfBuffer;
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/supabase.ts.html b/coverage/src/supabase.ts.html new file mode 100644 index 00000000..39f6639f --- /dev/null +++ b/coverage/src/supabase.ts.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for src/supabase.ts + + + + + + + + + +
+
+

All files / src supabase.ts

+
+ +
+ 0% + Statements + 0/9 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/9 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { createClient, SupabaseClient } from '@supabase/supabase-js';
+import dotenv from 'dotenv';
+ 
+dotenv.config();
+ 
+const supabaseUrl: string = process.env.SUPABASE_URL || '';
+const supabaseKey: string = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
+ 
+Iif (!supabaseUrl || !supabaseKey) {
+  throw new Error('Missing Supabase environment variables');
+}
+ 
+const supabase: SupabaseClient = createClient(supabaseUrl, supabaseKey, {
+  auth: {
+    persistSession: false,
+    autoRefreshToken: false,
+    detectSessionInUrl: false,
+  },
+});
+ 
+export default supabase;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/types.ts.html b/coverage/src/types.ts.html new file mode 100644 index 00000000..b92e1342 --- /dev/null +++ b/coverage/src/types.ts.html @@ -0,0 +1,1126 @@ + + + + + + Code coverage report for src/types.ts + + + + + + + + + +
+
+

All files / src types.ts

+
+ +
+ 0% + Statements + 0/121 +
+ + +
+ 0% + Branches + 0/26 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 0% + Lines + 0/121 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Request } from 'express';
+import type { File as MulterFile } from 'multer';
+import { User } from './entities/User';
+ 
+export enum ServiceTypes{
+  LABOR_SUPPORT = "Labor Support",
+  POSTPARTUM_SUPPORT= "Postpartum Support",
+  PERINATAL_EDUCATION= "Perinatal Education",
+  FIRST_NIGHT = "First Night Care",
+  LACTATION_SUPPORT = "Lactation Support",
+  PHOTOGRAPHY = "Photography",
+  OTHER = "Other"
+}
+ 
+export enum RequestStatus {
+  PENDING = "pending",
+  REVIEWING = "reviewing",
+  APPROVED = "approved",
+  REJECTED = "rejected",
+  COMPLETED = "completed"
+}
+ 
+export enum HomeType {
+  HOUSE = "House",
+  APARTMENT = "Apartment",
+  CONDO = "Condo",
+  TOWNHOUSE = "Townhouse",
+  OTHER = "Other"
+}
+ 
+export enum RelationshipStatus {
+  SINGLE = "Single",
+  MARRIED = "Married",
+  PARTNERED = "Partnered",
+  DIVORCED = "Divorced",
+  WIDOWED = "Widowed",
+  OTHER = "Other"
+}
+ 
+export enum ProviderType {
+  OB = "OB",
+  MIDWIFE = "Midwife",
+  FAMILY_PHYSICIAN = "Family Physician",
+  OTHER = "Other"
+}
+ 
+export enum ClientAgeRange {
+  UNDER_18 = "Under 18",
+  AGE_18_24 = "18-24",
+  AGE_25_34 = "25-34",
+  AGE_35_44 = "35-44",
+  AGE_45_54 = "45-54",
+  AGE_55_PLUS = "55+"
+}
+ 
+export enum Pronouns{
+  HE_HIM = "he/him",
+  SHE_HER = "she/her",
+  THEY_THEM = "they/them",
+  OTHER = "other",
+}
+ 
+export enum Sex{
+  MALE = "Male",
+  FEMALE = "Female"
+}
+ 
+export enum IncomeLevel{
+  FROM_0_TO_24999 = "$0 - $24,999",
+  FROM_25000_TO_44999 = "$25,000 - $44,999",
+  FROM_45000_TO_64999 = "$45,000 - $64,999",
+  FROM_65000_TO_84999 = "$65,000 - $84,999",
+  FROM_85000_TO_99999 = "$85,000 - $99,999",
+  ABOVE_100000 = "$100,000 and above"
+}
+ 
+export interface AuthRequest extends Request {
+  user?: User;
+}
+ 
+export interface UpdateRequest extends Request {
+  user?: User;
+  file?: MulterFile;
+}
+ 
+export interface UserData {
+  id?: string;
+  email?: string;
+  firstname?: string;
+  lastname?: string;
+  created_at?: Date;
+  updated_at?: Date;
+  role?: ROLE;
+  address?: string;
+  city?: string;
+  state?: STATE;
+  country?: string;
+  zip_code?: number;
+  profile_picture?: File;  
+  account_status?: ACCOUNT_STATUS;
+  business?: string;
+  bio?: string;  
+}
+ 
+export interface SignupBody {
+  email: string;
+  password: string;
+  firstname?: string;
+  lastname?: string;
+}
+ 
+export interface LoginBody {
+  email: string;
+  password: string;
+}
+ 
+export interface TokenBody {
+  access_token: string;
+}
+ 
+export interface PasswordResetBody {
+  email: string;
+}
+ 
+export interface UpdatePasswordBody {
+  password: string;
+}
+ 
+export interface RequestFormData {
+  // Step 1: Client Details
+  firstname: string;
+  lastname: string;
+  email: string;
+  phone_number: string;
+  pronouns?: Pronouns;
+  pronouns_other?: string;
+  children_expected?: string;
+  
+  // Step 2: Home Details
+  address: string;
+  city: string;
+  state: STATE;
+  zip_code: string;
+  home_phone?: string;
+  home_type?: HomeType;
+  home_access?: string;
+  pets?: string;
+  
+  // Step 3: Family Members
+  relationship_status?: RelationshipStatus;
+  first_name?: string;
+  last_name?: string;
+  middle_name?: string;
+  mobile_phone?: string;
+  work_phone?: string;
+  
+  // Step 4: Referral
+  referral_source?: string;
+  referral_name?: string;
+  referral_email?: string;
+  
+  // Step 5: Health History
+  health_history?: string;
+  allergies?: string;
+  health_notes?: string;
+  
+  // Step 6: Payment Info
+  annual_income?: IncomeLevel;
+  service_needed: ServiceTypes;
+  service_specifics?: string;
+  
+  // Step 7: Pregnancy/Baby
+  due_date?: Date;
+  birth_location?: string;
+  birth_hospital?: string;
+  number_of_babies?: number;
+  baby_name?: string;
+  provider_type?: ProviderType;
+  pregnancy_number?: number;
+  hospital?: string;
+  baby_sex?: string;
+  
+  // Step 8: Past Pregnancies
+  had_previous_pregnancies?: boolean;
+  previous_pregnancies_count?: number;
+  living_children_count?: number;
+  past_pregnancy_experience?: string;
+  
+  // Step 9: Services Interested
+  services_interested?: string[];
+  service_support_details?: string;
+  
+  // Step 10: Client Demographics (Optional)
+  race_ethnicity?: string;
+  primary_language?: string;
+  client_age_range?: ClientAgeRange;
+  insurance?: string;
+  demographics_multi?: string[];
+}
+ 
+export interface RequestFormResponse {
+  id: string;
+  status: RequestStatus;
+  requested?: string;
+  created_at: string;
+  updated_at: string;
+  user_id: string;
+  // Include all RequestFormData fields
+  firstname: string;
+  lastname: string;
+  email: string;
+  phone_number: string;
+  pronouns?: Pronouns;
+  pronouns_other?: string;
+  children_expected?: string;
+  address: string;
+  city: string;
+  state: STATE;
+  zip_code: string;
+  home_phone?: string;
+  home_type?: HomeType;
+  home_access?: string;
+  pets?: string;
+  relationship_status?: RelationshipStatus;
+  first_name?: string;
+  last_name?: string;
+  middle_name?: string;
+  mobile_phone?: string;
+  work_phone?: string;
+  referral_source?: string;
+  referral_name?: string;
+  referral_email?: string;
+  health_history?: string;
+  allergies?: string;
+  health_notes?: string;
+  annual_income?: IncomeLevel;
+  service_needed: ServiceTypes;
+  service_specifics?: string;
+  due_date?: string;
+  birth_location?: string;
+  birth_hospital?: string;
+  number_of_babies?: number;
+  baby_name?: string;
+  provider_type?: ProviderType;
+  pregnancy_number?: number;
+  hospital?: string;
+  baby_sex?: string;
+  had_previous_pregnancies?: boolean;
+  previous_pregnancies_count?: number;
+  living_children_count?: number;
+  past_pregnancy_experience?: string;
+  services_interested?: string[];
+  service_support_details?: string;
+  race_ethnicity?: string;
+  primary_language?: string;
+  client_age_range?: ClientAgeRange;
+  insurance?: string;
+  demographics_multi?: string[];
+}
+ 
+export interface DatabaseError {
+  code?: string;
+  message: string;
+  details?: string;
+  hint?: string;
+}
+ 
+export interface SupabaseUserMetadata {
+  given_name?: string;
+  family_name?: string;
+  name?: string;
+  [key: string]: unknown;
+}
+ 
+export enum CLIENT_STATUS {
+  LEAD = 'lead',
+  CONTACTED = 'contacted',
+  MATCHING = 'matching',
+  INTERVIEWING = 'interviewing',
+  'FOLLOW UP' = 'follow up',
+  CONTRACT = 'contract',
+  ACTIVE = 'active',
+  COMPLETE = 'complete',
+};
+ 
+export enum ACCOUNT_STATUS {
+  PENDING = "pending",
+  APPROVED = "approved"
+};
+ 
+export enum ROLE {
+  ADMIN = "admin",
+  DOULA = "doula",
+  CLIENT = "client"
+};
+ 
+export enum STATE {
+  AL = "AL",
+  AK = "AK",
+  AZ = "AZ",
+  AR = "AR",
+  CA = "CA",
+  CO = "CO",
+  CT = "CT",
+  DE = "DE",
+  FL = "FL",
+  GA = "GA",
+  HI = "HI",
+  ID = "ID",
+  IL = "IL",
+  IN = "IN",
+  IA = "IA",
+  KS = "KS",
+  KY = "KY",
+  LA = "LA",
+  ME = "ME",
+  MD = "MD",
+  MA = "MA",
+  MI = "MI",
+  MN = "MN",
+  MS = "MS",
+  MO = "MO",
+  MT = "MT",
+  NE = "NE",
+  NV = "NV",
+  NH = "NH",
+  NJ = "NJ",
+  NM = "NM",
+  NY = "NY",
+  NC = "NC",
+  ND = "ND",
+  OH = "OH",
+  OK = "OK",
+  OR = "OR",
+  PA = "PA",
+  RI = "RI",
+  SC = "SC",
+  SD = "SD",
+  TN = "TN",
+  TX = "TX",
+  UT = "UT",
+  VT = "VT",
+  VA = "VA",
+  WA = "WA",
+  WV = "WV",
+  WI = "WI",
+  WY = "WY"  
+};
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/usecase/authUseCase.ts.html b/coverage/src/usecase/authUseCase.ts.html new file mode 100644 index 00000000..1ae8f3e1 --- /dev/null +++ b/coverage/src/usecase/authUseCase.ts.html @@ -0,0 +1,1135 @@ + + + + + + Code coverage report for src/usecase/authUseCase.ts + + + + + + + + + +
+
+

All files / src/usecase authUseCase.ts

+
+ +
+ 0% + Statements + 0/90 +
+ + +
+ 0% + Branches + 0/32 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 0% + Lines + 0/90 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { AuthService } from '../services/interface/authService';
+ 
+import {
+  AuthenticationError,
+  AuthorizationError,
+  NotFoundError,
+  ValidationError
+} from '../domains/errors';
+import { User } from '../entities/User';
+import { UserRepository } from '../repositories/interface/userRepository';
+ 
+ 
+export class AuthUseCase {
+  private authService: AuthService;
+  private userRepository: UserRepository;
+ 
+  constructor(authService: AuthService, userRepository: UserRepository) {
+    this.authService = authService;
+    this.userRepository = userRepository;
+  }
+ 
+  //
+  // Sign up the user if they are already in the users table
+  //
+  // returns:
+  //    user
+  //
+  async signup(
+    email: string, 
+    password: string, 
+    firstname: string, 
+    lastname: string
+  ): Promise<User> {
+      
+    Iif (!email || !password) {
+      throw new ValidationError("Email and password are required");
+    }
+ 
+    Iif (password.length < 8) {
+      throw new ValidationError("Password must be at least 8 characters long");
+    }
+ 
+    // Check that the user is pre-approved by an admin
+    const existingUser = await this.userRepository.findByEmail(email);
+    Iif (!existingUser) {
+      throw new AuthorizationError("You are not authorized to sign up. Please email the office if the issue persists.");
+    }
+    Iif (existingUser.account_status !== 'pending') {
+      throw new AuthorizationError("This account already exists");
+    }
+ 
+    // Continue with signup
+    return await this.authService.signup(
+      email,
+      password,
+      firstname,
+      lastname
+    );
+  }
+ 
+  //
+  // login if valid credentials
+  //
+  // returns:
+  //    user
+  //
+  async login(
+    email: string,
+    password: string
+  ): Promise<{user: any, token: any}> {
+    
+    Iif (!email || !password) {
+      throw new ValidationError("Email and password are required");
+    }
+ 
+    try {
+      const existingUser = await this.userRepository.findByEmail(email);
+      Iif (!existingUser) {
+        throw new AuthorizationError("Invalid credentials. Please try again or contact the office.");
+      }
+      // let auth service return the user who just logged in alongside the session token
+      const { user, token } = await this.authService.login(
+        email,
+        password
+      );
+ 
+      return { user, token };
+    } catch (error) {
+      throw new AuthenticationError(error.message);
+    }
+  }
+ 
+  //
+  // forward to authService the token to retrieve user
+  //
+  // returns:
+  //    user
+  //
+  async getMe(
+      token: string
+  ): Promise<User> {
+    
+    Iif (!token) {
+      throw new AuthenticationError("Not authenticated");
+    }
+ 
+    try {
+      // let auth service return the user we requested
+      const user = await this.authService.getMe(token);
+ 
+      return user;
+    } catch (error) {
+      throw new AuthenticationError(error.message);
+    }
+  }
+ 
+  //
+  // signs out current user from the auth service
+  //
+  // returns:
+  //    none
+  //
+  async logout(): Promise<void> {
+    await this.authService.logout();
+  }
+ 
+  //
+  // redirect user to our custom verification page with a valid supabase otp
+  //
+  // returns:
+  //    user
+  //
+  async verifyEmail(
+    token_hash: string,
+    type: string
+  ): Promise<string> {
+ 
+    Iif (!token_hash || type != 'signup') {
+      throw new ValidationError("invalid_verification");
+    }
+ 
+    try {
+      const session = await this.authService.verifyEmail(token_hash, type);
+      const queryParams = new URLSearchParams({
+        access_token: session.access_token,
+        refresh_token: session.refresh_token,
+        expires_in: session.expires_in.toString(),
+        type: 'signup',
+      }).toString();
+ 
+      return queryParams;
+    } catch (error) {
+      throw new AuthenticationError(error.message);
+    }
+  }
+ 
+  //
+  // get all users
+  //
+  // returns:
+  //    user
+  //
+  async getAllUsers() {
+    try {
+      const users = await this.userRepository.findAll();
+      return users;
+    } catch (error) {
+      throw new AuthenticationError(`Error fetching users: ${error.message}`);
+    }
+  }
+ 
+  //
+  // redirect to google auth service
+  //
+  // returns:
+  //    user
+  //
+  async googleAuth(
+    redirectTo: string
+  ): Promise<string> {
+    try {
+      const url = await this.authService.getGoogleAuthUrl(redirectTo);
+      return url;
+    } catch (error) {
+      throw new AuthenticationError(`Failed to initialize Google auth: ${error.message}`);
+    }
+  }
+ 
+  //
+  // handle response from google oauth
+  //
+  // returns:
+  //    user
+  //
+  async handleOAuthCallback(
+    code: string,
+  ): Promise<{session: any, user: User}> {
+    
+    Iif (!code) {
+      throw new ValidationError('No code provided');
+    }
+ 
+    try {
+      // Exchange code for session
+      const { session, userData } = await this.authService.exchangeCodeForSession(code);
+      
+      // Check if user exists
+      let user = await this.userRepository.findByEmail(userData.email);
+      
+      // User should already exist in users table
+      Iif (!user) {
+        throw new AuthorizationError('You are not authorized to sign in. Please email the office if the issue persists.');
+      }
+      
+      return { session, user };
+    } catch (error) {
+      throw new AuthenticationError(`${error.message}`);
+    }
+  }
+ 
+  //
+  // forward to authService to authenticate and return our user
+  //
+  // returns:
+  //    user
+  //
+  async handleToken(
+    accessToken: string
+  ): Promise<User> {
+ 
+    Iif (!accessToken) {
+      throw new ValidationError('No access token provided');
+    }
+ 
+    try {
+      // Get user data from token
+      let user = await this.authService.getUserFromToken(accessToken);
+      
+      // Create user if doesn't exist
+      Iif (!user) {
+        const newUser = new User({
+          email: user.email,
+          firstname: user.user_metadata?.given_name || 
+                    user.user_metadata?.name?.split(' ')[0] || 
+                    null,
+          lastname: user.user_metadata?.family_name || 
+                   user.user_metadata?.name?.split(' ')[1] || 
+                   null,
+        });
+        
+        user = await this.userRepository.save(newUser);
+      }
+      
+      return user;
+    } catch (error) {
+      throw new AuthenticationError(`Token handling error: ${error.message}`);
+    }
+  }
+ 
+  //
+  // forward to authService to authenticate and return our user
+  //
+  // returns:
+  //    user
+  //
+  async requestPasswordReset(
+    email: string,
+    redirectTo: string
+  ): Promise<void> {
+ 
+    Iif (!email) {
+      throw new ValidationError('Email is required');
+    }
+ 
+    try {
+      await this.authService.requestPasswordReset(email, redirectTo);
+    } catch (error) {
+      throw new AuthenticationError(`Failed to process password reset request: ${error.message}`);
+    }
+  }
+ 
+  //
+  // forward to authService to authenticate and return our user
+  //
+  // returns:
+  //    user
+  //
+  async handlePasswordRecovery(
+    tokenHash: string,
+    type: string
+  ): Promise<string> {
+ 
+    Iif (!tokenHash || type !== 'recovery') {
+      throw new ValidationError('Invalid password recovery link');
+    }
+ 
+    try {
+      const session = await this.authService.verifyRecoveryToken(tokenHash);
+      
+      const queryParams = new URLSearchParams({
+        access_token: session.access_token,
+        refresh_token: session.refresh_token,
+        type: 'recovery',
+      }).toString();
+      
+      return queryParams;
+    } catch (error) {
+      throw new AuthenticationError(`Failed to process password recovery: ${error.message}`);
+    }
+  }
+ 
+  //
+  // forward to authService to authenticate and return our user
+  //
+  // returns:
+  //    user
+  //
+  async updatePassword (
+    password: string,
+    token: string
+  ): Promise<User> {
+    Iif (!password) {
+      throw new ValidationError('New password is required');
+    }
+ 
+    Iif (!token) {
+      throw new ValidationError('Authorization token is required');
+    }
+ 
+    try {
+      // Validate session
+      await this.authService.setSession(token);
+      
+      // Update password
+      const userData = await this.authService.updateUserPassword(password);
+      
+      // Get domain user
+      const user = await this.userRepository.findByEmail(userData.email);
+      Iif (!user) {
+        throw new NotFoundError('User not found');
+      }
+      
+      return user;
+    } catch (error) {
+      Iif (error instanceof NotFoundError) {
+        throw error;
+      }
+      throw new AuthenticationError(`Failed to update password: ${error.message}`);
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/usecase/clientUseCase.ts.html b/coverage/src/usecase/clientUseCase.ts.html new file mode 100644 index 00000000..ed051e2c --- /dev/null +++ b/coverage/src/usecase/clientUseCase.ts.html @@ -0,0 +1,358 @@ + + + + + + Code coverage report for src/usecase/clientUseCase.ts + + + + + + + + + +
+
+

All files / src/usecase clientUseCase.ts

+
+ +
+ 0% + Statements + 0/25 +
+ + +
+ 0% + Branches + 0/8 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/25 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Client } from '../entities/Client';
+import { ClientRepository } from '../repositories/interface/clientRepository';
+ 
+export class ClientUseCase {
+  private clientRepository: ClientRepository;
+ 
+  constructor (clientRepository: ClientRepository) {
+    this.clientRepository = clientRepository;
+  }
+ 
+  // Summary of clients for use in brief list of clients
+  async getClientsLite(id: string, role: string): Promise<Client[]> {
+    if (role === 'admin') {
+      return this.clientRepository.findClientsLiteAll();
+    } else {
+      // console.log("calling findClientsLiteByDoula in clientUseCase ");
+      return this.clientRepository.findClientsLiteByDoula(id);
+    }
+  }
+ 
+  // Detailed view of clients for profile
+  async getClientsDetailed(id: string, role: string): Promise<Client[]> {
+    if (role === 'admin') {
+      return this.clientRepository.findClientsDetailedAll();
+    } else {
+      return this.clientRepository.findClientsDetailedByDoula(id);
+    }
+  }
+ 
+ 
+    //
+  // // forward to repository to Fetch csv client data
+  // //
+  // // returns:
+  // //    CSV data of Client
+  // //
+  async exportCSV(role:string): Promise<string|null> {
+    try {
+      Iif (role == "admin"|| role == "client"){
+        const csvData = await this.clientRepository.exportCSV()
+        Iif (!csvData) {
+          throw new Error("No data available for CSV export");
+        }
+        return csvData;
+      }
+    } catch (error) {
+      throw new Error(`Failed to retrive CSV data ${error.message}`)
+    }
+  }
+ 
+  async getClientLite(clientId: string): Promise<Client> {
+    return this.clientRepository.findClientLiteById(clientId);
+  }
+ 
+  async getClientDetailed(clientId: string): Promise<Client> {
+    return this.clientRepository.findClientDetailedById(clientId);
+  }
+ 
+  // updates a client's status
+  async updateClientStatus(
+    clientId: string,
+    status: string
+  ): Promise<Client> {
+ 
+    try {
+      // Update the client status directly
+      const client = await this.clientRepository.updateStatus(clientId, status);
+ 
+      return client;
+    }
+    catch (error) {
+      throw new Error(`Could not update client: ${error.message}`);
+    }
+  }
+ 
+  // updates client profile fields
+  async updateClientProfile(
+    clientId: string,
+    fieldsToUpdate: Partial<Client>
+  ): Promise<Client> {
+ 
+    try {
+      // Update the client directly
+      const client = await this.clientRepository.updateClient(clientId, fieldsToUpdate);
+ 
+      return client;
+    }
+    catch (error) {
+      throw new Error(`Could not update client profile: ${error.message}`);
+    }
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/usecase/contractUseCase.ts.html b/coverage/src/usecase/contractUseCase.ts.html new file mode 100644 index 00000000..b3f8c603 --- /dev/null +++ b/coverage/src/usecase/contractUseCase.ts.html @@ -0,0 +1,244 @@ + + + + + + Code coverage report for src/usecase/contractUseCase.ts + + + + + + + + + +
+
+

All files / src/usecase contractUseCase.ts

+
+ +
+ 0% + Statements + 0/10 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/8 +
+ + +
+ 0% + Lines + 0/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { MulterFile as File } from 'multer';
+import { Contract } from '../entities/Contract';
+import { Template } from '../entities/Template';
+import { ContractService } from '../services/interface/contractService';
+ 
+export class ContractUseCase {
+  constructor(private readonly contractService: ContractService) {}
+ 
+  async createContract(params: {
+    templateId: string;
+    clientId: string;
+    fields: Record<string, string>;
+    note?: string;
+    fee?: string;
+    deposit?: string;
+    generatedBy: string;
+  }): Promise<Contract> {
+    return await this.contractService.createContract(
+      params.templateId,
+      params.clientId,
+      params.fields,
+      params.note,
+      params.fee,
+      params.deposit,
+      params.generatedBy
+    );
+  }
+ 
+  async fetchContractPDF(contractId: string): Promise<{ buffer: Buffer; filename: string }> {
+    return await this.contractService.fetchContractPDF(contractId);
+  }
+ 
+  async getAllTemplates(): Promise<Template[]> {
+    return await this.contractService.getAllTemplates()
+  }
+ 
+  async deleteTemplate(templateName: string): Promise<boolean> {
+    return await this.contractService.deleteTemplate(templateName);
+  }
+ 
+  async updateTemplate(templateName: string, deposit: number, fee: number, template: File) {
+    return await this.contractService.uploadTemplate(template, templateName, deposit, fee);
+  }
+ 
+  async uploadTemplate(template: File, name: string, deposit: number, fee: number): Promise<Boolean> {
+    return await this.contractService.uploadTemplate(template, name, deposit, fee);
+  }
+ 
+  async generateTemplate(templateName: string, fields: Record<string, string>): Promise<Buffer> {
+    // grab the template from supabase
+    const buffer = await this.contractService.getTemplate(templateName);
+    return await this.contractService.generateTemplate(buffer, fields);
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/usecase/index.html b/coverage/src/usecase/index.html new file mode 100644 index 00000000..8b7ec149 --- /dev/null +++ b/coverage/src/usecase/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/usecase + + + + + + + + + +
+
+

All files src/usecase

+
+ +
+ 0% + Statements + 0/155 +
+ + +
+ 0% + Branches + 0/47 +
+ + +
+ 0% + Functions + 0/41 +
+ + +
+ 0% + Lines + 0/155 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
authUseCase.ts +
+
0%0/900%0/320%0/130%0/90
clientUseCase.ts +
+
0%0/250%0/80%0/80%0/25
contractUseCase.ts +
+
0%0/10100%0/00%0/80%0/10
userUseCase.ts +
+
0%0/300%0/70%0/120%0/30
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/usecase/userUseCase.ts.html b/coverage/src/usecase/userUseCase.ts.html new file mode 100644 index 00000000..45f4d260 --- /dev/null +++ b/coverage/src/usecase/userUseCase.ts.html @@ -0,0 +1,352 @@ + + + + + + Code coverage report for src/usecase/userUseCase.ts + + + + + + + + + +
+
+

All files / src/usecase userUseCase.ts

+
+ +
+ 0% + Statements + 0/30 +
+ + +
+ 0% + Branches + 0/7 +
+ + +
+ 0% + Functions + 0/12 +
+ + +
+ 0% + Lines + 0/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { File as MulterFile } from 'multer';
+import { NotFoundError } from '../domains/errors';
+import { WORK_ENTRY } from '../entities/Hours';
+import { User } from '../entities/User';
+import { UserRepository } from '../repositories/interface/userRepository';
+ 
+export class UserUseCase {
+  private userRepository: UserRepository;
+ 
+  constructor(userRepository: UserRepository) {
+    this.userRepository = userRepository;
+  }
+ 
+  async getUserById(targetUserId: string): Promise<User> {
+    const user = await this.userRepository.findById(targetUserId);
+ 
+    Iif(!user) {
+      throw new NotFoundError("User not found");
+    }
+ 
+    return user;
+  }
+  
+  async getHoursById(targetUserId: string): Promise<WORK_ENTRY[]> {
+    const hours = await this.userRepository.getHoursById(targetUserId);
+    
+    Iif(!hours) {
+      throw new NotFoundError("Could not get hours based on Id");
+    }
+ 
+    return hours;
+  }
+ 
+  async getAllHours(): Promise<WORK_ENTRY[]> {
+    const hours = await this.userRepository.getAllHours();
+ 
+    Iif(!hours) {
+      throw new NotFoundError("Could not retrieve all work entries");
+    }
+ 
+    return hours;
+  }
+ 
+  async addNewHours(doula_id: string, client_id: string, start_time: Date, end_time: Date, note: string) {
+    const newWorkEntry = await this.userRepository.addNewHours(doula_id, client_id, start_time, end_time, note);
+ 
+    return newWorkEntry;
+  }
+ 
+  async uploadProfilePicture(user: User, profilePicture: MulterFile) {
+    const signedUrl = await this.userRepository.uploadProfilePicture(user, profilePicture);
+    return signedUrl;
+  }
+ 
+  async updateUser(user: User, updateData: Partial<User>) {
+ 
+    const fieldsToUpdate = Object.entries(updateData).reduce((acc, [key, value]) => {
+      Iif (value !== '' && user[key] !== value) {
+        acc[key] = value;
+      }
+      return acc;
+    }, {} as Partial<User>);
+ 
+    Iif (Object.keys(fieldsToUpdate).length === 0) {
+      return user; // Nothing to update
+    }
+ 
+    return this.userRepository.update(user.id, fieldsToUpdate);
+  }
+ 
+  async getAllUsers(): Promise<User[]> {
+    return this.userRepository.findAll();
+  }
+ 
+  async getAllTeamMembers(): Promise<User[]> {
+    return this.userRepository.findAllTeamMembers();
+  }
+ 
+  async deleteMember(userId: string): Promise<void> {
+    return this.userRepository.delete(userId);
+  }
+ 
+  async addMember(firstname: string, lastname: string, userEmail: string, userRole: string): Promise<User> {
+    return this.userRepository.addMember(firstname, lastname, userEmail, userRole);
+  }
+ 
+ 
+}
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/convertToPdf.ts.html b/coverage/src/utils/convertToPdf.ts.html new file mode 100644 index 00000000..f49e63b8 --- /dev/null +++ b/coverage/src/utils/convertToPdf.ts.html @@ -0,0 +1,238 @@ + + + + + + Code coverage report for src/utils/convertToPdf.ts + + + + + + + + + +
+
+

All files / src/utils convertToPdf.ts

+
+ +
+ 0% + Statements + 0/22 +
+ + +
+ 0% + Branches + 0/6 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 0% + Lines + 0/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import CloudConvert from 'cloudconvert';
+ 
+export default async function convertToPdf(docxBuffer: Buffer): Promise<Buffer> {
+  const cloudConvert = new CloudConvert(process.env.CLOUDCONVERT_API_KEY);
+  try {
+    const job = await cloudConvert.jobs.create({
+      tasks: {
+        upload: {
+          operation: 'import/upload',
+        },
+        convert: {
+          operation: 'convert',
+          input: 'upload',
+          input_format: 'docx',
+          output_format: 'pdf',
+          engine: 'libreoffice',
+        },
+        export: {
+          operation: 'export/url',
+          input: 'convert',
+        },
+      },
+    });
+    
+      const uploadTask = job.tasks.find((t: any) => t.name === 'upload');
+      Iif (!uploadTask) throw new Error('Upload task not found');
+    
+      await cloudConvert.tasks.upload(uploadTask, docxBuffer, 'contract.docx', docxBuffer.length);
+    
+      const completedJob = await cloudConvert.jobs.wait(job.id);
+    
+      const exportTask = completedJob.tasks.find(
+        (t: any) => t.name === 'export' && t.status === 'finished'
+      );
+    
+      Iif (!exportTask?.result?.files?.[0]?.url) {
+        throw new Error('Export task failed or URL missing');
+      }
+    
+      const pdfUrl = exportTask.result.files[0].url;
+      const pdfRes = await fetch(pdfUrl);
+      const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());
+    
+      return pdfBuffer;
+  }
+  catch (err) {
+    console.error('Job creation error:', err.message);
+    console.error(err.response?.data || err);
+    throw err;
+  }
+  
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/generateInvoicePdf.ts.html b/coverage/src/utils/generateInvoicePdf.ts.html new file mode 100644 index 00000000..b2dc8da3 --- /dev/null +++ b/coverage/src/utils/generateInvoicePdf.ts.html @@ -0,0 +1,448 @@ + + + + + + Code coverage report for src/utils/generateInvoicePdf.ts + + + + + + + + + +
+
+

All files / src/utils generateInvoicePdf.ts

+
+ +
+ 0% + Statements + 0/40 +
+ + +
+ 0% + Branches + 0/3 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 0% + Lines + 0/40 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import PDFDocument from 'pdfkit';
+ 
+export interface InvoiceData {
+  invoiceNumber: string;
+  customerName: string;
+  customerEmail: string;
+  customerAddress?: string;
+  lineItems: Array<{
+    description: string;
+    quantity: number;
+    rate: number;
+    amount: number;
+  }>;
+  subtotal: number;
+  tax?: number;
+  total: number;
+  dueDate: string;
+  issueDate: string;
+  memo?: string;
+}
+ 
+export function generateInvoicePDF(invoiceData: InvoiceData): Promise<Buffer> {
+  return new Promise((resolve, reject) => {
+    try {
+      const doc = new PDFDocument({ margin: 50 });
+      const buffers: Buffer[] = [];
+ 
+      doc.on('data', buffers.push.bind(buffers));
+      doc.on('end', () => {
+        const pdfBuffer = Buffer.concat(buffers);
+        resolve(pdfBuffer);
+      });
+ 
+      // Company Header
+      doc.fontSize(20)
+         .text('Sokana CRM', 50, 50);
+      
+      doc.fontSize(10)
+         .text('Professional Services', 50, 75)
+         .text('Contact: info@sokanacrm.org', 50, 90);
+ 
+      // Invoice Title
+      doc.fontSize(24)
+         .text('INVOICE', 400, 50);
+ 
+      // Invoice Details
+      doc.fontSize(12)
+         .text(`Invoice #: ${invoiceData.invoiceNumber}`, 400, 80)
+         .text(`Issue Date: ${invoiceData.issueDate}`, 400, 100)
+         .text(`Due Date: ${invoiceData.dueDate}`, 400, 120);
+ 
+      // Customer Information
+      doc.fontSize(14)
+         .text('Bill To:', 50, 150);
+      
+      doc.fontSize(12)
+         .text(invoiceData.customerName, 50, 170)
+         .text(invoiceData.customerEmail, 50, 185);
+      
+      Iif (invoiceData.customerAddress) {
+        doc.text(invoiceData.customerAddress, 50, 200);
+      }
+ 
+      // Line Items Table
+      const tableTop = 250;
+      doc.fontSize(12);
+ 
+      // Table Headers
+      doc.text('Description', 50, tableTop)
+         .text('Qty', 300, tableTop)
+         .text('Rate', 350, tableTop)
+         .text('Amount', 450, tableTop);
+ 
+      // Table line
+      doc.moveTo(50, tableTop + 15)
+         .lineTo(550, tableTop + 15)
+         .stroke();
+ 
+      // Line Items
+      let yPosition = tableTop + 30;
+      invoiceData.lineItems.forEach((item) => {
+        doc.text(item.description, 50, yPosition)
+           .text(item.quantity.toString(), 300, yPosition)
+           .text(`$${item.rate.toFixed(2)}`, 350, yPosition)
+           .text(`$${item.amount.toFixed(2)}`, 450, yPosition);
+        yPosition += 20;
+      });
+ 
+      // Totals
+      const totalsX = 400;
+      yPosition += 20;
+      
+      doc.text(`Subtotal: $${invoiceData.subtotal.toFixed(2)}`, totalsX, yPosition);
+      
+      Iif (invoiceData.tax) {
+        yPosition += 20;
+        doc.text(`Tax: $${invoiceData.tax.toFixed(2)}`, totalsX, yPosition);
+      }
+      
+      yPosition += 20;
+      doc.fontSize(14)
+         .text(`Total: $${invoiceData.total.toFixed(2)}`, totalsX, yPosition);
+ 
+      // Memo
+      Iif (invoiceData.memo) {
+        yPosition += 50;
+        doc.fontSize(12)
+           .text('Notes:', 50, yPosition)
+           .text(invoiceData.memo, 50, yPosition + 15);
+      }
+ 
+      // Footer
+      doc.fontSize(10)
+         .text('Thank you for your business!', 50, doc.page.height - 100)
+         .text('Please remit payment by the due date.', 50, doc.page.height - 85);
+ 
+      doc.end();
+    } catch (error) {
+      reject(error);
+    }
+  });
+} 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/index.html b/coverage/src/utils/index.html new file mode 100644 index 00000000..e6353a3d --- /dev/null +++ b/coverage/src/utils/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/utils + + + + + + + + + +
+
+

All files src/utils

+
+ +
+ 0% + Statements + 0/162 +
+ + +
+ 0% + Branches + 0/30 +
+ + +
+ 0% + Functions + 0/16 +
+ + +
+ 0% + Lines + 0/158 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
convertToPdf.ts +
+
0%0/220%0/60%0/30%0/20
generateInvoicePdf.ts +
+
0%0/400%0/30%0/40%0/40
qboClient.ts +
+
0%0/260%0/110%0/40%0/24
tokenUtils.ts +
+
0%0/740%0/100%0/50%0/74
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/qboClient.ts.html b/coverage/src/utils/qboClient.ts.html new file mode 100644 index 00000000..03e52034 --- /dev/null +++ b/coverage/src/utils/qboClient.ts.html @@ -0,0 +1,352 @@ + + + + + + Code coverage report for src/utils/qboClient.ts + + + + + + + + + +
+
+

All files / src/utils qboClient.ts

+
+ +
+ 0% + Statements + 0/26 +
+ + +
+ 0% + Branches + 0/11 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 0% + Lines + 0/24 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/utils/qboClient.ts
+ 
+import dotenv from 'dotenv';
+dotenv.config();
+ 
+import { RequestInit } from 'node-fetch';
+import { getTokenFromDatabase, refreshQuickBooksToken } from './tokenUtils';
+ 
+const {
+  QB_CLIENT_ID = '',
+  QB_CLIENT_SECRET = '',
+  QBO_ENV = 'production'
+} = process.env;
+ 
+interface AccessTokenResult {
+  accessToken: string;
+  realmId: string;
+}
+ 
+/**
+ * Retrieve (and refresh, if needed) the current OAuth tokens & realm ID.
+ */
+export async function getAccessToken(): Promise<AccessTokenResult> {
+  const tokens = await getTokenFromDatabase();
+  Iif (!tokens) {
+    throw new Error('No QuickBooks tokens found');
+  }
+ 
+  // Check if token is expired or will expire in the next minute
+  Iif (new Date(tokens.expiresAt) <= new Date(Date.now() + 60000)) {
+    const newTokens = await refreshQuickBooksToken();
+    return {
+      accessToken: newTokens.accessToken,
+      realmId: newTokens.realmId
+    };
+  }
+ 
+  return {
+    accessToken: tokens.accessToken,
+    realmId: tokens.realmId
+  };
+}
+ 
+/**
+ * Make a QuickBooks Online API request.
+ * @param path    e.g. '/customer?minorversion=65'
+ * @param options fetch options (method, body, headers, etc.)
+ */
+export async function qboRequest<T = any>(
+  path: string,
+  options: RequestInit = {}
+): Promise<T> {
+  const { accessToken, realmId } = await getAccessToken();
+ 
+  const host = QBO_ENV === 'sandbox'
+    ? 'https://sandbox-quickbooks.api.intuit.com'
+    : 'https://quickbooks.api.intuit.com';
+ 
+  const url = `${host}/v3/company/${realmId}${path}`;
+  console.log('QBO URL →', url);
+ 
+  // Use dynamic import for node-fetch
+  const fetch = (await import('node-fetch')).default;
+ 
+  const resp = await fetch(url, {
+    ...options,
+    headers: {
+      Authorization: `Bearer ${accessToken}`,
+      Accept: 'application/json',
+      'Content-Type': 'application/json',
+      ...(options.headers as Record<string, string>)
+    }
+  });
+ 
+  Iif (!resp.ok) {
+    // define the shape of a QuickBooks error
+    type QboError = {
+      Fault?: {
+        Error?: Array<{ Message: string }>;
+      };
+    };
+    // cast the parsed JSON to that type
+    const errBody = (await resp.json().catch(() => ({}))) as QboError;
+    const msg = errBody.Fault?.Error?.[0]?.Message ?? resp.statusText;
+    throw new Error(`QBO ${resp.status}: ${msg}`);
+  }
+ 
+  return resp.json() as Promise<T>;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/tokenUtils.ts.html b/coverage/src/utils/tokenUtils.ts.html new file mode 100644 index 00000000..ba05d819 --- /dev/null +++ b/coverage/src/utils/tokenUtils.ts.html @@ -0,0 +1,625 @@ + + + + + + Code coverage report for src/utils/tokenUtils.ts + + + + + + + + + +
+
+

All files / src/utils tokenUtils.ts

+
+ +
+ 0% + Statements + 0/74 +
+ + +
+ 0% + Branches + 0/10 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/74 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
// src/features/quickbooks/utils/tokenUtils.ts
+import supabase from '../supabase';
+ 
+export interface TokenStore {
+  realmId: string;
+  accessToken: string;
+  refreshToken: string;
+  expiresAt: string;
+}
+ 
+/**
+ * Load the QuickBooks OAuth tokens.
+ */
+export async function getTokenFromDatabase(): Promise<TokenStore | null> {
+  console.log('🔍 [QB] Loading tokens from database...');
+  
+  const { data, error } = await supabase
+    .from('quickbooks_tokens')
+    .select('realm_id, access_token, refresh_token, expires_at')
+    .single();
+ 
+  Iif (error) {
+    Iif (error.code === 'PGRST116') { // no rows found
+      console.log('❌ [QB] No tokens found in database');
+      return null;
+    }
+    console.error('❌ [QB] Database error loading tokens:', error.message);
+    throw new Error(`Could not load QuickBooks tokens: ${error.message}`);
+  }
+ 
+  const tokens = {
+    realmId: data.realm_id,
+    accessToken: data.access_token,
+    refreshToken: data.refresh_token,
+    expiresAt: data.expires_at,
+  };
+  
+  console.log('✅ [QB] Tokens loaded successfully');
+  console.log('📅 [QB] Token expires at:', tokens.expiresAt);
+  console.log('⏰ [QB] Current time:', new Date().toISOString());
+  console.log('🔍 [QB] Token expired?', new Date(tokens.expiresAt) <= new Date());
+  
+  return tokens;
+}
+ 
+/**
+ * Refresh QuickBooks access token using the refresh token.
+ * Returns the new access token or null if refresh fails.
+ */
+export async function refreshQuickBooksToken(): Promise<TokenStore | null> {
+  console.log('🔄 [QB] Starting token refresh...');
+  
+  const tokens = await getTokenFromDatabase();
+  Iif (!tokens) {
+    console.log('❌ [QB] No tokens to refresh');
+    return null;
+  }
+ 
+  const url = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
+  const auth = Buffer.from(`${process.env.QB_CLIENT_ID}:${process.env.QB_CLIENT_SECRET}`).toString('base64');
+  const body = new URLSearchParams({
+    grant_type: 'refresh_token',
+    refresh_token: tokens.refreshToken
+  });
+ 
+  console.log('📤 [QB] Making refresh request to:', url);
+ 
+  try {
+    const resp = await fetch(url, {
+      method: 'POST',
+      headers: {
+        Authorization: `Basic ${auth}`,
+        'Content-Type': 'application/x-www-form-urlencoded'
+      },
+      body: body.toString()
+    });
+ 
+    console.log('📥 [QB] Refresh response status:', resp.status);
+ 
+    Iif (!resp.ok) {
+      const errorText = await resp.text();
+      console.error('❌ [QB] Refresh failed:', resp.status, errorText);
+      throw new Error(`Failed to refresh token: ${resp.status}`);
+    }
+ 
+    const json = await resp.json();
+    console.log('✅ [QB] Refresh successful, expires in:', json.expires_in, 'seconds');
+    
+    const tokenData: TokenStore = {
+      realmId: tokens.realmId,
+      accessToken: json.access_token,
+      refreshToken: json.refresh_token,
+      expiresAt: new Date(Date.now() + json.expires_in * 1000).toISOString()
+    };
+ 
+    console.log('💾 [QB] Saving refreshed tokens...');
+    await saveTokensToDatabase(tokenData);
+    console.log('✅ [QB] Refreshed tokens saved successfully');
+    
+    return tokenData;
+  } catch (error) {
+    console.error('❌ [QB] Error refreshing token:', error);
+    return null;
+  }
+}
+ 
+/**
+ * Get a valid access token, refreshing if necessary.
+ * Returns null if no token exists or refresh fails.
+ */
+export async function getValidAccessToken(): Promise<string | null> {
+  console.log('🎯 [QB] Getting valid access token...');
+  
+  const tokens = await getTokenFromDatabase();
+  Iif (!tokens) {
+    console.log('❌ [QB] No tokens available');
+    return null;
+  }
+ 
+  const now = Date.now();
+  const expiresAt = new Date(tokens.expiresAt).getTime();
+  const timeUntilExpiry = expiresAt - now;
+  
+  console.log('⏱️ [QB] Time until expiry:', Math.round(timeUntilExpiry / 1000), 'seconds');
+ 
+  // Check if token is expired or will expire in the next minute
+  Iif (new Date(tokens.expiresAt) <= new Date(Date.now() + 60000)) {
+    console.log('🔄 [QB] Token expired or expiring soon, refreshing...');
+    const refreshed = await refreshQuickBooksToken();
+    return refreshed ? refreshed.accessToken : null;
+  }
+ 
+  console.log('✅ [QB] Using existing valid token');
+  return tokens.accessToken;
+}
+ 
+/**
+ * Save QuickBooks tokens to the database.
+ */
+export async function saveTokensToDatabase(tokens: TokenStore): Promise<void> {
+  console.log('💾 [QB] Saving tokens to database...');
+  
+  const { error } = await supabase
+    .from('quickbooks_tokens')
+    .upsert({
+      realm_id: tokens.realmId,
+      access_token: tokens.accessToken,
+      refresh_token: tokens.refreshToken,
+      expires_at: tokens.expiresAt,
+      updated_at: new Date().toISOString(),
+    });
+ 
+  Iif (error) {
+    console.error('❌ [QB] Failed to save tokens:', error.message);
+    throw new Error(`Failed to save QuickBooks tokens: ${error.message}`);
+  }
+  
+  console.log('✅ [QB] Tokens saved successfully');
+}
+ 
+/** Delete QuickBooks tokens */
+export async function deleteTokens(): Promise<void> {
+  console.log('🗑️ [QB] Deleting tokens...');
+  
+  const { error } = await supabase
+    .from('quickbooks_tokens')
+    .delete()
+    .gt('realm_id', ''); // Delete all rows where realm_id > '' (which means all rows)
+ 
+  Iif (error) {
+    console.error('❌ [QB] Failed to delete tokens:', error.message);
+    throw new Error(`Failed to delete QuickBooks tokens: ${error.message}`);
+  }
+  
+  console.log('✅ [QB] Tokens deleted successfully');
+}
+ 
+// Add these exports for the QuickBooks service
+export const getTokens = getTokenFromDatabase;
+export const saveTokens = saveTokensToDatabase;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/create_updated_at_trigger.sql b/create_updated_at_trigger.sql new file mode 100644 index 00000000..f5c5163b --- /dev/null +++ b/create_updated_at_trigger.sql @@ -0,0 +1,23 @@ +-- Create the updated_at trigger for client_info table +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create the trigger +CREATE TRIGGER update_client_info_updated_at + BEFORE UPDATE ON client_info + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Verify the trigger was created +SELECT + trigger_name, + event_manipulation, + action_statement +FROM information_schema.triggers +WHERE event_object_table = 'client_info' +AND trigger_name = 'update_client_info_updated_at'; \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..d94527d0 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ID="sokana-private-data" +REGION="us-central1" +REPO="backend-repo" +IMAGE="us-central1-docker.pkg.dev/${PROJECT_ID}/${REPO}/api:latest" +SERVICE="sokana-private-api" + +# Ensure Artifact Registry repo exists (docker format) +if ! gcloud artifacts repositories describe "${REPO}" --location="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then + gcloud artifacts repositories create "${REPO}" \ + --repository-format=docker \ + --location="${REGION}" \ + --project="${PROJECT_ID}" +fi + +# Build and push image +gcloud builds submit \ + --project "${PROJECT_ID}" \ + --tag "${IMAGE}" + +# Deploy to Cloud Run +gcloud run deploy "${SERVICE}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --image "${IMAGE}" \ + --platform managed \ + --no-allow-unauthenticated diff --git a/docs/ACTIVITY_TRACKING_API.md b/docs/ACTIVITY_TRACKING_API.md new file mode 100644 index 00000000..bdbdd2c8 --- /dev/null +++ b/docs/ACTIVITY_TRACKING_API.md @@ -0,0 +1,391 @@ +# Activity Tracking API Documentation + +This document describes the new activity tracking system for client management. + +## Overview + +The activity tracking system provides comprehensive logging of all client-related activities, including status changes, profile updates, and custom activity entries. This enables better client relationship management and audit trails. + +## Database Schema + +### Client Activities Table + +```sql +CREATE TABLE client_activities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID REFERENCES client_info(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + description TEXT, + metadata JSONB, + timestamp TIMESTAMP DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id) +); +``` + +### Auto-update Trigger + +The `client_info` table already has an auto-update trigger that automatically updates the `updated_at` timestamp whenever any field is modified: + +```sql +CREATE OR REPLACE FUNCTION update_client_info_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_client_info_updated_at + BEFORE UPDATE ON client_info + FOR EACH ROW + EXECUTE FUNCTION update_client_info_updated_at(); +``` + +## API Endpoints + +### 1. Update Client Status (Enhanced) + +**PUT** `/clients/status` + +Updates client status and automatically logs the activity. + +**Headers:** +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "clientId": "uuid-here", + "status": "contacted" +} +``` + +**Response (200 OK):** +```json +{ + "success": true, + "client": { + "id": "uuid-here", + "status": "contacted", + "updatedAt": "2025-01-15T10:30:00Z", + "firstname": "Jane", + "lastname": "Doe", + "email": "jane@example.com", + "role": "client", + "serviceNeeded": "Labor Support", + "requestedAt": "2025-01-10T09:00:00Z" + }, + "activity": { + "type": "status_change", + "field": "status", + "oldValue": "lead", + "newValue": "contacted", + "timestamp": "2025-01-15T10:30:00Z" + } +} +``` + +### 2. Update Client Profile + +**PUT** `/clients/{id}` + +Updates client profile fields and logs changes. + +**Headers:** +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "user": { + "firstname": "Jane", + "lastname": "Smith", + "email": "jane.smith@example.com" + }, + "serviceNeeded": "Postpartum Support" +} +``` + +**Response (200 OK):** +```json +{ + "success": true, + "client": { + "id": "uuid-here", + "updatedAt": "2025-01-15T10:30:00Z", + "firstname": "Jane", + "lastname": "Smith", + "email": "jane.smith@example.com", + "role": "client", + "status": "contacted", + "serviceNeeded": "Postpartum Support", + "requestedAt": "2025-01-10T09:00:00Z" + }, + "activity": { + "type": "profile_update", + "changedFields": ["firstname", "lastname", "email", "serviceNeeded"], + "timestamp": "2025-01-15T10:30:00Z" + } +} +``` + +### 3. Create Custom Activity + +**POST** `/clients/{id}/activity` + +Creates a custom activity entry for a client. + +**Headers:** +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "type": "note_added", + "description": "Client called to discuss birth plan", + "metadata": { + "noteText": "Client prefers natural birth if possible", + "category": "birth_planning", + "contactMethod": "phone" + } +} +``` + +**Response (200 OK):** +```json +{ + "success": true, + "activity": { + "id": "uuid-here", + "clientId": "uuid-here", + "type": "note_added", + "description": "Client called to discuss birth plan", + "metadata": { + "noteText": "Client prefers natural birth if possible", + "category": "birth_planning", + "contactMethod": "phone" + }, + "timestamp": "2025-01-15T10:30:00Z" + } +} +``` + +## Activity Types + +### System-Generated Activities + +1. **`status_change`** - Automatically created when client status is updated + - Metadata: `{ field: "status", oldValue: string, newValue: string }` + +2. **`profile_update`** - Automatically created when client profile is updated + - Metadata: `{ changedFields: string[] }` + +### Custom Activity Types + +1. **`note_added`** - General notes about the client +2. **`document_uploaded`** - When documents are uploaded +3. **`appointment_scheduled`** - When appointments are scheduled +4. **`contact_made`** - When contact is made with the client +5. **`profile_updated`** - Manual profile updates + +## Database Migration + +Run this SQL script in your Supabase SQL editor to create the activity tracking table: + +```sql +-- Create client_activities table for activity tracking +CREATE TABLE IF NOT EXISTS client_activities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID REFERENCES client_info(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + description TEXT, + metadata JSONB, + timestamp TIMESTAMP DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id) +); + +-- Index for performance +CREATE INDEX IF NOT EXISTS idx_client_activities_client_id ON client_activities(client_id); +CREATE INDEX IF NOT EXISTS idx_client_activities_timestamp ON client_activities(timestamp); +CREATE INDEX IF NOT EXISTS idx_client_activities_type ON client_activities(type); + +-- Enable Row Level Security +ALTER TABLE client_activities ENABLE ROW LEVEL SECURITY; + +-- Create RLS policies for activity access +CREATE POLICY IF NOT EXISTS "Users can view own client activities" ON client_activities + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM client_info + WHERE client_info.id = client_activities.client_id + AND client_info.user_id = auth.uid() + ) + ); + +CREATE POLICY IF NOT EXISTS "Admins can view all client activities" ON client_activities + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM auth.users + WHERE auth.users.id = auth.uid() + AND auth.users.raw_user_meta_data->>'role' = 'admin' + ) + ); + +CREATE POLICY IF NOT EXISTS "Doulas can view assigned client activities" ON client_activities + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM assignments + WHERE assignments.client_id = client_activities.client_id + AND assignments.doula_id = auth.uid() + ) + ); + +CREATE POLICY IF NOT EXISTS "Users can insert own client activities" ON client_activities + FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM client_info + WHERE client_info.id = client_activities.client_id + AND client_info.user_id = auth.uid() + ) + ); + +CREATE POLICY IF NOT EXISTS "Admins can insert all client activities" ON client_activities + FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM auth.users + WHERE auth.users.id = auth.uid() + AND auth.users.raw_user_meta_data->>'role' = 'admin' + ) + ); +``` + +## Frontend Integration Examples + +### Update Client Status + +```javascript +const updateClientStatus = async (clientId, newStatus) => { + try { + const response = await fetch('/api/clients/status', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + clientId, + status: newStatus + }) + }); + + if (response.ok) { + const result = await response.json(); + console.log('Status updated:', result.client); + console.log('Activity logged:', result.activity); + + // Update UI with new status and activity + updateClientInUI(result.client); + addActivityToTimeline(result.activity); + } + } catch (error) { + console.error('Error updating status:', error); + } +}; +``` + +### Update Client Profile + +```javascript +const updateClientProfile = async (clientId, updateData) => { + try { + const response = await fetch(`/api/clients/${clientId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updateData) + }); + + if (response.ok) { + const result = await response.json(); + console.log('Profile updated:', result.client); + + if (result.activity) { + console.log('Changes tracked:', result.activity); + addActivityToTimeline(result.activity); + } + } + } catch (error) { + console.error('Error updating profile:', error); + } +}; +``` + +### Create Custom Activity + +```javascript +const createActivity = async (clientId, activityData) => { + try { + const response = await fetch(`/api/clients/${clientId}/activity`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(activityData) + }); + + if (response.ok) { + const result = await response.json(); + console.log('Activity created:', result.activity); + addActivityToTimeline(result.activity); + } + } catch (error) { + console.error('Error creating activity:', error); + } +}; + +// Example usage +createActivity('client-uuid', { + type: 'note_added', + description: 'Client called to discuss birth plan', + metadata: { + noteText: 'Client prefers natural birth if possible', + category: 'birth_planning' + } +}); +``` + +## Benefits + +1. **Complete Audit Trail** - Every client interaction is logged with timestamps +2. **Automatic Activity Logging** - Status changes and profile updates are automatically tracked +3. **Custom Activity Support** - Add custom activities for notes, documents, appointments, etc. +4. **Role-Based Access** - Different user roles can view appropriate activities +5. **Performance Optimized** - Indexed database queries for fast activity retrieval +6. **Flexible Metadata** - JSONB fields allow for rich activity data + +## Security Features + +1. **Row Level Security (RLS)** - Users can only access activities for their assigned clients +2. **Role-Based Access Control** - Admins can view all activities, doulas see assigned clients +3. **JWT Authentication** - All endpoints require valid authentication +4. **Input Validation** - Comprehensive validation of all activity data +5. **Audit Trail** - All activities are timestamped and linked to the user who created them + +## Migration Notes + +- The existing `PUT /clients/status` endpoint has been enhanced to include activity tracking +- The `updated_at` field in `client_info` is automatically updated via database trigger +- All new endpoints follow the existing authentication and authorization patterns +- Backward compatibility is maintained for existing client management functionality \ No newline at end of file diff --git a/docs/ADMIN_INVITE_DOULA_ENDPOINT.md b/docs/ADMIN_INVITE_DOULA_ENDPOINT.md new file mode 100644 index 00000000..810a3ec6 --- /dev/null +++ b/docs/ADMIN_INVITE_DOULA_ENDPOINT.md @@ -0,0 +1,289 @@ +# Admin Invite Doula Endpoint + +## Overview +Endpoint for administrators to invite doulas to join the platform. The doula receives an email invitation with a link to create their profile and upload necessary documents. + +## Endpoint +**POST** `/api/admin/doulas/invite` + +## Authentication +- **Required:** Yes +- **Role:** `admin` only +- **Header:** `Authorization: Bearer ` + +## Request Body +```json +{ + "email": "doula@example.com", + "firstname": "Jane", + "lastname": "Doe" +} +``` + +### Required Fields: +- `email` (string) - Valid email address +- `firstname` (string) - First name of the doula +- `lastname` (string) - Last name of the doula + +## Response + +### Success (200 OK) +```json +{ + "success": true, + "message": "Invitation email sent to doula@example.com", + "data": { + "email": "doula@example.com", + "firstname": "Jane", + "lastname": "Doe", + "inviteToken": "example_invite_token_abc123xyz" + } +} +``` + +### Error Responses + +#### 400 Bad Request - Missing Fields +```json +{ + "success": false, + "error": "Missing required fields: email, firstname, and lastname are required" +} +``` + +#### 400 Bad Request - Invalid Email +```json +{ + "success": false, + "error": "Invalid email format" +} +``` + +#### 401 Unauthorized - Not Authenticated +```json +{ + "error": "No session token provided" +} +``` + +#### 403 Forbidden - Not Admin +```json +{ + "error": "Only administrators can invite doulas." +} +``` + +#### 500 Internal Server Error +```json +{ + "success": false, + "error": "Failed to send invitation email" +} +``` + +## Email Invitation + +The doula receives an email with: +- **Subject:** "You're Invited to Join Our Doula Team!" +- **Content:** + - Personalized greeting with firstname and lastname + - Invitation to create profile + - Link to signup page: `${FRONTEND_URL}/signup` + - Instructions to use the provided email address + - Professional HTML formatting + +## Example Usage + +### cURL +```bash +curl -X POST http://localhost:5050/api/admin/doulas/invite \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "email": "doula@example.com", + "firstname": "Jane", + "lastname": "Doe" + }' +``` + +### JavaScript/TypeScript +```typescript +async function inviteDoula(token: string, email: string, firstname: string, lastname: string) { + const response = await fetch('http://localhost:5050/api/admin/doulas/invite', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email, + firstname, + lastname + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to invite doula'); + } + + return data; +} + +// Usage +try { + const result = await inviteDoula( + adminToken, + 'doula@example.com', + 'Jane', + 'Doe' + ); + console.log('Invitation sent:', result.message); +} catch (error) { + console.error('Error:', error.message); +} +``` + +## Frontend Implementation + +### UI Requirements: +1. **Form Fields:** + - Email input (with validation) + - First name input + - Last name input + - Submit button + +2. **Validation:** + - All fields required + - Email format validation + - Show validation errors + +3. **Success Handling:** + - Show success message + - Clear form + - Optionally show invite token for tracking + +4. **Error Handling:** + - Display error messages + - Handle 401/403 errors (redirect to login or show permission error) + - Handle network errors + +### Example React Component: +```tsx +import { useState } from 'react'; + +function InviteDoulaForm() { + const [email, setEmail] = useState(''); + const [firstname, setFirstname] = useState(''); + const [lastname, setLastname] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + setSuccess(''); + + try { + const token = localStorage.getItem('authToken'); // Replace with your actual token storage method + const response = await fetch('http://localhost:5050/api/admin/doulas/invite', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email, firstname, lastname }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to invite doula'); + } + + setSuccess(data.message); + setEmail(''); + setFirstname(''); + setLastname(''); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setFirstname(e.target.value)} + required + /> +
+
+ + setLastname(e.target.value)} + required + /> +
+ {error &&
{error}
} + {success &&
{success}
} + +
+ ); +} +``` + +## Testing + +The endpoint has been tested and verified. Test results: +- ✅ Admin authentication +- ✅ Role authorization (admin only) +- ✅ Email validation +- ✅ Required fields validation +- ✅ Email sending functionality +- ✅ Success response format + +## Notes + +1. **Invite Token:** The endpoint generates and returns an invite token. This can be used for: + - Tracking invitations + - Analytics + - Future features (e.g., invitation expiration, resend) + +2. **Email Service:** Requires email service configuration (Nodemailer with SMTP settings) + +3. **Frontend URL:** The email contains a link to `${FRONTEND_URL}/signup`. Ensure this environment variable is set correctly. + +4. **Doula Signup:** After receiving the email, the doula should: + - Click the signup link + - Use the exact email address they were invited with + - Create their account with role "doula" + - Complete their profile + - Upload required documents + +## Related Endpoints + +- **Doula Signup:** `POST /api/auth/signup` (with role="doula") +- **Doula Profile:** `GET /api/doulas/profile` +- **Upload Documents:** `POST /api/doulas/documents` diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 00000000..ed2b3e53 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,303 @@ +# Contract System API Reference + +## 🚀 Enhanced Contract System Endpoints + +### Contract Calculation & Generation + +#### Calculate Contract Amounts +```http +POST /api/contract/postpartum/calculate +Content-Type: application/json + +{ + "total_hours": 120, + "hourly_rate": 35, + "deposit_type": "percent", + "deposit_value": 15, + "installments_count": 3, + "cadence": "monthly" +} +``` + +**Response:** +```json +{ + "success": true, + "amounts": { + "total_amount": 4200.00, + "deposit_amount": 630.00, + "balance_amount": 3570.00, + "installments_amounts": [1785.00, 1785.00] + }, + "fields": { + "total_hours": "120", + "hourly_rate_fee": "35.00", + "deposit": "630.00", + "overnight_fee_amount": "0.00", + "total_amount": "4200.00" + } +} +``` + +#### Send Contract for Signature +```http +POST /api/contract/postpartum/send +Content-Type: application/json + +{ + "contract_input": { + "total_hours": 120, + "hourly_rate": 35, + "deposit_type": "percent", + "deposit_value": 15, + "installments_count": 3, + "cadence": "monthly" + }, + "client": { + "email": "client@example.com", + "name": "John Doe" + } +} +``` + +**Response:** +```json +{ + "success": true, + "message": "Contract created with prefilled values and sent to client via DocuSign", + "amounts": { + "total_amount": 4200.00, + "deposit_amount": 630.00, + "balance_amount": 3570.00, + "installments_amounts": [1785.00, 1785.00] + }, + "envelopeId": "envelope-12345", + "docusign": { + "envelopeId": "envelope-12345", + "status": "sent" + }, + "prefilledValues": { + "total_hours": "120", + "hourly_rate_fee": "35.00", + "deposit": "630.00", + "overnight_fee_amount": "0.00", + "total_amount": "4200.00" + } +} +``` + +### Payment Processing (After Contract Signing) + +#### Create Payment Intent +```http +POST /api/stripe/contract/{contractId}/create-payment +``` + +**Response:** +```json +{ + "success": true, + "data": { + "payment_intent_id": "pi_1234567890", + "client_secret": "pi_1234567890_secret_abc123", + "amount": 63000, + "currency": "usd", + "status": "requires_payment_method", + "customer_email": "client@example.com" + } +} +``` + +#### Check Payment Status +```http +GET /api/stripe/check-payment-status/{paymentIntentId} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "pi_1234567890", + "status": "succeeded", + "amount": 63000, + "currency": "usd" + } +} +``` + +#### Get Next Payment for Contract +```http +GET /api/stripe/contract/{contractId}/next-payment +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "payment-123", + "contract_id": "contract-456", + "payment_type": "deposit", + "amount": 630.00, + "due_date": "2024-01-15", + "status": "pending", + "is_overdue": false + } +} +``` + +#### Get Payment Summary +```http +GET /api/stripe/contract/{contractId}/payment-summary +``` + +**Response:** +```json +{ + "success": true, + "data": { + "total_amount": 4200.00, + "deposit_amount": 630.00, + "balance_amount": 3570.00, + "total_paid": 630.00, + "total_due": 3570.00, + "installments_remaining": 2, + "next_payment_due": "2024-02-15", + "next_payment_amount": 1785.00 + } +} +``` + +### Webhook Endpoint (for Stripe) + +#### Stripe Webhook +```http +POST /api/stripe/webhook +Content-Type: application/json +Stripe-Signature: t=1234567890,v1=signature + +{ + "type": "payment_intent.succeeded", + "data": { + "object": { + "id": "pi_1234567890", + "amount": 63000, + "currency": "usd", + "status": "succeeded", + "metadata": { + "contract_id": "contract-456", + "payment_id": "payment-123" + } + } + } +} +``` + +## 📋 Input Validation Rules + +### Contract Input Validation +- `total_hours`: Must be > 0 +- `hourly_rate`: Must be > 0 +- `deposit_type`: Must be "percent" or "flat" +- `deposit_value`: + - If percent: 10-20% + - If flat: > 0 and < total amount +- `installments_count`: 2-5 installments +- `cadence`: "monthly" or "biweekly" + +### Client Information Validation +- `email`: Valid email format +- `name`: Non-empty string + +## 🔄 Complete Workflow Example + +### 1. Calculate Contract +```javascript +const response = await fetch('/api/contract/postpartum/calculate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + total_hours: 120, + hourly_rate: 35, + deposit_type: 'percent', + deposit_value: 15, + installments_count: 3, + cadence: 'monthly' + }) +}); +const result = await response.json(); +``` + +### 2. Send Contract +```javascript +const response = await fetch('/api/contract/postpartum/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contract_input: contractData, + client: { email: 'client@example.com', name: 'John Doe' } + }) +}); +const result = await response.json(); +``` + +### 3. Create Payment Intent (After Signing) +```javascript +const response = await fetch(`/api/stripe/contract/${contractId}/create-payment`, { + method: 'POST' +}); +const result = await response.json(); +const { client_secret } = result.data; +``` + +### 4. Process Payment with Stripe Elements +```javascript +const stripe = Stripe('pk_test_your_publishable_key'); +const elements = stripe.elements({ clientSecret: client_secret }); +// Initialize payment form with Stripe Elements +``` + +## 🚨 Error Handling + +### Validation Errors +```json +{ + "success": false, + "error": "Total hours must be greater than 0" +} +``` + +### Server Errors +```json +{ + "success": false, + "error": "Failed to calculate contract amounts" +} +``` + +### Payment Errors +```json +{ + "success": false, + "error": "No pending payments found for this contract" +} +``` + +## 🔧 Environment Variables Needed + +- `STRIPE_SECRET_KEY`: Your Stripe secret key +- `STRIPE_PUBLISHABLE_KEY`: Your Stripe publishable key (for frontend) +- `STRIPE_WEBHOOK_SECRET`: Webhook endpoint secret +- `DOCUSIGN_*`: DocuSign configuration variables + +## 📱 Frontend Integration Notes + +1. **Always validate inputs** before sending to API +2. **Show loading states** during API calls +3. **Handle errors gracefully** with user-friendly messages +4. **Use the calculated amounts** to show preview before sending +5. **Implement proper error boundaries** for payment processing +6. **Test with Stripe test mode** before going live + +This API provides a complete contract-to-payment workflow with automatic calculations and seamless integration. diff --git a/docs/ARCHITECTURE_AUTH_AND_DATA.md b/docs/ARCHITECTURE_AUTH_AND_DATA.md new file mode 100644 index 00000000..fc159bdd --- /dev/null +++ b/docs/ARCHITECTURE_AUTH_AND_DATA.md @@ -0,0 +1,24 @@ +# Architecture: Supabase = Auth Only, Cloud SQL = All App Data + +## Current design + +| Responsibility | System | What lives there | +|----------------|--------|-------------------| +| **Authentication** | **Supabase** | Auth only: sign-in, sign-up, sessions, password reset, OAuth (Google). No app data. | +| **Application data** | **Google Cloud SQL** | Clients, assignments, activities, and all other business data. | + +- **Supabase** is used only for **auth** (e.g. `auth.users`, sessions, cookies/tokens). Login validates credentials against Supabase Auth; the backend does not depend on Supabase `public.users` or `client_info` for login. +- **Cloud SQL** is the **single source of truth** for app data when `CLOUD_SQL_HOST` is set. Client list/detail/update, assignments, and activities are read/written there. + +## Backend behavior + +- **Env:** With `CLOUD_SQL_HOST` set, the backend uses `CloudSqlClientRepository` for all client operations. Without it, client operations still use Supabase (legacy). +- **Login:** `POST /auth/login` → Supabase Auth `signInWithPassword`. User/role can come from auth user metadata when `public.users` is absent. +- **Protected routes:** Same session (cookie or Bearer) is validated with Supabase Auth; then data is loaded from Cloud SQL. + +## Summary + +- **Supabase = auth only.** +- **All app data = Google Cloud SQL** (when Cloud SQL is configured). + +See also: [CLOUD_SQL_MIGRATION.md](./CLOUD_SQL_MIGRATION.md) for setup and testing. diff --git a/docs/Agreement for Postpartum Doula Services (1).docx b/docs/Agreement for Postpartum Doula Services (1).docx new file mode 100644 index 00000000..0f5ceff7 Binary files /dev/null and b/docs/Agreement for Postpartum Doula Services (1).docx differ diff --git a/docs/BACKEND_BILLING_STRIPE_CHARGE_PROMPT.md b/docs/BACKEND_BILLING_STRIPE_CHARGE_PROMPT.md new file mode 100644 index 00000000..e7033f0f --- /dev/null +++ b/docs/BACKEND_BILLING_STRIPE_CHARGE_PROMPT.md @@ -0,0 +1,73 @@ +# Backend billing / Stripe charge – spec & checklist + +This document describes what the backend must provide for the Billing UI to charge a client’s card (Stripe). The frontend is already wired to **POST `/api/payments/customers/:customerId/charge`** with auth. + +--- + +## 1. Frontend flow + +- Billing page uses **clients with signed contracts** (from GET /clients). +- Admin picks a **client** (your app client UUID), **amount (USD)**, and **description**. +- Frontend calls **POST `/api/payments/customers/:customerId/charge`** with body `{ amount, description }` (amount in **cents**) and sends auth (cookie or Bearer). + +**Note:** If your Billing UI uses **client id** (from GET /clients), then either: +- **Option A:** Backend accepts **client id** as `customerId` and resolves it to your internal customer / Stripe customer (e.g. via a `customers` or `clients` table that has `stripe_customer_id`), or +- **Option B:** Frontend sends the **customer id** that already has a Stripe customer (e.g. from GET /api/payments/customers). + +Current backend implementation uses a **customers** table (Supabase) with `stripe_customer_id`; `customerId` in the route is the **customer** row id. If clients and customers are the same entity, use the same id; otherwise add a mapping (client id → customer id) in backend or frontend. + +--- + +## 2. Stripe setup + +- **One Stripe Customer per chargeable client/customer.** +- Store **Stripe Customer ID** (e.g. `stripe_customer_id`) on the client/customer record. +- When a client adds a card (e.g. in portal or admin flow), set that card as the **default payment method** for the Stripe customer so “charge default” works. + +--- + +## 3. Charge endpoint + +**POST `/api/payments/customers/:customerId/charge`** + +- **`:customerId`** = your app **customer** (or client) id. Backend resolves this to a Stripe Customer (e.g. via `stripe_customer_id` on the customer/client record). +- **Body:** `{ amount: number (cents), description?: string }`. +- **Auth:** Required; same as rest of app (cookie or Bearer). Backend should require **admin** (or allow the customer themselves for self-serve). Current implementation allows **admin** or **same user as customerId**. +- **Backend must:** + 1. Resolve `customerId` → Stripe Customer (using stored `stripe_customer_id`). + 2. Charge that customer’s **default payment method** (Stripe Payment Intents or Charges API). + 3. Return **`{ success: true, data }`** or **`{ success: false, error }`** so the Billing UI can show success or error. + +**Errors (clear responses):** + +- Client/customer has **no Stripe customer** → 400 or 404 with message like “No Stripe customer for this client.” +- **No default payment method** → 400 with message like “No payment method on file. Add a card first.” + +--- + +## 4. Persistence + +- **Supabase `charges`** – The charge is still saved to Supabase `charges` for existing integrations (e.g. QuickBooks sync). +- **Google Cloud SQL `payments`** – After a successful Stripe charge, the payment is **also** recorded in Cloud SQL table **`payments`** (columns: `txn_date`, `amount` [dollars], `method`, `gateway` = 'stripe', `transaction_id` = Stripe payment intent id, `client_id` = customerId). This makes charges visible in **GET /api/payments** and in reconciliation. If Cloud SQL is unavailable or the insert fails, the charge still succeeds; the failure is logged and the response is unchanged. +- **Card CRUD** (save card, list cards, update card) can use the same `customerId` (app customer/client id) and same auth. + +--- + +## 5. Backend checklist + +| Item | Purpose | +|------|--------| +| Store `stripe_customer_id` per customer/client | Map app customer id → Stripe Customer | +| Set default payment method when client adds a card | So “charge default” works | +| **POST /api/payments/customers/:customerId/charge** | Accept customer id, body `{ amount, description }`; resolve to Stripe and charge default payment method | +| Require admin (or allowed role) on charge | Auth same as rest of app (cookie/Bearer) | +| Return `{ success, data \| error }` | So Billing UI can show success or error | +| Save charge in Cloud SQL `payments` table | Done after successful Stripe charge; visible in GET /api/payments and reconciliation | + +--- + +## 6. Current implementation status + +- **StripePaymentService** (`src/services/payments/stripePaymentService.ts`): `ensureStripeCustomer`, `chargeCard`, `saveCard`, `getPaymentMethods`; uses Supabase **customers** table and `stripe_customer_id`. +- **PaymentController** (`src/controllers/paymentController.ts`): `processCharge` calls `paymentService.chargeCard({ customerId, amount, description })`; auth: admin or same user as `customerId`; returns `{ success, data }` or `{ success, error }`. +- **Route:** Must be mounted under `/api/payments` as **POST `/customers/:customerId/charge`** (see `src/routes/paymentRoutes.ts`). When Stripe is enabled, this route is registered so the Billing UI works end-to-end. diff --git a/docs/CLIENT_DETAIL_FORM_POPULATION.md b/docs/CLIENT_DETAIL_FORM_POPULATION.md new file mode 100644 index 00000000..8006321a --- /dev/null +++ b/docs/CLIENT_DETAIL_FORM_POPULATION.md @@ -0,0 +1,62 @@ +# Why client details might not populate in the form + +GET **/clients/:id** returns a **single merged client object** (Supabase + PHI Broker). If the form stays empty, the cause is almost always one of these two. + +--- + +## 1. Using the wrong part of the response + +The HTTP body is: + +```json +{ "success": true, "data": { "id": "...", "first_name": "Test", "last_name": "Client", "email": "...", "phone_number": "+15551234567", "date_of_birth": "1990-01-15", "address_line1": "123 Test St", "due_date": "2025-06-01", ... } } +``` + +The **client** object is the value of **`data`**, not the whole body. + +- With **axios**: `response.data` is the whole body, so the client is **`response.data.data`**. +- If your API client already unwraps and returns `body.data`, then that unwrapped value is the client. + +**Fix:** Use the object at **`body.data`** (e.g. `response.data.data` with axios) as the source for the modal/form. Do not pass the full `{ success, data }` object into the form. + +Example: + +```ts +const res = await api.get('/clients/' + id); +const client = res.data?.data; // client object for form +if (client) setFormSource(client); +``` + +--- + +## 2. Field names: backend is snake_case, form may expect camelCase + +The backend returns **snake_case** keys, for example: + +- `first_name`, `last_name` +- `email`, `phone_number` +- `date_of_birth`, `address_line1`, `due_date` +- `service_needed`, `portal_status`, `requested_at`, `updated_at` +- `health_history`, `allergies`, `medications`, etc. + +If the form state or field names use **camelCase** (`firstName`, `phoneNumber`, `dateOfBirth`, `dueDate`, `addressLine1`, …), then the keys don’t match and the form won’t show the values. + +**Fix (pick one):** + +- **A)** Initialize form state from the client object by **mapping snake_case → camelCase** before calling `setState` / `setValues`, e.g. + `firstName: client.first_name`, + `phoneNumber: client.phone_number`, + `dateOfBirth: client.date_of_birth`, + `dueDate: client.due_date`, + `addressLine1: client.address_line1`, + etc. +- **B)** Use the **same snake_case keys** in the form state and in the form fields so you can use the client object as-is (e.g. `client.first_name`, `client.phone_number`). + +--- + +## Quick check + +1. In the browser, open the **Network** tab, call GET **/clients/ced55ced-c62c-48c0-81fb-353fe4a99cc4**, and inspect the response body. You should see `{ success: true, data: { ... } }` with snake_case fields inside `data`. +2. In the frontend, log the value you pass into the form (e.g. `detailSource` or `initialValues`). It should be the **object inside `data`**, and its keys should match what your form reads (either snake_case or after mapping to camelCase). + +If both are correct, the form will populate. diff --git a/docs/CLIENT_DETAIL_MERGED_RESPONSE.md b/docs/CLIENT_DETAIL_MERGED_RESPONSE.md new file mode 100644 index 00000000..11744c5c --- /dev/null +++ b/docs/CLIENT_DETAIL_MERGED_RESPONSE.md @@ -0,0 +1,28 @@ +# Client detail: merged response for modal + +## Contract + +**GET /clients/:id** (when authorized for PHI) returns **one merged object** in `data`: + +- **Base (Supabase):** Operational fields from `client_info` (id, status, service_needed, portal_status, requested_at, updated_at, is_eligible, etc.). +- **PHI (Cloud Run broker):** Sensitive fields from PHI Broker (phone_number, due_date, date_of_birth, address_line1, health_history, allergies, medications, etc.). +- **Response shape:** `{ success: true, data: { ...supabaseDTO, ...phiFromBroker } }`. + +The backend merges in the handler before responding: `merged = { ...dto, ...phiData }` → `res.json(ApiResponse.success(merged))`. The frontend’s `get('/clients/${id}')` only sees `response.data`, so it receives that single merged object. + +## Why one object in `data` + +- **LeadProfileModal** (and any client-detail UI) uses one source for display and form init: `detailSource = client.data ?? client` (and, if present, `client.phi` or `client.data.phi` is merged on the frontend for legacy/direct-fetch cases). +- For the normal flow that uses the generic `get()` and only gets `response.data`, **all fields must be on `data`**. So the backend merges Supabase + PHI into that one `data` object. Then: + - Phone Number, Due Date, Date of Birth, Address, Service Needed, etc. all come from `data`. + - One object populates the modal; no separate “operational vs PHI” handling needed in the UI for this flow. + +## If backend ever returned two groups + +If the backend instead returned `{ success, data: { ...supabase }, phi: { ...cloudRun } }`, the current `get()` would only pass `data` to the frontend (no `phi`). To support both groups without changing the generic `get()`, the backend would still merge into `data` before sending, e.g. `data: { ...supabase, ...phi }`, so the frontend still receives one merged object in `data`. The current implementation already does this merge in the handler and returns a single `data` object. + +## Summary + +- Backend: **merge Supabase DTO + PHI Broker result** and return **one payload** in `data`. +- Frontend: uses `response.data` as the single source for the modal when using `get('/clients/${id}')`. +- Result: both operational and PHI fields show up in the modal from one request. diff --git a/docs/CLIENT_PROFILE_UPDATE_FIX_VERIFICATION.md b/docs/CLIENT_PROFILE_UPDATE_FIX_VERIFICATION.md new file mode 100644 index 00000000..488ac494 --- /dev/null +++ b/docs/CLIENT_PROFILE_UPDATE_FIX_VERIFICATION.md @@ -0,0 +1,78 @@ +# Client Profile Update Fix — Verification Checklist + +## What was fixed + +1. **Missing `city` column** — Supabase error: "Could not find the 'city' column of 'client_info' in the schema cache". +2. **Guardrails** — Unknown payload keys are now dropped so schema cache / missing column errors don’t break updates. +3. **Logging** — Removed logging of full update/response payloads (no PHI/PII in logs). + +--- + +## 1) Endpoint and path + +- **Endpoint:** `PUT /clients/:id` +- **Route:** `src/routes/clientRoutes.ts` → `clientRoutes.put('/:id', ..., clientController.updateClient)` +- **Controller:** `src/controllers/clientController.ts` → `updateClient()` +- **Use case:** `src/usecase/clientUseCase.ts` → `updateClientProfile()` +- **Repository:** `src/repositories/supabaseClientRepository.ts` → `updateClient()` → Supabase `.from('client_info').update(...)`. + +--- + +## 2) Migration (run first) + +**File:** `src/db/migrations/add_client_info_address_columns.sql` + +Run in **Supabase → SQL Editor** (or your migration pipeline): + +```sql +ALTER TABLE public.client_info + ADD COLUMN IF NOT EXISTS address TEXT, + ADD COLUMN IF NOT EXISTS city TEXT, + ADD COLUMN IF NOT EXISTS state TEXT, + ADD COLUMN IF NOT EXISTS zip_code TEXT; +``` + +- After running, Supabase may need a short time to refresh the schema cache. +- If your project uses a migration runner, run this migration in that pipeline instead. + +--- + +## 3) Code changes summary + +| File | Change | +|------|--------| +| `src/db/migrations/add_client_info_address_columns.sql` | New migration: add address, city, state, zip_code to client_info. | +| `src/repositories/supabaseClientRepository.ts` | Added `ALLOWED_CLIENT_INFO_UPDATE_COLUMNS` whitelist; sanitize update payload before `.update()`; skip update when payload is empty after sanitization. | +| `src/controllers/clientController.ts` | Log only update keys (no values); log only response keys (no full response body). | +| `src/__tests__/clientRepositoryUpdateWhitelist.test.ts` | New test: unknown keys are dropped; known keys (e.g. city) are sent. | + +--- + +## 4) Verification checklist + +- [ ] **Run migration** in Supabase SQL Editor (or migration pipeline). +- [ ] **Wait** 1–2 minutes if needed for schema cache refresh. +- [ ] **Update client profile** from the frontend (e.g. save with city/address) or: + ```bash + curl -X PUT "http://localhost:5050/clients/" \ + -H "Content-Type: application/json" \ + -H "Cookie: sb-access-token=" \ + -d '{"city":"NYC","first_name":"Test"}' + ``` +- [ ] **Confirm 200** response and no "Could not find the 'city' column" error. +- [ ] **Confirm in Supabase** that `client_info` row was updated (e.g. city / first_name). +- [ ] **Confirm logs** do not contain full request/response bodies (only keys/counts). +- [ ] **Run tests** (if watchman is disabled or in CI): + `npm test -- --testPathPattern=clientRepositoryUpdateWhitelist --watchAll=false` + +--- + +## 5) If you don’t want a `city` column (Option 2) + +If you prefer **not** to add `city` (and only allow fields that already exist): + +1. Do **not** run the migration that adds address/city/state/zip_code. +2. Remove `address`, `city`, `state`, `zip_code` from `ALLOWED_CLIENT_INFO_UPDATE_COLUMNS` in `src/repositories/supabaseClientRepository.ts` (and remove the corresponding `if (fieldsToUpdate.city !== undefined)`-style mappings if you don’t want them sent at all). +3. Frontend should stop sending `city` (or map it to an existing column if you have one). + +Recommendation: **Option 1 (migration)** so the UI can keep saving city/address/state/zip. diff --git a/docs/CLIENT_RESPONSE_DOULAS_ASSIGNMENTS_SPREADSHEET_CONTRACTS.md b/docs/CLIENT_RESPONSE_DOULAS_ASSIGNMENTS_SPREADSHEET_CONTRACTS.md new file mode 100644 index 00000000..61bec01a --- /dev/null +++ b/docs/CLIENT_RESPONSE_DOULAS_ASSIGNMENTS_SPREADSHEET_CONTRACTS.md @@ -0,0 +1,72 @@ +# Draft response to client – doulas, assignments, spreadsheet, contracts + +Use or adapt this when replying to your client. + +--- + +**Hi [Client name],** + +Thanks for offering to work on this. Here’s what I need and what I suggest: + +--- + +### 1. Doulas + +**Yes – name and email for each doula is enough to start.** +I’ll use that to set up doula accounts in the system so they can log in and be listed. If you have phone or other contact info, we can add that later, but name + email is the minimum. + +--- + +### 2. Doula-to-client assignments + +**What I need:** For each assignment, I need to know **which doula** is (or was) assigned to **which client**. + +- **Doulas:** Name and email (so I can match to the doula accounts we create). +- **Clients:** Either: + - **Client name + email**, or + - **Client name + any ID you use** (e.g. from your spreadsheet) so we can match them to the clients already in the system. + +A simple format works: e.g. a list or table with columns like **Doula name**, **Doula email**, **Client name**, **Client email** (or client ID), and **Status** if you track it (e.g. active, past, completed). I can then map these into the system. + +--- + +### 3. Your spreadsheet (color-coded status, all clients since 2021) + +**We won’t lose that data.** Here’s what I suggest: + +1. **Keep your spreadsheet as the master backup.** + Don’t delete or overwrite it. If possible, save a copy (e.g. “Backup as of [date]”) so you always have the full history. + +2. **Export a copy for me.** + Export the full sheet (all clients since 2021) as **Excel or CSV** so I have the same data. Include: + - All columns you use (client name, email, status, dates, etc.). + - A short note or separate column explaining what each **color** means (e.g. “green = active”, “yellow = pending”, “red = completed”). That way we can mirror your status logic in the system. + +3. **Next step on our side.** + We can then either: + - Import or migrate that data into the app’s database (so status and history live in the system), or + - Use it as a reference to backfill status and assignments without changing your spreadsheet. + +So: **no need to get rid of the spreadsheet** – we’ll use it as the source of truth and make sure the system reflects it (and your color coding) rather than replacing it in a way that loses anything. + +--- + +### 4. Contracts in edoula (left sidebar) + +**Yes – I still need those.** +If you can **download/export the contracts from edoula** and send them to me (or upload them to a folder we agree on), I’ll store them in our system so we have our own copies and aren’t dependent on edoula long-term. You don’t need to change how you use the left sidebar in edoula; just getting the files (e.g. PDFs) is enough. If there are a lot, we can do it in batches or prioritize the most recent first. + +--- + +**Summary** + +| What | What I need from you | +|-------------------|-----------------------| +| Doulas | Name + email per doula. | +| Assignments | Who is assigned to whom: doula (name/email) + client (name/email or ID), plus status if you have it. | +| Spreadsheet | Don’t delete it; export a full copy (Excel/CSV) and a note on what each color means so we can preserve that in the system. | +| Contracts (edoula)| Download/export from edoula and send/upload to me so we can store them in our system. | + +Thanks again – once I have these, I can wire everything into the app and we’ll keep your history and status logic intact. + +**Jerry** diff --git a/docs/CLOUD_BUILD_TRIGGER_CONFIG.md b/docs/CLOUD_BUILD_TRIGGER_CONFIG.md new file mode 100644 index 00000000..90fba04c --- /dev/null +++ b/docs/CLOUD_BUILD_TRIGGER_CONFIG.md @@ -0,0 +1,45 @@ +# Cloud Build trigger and "gcr.io repo does not exist" + +## What the repo uses + +**This repo does not push to `gcr.io`.** The only build config in the repo is `cloudbuild.yaml` at the repo root, and it pushes to **Artifact Registry**: + +- Image: `us-central1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/backend/sokana-private-api:$COMMIT_SHA` + +Git history shows this file has used Artifact Registry since it was added; the app image has never been pushed to `gcr.io` in this repo. + +## Why you see "gcr.io repo does not exist" + +That error appears when the build that runs **is not** using `cloudbuild.yaml`. In that case Cloud Build uses its default flow and a default image name like: + +`gcr.io/sokana-private-data/github.com/sokanacollectivecrm/backend:` + +So the failure is from **how the trigger is configured in GCP**, not from a recent code change. + +## What to fix in GCP + +1. **Cloud Console → Cloud Build → Triggers** +2. Open the trigger that runs on your branch (e.g. `main` or `phi-compliance-refactor`). +3. **Edit** the trigger. +4. Under **Configuration**: + - Set **Type** to **"Cloud Build configuration file (yaml or json)"**. + - Set **Location** to **"Repository"** (or "Cloud Source Repositories" if that’s what you use). + - Set **Cloud Build configuration file location** to **`cloudbuild.yaml`** (repo root). +5. Save. + +If the trigger was set to **Autodetect**, **Dockerfile**, or **Buildpack** without a config file, Cloud Build ignores `cloudbuild.yaml` and uses the default `gcr.io` image name, which leads to the push error. + +## Artifact Registry + +The config expects this Artifact Registry repo to exist: + +- **Name:** `cloud-run-source-deploy` +- **Region:** `us-central1` +- **Project:** your `$PROJECT_ID` (e.g. `sokana-private-data`) + +If it doesn’t exist, create it (e.g. in Console: Artifact Registry → Create repository → format **Docker**, region **us-central1**), or ensure the Cloud Build service account has **Artifact Registry Writer** (and, if you want create-on-push, the right permissions for that). + +## Summary + +- **In the code:** Nothing was changed to introduce `gcr.io`; the repo has always pushed to Artifact Registry via `cloudbuild.yaml`. +- **Fix:** In GCP, set the trigger to use **Cloud Build configuration file** → **`cloudbuild.yaml`** so the build uses Artifact Registry and stops trying to push to `gcr.io`. diff --git a/docs/CLOUD_SQL_FULL_SCHEMA.md b/docs/CLOUD_SQL_FULL_SCHEMA.md new file mode 100644 index 00000000..85367035 --- /dev/null +++ b/docs/CLOUD_SQL_FULL_SCHEMA.md @@ -0,0 +1,130 @@ +# Google Cloud SQL – full schema (from repo) + +This is the schema defined in the backend repo for Cloud SQL. Apply in order: **step3** first, then **phi_notes** if you use PHI notes. + +--- + +## 1. Core tables (`migrations/step3_create_cloudsql_schema.sql`) + +### Table: `clients` + +| Column | Type | Default | Notes | +|--------|------|---------|--------| +| id | UUID | gen_random_uuid() | PK | +| user_id | UUID | — | Supabase auth.users.id | +| first_name | VARCHAR(100) | — | PHI | +| last_name | VARCHAR(100) | — | PHI | +| email | VARCHAR(255) | — | UNIQUE, PHI | +| phone_number | VARCHAR(20) | — | PHI | +| date_of_birth | DATE | — | PHI | +| due_date | DATE | — | PHI | +| address_line1 | VARCHAR(255) | — | PHI | +| address_line2 | VARCHAR(255) | — | PHI | +| city | VARCHAR(100) | — | | +| state | VARCHAR(50) | — | | +| zip_code | VARCHAR(10) | — | | +| country | VARCHAR(50) | 'USA' | | +| health_history | TEXT | — | PHI | +| health_notes | TEXT | — | PHI | +| allergies | TEXT | — | PHI | +| medications | TEXT | — | PHI | +| status | VARCHAR(50) | 'pending' | | +| service_needed | VARCHAR(100) | — | | +| portal_status | VARCHAR(50) | 'not_invited' | | +| invited_at | TIMESTAMP | — | | +| last_invite_sent_at | TIMESTAMP | — | | +| invite_sent_count | INTEGER | 0 | | +| profile_picture | TEXT | — | | +| pronouns | VARCHAR(50) | — | | +| preferred_name | VARCHAR(100) | — | | +| payment_method | VARCHAR(50) | — | | +| home_type | VARCHAR(100) | — | | +| service_specifics | TEXT | — | | +| service_support_details | TEXT | — | | +| services_interested | JSONB | '[]' | | +| baby_name | VARCHAR(100) | — | | +| baby_sex | VARCHAR(20) | — | | +| number_of_babies | INTEGER | — | | +| birth_hospital | VARCHAR(255) | — | | +| provider_type | VARCHAR(100) | — | | +| pregnancy_number | INTEGER | — | | +| had_previous_pregnancies | BOOLEAN | — | | +| previous_pregnancies_count | INTEGER | — | | +| living_children_count | INTEGER | — | | +| past_pregnancy_experience | TEXT | — | | +| race_ethnicity | VARCHAR(100) | — | | +| primary_language | VARCHAR(50) | 'English' | | +| client_age_range | VARCHAR(50) | — | | +| insurance | VARCHAR(100) | — | | +| annual_income | VARCHAR(50) | — | | +| preferred_contact_method | VARCHAR(50) | — | | +| relationship_status | VARCHAR(50) | — | | +| referral_source | VARCHAR(100) | — | | +| referral_name | VARCHAR(100) | — | | +| referral_email | VARCHAR(255) | — | | +| requested_at | TIMESTAMP | CURRENT_TIMESTAMP | | +| updated_at | TIMESTAMP | CURRENT_TIMESTAMP | | +| created_at | TIMESTAMP | CURRENT_TIMESTAMP | | + +**Indexes:** idx_clients_user_id, idx_clients_email, idx_clients_status, idx_clients_updated_at, idx_clients_portal_status +**Trigger:** update_clients_updated_at (sets updated_at on UPDATE) + +--- + +### Table: `assignments` + +| Column | Type | Default | Notes | +|--------|------|---------|--------| +| id | UUID | gen_random_uuid() | PK | +| client_id | UUID | — | FK → clients(id) ON DELETE CASCADE | +| doula_id | UUID | — | Supabase auth user | +| assigned_by | UUID | — | Supabase auth user (admin) | +| status | VARCHAR(50) | 'active' | | +| assigned_at | TIMESTAMP | CURRENT_TIMESTAMP | | +| unassigned_at | TIMESTAMP | — | | +| created_at | TIMESTAMP | CURRENT_TIMESTAMP | | + +**Indexes:** idx_assignments_client_id, idx_assignments_doula_id, idx_assignments_status + +--- + +### Table: `activities` + +| Column | Type | Default | Notes | +|--------|------|---------|--------| +| id | UUID | gen_random_uuid() | PK | +| client_id | UUID | — | FK → clients(id) ON DELETE CASCADE | +| created_by | UUID | — | Supabase auth user | +| activity_type | VARCHAR(50) | — | | +| content | TEXT | — | | +| created_at | TIMESTAMP | CURRENT_TIMESTAMP | | + +**Indexes:** idx_activities_client_id, idx_activities_created_at + +--- + +## 2. Optional: `phi_notes` (`migrations/phi_notes_dedupe_index.sql`) + +| Column | Type | Default | Notes | +|--------|------|---------|--------| +| id | UUID | gen_random_uuid() | PK | +| client_id | UUID | — | NOT NULL | +| note_date | DATE | — | NOT NULL | +| title | TEXT | — | | +| note_content | TEXT | — | | +| created_at | TIMESTAMPTZ | now() | NOT NULL | + +**Unique index:** uq_phi_notes_migration_dedupe on (client_id, note_date, md5(coalesce(title,'') \|\| '|' \|\| coalesce(note_content,''))) +**Function:** insert_phi_note(client_id, note_date, title, note_content) → (out_id, out_inserted) for idempotent insert + +--- + +## Applying the schema + +1. **Core (required for backend with Cloud SQL):** + Run `migrations/step3_create_cloudsql_schema.sql` in your Cloud SQL instance (psql or Cloud Console). + +2. **PHI notes (optional):** + Run `migrations/phi_notes_dedupe_index.sql` if you use phi_notes. + +The backend uses **only** the `clients` and `assignments` tables (and optionally `activities` / `phi_notes`) from Cloud SQL; auth stays in Supabase. diff --git a/docs/CLOUD_SQL_LOCAL_TEST.md b/docs/CLOUD_SQL_LOCAL_TEST.md new file mode 100644 index 00000000..f52893dd --- /dev/null +++ b/docs/CLOUD_SQL_LOCAL_TEST.md @@ -0,0 +1,141 @@ +# Cloud SQL local dev and E2E test + +Backend reads/writes client data from **Google Cloud SQL** (database: `sokana_private`). Supabase is **auth only**. This doc covers local connection and testing. + +## Env vars (required for backend) + +```bash +# Cloud SQL (required — backend fails fast on boot if missing) +CLOUD_SQL_HOST=127.0.0.1 +CLOUD_SQL_PORT=5433 +CLOUD_SQL_DATABASE=sokana_private +CLOUD_SQL_USER=app_user +# In .env use as-is; in shell use single quotes: CLOUD_SQL_PASSWORD='StrongPass_2026!NoSymbolsWeird' +CLOUD_SQL_PASSWORD=StrongPass_2026!NoSymbolsWeird +CLOUD_SQL_SSLMODE=disable + +# Supabase (auth only) +SUPABASE_URL=https://.supabase.co +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_ANON_KEY= +``` + +Optional: `CLOUD_SQL_SSLMODE=require` (or `verify-full`) for production. + +## 1) Start Cloud SQL Proxy + +Use the correct connection name for your instance (e.g. `sokana-private-data:us-central1:sokana-phi-postgres`) and bind to `127.0.0.1:5433`: + +```bash +cloud-sql-proxy --port 5433 sokana-private-data:us-central1:sokana-phi-postgres +``` + +Or with TCP: + +```bash +cloud-sql-proxy --address 127.0.0.1 --port 5433 sokana-private-data:us-central1:sokana-phi-postgres +``` + +Leave this running in a terminal. + +## 2) Export env and start backend + +In another terminal, from the backend repo root. **Use single quotes around the password** so zsh doesn’t treat `!` as history expansion: + +```bash +export CLOUD_SQL_HOST=127.0.0.1 +export CLOUD_SQL_PORT=5433 +export CLOUD_SQL_DATABASE=sokana_private +export CLOUD_SQL_USER=app_user +export CLOUD_SQL_PASSWORD='StrongPass_2026!NoSymbolsWeird' +export CLOUD_SQL_SSLMODE=disable + +npm run dev +``` + +Alternatively, put these in `.env` (no need to quote there) and run `npm run dev`; the app loads `.env` via dotenv. + +Backend will fail on startup if any required Cloud SQL env var is missing. + +## 3) (One-time) Add backend columns to phi_clients + +If `phi_clients` was created without the backend columns, run the migration (with Cloud SQL Proxy running): + +```bash +psql "host=127.0.0.1 port=5433 dbname=sokana_private user=app_user password=YOUR_PASSWORD" -f migrations/alter_phi_clients_backend_columns.sql +``` + +Use single quotes around the password in the shell if it contains `!`. Or set `PGPASSWORD='...'` and omit `password=` from the connection string. + +## 4) Run E2E test (login + GET /clients) + +Using the script that logs in and then calls GET /clients (cookie or Bearer): + +```bash +TEST_ADMIN_EMAIL=jerrybony5@gmail.com TEST_ADMIN_PASSWORD=Bony5690 npx tsx scripts/fetch-cloudsql-data.ts +``` + +- **Expected:** HTTP 200 and JSON with `{ success: true, data: [...], meta: { count: N } }` (client list from Cloud SQL). No 503. +- **If 503:** Cloud SQL env vars are not set or proxy is not running. + +## 5) Optional: Bearer token + +Login response includes `token`. You can call protected routes with: + +```bash +TOKEN=$(curl -s -X POST http://localhost:5050/auth/login -H "Content-Type: application/json" -d '{"email":"jerrybony5@gmail.com","password":"Bony5690"}' | jq -r '.token') +curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:5050/clients?limit=5" +``` + +## 6) Health check + +Public endpoint (no auth): + +```bash +curl -s http://localhost:5050/health +``` + +Expected: `{"status":"ok","service":"sokana-private-api","timestamp":"..."}` + +## 7) Cloud SQL read/write test (no real data) + +Verifies that the **test user** (app DB user) can read and write to Cloud SQL using a dedicated test table only (no `phi_clients`, `payments`, or other real data): + +```bash +export CLOUD_SQL_HOST=127.0.0.1 CLOUD_SQL_PORT=5433 CLOUD_SQL_DATABASE=sokana_private CLOUD_SQL_USER=app_user CLOUD_SQL_PASSWORD='YourPassword' CLOUD_SQL_SSLMODE=disable +npx tsx scripts/test-cloudsql-read-write.ts +``` + +The script creates `cloudsql_connectivity_test` if missing, inserts one row, selects it back, deletes it, and exits with **PASS** or **FAIL**. Use this to confirm read and write work before relying on the app. + +--- + +## Login troubleshooting + +**Routes:** The backend accepts **POST /auth/login** and **POST /login** (alias). The frontend must call one of these; **POST /login** alone (without the `/auth` prefix) is supported so both base paths work. + +**GET /auth/me and "No token found":** After a successful login the backend sets a cookie `sb-access-token` and returns `token` in the JSON. For **GET /auth/me** the backend looks for the token in: **X-Session-Token** header, **Authorization: Bearer <token>** header, or **cookie** `sb-access-token`. The frontend must send one of these (e.g. `credentials: 'include'` for cookies, or `Authorization: Bearer <token>`). + +### "Invalid login credentials" + +Login is validated **only by Supabase Auth** (`auth.users`). The backend does not check a Cloud SQL users table for passwords. + +1. **Same Supabase project** + Ensure `.env` has `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` for the **same** Supabase project where the user was created. If the backend points at a different project (or anon key from another project), sign-in will fail. + +2. **User exists and is allowed to sign in** + In Supabase Dashboard → Authentication → Users, confirm the user exists and is not disabled. If "Confirm email" is enabled, the user must have confirmed their email (or you confirm them in the dashboard). + +3. **Reset password to a known value** + From the backend repo (with the same Supabase env loaded): + ```bash + ADMIN_EMAIL=jerrybony5@gmail.com ADMIN_NEW_PASSWORD=Bony5690 npx tsx scripts/set-admin-password.ts + ``` + Then log in with that exact email and password (no extra spaces). + +4. **Backend logs** + On failed login the backend logs a line like: + ```text + [auth] Login failed { email: '...', reason: 'Invalid login credentials' } + ``` + Use it to confirm which email is being checked and the exact Supabase error (e.g. "Email not confirmed"). diff --git a/docs/CLOUD_SQL_MIGRATION.md b/docs/CLOUD_SQL_MIGRATION.md new file mode 100644 index 00000000..31137007 --- /dev/null +++ b/docs/CLOUD_SQL_MIGRATION.md @@ -0,0 +1,95 @@ +# Cloud SQL migration – backend + +Use this when client data lives in **Google Cloud SQL** and auth stays in **Supabase**. + +## 1. Run the schema on Cloud SQL + +Connect to your Cloud SQL instance and run: + +```bash +# From repo root, connect (adjust instance and user): +# gcloud sql connect YOUR_INSTANCE --user=postgres --database=YOUR_DB + +# Then run: +\i migrations/step3_create_cloudsql_schema.sql +``` + +Or paste the contents of `migrations/step3_create_cloudsql_schema.sql` into the Cloud SQL Studio / psql. + +This creates: + +- `clients` (unified PHI + operational) +- `assignments` +- `activities` + +## 2. Configure backend env + +In `.env` (or your deployment env), set: + +```bash +# Cloud SQL (required for backend to use Cloud SQL for client data) +CLOUD_SQL_HOST=YOUR_CLOUD_SQL_IP_OR_PRIVATE_IP +CLOUD_SQL_DATABASE=postgres +CLOUD_SQL_USER=your_user +CLOUD_SQL_PASSWORD=your_password +CLOUD_SQL_PORT=5432 + +# Keep Supabase for auth +SUPABASE_URL=https://xxx.supabase.co +SUPABASE_ANON_KEY=... +SUPABASE_SERVICE_ROLE_KEY=... + +# Primary mode so list/detail use the repo (Cloud SQL or Supabase) +SPLIT_DB_READ_MODE=primary +``` + +If `CLOUD_SQL_HOST` is **not** set, the backend keeps using **Supabase** for client data (client_info, etc.). + +## 3. Test authenticated access + +From repo root: + +```bash +# Load env (optional; or export vars manually) +set -a && source .env && set +a + +# Use script (uses TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, BACKEND_URL from env) +./scripts/test-cloudsql-auth.sh +``` + +Or manually: + +```bash +export BASE_URL=http://localhost:5050 # or your backend URL +export EMAIL=jerrybony5@gmail.com +export PASSWORD='@Bony5690' + +# 1) Login (saves cookie to cookies.txt) +curl -s -c cookies.txt -X POST "$BASE_URL/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" + +# 2) Who am I +curl -s -b cookies.txt "$BASE_URL/auth/me" + +# 3) Get clients (should return list from Cloud SQL when CLOUD_SQL_HOST is set) +curl -s -b cookies.txt "$BASE_URL/clients?limit=5" +``` + +## 4. Optional: seed a test client in Cloud SQL + +```sql +INSERT INTO clients (id, first_name, last_name, email, status, service_needed, requested_at, updated_at) +VALUES ( + gen_random_uuid(), + 'Test', + 'Client', + 'test@example.com', + 'pending', + 'Birth Support', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); +``` + +Then run the test again; you should see this client in `GET /clients`. diff --git a/docs/CLOUD_SQL_QUICKBOOKS_STRIPE_SCHEMA.md b/docs/CLOUD_SQL_QUICKBOOKS_STRIPE_SCHEMA.md new file mode 100644 index 00000000..456c3a5e --- /dev/null +++ b/docs/CLOUD_SQL_QUICKBOOKS_STRIPE_SCHEMA.md @@ -0,0 +1,126 @@ +# Cloud SQL schema for QuickBooks and Stripe (post–Supabase) + +**Note:** When Supabase is removed for data storage, QuickBooks and Stripe customer/sync data must live in **Google Cloud SQL**. This doc records the agreed approach. + +--- + +## 1. QuickBooks OAuth tokens → dedicated table + +- **Table:** `quickbooks_tokens` in Cloud SQL. +- **Purpose:** Store the connected company’s refresh token (and related fields) so the app can call the QuickBooks API and know “QuickBooks is connected.” +- **Implemented:** `src/utils/tokenUtils.ts` now reads and writes **Google Cloud SQL** only (getTokenFromDatabase, saveTokensToDatabase, deleteTokens). The QuickBooks connection/callback route stores tokens in Cloud SQL. Optional env: `QUICKBOOKS_ENVIRONMENT` (default `production`; set to `sandbox` when using QuickBooks Sandbox so the token row matches). + +--- + +## Local testing (QuickBooks connection → Cloud SQL) + +**Should you test locally?** Yes. That’s the right place to confirm the connect flow saves tokens to Cloud SQL. + +**1. Confirm sandbox is on** + +- In `.env`: `QBO_ENV=sandbox` (OAuth uses Intuit Sandbox). +- For token storage to match, also set **`QUICKBOOKS_ENVIRONMENT=sandbox`** so `tokenUtils` reads/writes the row with `environment = 'sandbox'`. If you leave it unset, it defaults to `production` and the token is stored with `environment = 'production'` (still in Cloud SQL). +- QuickBooks routes only exist when **`FEATURE_QUICKBOOKS=true`** (or `1`). Add to `.env` if needed. + +**2. Prerequisites** + +- Cloud SQL Proxy running on `127.0.0.1:5433`. +- Cloud SQL env set: `CLOUD_SQL_HOST=127.0.0.1`, `CLOUD_SQL_PORT=5433`, `CLOUD_SQL_DATABASE=sokana_private`, `CLOUD_SQL_USER`, `CLOUD_SQL_PASSWORD`. +- QuickBooks: `QB_CLIENT_ID`, `QB_CLIENT_SECRET`, `QB_REDIRECT_URI` (e.g. `http://localhost:5050/quickbooks/callback`). + +**3. Test steps** + +1. Start backend: `npm run dev`. +2. In the app (or via browser), go to the “Connect to QuickBooks” flow. That hits the auth URL route (e.g. GET `/quickbooks/auth` or `/quickbooks/auth/url`), which redirects to Intuit. +3. Sign in with your **Sandbox** QuickBooks company and authorize. You are redirected to `QB_REDIRECT_URI` (e.g. `http://localhost:5050/quickbooks/callback?...`). +4. The callback handler exchanges the code for tokens and calls `saveTokens()` → tokens are written to **Cloud SQL** `public.quickbooks_tokens`. +5. **Verify in Cloud SQL:** + + ```bash + PGPASSWORD='YourPassword' psql -h 127.0.0.1 -p 5433 -U app_user -d sokana_private -c "SELECT id, realm_id, environment, access_token_expires_at, updated_at FROM public.quickbooks_tokens;" + ``` + + You should see one row with `realm_id` and `environment` = `sandbox` (if you set `QUICKBOOKS_ENVIRONMENT=sandbox`) or `production`. + +**4. Optional: status route** + +- GET `/quickbooks/status` (if implemented) may report connected and use `getTokenFromDatabase()`; that now reads from Cloud SQL. + +--- + +## 2. Customer ↔ Stripe and QuickBooks → columns on `phi_clients` + +- **No separate “customers” table required.** +- Add to **`phi_clients`** (or ensure they exist): + - **`stripe_customer_id`** – Stripe customer ID for this client. + - **`qbo_customer_id`** – QuickBooks customer ID for this client. +- Then `ensureStripeCustomer` and `ensureCustomerInQuickBooks` read/write Cloud SQL instead of Supabase `customers`. + +--- + +## 3. Payment ↔ QuickBooks sync status → columns on `payments` + +- **No separate “QuickBooks payment” table required.** +- Add to **`payments`**: + - **`qbo_payment_id`** – QuickBooks payment ID after successful sync. + - **`qb_sync_status`** – e.g. `'pending'`, `'synced'`, `'failed'`. + - **`qb_sync_error`** – error message when sync fails (nullable). +- Then QuickBooks sync reads/updates the payment row in Cloud SQL instead of Supabase `charges`. + +--- + +## Summary + +| Data | Where in Cloud SQL | +|------|--------------------| +| QuickBooks OAuth tokens | New table: **`quickbooks_tokens`** | +| Stripe / QuickBooks customer IDs | Columns on **`phi_clients`**: `stripe_customer_id`, `qbo_customer_id` | +| QuickBooks payment sync status | Columns on **`payments`**: `qbo_payment_id`, `qb_sync_status`, `qb_sync_error` | + +--- + +## How to run (terminal) + +With Cloud SQL Proxy running (e.g. listening on `127.0.0.1:5433`), from the repo root: + +```bash +PGPASSWORD='StrongPass_2026!NoSymbolsWeird' \ +psql -h 127.0.0.1 -p 5433 -U app_user -d sokana_private -f migrations/cloudsql_quickbooks_stripe_columns.sql +``` + +Or with a heredoc (same SQL as in the file): + +```bash +PGPASSWORD='StrongPass_2026!NoSymbolsWeird' \ +psql -h 127.0.0.1 -p 5433 -U app_user -d sokana_private <<'SQL' +# ... paste contents of migrations/cloudsql_quickbooks_stripe_columns.sql ... +SQL +``` + +**Verify:** + +```bash +PGPASSWORD='StrongPass_2026!NoSymbolsWeird' \ +psql -h 127.0.0.1 -p 5433 -U app_user -d sokana_private -c " +SELECT table_name, column_name, data_type +FROM information_schema.columns +WHERE table_schema='public' + AND ( + (table_name='phi_clients' AND column_name IN ('stripe_customer_id','qbo_customer_id')) + OR (table_name='payments' AND column_name IN ('qbo_payment_id','qb_sync_status','qb_sync_error')) + OR (table_name='quickbooks_tokens') + ) +ORDER BY table_name, ordinal_position; +" +``` + +**Note:** The `payments` block runs only if the `payments` table exists; if you don’t have it yet, that block is skipped (no error). + +--- + +## quickbooks_tokens column mapping (for later code change) + +The Cloud SQL table uses **`access_token_expires_at`** (and optionally `refresh_token_expires_at`). Current `tokenUtils.ts` expects **`expires_at`**. When switching QuickBooks token storage from Supabase to Cloud SQL, either: + +- Add a column **`expires_at`** and keep it in sync with `access_token_expires_at`, or +- Change `tokenUtils` to read/write **`access_token_expires_at`** and use that as the single “expires at” for the access token. diff --git a/docs/CLOUD_SQL_SOKANA_PRIVATE_SCHEMA.md b/docs/CLOUD_SQL_SOKANA_PRIVATE_SCHEMA.md new file mode 100644 index 00000000..3cd4e23d --- /dev/null +++ b/docs/CLOUD_SQL_SOKANA_PRIVATE_SCHEMA.md @@ -0,0 +1,188 @@ +# Google Cloud SQL — sokana_private schema outline + +Target database: **sokana_private** (PostgreSQL, accessed via Cloud SQL Proxy). + +--- + +## Admin login and profiles (planned) + +- **Admin login:** `jerrybony5@gmail.com` should be able to log in as admin. Role is read from Supabase Auth (`user_metadata.role` or `app_metadata.role`). Ensure that user has `role: 'admin'` in Supabase Dashboard → Authentication → Users → (user) → Edit → User Metadata or App Metadata. +- **Profiles table:** A table will be created to hold profiles (see `migrations/create_profiles_table_placeholder.sql`). **Who is in stages of progress and who should have a profile will be decided later**; the placeholder table can be extended once that is defined. + +--- + +## 1. Overview + +| Schema | Purpose | +|--------|--------| +| **public** | All application and migration tables | + +**PHI tables (migration + app):** `phi_clients`, `phi_notes`, `phi_contracts`, `phi_events`, `phi_invoices`, `phi_time_track`, `phi_access_audit` +**Non-PHI tables (migration):** `library_items`, `expenses`, `payments` + +--- + +## 2. Primary keys + +| Table | Constraint | Column(s) | Type | +|-------|------------|-----------|------| +| phi_clients | phi_clients_pkey | id | uuid | +| phi_notes | phi_notes_pkey | id | uuid | +| phi_contracts | phi_contracts_pkey | id | uuid | +| phi_events | phi_events_pkey | id | uuid | +| phi_invoices | phi_invoices_pkey | id | uuid | +| phi_time_track | phi_time_track_pkey | id | uuid | +| phi_access_audit | phi_access_audit_pkey | id | bigint (serial) | +| library_items | library_items_pkey | id | integer (serial) | +| expenses | expenses_pkey | id | integer (serial) | +| payments | payments_pkey | id | integer (serial) | + +--- + +## 3. Foreign keys and relationships + +| Child table | FK column | Parent table | Parent PK | Constraint name | ON DELETE | +|-------------|-----------|--------------|-----------|------------------|-----------| +| phi_notes | client_id | phi_clients | id | phi_notes_client_id_fkey | CASCADE | +| phi_contracts | client_id | phi_clients | id | phi_contracts_client_id_fkey | CASCADE | +| phi_events | client_id | phi_clients | id | phi_events_client_id_fkey | CASCADE | +| phi_invoices | client_id | phi_clients | id | phi_invoices_client_id_fkey | CASCADE | +| phi_time_track | client_id | phi_clients | id | phi_time_track_client_id_fkey | CASCADE | +| payments | client_id | phi_clients | id | payments_client_id_fkey | (default) | + +**Relationship summary:** `phi_clients` is the only parent; all PHI child tables reference it with ON DELETE CASCADE (except `payments`, nullable, no CASCADE). `phi_access_audit` has no FK; it links via `resource_type` + `resource_id`. `library_items` and `expenses` have no foreign keys. + +--- + +## 4. Full table definitions (summary) + +### public.phi_clients + +| Column | Type | Nullable | Default | +|--------|------|----------|---------| +| id | uuid | NO | — | +| first_name | text | NO | — | +| last_name | text | NO | — | +| email | text | YES | — | +| phone | text | YES | — | +| date_of_birth | date | YES | — | +| address_line1 | text | YES | — | +| due_date | date | YES | — | +| health_history | text | YES | — | +| allergies | text | YES | — | +| medications | text | YES | — | +| created_at | timestamptz | YES | now() | +| updated_at | timestamptz | YES | now() | +| client_id | uuid | YES | — | +| health_notes | text | YES | — | +| pregnancy_number | integer | YES | — | +| had_previous_pregnancies | boolean | YES | — | +| previous_pregnancies_count | integer | YES | — | +| living_children_count | integer | YES | — | +| past_pregnancy_experience | text | YES | — | +| baby_sex | text | YES | — | +| baby_name | text | YES | — | +| number_of_babies | integer | YES | — | +| race_ethnicity | text | YES | — | +| client_age_range | text | YES | — | +| referral_source | text | YES | — | +| referral_name | text | YES | — | +| referral_email | text | YES | — | +| referral_source_other | text | YES | — | +| annual_income | text | YES | — | +| insurance | text | YES | — | +| payment_method | text | YES | — | +| insurance_provider | text | YES | — | +| insurance_member_id | text | YES | — | +| insurance_policy_holder_name | text | YES | — | +| insurance_policy_holder_dob | date | YES | — | +| insurance_policy_holder_relationship | text | YES | — | +| insurance_plan_type | text | YES | — | +| policy_number | text | YES | — | +| insurance_phone_number | text | YES | — | +| has_secondary_insurance | boolean | YES | — | +| secondary_insurance_provider | text | YES | — | +| secondary_insurance_member_id | text | YES | — | +| secondary_policy_number | text | YES | — | +| self_pay_card_info | text | YES | — | + +**Billing / insurance semantics** + +- `insurance` — optional legacy/display field; CRM may mirror `insurance_provider`. +- `payment_method` — Self-Pay, Commercial Insurance, Private Insurance, Medicaid. +- `insurance_policy_holder_*`, `insurance_plan_type` — added in `src/db/migrations/add_phi_clients_expanded_primary_insurance.sql` (policy holder PHI; plan type: HMO, PPO, EPO, POS, HDHP, Medicaid, Medicare, Other; relationship: Self, Spouse, Partner, Parent, Child, Sibling, Other). +- `policy_number` — group number; **optional** for Commercial, Private, and Medicaid (see `add_client_billing_fields.sql` for initial billing columns; secondary billing in `add_phi_clients_secondary_billing_fields.sql`). + +**Indexes:** phi_clients_pkey (id), idx_phi_clients_email (email). Migration: upsert on `id` (ON CONFLICT DO UPDATE). + +### public.phi_notes + +| Column | Type | Nullable | Default | +|--------|------|----------|---------| +| id | uuid | NO | gen_random_uuid() | +| client_id | uuid | NO | — | +| note_date | timestamptz | NO | — | +| title | text | YES | — | +| note_content | text | YES | '' | +| created_at | timestamptz | YES | now() | + +**Unique index (dedupe):** `uq_phi_notes_migration_dedupe` UNIQUE (`client_id`, `note_date`, `md5(coalesce(title,'') || '|' || coalesce(note_content,'')))`. Migration uses ON CONFLICT on this for idempotent inserts. + +### public.phi_contracts, phi_events, phi_invoices, phi_time_track, phi_access_audit + +See full outline in repo; all reference `phi_clients(id)` except phi_access_audit (logical link via resource_id/type). + +### public.library_items, expenses, payments + +Non-PHI; payments has optional `client_id` → phi_clients(id). + +--- + +## 5. Backend alignment (Express app) + +- **Database name:** Set `CLOUD_SQL_DATABASE=sokana_private` in `.env`. +- **Client table:** Backend uses **`phi_clients`** (not `clients`). Column **`phone`** is mapped to app `phone_number`. +- **Backend-required columns on phi_clients:** For list/detail and role scoping, the backend expects these columns on `phi_clients`. If missing, add them with `migrations/alter_phi_clients_backend_columns.sql`: + - `status`, `service_needed`, `portal_status`, `user_id`, `requested_at` + - `invited_at`, `last_invite_sent_at`, `invite_sent_count` (for portal flows) + - **Expanded primary insurance (Medicaid parity):** `insurance_policy_holder_name`, `insurance_policy_holder_dob`, `insurance_policy_holder_relationship`, `insurance_plan_type` — see `src/db/migrations/add_phi_clients_expanded_primary_insurance.sql` + - **Intake referral (CRM):** `referral_source`, `referral_name`, `referral_email`, `referral_source_other` — see `src/db/migrations/add_phi_clients_referral_intake_fields.sql` (`referral_source_other` required in app when source is `Other`). +- **Doula scoping:** Use `public.assignments` (FK to `phi_clients(id)`). Create it with `migrations/create_phi_assignments_if_not_exists.sql` so the backend can filter clients by doula. +- **Creating clients:** A client profile will usually need to be created in **Google Cloud SQL** when creating new clients from the app (e.g. insert into `phi_clients`). Ensure the backend or a sync job inserts into `phi_clients` when a new client is added so list/detail stay in sync. If using a separate Cloud SQL instance, ensure a **database/user (client) profile** exists there for the app to connect (e.g. Cloud SQL Proxy and DB user for `sokana_private`). + +--- + +## 6. Truncate order + +```sql +TRUNCATE TABLE + public.phi_notes, + public.phi_contracts, + public.phi_events, + public.phi_time_track, + public.phi_invoices, + public.phi_access_audit, + public.phi_clients +RESTART IDENTITY CASCADE; +``` + +Non-PHI tables (`library_items`, `expenses`, `payments`) truncated separately if needed. + +--- + +## 7. Migration script mapping + +| CSV / source | Table | Idempotency | +|--------------|--------|-------------| +| Clients.csv | phi_clients | ON CONFLICT (id) DO UPDATE | +| ClientNote.csv | phi_notes | ON CONFLICT (client_id, note_date, md5(...)) DO NOTHING | +| Contracts.csv | phi_contracts | Insert only | +| Events.csv | phi_events | Insert only | +| Invoices.csv | phi_invoices | ON CONFLICT (id) DO NOTHING | +| TimeTrack.csv | phi_time_track | Insert only | +| — (per client) | phi_access_audit | ON CONFLICT (partial index) DO NOTHING | +| LibraryItems.csv | library_items | Insert only | +| Expenses.csv | expenses | Insert only | +| Payments.csv | payments | Insert only | + +For exact DDL, run `pg_dump -s` against the database. diff --git a/docs/CONSOLIDATE_TO_CLOUD_SQL.md b/docs/CONSOLIDATE_TO_CLOUD_SQL.md new file mode 100644 index 00000000..3e7ab23b --- /dev/null +++ b/docs/CONSOLIDATE_TO_CLOUD_SQL.md @@ -0,0 +1,264 @@ +# Migration Plan: Consolidate to Google Cloud SQL + +## Overview +Migrate from split architecture (Supabase + Cloud SQL) to single Cloud SQL database. + +--- + +## Step 1: Prepare Cloud SQL Database + +### Add operational columns to existing `clients` table + +```sql +-- Add operational fields to clients table in Cloud SQL +ALTER TABLE clients +ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'pending', +ADD COLUMN IF NOT EXISTS service_needed VARCHAR(100), +ADD COLUMN IF NOT EXISTS portal_status VARCHAR(50), +ADD COLUMN IF NOT EXISTS requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN IF NOT EXISTS invited_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS last_invite_sent_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS invite_sent_count INTEGER DEFAULT 0, +ADD COLUMN IF NOT EXISTS profile_picture TEXT, +ADD COLUMN IF NOT EXISTS pronouns VARCHAR(50), +ADD COLUMN IF NOT EXISTS preferred_name VARCHAR(100), +ADD COLUMN IF NOT EXISTS payment_method VARCHAR(50), +ADD COLUMN IF NOT EXISTS home_type VARCHAR(100); + +-- Add indexes for common queries +CREATE INDEX IF NOT EXISTS idx_clients_status ON clients(status); +CREATE INDEX IF NOT EXISTS idx_clients_updated_at ON clients(updated_at); + +-- Add update trigger +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_clients_updated_at BEFORE UPDATE +ON clients FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +``` + +--- + +## Step 2: Export Data from Supabase + +```bash +# Export client_info table from Supabase +# Go to Supabase Dashboard → Table Editor → client_info → Export as CSV + +# Or use SQL: +COPY ( + SELECT + id, + status, + service_needed, + portal_status, + requested_at, + updated_at, + invited_at, + profile_picture + FROM client_info +) TO '/tmp/supabase_operational_data.csv' WITH CSV HEADER; +``` + +--- + +## Step 3: Import to Cloud SQL + +```bash +# Upload CSV to Cloud Storage +gsutil cp /tmp/supabase_operational_data.csv gs://your-bucket/ + +# Import to Cloud SQL +gcloud sql import csv your-instance-name \ + gs://your-bucket/supabase_operational_data.csv \ + --database=your-database \ + --table=clients +``` + +--- + +## Step 4: Update Backend to Use Cloud SQL Only + +### Replace Supabase Client with Cloud SQL + +**Before (Supabase):** +```typescript +import supabase from './supabase'; + +const { data } = await supabase + .from('client_info') + .select('*') + .eq('id', clientId); +``` + +**After (Cloud SQL):** +```typescript +import { Pool } from 'pg'; + +const pool = new Pool({ + host: process.env.CLOUD_SQL_HOST, + database: process.env.CLOUD_SQL_DATABASE, + user: process.env.CLOUD_SQL_USER, + password: process.env.CLOUD_SQL_PASSWORD, +}); + +const { rows } = await pool.query( + 'SELECT * FROM clients WHERE id = $1', + [clientId] +); +``` + +--- + +## Step 5: Update Repository Pattern + +**Create unified client repository:** + +```typescript +// src/repositories/cloudSqlClientRepository.ts +import { Pool } from 'pg'; + +export class CloudSqlClientRepository { + private pool: Pool; + + constructor() { + this.pool = new Pool({ + host: process.env.CLOUD_SQL_HOST, + database: process.env.CLOUD_SQL_DATABASE, + user: process.env.CLOUD_SQL_USER, + password: process.env.CLOUD_SQL_PASSWORD, + }); + } + + async getClientById(id: string) { + const { rows } = await this.pool.query( + 'SELECT * FROM clients WHERE id = $1', + [id] + ); + return rows[0]; + } + + async updateClient(id: string, data: Record) { + const fields = Object.keys(data); + const values = Object.values(data); + + const setClause = fields + .map((field, i) => `${field} = $${i + 2}`) + .join(', '); + + const { rows } = await this.pool.query( + `UPDATE clients SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return rows[0]; + } + + async getClients() { + const { rows } = await this.pool.query( + 'SELECT * FROM clients ORDER BY updated_at DESC' + ); + return rows; + } +} +``` + +--- + +## Step 6: Remove Supabase and PHI Broker + +**Delete files:** +- `src/supabase.ts` +- `src/services/phiBrokerService.ts` +- `src/repositories/supabaseClientRepository.ts` + +**Remove dependencies:** +```bash +npm uninstall @supabase/supabase-js +``` + +**Update environment variables:** +```bash +# Remove +- SUPABASE_URL +- SUPABASE_KEY +- PHI_BROKER_URL +- PHI_BROKER_SHARED_SECRET + +# Keep/Add ++ CLOUD_SQL_HOST ++ CLOUD_SQL_DATABASE ++ CLOUD_SQL_USER ++ CLOUD_SQL_PASSWORD +``` + +--- + +## Step 7: Update Controllers + +**Simplified controller (no split logic):** + +```typescript +// src/controllers/clientController.ts +export class ClientController { + private clientRepository: CloudSqlClientRepository; + + async updateClient(req: AuthRequest, res: Response) { + const { id } = req.params; + const updateData = req.body; + + // Simple update - no split, no broker! + const updated = await this.clientRepository.updateClient(id, updateData); + + res.json(ApiResponse.success(updated)); + } +} +``` + +--- + +## Benefits After Migration + +✅ **Single source of truth** - Everything in Cloud SQL +✅ **No PHI Broker overhead** - Direct database access +✅ **No Supabase cost** - One less subscription +✅ **Simpler code** - No split logic +✅ **Full control** - Manage your own database +✅ **HIPAA compliant** - Cloud SQL supports BAA + +--- + +## Estimated Time + +- **Step 1**: 1 hour (add columns) +- **Step 2**: 30 minutes (export) +- **Step 3**: 30 minutes (import) +- **Step 4**: 3 hours (update backend) +- **Step 5**: 2 hours (repository pattern) +- **Step 6**: 1 hour (cleanup) +- **Step 7**: 2 hours (controllers) + +**Total**: ~10 hours + +--- + +## Trade-offs vs Supabase + +### You Lose: +- ❌ Instant REST APIs (need to build) +- ❌ Built-in auth integration +- ❌ Realtime subscriptions +- ❌ Auto-generated SDK +- ❌ Nice dashboard UI + +### You Gain: +- ✅ Full database control +- ✅ Lower cost (no Supabase) +- ✅ Single database +- ✅ Raw SQL power +- ✅ Less vendor lock-in diff --git a/docs/CONSOLIDATE_TO_ONE_DATABASE.md b/docs/CONSOLIDATE_TO_ONE_DATABASE.md new file mode 100644 index 00000000..9d460af7 --- /dev/null +++ b/docs/CONSOLIDATE_TO_ONE_DATABASE.md @@ -0,0 +1,213 @@ +# Migration Plan: Consolidate to One Database (Supabase) + +## Overview +Migrate from split architecture (Supabase + Cloud SQL) to single Supabase database. + +--- + +## Step 1: Add Missing PHI Columns to Supabase + +```sql +-- Add PHI columns to client_info table in Supabase +ALTER TABLE client_info +ADD COLUMN IF NOT EXISTS first_name TEXT, +ADD COLUMN IF NOT EXISTS last_name TEXT, +ADD COLUMN IF NOT EXISTS email TEXT, +ADD COLUMN IF NOT EXISTS phone_number TEXT, +ADD COLUMN IF NOT EXISTS date_of_birth DATE, +ADD COLUMN IF NOT EXISTS due_date DATE, +ADD COLUMN IF NOT EXISTS address_line1 TEXT, +ADD COLUMN IF NOT EXISTS city TEXT, +ADD COLUMN IF NOT EXISTS state TEXT, +ADD COLUMN IF NOT EXISTS zip_code TEXT, +ADD COLUMN IF NOT EXISTS country TEXT DEFAULT 'USA', +ADD COLUMN IF NOT EXISTS health_history TEXT, +ADD COLUMN IF NOT EXISTS health_notes TEXT, +ADD COLUMN IF NOT EXISTS allergies TEXT, +ADD COLUMN IF NOT EXISTS medications TEXT; + +-- Add comments +COMMENT ON COLUMN client_info.first_name IS 'PHI: Client first name'; +COMMENT ON COLUMN client_info.health_history IS 'PHI: Medical history'; +-- ... etc +``` + +--- + +## Step 2: Migrate Existing PHI Data + +```sql +-- Export from Cloud SQL (phi-broker database) +-- Import to Supabase client_info table + +-- Example migration query (run in Cloud SQL first) +SELECT + client_id, + first_name, + last_name, + email, + phone_number, + date_of_birth, + due_date, + address_line1, + city, + state, + zip_code, + health_history, + allergies, + medications +FROM phi_data; + +-- Then import the CSV to Supabase client_info +``` + +--- + +## Step 3: Update Backend Code + +### Remove PHI Broker Service + +**Delete/disable:** +- `src/services/phiBrokerService.ts` (no longer needed) +- PHI Broker environment variables + +### Simplify Client Controller + +**Before (split):** +```typescript +// Split operational and PHI fields +const { operational, phi } = splitClientPatch(normalized); + +// Write operational to Supabase +await clientRepository.updateClientOperational(id, operational); + +// Write PHI to broker (slow!) +await updateClientPhi(id, requester, phi); +``` + +**After (consolidated):** +```typescript +// Write everything to Supabase (fast!) +await clientRepository.updateClient(id, normalized); +``` + +### Remove PUT /clients/:id/phi Endpoint + +**No longer needed** - use `PUT /clients/:id` for everything + +--- + +## Step 4: Enable Row-Level Security (RLS) + +Protect PHI in Supabase with RLS policies: + +```sql +-- Enable RLS on client_info +ALTER TABLE client_info ENABLE ROW LEVEL SECURITY; + +-- Policy: Admins can see everything +CREATE POLICY "Admins can view all clients" +ON client_info FOR SELECT +TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM auth.users + WHERE auth.uid() = id + AND raw_user_meta_data->>'role' = 'admin' + ) +); + +-- Policy: Doulas can only see assigned clients +CREATE POLICY "Doulas can view assigned clients" +ON client_info FOR SELECT +TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM assignments + WHERE assignments.client_id = client_info.id + AND assignments.doula_id = auth.uid() + AND assignments.status = 'active' + ) +); + +-- Policy: Admins and assigned doulas can update +CREATE POLICY "Admins and assigned doulas can update" +ON client_info FOR UPDATE +TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM auth.users + WHERE auth.uid() = id + AND ( + raw_user_meta_data->>'role' = 'admin' + OR EXISTS ( + SELECT 1 FROM assignments + WHERE assignments.client_id = client_info.id + AND assignments.doula_id = auth.uid() + AND assignments.status = 'active' + ) + ) + ) +); +``` + +--- + +## Step 5: Update Frontend + +**Before:** +```typescript +// Had to use special PHI endpoint +await updateClientPhi(clientId, { firstName: 'Jane' }); +``` + +**After:** +```typescript +// Use single endpoint for everything (simpler!) +await updateClient(clientId, { + firstName: 'Jane', + status: 'active', + serviceNeeded: 'Doula' +}); +``` + +--- + +## Benefits After Migration + +✅ **No more split-write logic** +✅ **No more PHI Broker delays** (~1 second per update) +✅ **No more missing column errors** +✅ **Simpler debugging** +✅ **Faster UI updates** +✅ **Single source of truth** +✅ **Still HIPAA compliant** (with RLS + encryption) + +--- + +## Rollback Plan + +If needed, you can always re-enable the PHI Broker later by: +1. Keeping PHI columns in Supabase (no data loss) +2. Re-enabling phiBrokerService.ts +3. Syncing data back to Cloud SQL + +--- + +## Estimated Time + +- **Step 1**: 30 minutes (add columns) +- **Step 2**: 1 hour (migrate data) +- **Step 3**: 2 hours (update backend code) +- **Step 4**: 1 hour (enable RLS) +- **Step 5**: 30 minutes (update frontend) + +**Total**: ~5 hours + +--- + +## Questions? + +1. Do you have existing PHI data in Cloud SQL that needs migration? +2. Do you need help with the actual migration scripts? +3. Should we proceed with consolidation? diff --git a/docs/CONTRACT_GENERATION_SYSTEM.md b/docs/CONTRACT_GENERATION_SYSTEM.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/CONTRACT_ID_PRESERVATION.md b/docs/CONTRACT_ID_PRESERVATION.md new file mode 100644 index 00000000..c76cb8ba --- /dev/null +++ b/docs/CONTRACT_ID_PRESERVATION.md @@ -0,0 +1,193 @@ +# Contract ID Preservation Strategy + +## ✅ **Yes, Contract IDs Will Be Preserved!** + +Here's exactly how the ID preservation works: + +## 🔑 **ID Preservation Plan** + +### 1. **Primary Contract IDs (UUIDs)** +- **Existing contracts**: If they have UUIDs, they're preserved exactly +- **New contracts**: Get new UUIDs generated automatically +- **Format**: `550e8400-e29b-41d4-a716-446655440000` + +### 2. **SignNow Document IDs** +- **Preserved in**: `contracts.signnow_document_id` column +- **Backward compatibility**: You can still query by SignNow ID +- **Format**: SignNow's internal document identifier + +### 3. **Original Data Preservation** +- **All original contract data** stored in `original_contract_data` JSONB column +- **Includes**: client_email, client_name, contract_data, amounts, timestamps +- **Purpose**: Full audit trail and data recovery if needed + +## 📊 **Migration Process** + +### Step 1: Backup +```sql +-- Creates contracts_old_backup table with all original data +CREATE TABLE contracts_old_backup AS +SELECT *, NOW() as backup_created_at FROM contracts; +``` + +### Step 2: Preserve IDs +```sql +-- Preserves existing UUIDs or generates new ones +INSERT INTO contracts_new ( + id, -- ✅ PRESERVED: COALESCE(c.id, gen_random_uuid()) + client_id, -- ✅ LINKED: Tries to match client_info by email + signnow_document_id, -- ✅ PRESERVED: c.signnow_document_id + original_contract_data -- ✅ PRESERVED: All original data in JSONB + -- ... other fields +) +``` + +### Step 3: Status Mapping +```sql +-- Maps old statuses to new ones +CASE + WHEN c.status = 'pending' THEN 'draft' + WHEN c.status = 'signed' THEN 'signed' + WHEN c.status = 'payment_completed' THEN 'active' + WHEN c.status = 'completed' THEN 'completed' + ELSE 'draft' +END as status +``` + +## 🔄 **Backward Compatibility** + +### Your Existing Code Will Still Work: + +```typescript +// This still works after migration +const contract = await contractService.getContractBySignNowId('signnow-doc-123'); + +// This also works +const contract = await contractService.getContractWithClient('contract-uuid-456'); +``` + +### Service Methods Added: +```typescript +// Get contract by SignNow ID (backward compatibility) +await contractService.getContractBySignNowId('signnow-doc-id'); + +// Update contract with SignNow ID +await contractService.updateContractWithSignNowId('contract-id', 'signnow-doc-id'); + +// Get contracts needing manual cleanup +await contractService.getContractsNeedingCleanup(); + +// Manually link contract to client +await contractService.linkContractToClient('contract-id', 'client-id', 'user-id'); +``` + +## 📋 **What Gets Preserved** + +### ✅ **Fully Preserved:** +- **Contract UUIDs** (if they exist) +- **SignNow Document IDs** +- **All original contract data** (JSONB) +- **Timestamps** (created_at, updated_at, signed_at, etc.) +- **Payment information** (amounts, status) +- **Client information** (email, name) + +### 🔄 **Enhanced/Mapped:** +- **Status values** (mapped to new enum) +- **Client linking** (attempts to link to client_info by email) +- **User tracking** (needs manual assignment for existing contracts) + +### 📝 **Needs Manual Cleanup:** +- **Client linking** (if email doesn't match client_info) +- **User assignment** (generated_by field) +- **Template linking** (if using templates) + +## 🚀 **Migration Commands** + +### 1. Run the Migration +```bash +psql -d your_database -f src/db/migrations/migrate_contracts_preserve_ids.sql +``` + +### 2. Check Migration Results +```sql +-- See migration summary +SELECT + 'Migration Summary' as status, + (SELECT COUNT(*) FROM contracts) as total_contracts, + (SELECT COUNT(*) FROM contracts WHERE client_id IS NOT NULL) as contracts_with_clients, + (SELECT COUNT(*) FROM contracts WHERE signnow_document_id IS NOT NULL) as contracts_with_signnow_ids, + (SELECT COUNT(*) FROM contracts WHERE original_contract_data IS NOT NULL) as contracts_with_original_data; +``` + +### 3. Manual Cleanup (if needed) +```typescript +// Get contracts that need manual linking +const contractsNeedingCleanup = await contractService.getContractsNeedingCleanup(); + +// Manually link a contract to a client +await contractService.linkContractToClient( + 'contract-id', + 'client-info-id', + 'user-id' +); +``` + +## 🔍 **Verification** + +### Check ID Preservation: +```sql +-- Compare old vs new +SELECT + old.id as old_contract_id, + new.id as new_contract_id, + old.signnow_document_id, + new.signnow_document_id, + new.original_contract_data->>'client_email' as original_email +FROM contracts_old_backup old +JOIN contracts new ON old.id = new.id; +``` + +### Check Data Integrity: +```sql +-- Verify all original data is preserved +SELECT + id, + signnow_document_id, + original_contract_data->>'client_name' as original_client_name, + original_contract_data->>'client_email' as original_client_email, + original_contract_data->>'deposit_amount' as original_deposit +FROM contracts +WHERE original_contract_data IS NOT NULL; +``` + +## ⚠️ **Important Notes** + +### 1. **Backup Created** +- Original table backed up as `contracts_old_backup` +- Can restore from backup if needed + +### 2. **No Data Loss** +- All original data preserved in `original_contract_data` +- All IDs preserved or properly generated + +### 3. **Gradual Transition** +- Old code continues to work +- New features available immediately +- Manual cleanup can be done over time + +### 4. **Client Linking** +- Attempts automatic linking by email +- Flags contracts that need manual linking +- Provides tools for manual cleanup + +## 🎯 **Result** + +After migration, you'll have: +- ✅ **All contract IDs preserved** +- ✅ **All SignNow document IDs preserved** +- ✅ **All original data preserved** +- ✅ **Proper client_info integration** +- ✅ **Backward compatibility maintained** +- ✅ **New features available** + +Your existing contracts will continue to work exactly as before, but now they'll also be properly integrated with your client management system! diff --git a/docs/CONTRACT_SYSTEM_SETUP.md b/docs/CONTRACT_SYSTEM_SETUP.md new file mode 100644 index 00000000..ec2c02ef --- /dev/null +++ b/docs/CONTRACT_SYSTEM_SETUP.md @@ -0,0 +1,222 @@ +# Contract System Setup - Client Integration + +## Overview + +The contract system has been redesigned to properly integrate with the existing `client_info` table, ensuring contracts are correctly associated with clients/users in the system. + +## Database Schema + +### Main Tables + +#### 1. `contracts` - Main Contract Table +```sql +CREATE TABLE contracts ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + client_id UUID REFERENCES client_info(id) ON DELETE CASCADE, -- ✅ Links to client_info + template_id BIGINT REFERENCES contract_templates(id), + template_name TEXT, + fee TEXT, + deposit TEXT, + note TEXT, + document_url TEXT, + status TEXT DEFAULT 'draft', + generated_by UUID REFERENCES users(id), -- ✅ Links to users table + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() +); +``` + +#### 2. `contract_templates` - Contract Templates +```sql +CREATE TABLE contract_templates ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + storage_path TEXT, + fee TEXT, + deposit TEXT +); +``` + +#### 3. `contract_signnow_integration` - SignNow Integration +```sql +CREATE TABLE contract_signnow_integration ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + contract_id UUID REFERENCES contracts(id) ON DELETE CASCADE, + signnow_document_id VARCHAR(255) UNIQUE, + signnow_envelope_id VARCHAR(255), + signing_url TEXT, + status VARCHAR(50) DEFAULT 'pending', + sent_at TIMESTAMP WITH TIME ZONE, + viewed_at TIMESTAMP WITH TIME ZONE, + signed_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +``` + +#### 4. `contract_payments` - Payment Tracking +```sql +CREATE TABLE contract_payments ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + contract_id UUID REFERENCES contracts(id) ON DELETE CASCADE, + payment_type VARCHAR(50) NOT NULL, -- deposit, installment, final + amount DECIMAL(10,2) NOT NULL, + stripe_payment_intent_id VARCHAR(255) UNIQUE, + status VARCHAR(50) NOT NULL, -- pending, succeeded, failed, canceled, refunded + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE, + failed_at TIMESTAMP WITH TIME ZONE, + refunded_at TIMESTAMP WITH TIME ZONE +); +``` + +## Key Relationships + +### Client Association +- **`contracts.client_id`** → **`client_info.id`** + - This is the **primary relationship** ensuring contracts are linked to actual clients + - Uses `ON DELETE CASCADE` so if a client is deleted, their contracts are also removed + +### User Association +- **`contracts.generated_by`** → **`users.id`** + - Tracks which user (staff/admin) created the contract + +### Template Association +- **`contracts.template_id`** → **`contract_templates.id`** + - Links contracts to predefined templates for consistency + +## Contract Status Flow + +``` +draft → pending_signature → signed → active → completed + ↓ +cancelled (can happen at any stage) +``` + +## Usage Examples + +### 1. Create a Contract for a Client +```typescript +import { ContractClientService } from '../services/contractClientService'; + +const contractService = new ContractClientService(); + +const contract = await contractService.createContract({ + client_id: 'client-uuid-from-client_info', // ✅ Must reference client_info.id + template_id: 1, + fee: '$2,500', + deposit: '$500', + note: 'Standard postpartum doula services', + generated_by: 'user-uuid-from-users' // ✅ Must reference users.id +}); +``` + +### 2. Get Contract with Client Information +```typescript +const contractWithClient = await contractService.getContractWithClient('contract-uuid'); + +console.log(contractWithClient.client_info.first_name); // Client's first name +console.log(contractWithClient.generated_by_user.firstname); // Staff member who created it +``` + +### 3. Get All Contracts for a Client +```typescript +const clientContracts = await contractService.getContractsByClient('client-uuid'); +``` + +### 4. Integrate with SignNow +```typescript +// Create SignNow integration +const signNowIntegration = await contractService.createSignNowIntegration( + 'contract-uuid', + 'signnow-document-id', + 'https://signnow.com/sign/...' +); + +// Update signing status +await contractService.updateSignNowStatus('contract-uuid', 'signed'); +``` + +### 5. Handle Payments +```typescript +// Create payment record +const payment = await contractService.createContractPayment( + 'contract-uuid', + 'deposit', + 500.00, + 'pi_stripe_payment_intent_id' +); + +// Get all payments for a contract +const payments = await contractService.getContractPayments('contract-uuid'); +``` + +## Migration Instructions + +### 1. Run the Migration +```bash +# Apply the migration to update your contracts table +psql -d your_database -f src/db/migrations/update_contracts_table_for_client_info.sql +``` + +### 2. Update Existing Data (if needed) +If you have existing contracts that need to be migrated: + +```sql +-- Example: Update existing contracts to link with client_info +UPDATE contracts +SET client_id = ( + SELECT id FROM client_info + WHERE client_info.email = contracts.client_email +) +WHERE client_id IS NULL; +``` + +### 3. Update Your Code +Replace any existing contract services with the new `ContractClientService`: + +```typescript +// Old way (if you were using the SignNow-focused service) +// import { ContractService } from '../services/contractService'; + +// New way +import { ContractClientService } from '../services/contractClientService'; +``` + +## Benefits of This Setup + +### ✅ Proper Client Association +- Contracts are now properly linked to `client_info` table +- Easy to query all contracts for a specific client +- Maintains data integrity with foreign key constraints + +### ✅ Separation of Concerns +- Main contract data separate from SignNow integration +- Payment tracking in dedicated table +- Template management in separate table + +### ✅ Flexible Status Tracking +- Clear contract status flow +- SignNow status tracked separately +- Payment status tracked separately + +### ✅ Audit Trail +- Tracks who generated each contract +- Timestamps for all major events +- Complete payment history + +### ✅ Scalable Design +- Supports multiple payment types (deposits, installments, final) +- Easy to add new contract templates +- SignNow integration can be extended or replaced + +## Next Steps + +1. **Run the migration** to update your database schema +2. **Update your existing code** to use the new `ContractClientService` +3. **Test the integration** with a few sample contracts +4. **Update your API endpoints** to use the new service methods +5. **Add contract templates** to the `contract_templates` table as needed + +This setup ensures your contracts are properly integrated with your existing client management system while maintaining flexibility for document signing and payment processing. diff --git a/docs/CURSOR_PROMPT_ADMIN_INVITE_DOULA.md b/docs/CURSOR_PROMPT_ADMIN_INVITE_DOULA.md new file mode 100644 index 00000000..d9fa9fa6 --- /dev/null +++ b/docs/CURSOR_PROMPT_ADMIN_INVITE_DOULA.md @@ -0,0 +1,236 @@ +# Cursor Prompt: Implement Admin Invite Doula Feature + +## Task +Implement a feature in the admin dashboard that allows administrators to invite doulas to join the platform. The admin should be able to enter a doula's email, first name, and last name, and send them an invitation email with a link to create their profile. + +## Backend API Endpoint + +**POST** `/api/admin/doulas/invite` + +**Authentication:** Required (Admin role only) +**Header:** `Authorization: Bearer ` + +**Request Body:** +```json +{ + "email": "doula@example.com", + "firstname": "Jane", + "lastname": "Doe" +} +``` + +**Success Response (200):** +```json +{ + "success": true, + "message": "Invitation email sent to doula@example.com", + "data": { + "email": "doula@example.com", + "firstname": "Jane", + "lastname": "Doe", + "inviteToken": "62e7d2ff379b935ceed3ecb32ef6b5cc0b452391b8e3859ac8fd088b224f111e" + } +} +``` + +**Error Responses:** +- `400` - Missing required fields or invalid email format +- `401` - Not authenticated +- `403` - Not an admin +- `500` - Server error + +## Implementation Requirements + +### 1. Create Invite Doula Form Component + +**Location:** Create in your admin dashboard/components area + +**Features:** +- Form with three fields: + - Email input (with email validation) + - First Name input + - Last Name input +- Submit button +- Loading state during API call +- Success message display +- Error message display +- Form validation (all fields required, valid email format) + +**UI/UX:** +- Clean, professional form design +- Clear labels and placeholders +- Real-time validation feedback +- Disable submit button while loading +- Show success message for 3-5 seconds after successful invite +- Clear form after successful submission + +### 2. API Service Function + +Create or update your API service file to include: + +```typescript +async function inviteDoula(email: string, firstname: string, lastname: string): Promise<{ + success: boolean; + message: string; + data: { + email: string; + firstname: string; + lastname: string; + inviteToken: string; + }; +}> { + const token = getAuthToken(); // Your token retrieval method + const response = await fetch(`${API_BASE_URL}/admin/doulas/invite`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email, firstname, lastname }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to invite doula'); + } + + return data; +} +``` + +### 3. Integration Points + +**Where to Add:** +- Add to admin dashboard navigation/menu +- Could be in a "Doulas" or "Team Management" section +- Consider adding to a modal or dedicated page + +**Suggested Locations:** +- Admin Dashboard > Doulas > Invite New Doula +- Or: Admin Dashboard > Team > Invite Doula +- Or: Modal triggered from a "Invite Doula" button + +### 4. Error Handling + +Handle these scenarios: +- **Network errors:** Show user-friendly message +- **401 Unauthorized:** Redirect to login or show "Please log in" message +- **403 Forbidden:** Show "You don't have permission" message +- **400 Validation errors:** Display specific validation messages +- **500 Server errors:** Show generic error message + +### 5. Success Flow + +After successful invitation: +1. Show success message: "Invitation sent to [email]" +2. Optionally show invite token (for admin tracking) +3. Clear form fields +4. Optionally add to a list of sent invitations (if you have that feature) + +### 6. Email Content Preview + +The doula will receive an email with: +- Subject: "Welcome to the Sokana Doula Team!" +- Personalized greeting +- Link to signup page: `${FRONTEND_URL}/signup?role=doula&email=${email}&invite_token=${token}` +- Instructions to use the provided email address +- Next steps information + +### 7. Example Component Structure + +```tsx +// Example React component structure +function InviteDoulaForm() { + // State management + // Form validation + // API call handling + // Success/error display + // Form submission + + return ( +
+ {/* Email input */} + {/* First name input */} + {/* Last name input */} + {/* Submit button */} + {/* Success message */} + {/* Error message */} +
+ ); +} +``` + +## Design Considerations + +1. **Form Layout:** + - Use a clean, centered form layout + - Consider using a card or modal container + - Add proper spacing and typography + +2. **Validation:** + - Real-time email format validation + - Show validation errors below each field + - Disable submit until all fields are valid + +3. **Loading States:** + - Show spinner or loading text on submit button + - Disable form during submission + - Prevent multiple submissions + +4. **Success Feedback:** + - Green success message + - Clear indication that email was sent + - Optionally show a checkmark icon + +5. **Error Feedback:** + - Red error message + - Clear, actionable error text + - Don't hide form on error (allow retry) + +## Testing Checklist + +- [ ] Form validates required fields +- [ ] Email format validation works +- [ ] Submit button disabled during loading +- [ ] Success message displays correctly +- [ ] Error messages display correctly +- [ ] Form clears after successful submission +- [ ] Handles 401 error (redirects to login) +- [ ] Handles 403 error (shows permission error) +- [ ] Handles network errors gracefully +- [ ] Works with actual API endpoint + +## Additional Features (Optional) + +1. **Invitation History:** + - List of sent invitations + - Show status (sent, accepted, pending) + - Resend invitation option + +2. **Bulk Invite:** + - Upload CSV with multiple doulas + - Invite multiple doulas at once + +3. **Invitation Status:** + - Track if doula has accepted invitation + - Show pending vs. completed invitations + +## API Base URL +``` +http://localhost:5050/api +``` + +## Notes + +- The endpoint has been tested and verified working +- The invite token is returned but optional for tracking +- The doula will receive an email with a signup link +- The doula must use the exact email address when signing up +- Ensure your frontend signup page handles the `role=doula` and `email` query parameters + +## Related Documentation + +- See `docs/ADMIN_INVITE_DOULA_ENDPOINT.md` for complete API documentation +- See `docs/FRONTEND_DOULA_VIEW_PROMPT.md` for doula dashboard implementation + diff --git a/docs/CURSOR_PROMPT_ADMIN_MATCH_DOULAS.md b/docs/CURSOR_PROMPT_ADMIN_MATCH_DOULAS.md new file mode 100644 index 00000000..7016838c --- /dev/null +++ b/docs/CURSOR_PROMPT_ADMIN_MATCH_DOULAS.md @@ -0,0 +1,752 @@ +# Frontend Implementation: Admin Doula Matching Feature + +## Overview + +Implement a UI in the **Admin Doulas Tab** that allows admins to match doulas +with clients. Only clients in the `'matching'` phase can be assigned to doulas. +This should be integrated into the existing doulas management interface, similar +to how the "Invite Doula" feature is implemented. + +## Integration Location + +**Primary Location:** Admin Dashboard > Doulas Tab + +Add a "Match Client" button/action for each doula in the doulas list, similar to +how you might have an "Invite Doula" button. This keeps all doula management +actions in one place. + +## API Endpoints + +### 1. Get Matching Clients + +``` +GET /api/admin/clients/matching +Authorization: Bearer +``` + +**Response:** + +```json +{ + "success": true, + "count": 2, + "data": [ + { + "id": "client-uuid", + "name": "Jane Doe", + "email": "jane@example.com", + "phoneNumber": "123-456-7890", + "serviceNeeded": "Labor Support", + "status": "matching", + "dueDate": "2025-06-15", + "hospital": "City Hospital", + "createdAt": "2025-01-01T00:00:00Z" + } + ] +} +``` + +### 2. Match Doula with Client + +``` +POST /api/admin/assignments/match +Authorization: Bearer +Content-Type: application/json + +{ + "clientId": "client-uuid", + "doulaId": "doula-uuid", + "notes": "Optional assignment notes" +} +``` + +**Success Response (201):** + +```json +{ + "success": true, + "message": "Doula successfully matched with client", + "data": { + "assignment": { + "id": "assignment-uuid", + "clientId": "client-uuid", + "doulaId": "doula-uuid", + "assignedAt": "2025-12-08T20:00:00Z", + "assignedBy": "admin-uuid", + "notes": "Optional assignment notes", + "status": "active" + }, + "client": { + "id": "client-uuid", + "name": "Jane Doe", + "status": "matching" + }, + "doula": { + "id": "doula-uuid", + "name": "Sarah Smith", + "email": "sarah@example.com" + } + } +} +``` + +**Error Responses:** + +- `400`: Client not in matching phase, doula already assigned, invalid input +- `404`: Client or doula not found +- `403`: Not an admin +- `500`: Server error + +## UI/UX Requirements + +### 1. Location: Admin Doulas Tab + +- **Primary Implementation:** Add to the existing Admin Doulas list/table +- Each doula row should have a "Match Client" button/action +- Opens a modal to select a client in matching phase +- Follows the same UI patterns as the "Invite Doula" feature + +### 2. Recommended Implementation: Modal from Doula Row + +**In the Doulas List/Table:** + +- Add a "Match Client" button/icon in each doula's row +- Button opens a modal with: + - Doula info display (read-only) at top + - Client selection dropdown (shows only clients with status `'matching'`) + - Optional notes textarea + - Submit/Cancel buttons +- Modal follows same design patterns as Invite Doula modal + +### 3. Client Selection + +- Fetch list of clients in matching phase using: + `GET /api/admin/clients/matching` +- Display client name, email, service needed, due date +- Support search/filter if many clients +- Show clear indication that only matching-phase clients are shown + +### 4. User Feedback + +- Loading states during API calls +- Success message/toast after successful match +- Error messages for validation failures +- Disable submit button while processing + +## Implementation Guide + +### Step 1: Create API Service Functions + +```typescript +// services/adminService.ts or similar +import axios from 'axios'; + +const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:5050/api'; + +export const adminService = { + // Get clients in matching phase + async getMatchingClients(token: string) { + const response = await axios.get(`${API_BASE}/admin/clients/matching`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return response.data; + }, + + // Match doula with client + async matchDoulaWithClient( + token: string, + clientId: string, + doulaId: string, + notes?: string + ) { + const response = await axios.post( + `${API_BASE}/admin/assignments/match`, + { clientId, doulaId, notes }, + { headers: { Authorization: `Bearer ${token}` } } + ); + return response.data; + }, + + // Get all doulas (if endpoint exists, otherwise use existing user endpoint) + async getAllDoulas(token: string) { + // Use existing endpoint or create new one + const response = await axios.get(`${API_BASE}/users?role=doula`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return response.data; + }, +}; +``` + +### Step 2: Create Match Client Modal Component + +```typescript +// components/admin/MatchClientModal.tsx +// Follows same pattern as InviteDoulaModal +import React, { useState, useEffect } from 'react'; +import { adminService } from '../../services/adminService'; +import { useAuth } from '../../contexts/AuthContext'; // Adjust to your auth context + +interface MatchClientModalProps { + doula: { + id: string; + firstname: string; + lastname: string; + email: string; + }; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +interface Client { + id: string; + name: string; + email: string; + phoneNumber?: string; + serviceNeeded?: string; + dueDate?: string; + status: string; +} + +export const MatchClientModal: React.FC = ({ + doula, + isOpen, + onClose, + onSuccess +}) => { + const { token } = useAuth(); + const [clients, setClients] = useState([]); + const [selectedClientId, setSelectedClientId] = useState(''); + const [notes, setNotes] = useState(''); + const [loading, setLoading] = useState(false); + const [fetchingClients, setFetchingClients] = useState(false); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + if (isOpen) { + fetchMatchingClients(); + } + }, [isOpen]); + + const fetchMatchingClients = async () => { + try { + setFetchingClients(true); + const data = await adminService.getMatchingClients(token); + setClients(data.data || []); + } catch (err: any) { + setError('Failed to load clients in matching phase'); + console.error(err); + } finally { + setFetchingClients(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedClientId) { + setError('Please select a client'); + return; + } + + setLoading(true); + setError(null); + + try { + await adminService.matchDoulaWithClient( + token, + selectedClientId, + doula.id, + notes.trim() || undefined + ); + + // Success + onSuccess(); + onClose(); + // Show success toast/notification + // Example: toast.success(`Successfully matched ${doula.firstname} ${doula.lastname} with client`); + } catch (err: any) { + setError( + err.response?.data?.error || + 'Failed to match doula with client' + ); + } finally { + setLoading(false); + } + }; + + const filteredClients = clients.filter(client => { + const name = client.name.toLowerCase(); + const email = client.email.toLowerCase(); + const search = searchTerm.toLowerCase(); + return name.includes(search) || email.includes(search); + }); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

Match Client to Doula

+ +
+ +
+ {/* Doula Info */} +
+

Doula Information

+

Name: {doula.firstname} {doula.lastname}

+

Email: {doula.email}

+
+ +
+ {/* Client Selection */} +
+ + + {fetchingClients ? ( +
Loading clients...
+ ) : clients.length === 0 ? ( +
+

No clients are currently in the matching phase.

+

Clients must have status 'matching' to be assigned to doulas.

+
+ ) : ( + <> + {/* Search/Filter */} + setSearchTerm(e.target.value)} + className="search-input" + /> + + + + {selectedClientId && ( +
+ {(() => { + const selected = clients.find(c => c.id === selectedClientId); + return selected ? ( +
+

Selected Client: {selected.name}

+

Email: {selected.email}

+ {selected.phoneNumber &&

Phone: {selected.phoneNumber}

} + {selected.serviceNeeded &&

Service: {selected.serviceNeeded}

} + {selected.dueDate &&

Due Date: {new Date(selected.dueDate).toLocaleDateString()}

} +
+ ) : null; + })()} +
+ )} + + )} +
+ + {/* Notes */} +
+ +