Skip to content
This repository was archived by the owner on Aug 14, 2026. It is now read-only.

Staging - #347

Closed
Gospelmairo wants to merge 67 commits into
devfrom
staging
Closed

Gospelmairo wants to merge 67 commits into
devfrom
staging

Conversation

@Gospelmairo

@Gospelmairo Gospelmairo commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request Template

Description

Related Issue (Link to issue ticket)

Motivation and Context

How Has This Been Tested?

Screenshots (if appropriate - Postman, etc)

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

Summary by CodeRabbit

Release Notes

  • New Features

    • Apple Sign-In authentication support
    • Password-based login with reset-code verification
    • Google Meet integration for meeting scheduling
    • Messenger contact mode for discovery calls
    • Trainer availability toggle and profile editing
    • Account deactivation, reactivation, and deletion
    • Admin session management (cancel/reschedule)
    • Real-time in-app notifications via WebSocket
    • Admin notifications for key events
  • Bug Fixes

    • Enhanced trainer booking-slot filtering by availability

ukemeikot and others added 30 commits May 18, 2026 08:07
* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>
* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* refactor(logger): ignore health logs (#103)

* refactor(logger): ignore health logs

* Update logger.go

---------

Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* feat(trainers): async intro-video and gallery-image upload pipelines (#121)

Adds two async upload pipelines for trainer profiles:

  * Intro video: multipart upload streamed to a temp file, ffprobe-gated
    on duration and stream presence, then enqueued to a worker pool that
    transcodes via ffmpeg (H.264/720p MP4, CRF 28, +faststart) and uploads
    to MinIO. A 302-redirect streaming endpoint keeps the Go server off
    the byte path so range requests are served directly by MinIO/nginx.
  * Trainer gallery images: batched multipart upload (up to 5 per trainer,
    JPEG/PNG/WebP/HEIC), validated all-or-nothing, enqueued atomically,
    and persisted with a per-trainer advisory lock for safe position
    allocation. A DB trigger enforces the 5-image cap as the authoritative
    guard against races.

Both pipelines use typed http.MaxBytesError for size-limit detection,
classify pq error codes as terminal vs transient for DB retry policy,
and surface configuration problems (missing ffmpeg, storage offline) as
503 rather than 400. OpenAPI spec covers 401/403/404/413/503 responses
across the new endpoints.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* refactor(logger): ignore health logs (#103)

* refactor(logger): ignore health logs

* Update logger.go

---------

Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* feat(trainers): async intro-video and gallery-image upload pipelines (#121)

Adds two async upload pipelines for trainer profiles:

  * Intro video: multipart upload streamed to a temp file, ffprobe-gated
    on duration and stream presence, then enqueued to a worker pool that
    transcodes via ffmpeg (H.264/720p MP4, CRF 28, +faststart) and uploads
    to MinIO. A 302-redirect streaming endpoint keeps the Go server off
    the byte path so range requests are served directly by MinIO/nginx.
  * Trainer gallery images: batched multipart upload (up to 5 per trainer,
    JPEG/PNG/WebP/HEIC), validated all-or-nothing, enqueued atomically,
    and persisted with a per-trainer advisory lock for safe position
    allocation. A DB trigger enforces the 5-image cap as the authoritative
    guard against races.

Both pipelines use typed http.MaxBytesError for size-limit detection,
classify pq error codes as terminal vs transient for DB retry policy,
and surface configuration problems (missing ffmpeg, storage offline) as
503 rather than 400. OpenAPI spec covers 401/403/404/413/503 responses
across the new endpoints.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(bookings): zoom meeting creation with booking session and email confirmation (#120)

- Integrate Zoom meeting creation into booking flow; link and meeting ID persisted via idempotent UpdateBookingZoom (zoom_meeting_id IS NULL guard)
- Create booking_session record immediately after booking is confirmed (non-fatal if it fails)
- Roll back orphaned Zoom meeting if DB update fails; cancel() called directly after DeleteMeeting
- Pass actual Zoom join URL to confirmation email instead of hardcoded link
- Add UpdateBookingZoom and CreateBookingSession to repository interface

Co-authored-by: Gospelmairo <gospelmairo@gmail.com>

* ci: update cd to run on merge to staging and main (#127)

* ci: update cd to run on merge to staging and main

* ci: add merge queue on dev branch

* Refactor/restful endpoints (#114)

* chore: make endpoints

* chore: delete register handler

* chore: delete local sign in tests

* chore: delete signup methods

deleted signup and sign in methods in auth.go

* refactor: remove local tests

- deleted tests that referenced local sign up and sign in
- refactored tests that used variables or methods from local sign up or sign in

* refactor: add security to session endpoints

* chore: move oAuth to Authentication

* chore: generate files with codegen

* chore: refactor bookings endpoint

* chore: update auth endpoints description

* Update gen.go

* refactor: define profileComplete requirements (#124)

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gospelmairo <gospelmairo@gmail.com>
Co-authored-by: Oluwaseyi Adisa <oluwaseyiadisaa@gmail.com>
* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* refactor(logger): ignore health logs (#103)

* refactor(logger): ignore health logs

* Update logger.go

---------

Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* feat(trainers): async intro-video and gallery-image upload pipelines (#121)

Adds two async upload pipelines for trainer profiles:

  * Intro video: multipart upload streamed to a temp file, ffprobe-gated
    on duration and stream presence, then enqueued to a worker pool that
    transcodes via ffmpeg (H.264/720p MP4, CRF 28, +faststart) and uploads
    to MinIO. A 302-redirect streaming endpoint keeps the Go server off
    the byte path so range requests are served directly by MinIO/nginx.
  * Trainer gallery images: batched multipart upload (up to 5 per trainer,
    JPEG/PNG/WebP/HEIC), validated all-or-nothing, enqueued atomically,
    and persisted with a per-trainer advisory lock for safe position
    allocation. A DB trigger enforces the 5-image cap as the authoritative
    guard against races.

Both pipelines use typed http.MaxBytesError for size-limit detection,
classify pq error codes as terminal vs transient for DB retry policy,
and surface configuration problems (missing ffmpeg, storage offline) as
503 rather than 400. OpenAPI spec covers 401/403/404/413/503 responses
across the new endpoints.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(bookings): zoom meeting creation with booking session and email confirmation (#120)

- Integrate Zoom meeting creation into booking flow; link and meeting ID persisted via idempotent UpdateBookingZoom (zoom_meeting_id IS NULL guard)
- Create booking_session record immediately after booking is confirmed (non-fatal if it fails)
- Roll back orphaned Zoom meeting if DB update fails; cancel() called directly after DeleteMeeting
- Pass actual Zoom join URL to confirmation email instead of hardcoded link
- Add UpdateBookingZoom and CreateBookingSession to repository interface

Co-authored-by: Gospelmairo <gospelmairo@gmail.com>

* ci: update cd to run on merge to staging and main (#127)

* ci: update cd to run on merge to staging and main

* ci: add merge queue on dev branch

* Refactor/restful endpoints (#114)

* chore: make endpoints

* chore: delete register handler

* chore: delete local sign in tests

* chore: delete signup methods

deleted signup and sign in methods in auth.go

* refactor: remove local tests

- deleted tests that referenced local sign up and sign in
- refactored tests that used variables or methods from local sign up or sign in

* refactor: add security to session endpoints

* chore: move oAuth to Authentication

* chore: generate files with codegen

* chore: refactor bookings endpoint

* chore: update auth endpoints description

* Update gen.go

* refactor: define profileComplete requirements (#124)

* fix(api): remove duplicate 'tags' keys breaking Swagger UI (#129)

PR #114 inserted `tags: [- Bookings]` above three booking operations
that already had `tags: [- Booking]`, producing duplicate mapping keys.
YAML 1.2 rejects duplicate keys, so Swagger UI fails to render with
"duplicated mapping key" on the first occurrence and gives up on the
whole spec.

Drop the trailing `tags: [- Booking]` block on each affected operation
(plural matches every other booking endpoint in the file):

  - POST /bookings/discovery
  - GET  /bookings/slots
  - PUT  /bookings/slots/{id}

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gospelmairo <gospelmairo@gmail.com>
Co-authored-by: Oluwaseyi Adisa <oluwaseyiadisaa@gmail.com>
* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* refactor(logger): ignore health logs (#103)

* refactor(logger): ignore health logs

* Update logger.go

---------

Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* feat(trainers): async intro-video and gallery-image upload pipelines (#121)

Adds two async upload pipelines for trainer profiles:

  * Intro video: multipart upload streamed to a temp file, ffprobe-gated
    on duration and stream presence, then enqueued to a worker pool that
    transcodes via ffmpeg (H.264/720p MP4, CRF 28, +faststart) and uploads
    to MinIO. A 302-redirect streaming endpoint keeps the Go server off
    the byte path so range requests are served directly by MinIO/nginx.
  * Trainer gallery images: batched multipart upload (up to 5 per trainer,
    JPEG/PNG/WebP/HEIC), validated all-or-nothing, enqueued atomically,
    and persisted with a per-trainer advisory lock for safe position
    allocation. A DB trigger enforces the 5-image cap as the authoritative
    guard against races.

Both pipelines use typed http.MaxBytesError for size-limit detection,
classify pq error codes as terminal vs transient for DB retry policy,
and surface configuration problems (missing ffmpeg, storage offline) as
503 rather than 400. OpenAPI spec covers 401/403/404/413/503 responses
across the new endpoints.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(bookings): zoom meeting creation with booking session and email confirmation (#120)

- Integrate Zoom meeting creation into booking flow; link and meeting ID persisted via idempotent UpdateBookingZoom (zoom_meeting_id IS NULL guard)
- Create booking_session record immediately after booking is confirmed (non-fatal if it fails)
- Roll back orphaned Zoom meeting if DB update fails; cancel() called directly after DeleteMeeting
- Pass actual Zoom join URL to confirmation email instead of hardcoded link
- Add UpdateBookingZoom and CreateBookingSession to repository interface

Co-authored-by: Gospelmairo <gospelmairo@gmail.com>

* ci: update cd to run on merge to staging and main (#127)

* ci: update cd to run on merge to staging and main

* ci: add merge queue on dev branch

* Refactor/restful endpoints (#114)

* chore: make endpoints

* chore: delete register handler

* chore: delete local sign in tests

* chore: delete signup methods

deleted signup and sign in methods in auth.go

* refactor: remove local tests

- deleted tests that referenced local sign up and sign in
- refactored tests that used variables or methods from local sign up or sign in

* refactor: add security to session endpoints

* chore: move oAuth to Authentication

* chore: generate files with codegen

* chore: refactor bookings endpoint

* chore: update auth endpoints description

* Update gen.go

* refactor: define profileComplete requirements (#124)

* fix(api): remove duplicate 'tags' keys breaking Swagger UI (#129)

PR #114 inserted `tags: [- Bookings]` above three booking operations
that already had `tags: [- Booking]`, producing duplicate mapping keys.
YAML 1.2 rejects duplicate keys, so Swagger UI fails to render with
"duplicated mapping key" on the first occurrence and gives up on the
whole spec.

Drop the trailing `tags: [- Booking]` block on each affected operation
(plural matches every other booking endpoint in the file):

  - POST /bookings/discovery
  - GET  /bookings/slots
  - PUT  /bookings/slots/{id}

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gospelmairo <gospelmairo@gmail.com>
Co-authored-by: Oluwaseyi Adisa <oluwaseyiadisaa@gmail.com>
* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* refactor(logger): ignore health logs (#103)

* refactor(logger): ignore health logs

* Update logger.go

---------

Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* feat(trainers): async intro-video and gallery-image upload pipelines (#121)

Adds two async upload pipelines for trainer profiles:

  * Intro video: multipart upload streamed to a temp file, ffprobe-gated
    on duration and stream presence, then enqueued to a worker pool that
    transcodes via ffmpeg (H.264/720p MP4, CRF 28, +faststart) and uploads
    to MinIO. A 302-redirect streaming endpoint keeps the Go server off
    the byte path so range requests are served directly by MinIO/nginx.
  * Trainer gallery images: batched multipart upload (up to 5 per trainer,
    JPEG/PNG/WebP/HEIC), validated all-or-nothing, enqueued atomically,
    and persisted with a per-trainer advisory lock for safe position
    allocation. A DB trigger enforces the 5-image cap as the authoritative
    guard against races.

Both pipelines use typed http.MaxBytesError for size-limit detection,
classify pq error codes as terminal vs transient for DB retry policy,
and surface configuration problems (missing ffmpeg, storage offline) as
503 rather than 400. OpenAPI spec covers 401/403/404/413/503 responses
across the new endpoints.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(bookings): zoom meeting creation with booking session and email confirmation (#120)

- Integrate Zoom meeting creation into booking flow; link and meeting ID persisted via idempotent UpdateBookingZoom (zoom_meeting_id IS NULL guard)
- Create booking_session record immediately after booking is confirmed (non-fatal if it fails)
- Roll back orphaned Zoom meeting if DB update fails; cancel() called directly after DeleteMeeting
- Pass actual Zoom join URL to confirmation email instead of hardcoded link
- Add UpdateBookingZoom and CreateBookingSession to repository interface

Co-authored-by: Gospelmairo <gospelmairo@gmail.com>

* ci: update cd to run on merge to staging and main (#127)

* ci: update cd to run on merge to staging and main

* ci: add merge queue on dev branch

* Refactor/restful endpoints (#114)

* chore: make endpoints

* chore: delete register handler

* chore: delete local sign in tests

* chore: delete signup methods

deleted signup and sign in methods in auth.go

* refactor: remove local tests

- deleted tests that referenced local sign up and sign in
- refactored tests that used variables or methods from local sign up or sign in

* refactor: add security to session endpoints

* chore: move oAuth to Authentication

* chore: generate files with codegen

* chore: refactor bookings endpoint

* chore: update auth endpoints description

* Update gen.go

* refactor: define profileComplete requirements (#124)

* fix(api): remove duplicate 'tags' keys breaking Swagger UI (#129)

PR #114 inserted `tags: [- Bookings]` above three booking operations
that already had `tags: [- Booking]`, producing duplicate mapping keys.
YAML 1.2 rejects duplicate keys, so Swagger UI fails to render with
"duplicated mapping key" on the first occurrence and gives up on the
whole spec.

Drop the trailing `tags: [- Booking]` block on each affected operation
(plural matches every other booking endpoint in the file):

  - POST /bookings/discovery
  - GET  /bookings/slots
  - PUT  /bookings/slots/{id}

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Oluwaseyi Adisa <oluwaseyiadisaa@gmail.com>
* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* refactor(logger): ignore health logs (#103)

* refactor(logger): ignore health logs

* Update logger.go

---------

Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* feat(trainers): async intro-video and gallery-image upload pipelines (#121)

Adds two async upload pipelines for trainer profiles:

  * Intro video: multipart upload streamed to a temp file, ffprobe-gated
    on duration and stream presence, then enqueued to a worker pool that
    transcodes via ffmpeg (H.264/720p MP4, CRF 28, +faststart) and uploads
    to MinIO. A 302-redirect streaming endpoint keeps the Go server off
    the byte path so range requests are served directly by MinIO/nginx.
  * Trainer gallery images: batched multipart upload (up to 5 per trainer,
    JPEG/PNG/WebP/HEIC), validated all-or-nothing, enqueued atomically,
    and persisted with a per-trainer advisory lock for safe position
    allocation. A DB trigger enforces the 5-image cap as the authoritative
    guard against races.

Both pipelines use typed http.MaxBytesError for size-limit detection,
classify pq error codes as terminal vs transient for DB retry policy,
and surface configuration problems (missing ffmpeg, storage offline) as
503 rather than 400. OpenAPI spec covers 401/403/404/413/503 responses
across the new endpoints.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(bookings): zoom meeting creation with booking session and email confirmation (#120)

- Integrate Zoom meeting creation into booking flow; link and meeting ID persisted via idempotent UpdateBookingZoom (zoom_meeting_id IS NULL guard)
- Create booking_session record immediately after booking is confirmed (non-fatal if it fails)
- Roll back orphaned Zoom meeting if DB update fails; cancel() called directly after DeleteMeeting
- Pass actual Zoom join URL to confirmation email instead of hardcoded link
- Add UpdateBookingZoom and CreateBookingSession to repository interface

Co-authored-by: Gospelmairo <gospelmairo@gmail.com>

* ci: update cd to run on merge to staging and main (#127)

* ci: update cd to run on merge to staging and main

* ci: add merge queue on dev branch

* Refactor/restful endpoints (#114)

* chore: make endpoints

* chore: delete register handler

* chore: delete local sign in tests

* chore: delete signup methods

deleted signup and sign in methods in auth.go

* refactor: remove local tests

- deleted tests that referenced local sign up and sign in
- refactored tests that used variables or methods from local sign up or sign in

* refactor: add security to session endpoints

* chore: move oAuth to Authentication

* chore: generate files with codegen

* chore: refactor bookings endpoint

* chore: update auth endpoints description

* Update gen.go

* refactor: define profileComplete requirements (#124)

* fix(api): remove duplicate 'tags' keys breaking Swagger UI (#129)

PR #114 inserted `tags: [- Bookings]` above three booking operations
that already had `tags: [- Booking]`, producing duplicate mapping keys.
YAML 1.2 rejects duplicate keys, so Swagger UI fails to render with
"duplicated mapping key" on the first occurrence and gives up on the
whole spec.

Drop the trailing `tags: [- Booking]` block on each affected operation
(plural matches every other booking endpoint in the file):

  - POST /bookings/discovery
  - GET  /bookings/slots
  - PUT  /bookings/slots/{id}

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gospelmairo <gospelmairo@gmail.com>
Co-authored-by: Oluwaseyi Adisa <oluwaseyiadisaa@gmail.com>
PR #114 inserted `tags: [- Bookings]` above three booking operations
that already had `tags: [- Booking]`, producing duplicate mapping keys.
YAML 1.2 rejects duplicate keys, so Swagger UI fails to render with
"duplicated mapping key" on the first occurrence and gives up on the
whole spec.

Drop the trailing `tags: [- Booking]` block on each affected operation
(plural matches every other booking endpoint in the file):

  - POST /bookings/discovery
  - GET  /bookings/slots
  - PUT  /bookings/slots/{id}

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing-trainer-upload

# Conflicts:
#	internal/models/queries/trainers.sql
#	internal/repository/db/trainers.sql.go
#	internal/routes/routes.go
…ainer-upload

Chore/sync dev to staging trainer upload
…on (#135) (#137)

* chore: add observability setup to server

* chore: add update to sample .env file

* chore: add documentation for observability setup

Co-authored-by: Andrew James Okpainmo <okpainmoandrew@gmail.com>
Brings PR #138 (observability semconv schema fix) into staging.
Resolves an add/add conflict on internal/observability/tracing.go by
taking dev's version — same content as the squash commit that landed
on staging via PR #137 but with the semconv import bumped from
v1.37.0 to v1.40.0 to match the SDK's resource.Default() schema URL.
…el-fix

Chore/sync dev to staging otel fix
* Feat/add dev scripts (#125)

* refactor: define profileComplete requirements

* feat: create dev scripts

* fix: trainer onboarding status

* fix: lint issues

* chore: add environment guard

* refactor: add trainer id to discovery (#141)

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
* Feat/add dev scripts (#125)

* refactor: define profileComplete requirements

* feat: create dev scripts

* fix: trainer onboarding status

* fix: lint issues

* chore: add environment guard

* refactor: add trainer id to discovery (#141)

* fix: production environtment fix (#144)

* feat(trainers): admin creates trainer with multipart, specialization catalog, benefits, training styles

POST /trainers becomes an admin-only end-to-end provisioning endpoint:
  - admin sends email + name + specializations[1..5] + training_styles[≤4]
    + benefits[{title, subtext}] + years_of_experience + optional display
    picture (multipart)
  - server upserts a local-auth user with role='trainer' and a generated
    bcrypt-hashed 16-char password (idempotent on email; re-invite rotates
    the password)
  - INSERTs the trainer row plus all benefits inside a single SQL TX so a
    partial write never leaves an orphan user with no trainer or a trainer
    missing half its benefits
  - asynchronously enqueues the display picture upload (post-commit, non-
    fatal: queue full just drops the optimistic URL)
  - emails the credentials via the existing Resend/SMTP/Log mailer; failure
    surfaces a 500 so the admin retries (upsert is idempotent)

Specializations are now multi-valued from a fixed 5-value catalog
(yoga, speed, cardio, endurance, strength) enforced by both a Postgres
CHECK constraint and a Go-side allow-list. Training styles are up to 4
free-text single-word tags. Benefits live in a new trainer_benefits
table with (trainer_id, position) uniqueness so display order is stable.

Calendly fields (calendly_connected, calendly_link) are removed across
the schema, queries, OpenAPI spec, and handler — the booking flow uses
our own scheduling tables.

TrainersAdminOnly middleware now accepts both 'admin' and 'super_admin'
(real-auth + mock-auth paths) — super_admin is a strict superset of
admin everywhere else, so it was a bug for it to be rejected here.

Migrations:
  000033 - drop specialization TEXT, add specializations TEXT[] + CHECK + GIN
  000034 - add training_styles TEXT[] with cardinality CHECK (0..4)
  000035 - create trainer_benefits table
  000036 - drop calendly_connected and calendly_link columns

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* Feat/add dev scripts (#125)

* refactor: define profileComplete requirements

* feat: create dev scripts

* fix: trainer onboarding status

* fix: lint issues

* chore: add environment guard

* refactor: add trainer id to discovery (#141)

* fix: production environtment fix (#144)

* feat(trainers): admin creates trainer with multipart, specialization catalog, benefits, training styles (#145)

POST /trainers becomes an admin-only end-to-end provisioning endpoint:
  - admin sends email + name + specializations[1..5] + training_styles[≤4]
    + benefits[{title, subtext}] + years_of_experience + optional display
    picture (multipart)
  - server upserts a local-auth user with role='trainer' and a generated
    bcrypt-hashed 16-char password (idempotent on email; re-invite rotates
    the password)
  - INSERTs the trainer row plus all benefits inside a single SQL TX so a
    partial write never leaves an orphan user with no trainer or a trainer
    missing half its benefits
  - asynchronously enqueues the display picture upload (post-commit, non-
    fatal: queue full just drops the optimistic URL)
  - emails the credentials via the existing Resend/SMTP/Log mailer; failure
    surfaces a 500 so the admin retries (upsert is idempotent)

Specializations are now multi-valued from a fixed 5-value catalog
(yoga, speed, cardio, endurance, strength) enforced by both a Postgres
CHECK constraint and a Go-side allow-list. Training styles are up to 4
free-text single-word tags. Benefits live in a new trainer_benefits
table with (trainer_id, position) uniqueness so display order is stable.

Calendly fields (calendly_connected, calendly_link) are removed across
the schema, queries, OpenAPI spec, and handler — the booking flow uses
our own scheduling tables.

TrainersAdminOnly middleware now accepts both 'admin' and 'super_admin'
(real-auth + mock-auth paths) — super_admin is a strict superset of
admin everywhere else, so it was a bug for it to be rejected here.

Migrations:
  000033 - drop specialization TEXT, add specializations TEXT[] + CHECK + GIN
  000034 - add training_styles TEXT[] with cardinality CHECK (0..4)
  000035 - create trainer_benefits table
  000036 - drop calendly_connected and calendly_link columns

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore(seed): restrict to development env, drop trainer seeding

Two changes to cmd/seed/main.go:

  1. Env check now exits unless APP_ENV='development' (was: rejects
     only 'production'). Staging and prod must never run this — any
     seeded data on those environments has to flow through the real
     admin endpoints so it's auditable. Without this, a misconfigured
     systemd unit or a copy-pasted scp from a dev box leaves the seed
     binary in a restart loop trying to insert fixtures against the
     wrong database.

  2. Trainer seeding removed entirely. Trainers are provisioned by
     admins via POST /trainers (#145) which generates the password,
     emails credentials, and writes specializations / training_styles
     / benefits with full schema-level validation. Duplicating that
     flow in seed kept the script in lockstep with every trainer
     schema change; safer to have a single source of truth.

Admin + 5 client users are still seeded for local dev convenience.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* Feat/add dev scripts (#125)

* refactor: define profileComplete requirements

* feat: create dev scripts

* fix: trainer onboarding status

* fix: lint issues

* chore: add environment guard

* refactor: add trainer id to discovery (#141)

* fix: production environtment fix (#144)

* feat(trainers): admin creates trainer with multipart, specialization catalog, benefits, training styles (#145)

POST /trainers becomes an admin-only end-to-end provisioning endpoint:
  - admin sends email + name + specializations[1..5] + training_styles[≤4]
    + benefits[{title, subtext}] + years_of_experience + optional display
    picture (multipart)
  - server upserts a local-auth user with role='trainer' and a generated
    bcrypt-hashed 16-char password (idempotent on email; re-invite rotates
    the password)
  - INSERTs the trainer row plus all benefits inside a single SQL TX so a
    partial write never leaves an orphan user with no trainer or a trainer
    missing half its benefits
  - asynchronously enqueues the display picture upload (post-commit, non-
    fatal: queue full just drops the optimistic URL)
  - emails the credentials via the existing Resend/SMTP/Log mailer; failure
    surfaces a 500 so the admin retries (upsert is idempotent)

Specializations are now multi-valued from a fixed 5-value catalog
(yoga, speed, cardio, endurance, strength) enforced by both a Postgres
CHECK constraint and a Go-side allow-list. Training styles are up to 4
free-text single-word tags. Benefits live in a new trainer_benefits
table with (trainer_id, position) uniqueness so display order is stable.

Calendly fields (calendly_connected, calendly_link) are removed across
the schema, queries, OpenAPI spec, and handler — the booking flow uses
our own scheduling tables.

TrainersAdminOnly middleware now accepts both 'admin' and 'super_admin'
(real-auth + mock-auth paths) — super_admin is a strict superset of
admin everywhere else, so it was a bug for it to be rejected here.

Migrations:
  000033 - drop specialization TEXT, add specializations TEXT[] + CHECK + GIN
  000034 - add training_styles TEXT[] with cardinality CHECK (0..4)
  000035 - create trainer_benefits table
  000036 - drop calendly_connected and calendly_link columns

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore(seed): restrict to development env, drop trainer seeding

Two changes to cmd/seed/main.go:

  1. Env check now exits unless APP_ENV='development' (was: rejects
     only 'production'). Staging and prod must never run this — any
     seeded data on those environments has to flow through the real
     admin endpoints so it's auditable. Without this, a misconfigured
     systemd unit or a copy-pasted scp from a dev box leaves the seed
     binary in a restart loop trying to insert fixtures against the
     wrong database.

  2. Trainer seeding removed entirely. Trainers are provisioned by
     admins via POST /trainers (#145) which generates the password,
     emails credentials, and writes specializations / training_styles
     / benefits with full schema-level validation. Duplicating that
     flow in seed kept the script in lockstep with every trainer
     schema change; safer to have a single source of truth.

Admin + 5 client users are still seeded for local dev convenience.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
cmd/server/main.go has had a 5-line copy of cmd/seed's env check
since some point in May. The check rejects any APP_ENV other than
'development', so the production API binary exits 1 at startup on
staging and crash-loops under systemd. journalctl shows nothing
because the check runs BEFORE the slog handler is installed —
makes it look like a silent failure.

Dev removed this block (commits ago) but every dev->staging sync
since has produced a squash commit with a stale base that loses
the deletion. Cherry-picking onto staging directly so we stop
fighting the merge-base problem.

Verified by running the staging binary locally with .env sourced:
prints 'ERROR seed script can only run in development' before this
fix, normal cmd/server startup after.
…er-env-check

fix(server): remove erroneous seed-script env check from cmd/server
* fix(trainers): ignore server-assigned fields on benefit input (#154)

Swagger UI for POST /trainers was inserting `id` and `position`
into the benefit JSON example because the TrainerBenefit schema
exposed them. The handler's DisallowUnknownFields decoder then
rejected the payload with:

  benefit at index 0: invalid JSON: json: unknown field "position"

Both fields are server-assigned (id is the PK, position is derived
from submitted order), so they have no business in the request.

Two changes:

  - api.yaml: mark id + position as `readOnly: true` on
    TrainerBenefit so Swagger UI hides them in request examples
    while still showing them in responses.

  - parseSingleBenefit: declare id (string) and position (int) on
    the request struct as optional pointer fields, then ignore them.
    The decoder still rejects genuine typos like `titl` because
    DisallowUnknownFields stays on; we just explicitly opt the two
    readOnly fields back in.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): clean up create request — bio field + benefits Input schema (#157)

Three related touch-ups to the POST /trainers create surface:

  1. Add 'bio' to CreateTrainerRequest. Admin can now supply the
     trainer's profile bio at create time; previously they had to
     create the trainer and PATCH it. Optional, capped at 2000 chars.
     Already exposed on every GET trainer response, so no client read
     changes needed.

  2. Split TrainerBenefit into TrainerBenefitInput (request) and
     TrainerBenefit (response). The previous shared schema exposed
     'id' and 'position' to clients, but both are server-assigned (id
     is the PK, position is derived from submitted order). readOnly
     hints helped Swagger UI but the schema still leaked the fields.
     The new Input schema is strictly title+subtext; server generates
     id (UUID PK) and position (submitted order).

  3. CreateTrainer SQL query now accepts bio so it lands on the
     trainers row instead of being silently dropped between the
     handler form parse and the INSERT.

Regenerates sqlc + oapi-codegen output. Builds on PR #154 which
already shipped the readOnly fix to dev.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(profile): drop avatar_url from update path, unblock profile_complete (#158)

Two related changes to the user profile surface:

  1. profile_complete no longer gates on AvatarUrl.Valid. Avatars are
     set exclusively via POST /users/me/profile/picture (which writes
     the column asynchronously through the avatar worker), so requiring
     a populated AvatarUrl for completion meant clients who finished
     onboarding via the JSON profile endpoint stayed flagged as
     'incomplete' indefinitely — even with name, gender, fitness goals,
     and fitness level all set. Now the gate is name + gender +
     fitness_level. Avatar is its own concern.

  2. avatar_url removed from UpdateProfileRequest. Letting it ride on
     the JSON endpoint as well as the dedicated upload was a TOCTOU
     hazard — the JSON path could clobber a URL the picture worker had
     just written. Handler now always passes empty string to
     UpdateUserOnboarding, and the SQL's COALESCE(NULLIF(...,''), avatar_url)
     preserves whatever the worker put there.

Regenerates gen.go.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* fix(trainers): ignore server-assigned fields on benefit input (#154)

Swagger UI for POST /trainers was inserting `id` and `position`
into the benefit JSON example because the TrainerBenefit schema
exposed them. The handler's DisallowUnknownFields decoder then
rejected the payload with:

  benefit at index 0: invalid JSON: json: unknown field "position"

Both fields are server-assigned (id is the PK, position is derived
from submitted order), so they have no business in the request.

Two changes:

  - api.yaml: mark id + position as `readOnly: true` on
    TrainerBenefit so Swagger UI hides them in request examples
    while still showing them in responses.

  - parseSingleBenefit: declare id (string) and position (int) on
    the request struct as optional pointer fields, then ignore them.
    The decoder still rejects genuine typos like `titl` because
    DisallowUnknownFields stays on; we just explicitly opt the two
    readOnly fields back in.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): clean up create request — bio field + benefits Input schema (#157)

Three related touch-ups to the POST /trainers create surface:

  1. Add 'bio' to CreateTrainerRequest. Admin can now supply the
     trainer's profile bio at create time; previously they had to
     create the trainer and PATCH it. Optional, capped at 2000 chars.
     Already exposed on every GET trainer response, so no client read
     changes needed.

  2. Split TrainerBenefit into TrainerBenefitInput (request) and
     TrainerBenefit (response). The previous shared schema exposed
     'id' and 'position' to clients, but both are server-assigned (id
     is the PK, position is derived from submitted order). readOnly
     hints helped Swagger UI but the schema still leaked the fields.
     The new Input schema is strictly title+subtext; server generates
     id (UUID PK) and position (submitted order).

  3. CreateTrainer SQL query now accepts bio so it lands on the
     trainers row instead of being silently dropped between the
     handler form parse and the INSERT.

Regenerates sqlc + oapi-codegen output. Builds on PR #154 which
already shipped the readOnly fix to dev.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(profile): drop avatar_url from update path, unblock profile_complete (#158)

Two related changes to the user profile surface:

  1. profile_complete no longer gates on AvatarUrl.Valid. Avatars are
     set exclusively via POST /users/me/profile/picture (which writes
     the column asynchronously through the avatar worker), so requiring
     a populated AvatarUrl for completion meant clients who finished
     onboarding via the JSON profile endpoint stayed flagged as
     'incomplete' indefinitely — even with name, gender, fitness goals,
     and fitness level all set. Now the gate is name + gender +
     fitness_level. Avatar is its own concern.

  2. avatar_url removed from UpdateProfileRequest. Letting it ride on
     the JSON endpoint as well as the dedicated upload was a TOCTOU
     hazard — the JSON path could clobber a URL the picture worker had
     just written. Handler now always passes empty string to
     UpdateUserOnboarding, and the SQL's COALESCE(NULLIF(...,''), avatar_url)
     preserves whatever the worker put there.

Regenerates gen.go.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): explicit ::uuid casts in AddTrainerImage to fix pq 42883 (#161)

The AddTrainerImage query was failing every insert with:
  pq: operator does not exist: uuid = text at position 8:22 (42883)

…leaving every uploaded gallery image as an orphaned MinIO object
with no DB row pointing at it (observed on staging: POST returned
202, file landed in the bucket, GET /trainers/{id}/images stayed
empty, worker logs showed all 3 retries failing then 'trainer image
uploaded to storage but DB insert failed — orphaned object').

Root cause: sqlc infers the Go type of a query parameter from the
first SQL cast it sees. The line

  hashtext('trainer_image_position:' || sqlc.arg(trainer_id)::text)

made sqlc bind trainer_id as text. The downstream
'WHERE trainer_id = sqlc.arg(trainer_id)' then became
'uuid_column = text_param', which Postgres rejects (no implicit
cast from text to uuid for comparison operators — only for literal
input).

Fix: cast every use of @trainer_id to ::uuid explicitly so the
parameter binds as uuid in pq, and double-cast ::uuid::text inside
hashtext so the lock-key string stays byte-for-byte identical (we
must keep the same hashtext value or concurrent inserts would land
on different advisory locks and stop being serialised).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* fix(trainers): ignore server-assigned fields on benefit input (#154)

Swagger UI for POST /trainers was inserting `id` and `position`
into the benefit JSON example because the TrainerBenefit schema
exposed them. The handler's DisallowUnknownFields decoder then
rejected the payload with:

  benefit at index 0: invalid JSON: json: unknown field "position"

Both fields are server-assigned (id is the PK, position is derived
from submitted order), so they have no business in the request.

Two changes:

  - api.yaml: mark id + position as `readOnly: true` on
    TrainerBenefit so Swagger UI hides them in request examples
    while still showing them in responses.

  - parseSingleBenefit: declare id (string) and position (int) on
    the request struct as optional pointer fields, then ignore them.
    The decoder still rejects genuine typos like `titl` because
    DisallowUnknownFields stays on; we just explicitly opt the two
    readOnly fields back in.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): clean up create request — bio field + benefits Input schema (#157)

Three related touch-ups to the POST /trainers create surface:

  1. Add 'bio' to CreateTrainerRequest. Admin can now supply the
     trainer's profile bio at create time; previously they had to
     create the trainer and PATCH it. Optional, capped at 2000 chars.
     Already exposed on every GET trainer response, so no client read
     changes needed.

  2. Split TrainerBenefit into TrainerBenefitInput (request) and
     TrainerBenefit (response). The previous shared schema exposed
     'id' and 'position' to clients, but both are server-assigned (id
     is the PK, position is derived from submitted order). readOnly
     hints helped Swagger UI but the schema still leaked the fields.
     The new Input schema is strictly title+subtext; server generates
     id (UUID PK) and position (submitted order).

  3. CreateTrainer SQL query now accepts bio so it lands on the
     trainers row instead of being silently dropped between the
     handler form parse and the INSERT.

Regenerates sqlc + oapi-codegen output. Builds on PR #154 which
already shipped the readOnly fix to dev.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(profile): drop avatar_url from update path, unblock profile_complete (#158)

Two related changes to the user profile surface:

  1. profile_complete no longer gates on AvatarUrl.Valid. Avatars are
     set exclusively via POST /users/me/profile/picture (which writes
     the column asynchronously through the avatar worker), so requiring
     a populated AvatarUrl for completion meant clients who finished
     onboarding via the JSON profile endpoint stayed flagged as
     'incomplete' indefinitely — even with name, gender, fitness goals,
     and fitness level all set. Now the gate is name + gender +
     fitness_level. Avatar is its own concern.

  2. avatar_url removed from UpdateProfileRequest. Letting it ride on
     the JSON endpoint as well as the dedicated upload was a TOCTOU
     hazard — the JSON path could clobber a URL the picture worker had
     just written. Handler now always passes empty string to
     UpdateUserOnboarding, and the SQL's COALESCE(NULLIF(...,''), avatar_url)
     preserves whatever the worker put there.

Regenerates gen.go.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): explicit ::uuid casts in AddTrainerImage to fix pq 42883 (#161)

The AddTrainerImage query was failing every insert with:
  pq: operator does not exist: uuid = text at position 8:22 (42883)

…leaving every uploaded gallery image as an orphaned MinIO object
with no DB row pointing at it (observed on staging: POST returned
202, file landed in the bucket, GET /trainers/{id}/images stayed
empty, worker logs showed all 3 retries failing then 'trainer image
uploaded to storage but DB insert failed — orphaned object').

Root cause: sqlc infers the Go type of a query parameter from the
first SQL cast it sees. The line

  hashtext('trainer_image_position:' || sqlc.arg(trainer_id)::text)

made sqlc bind trainer_id as text. The downstream
'WHERE trainer_id = sqlc.arg(trainer_id)' then became
'uuid_column = text_param', which Postgres rejects (no implicit
cast from text to uuid for comparison operators — only for literal
input).

Fix: cast every use of @trainer_id to ::uuid explicitly so the
parameter binds as uuid in pq, and double-cast ::uuid::text inside
hashtext so the lock-key string stays byte-for-byte identical (we
must keep the same hashtext value or concurrent inserts would land
on different advisory locks and stop being serialised).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(email): add missing To: header to SMTPMailer messages (#163)

Resend's SMTP gateway rejected outgoing mail with:
  550 Missing `to` field.

…because the SMTPMailer was only setting From:, Subject:,
MIME-Version:, and Content-Type: in the message headers. The envelope
recipient (the []string{toAddr} arg to smtp.SendMail) is separate
from the headers inside the body, and most modern SMTP gateways
(Resend, SendGrid, etc.) require the To: header to be present in
the message itself for anti-abuse + threading purposes — Go's
smtp.SendMail doesn't add it automatically.

All 12 SMTPMailer send methods had the same pattern, so the fix is
applied uniformly: add 'To: %s\r\n' to the header block and pass
toAddr (already built via sanitizeAddress earlier in each function)
as the additional Sprintf arg.

Affects: SendVerificationCode, SendAdminCredentials, SendTrainerCredentials,
SendPasswordResetCode, SendDiscoveryBookingConfirmation,
SendDiscoveryBookingAdminNotification, SendWaitlistConfirmation,
SendContactConfirmation, SendDiscoveryRescheduleConfirmation,
SendPaidSessionRescheduleConfirmation,
SendPaidSessionRescheduleTrainerNotification, SendBookingConfirmation.

ResendMailer (HTTP API) was unaffected — it sets To explicitly in
the JSON body via resendRequest.To.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* fix(trainers): ignore server-assigned fields on benefit input (#154)

Swagger UI for POST /trainers was inserting `id` and `position`
into the benefit JSON example because the TrainerBenefit schema
exposed them. The handler's DisallowUnknownFields decoder then
rejected the payload with:

  benefit at index 0: invalid JSON: json: unknown field "position"

Both fields are server-assigned (id is the PK, position is derived
from submitted order), so they have no business in the request.

Two changes:

  - api.yaml: mark id + position as `readOnly: true` on
    TrainerBenefit so Swagger UI hides them in request examples
    while still showing them in responses.

  - parseSingleBenefit: declare id (string) and position (int) on
    the request struct as optional pointer fields, then ignore them.
    The decoder still rejects genuine typos like `titl` because
    DisallowUnknownFields stays on; we just explicitly opt the two
    readOnly fields back in.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): clean up create request — bio field + benefits Input schema (#157)

Three related touch-ups to the POST /trainers create surface:

  1. Add 'bio' to CreateTrainerRequest. Admin can now supply the
     trainer's profile bio at create time; previously they had to
     create the trainer and PATCH it. Optional, capped at 2000 chars.
     Already exposed on every GET trainer response, so no client read
     changes needed.

  2. Split TrainerBenefit into TrainerBenefitInput (request) and
     TrainerBenefit (response). The previous shared schema exposed
     'id' and 'position' to clients, but both are server-assigned (id
     is the PK, position is derived from submitted order). readOnly
     hints helped Swagger UI but the schema still leaked the fields.
     The new Input schema is strictly title+subtext; server generates
     id (UUID PK) and position (submitted order).

  3. CreateTrainer SQL query now accepts bio so it lands on the
     trainers row instead of being silently dropped between the
     handler form parse and the INSERT.

Regenerates sqlc + oapi-codegen output. Builds on PR #154 which
already shipped the readOnly fix to dev.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(profile): drop avatar_url from update path, unblock profile_complete (#158)

Two related changes to the user profile surface:

  1. profile_complete no longer gates on AvatarUrl.Valid. Avatars are
     set exclusively via POST /users/me/profile/picture (which writes
     the column asynchronously through the avatar worker), so requiring
     a populated AvatarUrl for completion meant clients who finished
     onboarding via the JSON profile endpoint stayed flagged as
     'incomplete' indefinitely — even with name, gender, fitness goals,
     and fitness level all set. Now the gate is name + gender +
     fitness_level. Avatar is its own concern.

  2. avatar_url removed from UpdateProfileRequest. Letting it ride on
     the JSON endpoint as well as the dedicated upload was a TOCTOU
     hazard — the JSON path could clobber a URL the picture worker had
     just written. Handler now always passes empty string to
     UpdateUserOnboarding, and the SQL's COALESCE(NULLIF(...,''), avatar_url)
     preserves whatever the worker put there.

Regenerates gen.go.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): explicit ::uuid casts in AddTrainerImage to fix pq 42883 (#161)

The AddTrainerImage query was failing every insert with:
  pq: operator does not exist: uuid = text at position 8:22 (42883)

…leaving every uploaded gallery image as an orphaned MinIO object
with no DB row pointing at it (observed on staging: POST returned
202, file landed in the bucket, GET /trainers/{id}/images stayed
empty, worker logs showed all 3 retries failing then 'trainer image
uploaded to storage but DB insert failed — orphaned object').

Root cause: sqlc infers the Go type of a query parameter from the
first SQL cast it sees. The line

  hashtext('trainer_image_position:' || sqlc.arg(trainer_id)::text)

made sqlc bind trainer_id as text. The downstream
'WHERE trainer_id = sqlc.arg(trainer_id)' then became
'uuid_column = text_param', which Postgres rejects (no implicit
cast from text to uuid for comparison operators — only for literal
input).

Fix: cast every use of @trainer_id to ::uuid explicitly so the
parameter binds as uuid in pq, and double-cast ::uuid::text inside
hashtext so the lock-key string stays byte-for-byte identical (we
must keep the same hashtext value or concurrent inserts would land
on different advisory locks and stop being serialised).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(email): add missing To: header to SMTPMailer messages (#163)

Resend's SMTP gateway rejected outgoing mail with:
  550 Missing `to` field.

…because the SMTPMailer was only setting From:, Subject:,
MIME-Version:, and Content-Type: in the message headers. The envelope
recipient (the []string{toAddr} arg to smtp.SendMail) is separate
from the headers inside the body, and most modern SMTP gateways
(Resend, SendGrid, etc.) require the To: header to be present in
the message itself for anti-abuse + threading purposes — Go's
smtp.SendMail doesn't add it automatically.

All 12 SMTPMailer send methods had the same pattern, so the fix is
applied uniformly: add 'To: %s\r\n' to the header block and pass
toAddr (already built via sanitizeAddress earlier in each function)
as the additional Sprintf arg.

Affects: SendVerificationCode, SendAdminCredentials, SendTrainerCredentials,
SendPasswordResetCode, SendDiscoveryBookingConfirmation,
SendDiscoveryBookingAdminNotification, SendWaitlistConfirmation,
SendContactConfirmation, SendDiscoveryRescheduleConfirmation,
SendPaidSessionRescheduleConfirmation,
SendPaidSessionRescheduleTrainerNotification, SendBookingConfirmation.

ResendMailer (HTTP API) was unaffected — it sets To explicitly in
the JSON body via resendRequest.To.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(auth): refresh endpoint always returned unauthorized (#165)

POST /auth/refresh has been silently 401-ing on every request because
of three compounding bugs:

  1. (handler) Read the refresh token from c.GetString(string(
     api.BearerAuthScopes)), but BearerAuthScopes is a marker key set
     by oapi-codegen to []string{} — it signals 'this route requires
     bearer auth', it is NOT the bearer token value. c.GetString on a
     []string returns '', so refreshTokenString was always empty and
     the handler always took the 'unauthorized' branch.

  2. (middleware) Even with #1 fixed, the route was guarded by
     'security: bearerAuth' in api.yaml, which makes the router run
     the standard auth middleware. That middleware checks
     tokenType == AccessToken and rejects refresh tokens with
     'invalid token type' — so the handler never got a chance to
     validate the refresh token itself.

  3. (UX) All error paths returned the same opaque 'unauthorized',
     making it impossible to tell from a 401 whether the token was
     missing, malformed, of the wrong type, or expired.

Fix:

  - api.yaml /auth/refresh now declares 'security: []' with a comment
    explaining why (handler does its own refresh-token auth, the
    standard middleware would block it).
  - refresh.go reads the Authorization header directly via a small
    bearerTokenFromHeader helper. The helper is documented with a
    pointer to the original bug so the next reader doesn't re-do
    the same mistake.
  - Error messages now distinguish 'missing', 'invalid or expired',
    'invalid claims', and 'missing subject' — easier to diagnose
    client-side without server logs.
  - Rate-limit moved to AFTER token validation so an unauthenticated
    attacker can't burn another user's quota by replaying their JWT.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* fix(trainers): ignore server-assigned fields on benefit input (#154)

Swagger UI for POST /trainers was inserting `id` and `position`
into the benefit JSON example because the TrainerBenefit schema
exposed them. The handler's DisallowUnknownFields decoder then
rejected the payload with:

  benefit at index 0: invalid JSON: json: unknown field "position"

Both fields are server-assigned (id is the PK, position is derived
from submitted order), so they have no business in the request.

Two changes:

  - api.yaml: mark id + position as `readOnly: true` on
    TrainerBenefit so Swagger UI hides them in request examples
    while still showing them in responses.

  - parseSingleBenefit: declare id (string) and position (int) on
    the request struct as optional pointer fields, then ignore them.
    The decoder still rejects genuine typos like `titl` because
    DisallowUnknownFields stays on; we just explicitly opt the two
    readOnly fields back in.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): clean up create request — bio field + benefits Input schema (#157)

Three related touch-ups to the POST /trainers create surface:

  1. Add 'bio' to CreateTrainerRequest. Admin can now supply the
     trainer's profile bio at create time; previously they had to
     create the trainer and PATCH it. Optional, capped at 2000 chars.
     Already exposed on every GET trainer response, so no client read
     changes needed.

  2. Split TrainerBenefit into TrainerBenefitInput (request) and
     TrainerBenefit (response). The previous shared schema exposed
     'id' and 'position' to clients, but both are server-assigned (id
     is the PK, position is derived from submitted order). readOnly
     hints helped Swagger UI but the schema still leaked the fields.
     The new Input schema is strictly title+subtext; server generates
     id (UUID PK) and position (submitted order).

  3. CreateTrainer SQL query now accepts bio so it lands on the
     trainers row instead of being silently dropped between the
     handler form parse and the INSERT.

Regenerates sqlc + oapi-codegen output. Builds on PR #154 which
already shipped the readOnly fix to dev.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(profile): drop avatar_url from update path, unblock profile_complete (#158)

Two related changes to the user profile surface:

  1. profile_complete no longer gates on AvatarUrl.Valid. Avatars are
     set exclusively via POST /users/me/profile/picture (which writes
     the column asynchronously through the avatar worker), so requiring
     a populated AvatarUrl for completion meant clients who finished
     onboarding via the JSON profile endpoint stayed flagged as
     'incomplete' indefinitely — even with name, gender, fitness goals,
     and fitness level all set. Now the gate is name + gender +
     fitness_level. Avatar is its own concern.

  2. avatar_url removed from UpdateProfileRequest. Letting it ride on
     the JSON endpoint as well as the dedicated upload was a TOCTOU
     hazard — the JSON path could clobber a URL the picture worker had
     just written. Handler now always passes empty string to
     UpdateUserOnboarding, and the SQL's COALESCE(NULLIF(...,''), avatar_url)
     preserves whatever the worker put there.

Regenerates gen.go.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainers): explicit ::uuid casts in AddTrainerImage to fix pq 42883 (#161)

The AddTrainerImage query was failing every insert with:
  pq: operator does not exist: uuid = text at position 8:22 (42883)

…leaving every uploaded gallery image as an orphaned MinIO object
with no DB row pointing at it (observed on staging: POST returned
202, file landed in the bucket, GET /trainers/{id}/images stayed
empty, worker logs showed all 3 retries failing then 'trainer image
uploaded to storage but DB insert failed — orphaned object').

Root cause: sqlc infers the Go type of a query parameter from the
first SQL cast it sees. The line

  hashtext('trainer_image_position:' || sqlc.arg(trainer_id)::text)

made sqlc bind trainer_id as text. The downstream
'WHERE trainer_id = sqlc.arg(trainer_id)' then became
'uuid_column = text_param', which Postgres rejects (no implicit
cast from text to uuid for comparison operators — only for literal
input).

Fix: cast every use of @trainer_id to ::uuid explicitly so the
parameter binds as uuid in pq, and double-cast ::uuid::text inside
hashtext so the lock-key string stays byte-for-byte identical (we
must keep the same hashtext value or concurrent inserts would land
on different advisory locks and stop being serialised).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(email): add missing To: header to SMTPMailer messages (#163)

Resend's SMTP gateway rejected outgoing mail with:
  550 Missing `to` field.

…because the SMTPMailer was only setting From:, Subject:,
MIME-Version:, and Content-Type: in the message headers. The envelope
recipient (the []string{toAddr} arg to smtp.SendMail) is separate
from the headers inside the body, and most modern SMTP gateways
(Resend, SendGrid, etc.) require the To: header to be present in
the message itself for anti-abuse + threading purposes — Go's
smtp.SendMail doesn't add it automatically.

All 12 SMTPMailer send methods had the same pattern, so the fix is
applied uniformly: add 'To: %s\r\n' to the header block and pass
toAddr (already built via sanitizeAddress earlier in each function)
as the additional Sprintf arg.

Affects: SendVerificationCode, SendAdminCredentials, SendTrainerCredentials,
SendPasswordResetCode, SendDiscoveryBookingConfirmation,
SendDiscoveryBookingAdminNotification, SendWaitlistConfirmation,
SendContactConfirmation, SendDiscoveryRescheduleConfirmation,
SendPaidSessionRescheduleConfirmation,
SendPaidSessionRescheduleTrainerNotification, SendBookingConfirmation.

ResendMailer (HTTP API) was unaffected — it sets To explicitly in
the JSON body via resendRequest.To.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(auth): refresh endpoint always returned unauthorized (#165)

POST /auth/refresh has been silently 401-ing on every request because
of three compounding bugs:

  1. (handler) Read the refresh token from c.GetString(string(
     api.BearerAuthScopes)), but BearerAuthScopes is a marker key set
     by oapi-codegen to []string{} — it signals 'this route requires
     bearer auth', it is NOT the bearer token value. c.GetString on a
     []string returns '', so refreshTokenString was always empty and
     the handler always took the 'unauthorized' branch.

  2. (middleware) Even with #1 fixed, the route was guarded by
     'security: bearerAuth' in api.yaml, which makes the router run
     the standard auth middleware. That middleware checks
     tokenType == AccessToken and rejects refresh tokens with
     'invalid token type' — so the handler never got a chance to
     validate the refresh token itself.

  3. (UX) All error paths returned the same opaque 'unauthorized',
     making it impossible to tell from a 401 whether the token was
     missing, malformed, of the wrong type, or expired.

Fix:

  - api.yaml /auth/refresh now declares 'security: []' with a comment
    explaining why (handler does its own refresh-token auth, the
    standard middleware would block it).
  - refresh.go reads the Authorization header directly via a small
    bearerTokenFromHeader helper. The helper is documented with a
    pointer to the original bug so the next reader doesn't re-do
    the same mistake.
  - Error messages now distinguish 'missing', 'invalid or expired',
    'invalid claims', and 'missing subject' — easier to diagnose
    client-side without server logs.
  - Rate-limit moved to AFTER token validation so an unauthenticated
    attacker can't burn another user's quota by replaying their JWT.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): admin sets trainer availability + public GET endpoints (#169)

Adds three endpoints around trainer weekly availability:

  - PUT /trainers/{id}/availability — admin (or super_admin) sets a
    specific trainer's weekly schedule. Gated by the existing
    TrainersAdminOnly middleware which admits both admin roles on any
    non-GET /trainers/{id}/* route; no separate /admin/ path needed.

  - GET /trainers/{id}/availability — any authenticated user can read
    a trainer's slots. Used by clients browsing trainers, by admins
    inspecting schedules, and by the trainer themselves via their
    public id.

  - GET /trainers/me/availability — trainer reads their own schedule
    via the JWT user_id lookup. Symmetric with the existing
    PUT /trainers/me/availability so the dashboard can populate the
    editor before letting the trainer make changes.

Refactors the existing PUT /trainers/me/availability to share its
validate-and-save core with the new admin PUT. Both call
replaceTrainerAvailability(c, trainerID) which:
  - parses the request
  - validates each slot (day_of_week, IANA tz, HH:MM times, end>start)
  - rejects overlapping slots on the same day
  - runs delete-then-insert in one TX (empty array clears the schedule)
  - never returns null for the slot list — empty array

The two GETs share fetchTrainerAvailability(c, trainerID).
availabilitySlotsToResponse always returns a non-nil slice so JSON
encodes as [] rather than null for trainers with no slots.

Existing bookings are not touched — availability is forward-looking
metadata, not retroactive constraint.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/discovery slots (#170)

* feat(discovery): discovery slots CRUD, Redis cache, token TTL bump

- Rename /booking-slots discovery endpoints to /discovery-slots
- Remove trainer_id from discovery slots — slots are global, not per-trainer
- Add migration 000045 to make booking_slots.trainer_id nullable
- Fix CreateBookingSlot RETURNING column order mismatch
- Add 15-min Redis cache to GET /booking-slots/:trainerId with invalidation on PUT /trainers/me/availability
- Bump access token TTL from 10 to 15 minutes
- Fix integration test to call /discovery-slots instead of /booking-slots

* fix(routes): remove stripe/subscription imports, add missing interface methods

- routes.go: use origin/dev base (no stripe/subscription packages) with
  Redis nil-guard for bookingSlot handler and redis wired into availabilityStore
- auth.go: add HandleLocalAuth (-> local.SignIn) and HandleRegister (-> local.Register)
  to satisfy api.ServerInterface from regenerated gen.go

* fix(review): address CodeRabbit comments

- api.yaml: update all expires_in examples from 600 to 900 (TTL bump)
- api.yaml: fix auth tag dashes (Auth - OAuth/Local -> Auth — OAuth/Local)
- migration 000045: add DELETE for NULL trainer_id rows in Down before SET NOT NULL

---------

Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Brings the two most recent dev commits across:

  - feat(bookings): remove subscription_id from POST /bookings (#172)
  - feat(trainers): admin sets trainer availability + public GET
    endpoints (#169)  — already on staging via #171, no-op here

Resolved two pieces of drift in api.yaml from the recurring squash-
merge stale-base problem:

  - /auth/login 200 response: staging had bare $ref SuccessResponse,
    dev had allOf with LocalAuthData. Took dev's (richer + more
    accurate documentation of the response shape).
  - /trainers POST content-type: staging had application/json (a
    leftover from before #145 shipped the admin-creates-trainer
    feature), dev has multipart/form-data (required so the admin can
    upload the trainer's display_picture in the same request). The
    three-way auto-merge silently picked staging's version because a
    nearby staging-only edit made git treat this region as
    staging-modified; reapplied dev's multipart shape by hand.

Regenerated gen.go after the multipart fix. Dev's checked-in gen.go is
slightly behind its own current api.yaml (someone hadn't regen'd after
the /auth/login allOf change); the regen output here is what dev SHOULD
have. Next time anyone touches the spec on dev they'll converge.
…okings-subscription

Chore/sync dev to staging bookings subscription
fix(bookings): drop subscription_id from POST /bookings end-to-end (#…
fix(bookings): expose session_id on /bookings/upcoming so clients can…
* feat(admin): add GET /admin/user/trainer/count endpoint (#180)

Returns total active clients and total approved trainers (super_admin only).

- api.yaml: new GET /admin/user/trainer/count with bearerAuth, 200/401/403/500 responses
- users.sql: CountClients — WHERE role='client' AND is_active=true
- trainers.sql: CountTrainers — WHERE onboarding_status='approved'
- routes/admin.go: GetUserTrainerCount handler with error logging
- Regenerate sqlc and oapi-codegen outputs
- Fix pre-existing build break: booking_slots.trainer_id is nullable
  (migration 000045 existed but sqlc was never re-run); update
  repository.go and bookings_cancel.go to use uuid.NullUUID

* test(booking_sessions): add unit tests (#181)

* test(booking_sessions): add unit tests

* Delete handler_test.go

* Fix/upcoming bookings expose session (#184)

* fix(bookings): expose session_id on /bookings/upcoming so clients can fetch session details

Background: clients hitting GET /bookings/upcoming get a list of
items whose 'id' field is the bookings.id (or discovery_bookings.id)
primary key. But GET /sessions/{id} expects the booking_session.id —
a different table, different PK. Passing one into the other always
404s.

Two tables, two ID spaces:

  - bookings.id              (created when a booking is placed)
  - booking_session.id       (created when the session is started;
                              references booking_id = bookings.id
                              with a UNIQUE constraint, so 1:1)

Fix: enrich each paid_session item in the /bookings/upcoming response
with an optional 'session_id' field. Populated when the
booking_session row exists for that booking; omitted otherwise
(discovery calls, or paid bookings whose session hasn't been started
yet). Client uses session_id to call /sessions/{id} directly.

Implementation kept entirely in Go (no SQL change, no sqlc regen) to
avoid the inference drift we've seen on other bookings queries where
sqlc generates uuid.NullUUID for columns the schema declares NOT NULL.
Per-row lookup is bounded by the response page size (max 100), and
GetBookingSessionByBookingID was already available in the generated
queries — just wasn't being called from this path.

Failures on the per-row lookup are logged but don't fail the whole
list — SessionID just stays nil for that one item. The client can
retry the detail call.

* fix(bookings): keep upcoming bookings visible past start, expose trainer_id

- /bookings/upcoming: replace strict scheduled_start > NOW() filter with a
  7-day grace past scheduled_end (and the discovery equivalent) so bookings
  that came and went without being started/cancelled/completed remain
  visible to the user instead of vanishing the moment their start time
  passes.
- /bookings/upcoming (paid sessions): expose trainer_id so clients can
  fetch trainer details without an extra round trip.
- GET /sessions/{id}: join booking_session with bookings to return
  trainer_id alongside the session row.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(trainer): return average_rating as float in API response (#182)

* feat: scaffold Google OAuth authentication and database layer

* fix(trainer): bug in average rating response

* refactor(email.go): added the 'to' addr to email

* refactor: implemented reviews

* added overide for trainers rating to sqlc.yaml file

* fix(trainers): remove redundant raw average_rating assignment in trainerToMap

   The raw sql.NullFloat64 struct was seeded into the response map before the
   conditional override, creating a latent bug where the SQL type would leak
   into the API response if the override block were ever removed.

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: CynthiaWahome <cynthiaawsajira@gmail.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

---------

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: CynthiaWahome <cynthiaawsajira@gmail.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>
ukemeikot and others added 27 commits May 23, 2026 02:40
* fix: reject bookings with a scheduled_start in the past (#209)

* fix(trainers): replace /me/sessions with /trainers/sessions?trainer_id=... (#210)

The old GET /trainers/me/sessions derived the target trainer from the
JWT user via GetTrainerByUserID, which 404'd whenever the caller
didn't have a trainer profile of their own — admin tools couldn't
list any trainer's sessions, and a non-trainer user hitting the
endpoint got an unhelpful 404 instead of a proper authz response.

New shape:
  GET /trainers/sessions?trainer_id=<uuid>&page=&limit=

trainer_id is passed explicitly as a query param. Authz check moves
into the handler:

  - caller is the owner of the trainer profile
    (trainer.user_id == JWT user_id) -> allowed
  - caller has role admin / super_admin               -> allowed
  - otherwise                                          -> 403

Unknown trainer_id returns 404. Missing trainer_id returns 400. No
implicit user->trainer lookup, no spurious 404s.

The existing TrainersAdminOnly middleware already lets any
authenticated GET through, so no middleware change was needed — the
handler does the trainer-or-admin gate itself.

Integration tests pin all four authz branches plus the 400/401/404
cases.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(auth): set exp in context unconditionally so refresh stops 401-ing (#212)

The refresh handler reads exp from gin's keys and 401s with
"missing or invalid exp in token context" when the value isn't
there. The middleware used to only set it for refresh-token paths:

  if expectedType == "refresh" {
      c.Set(string(common.ContextKeyExpTime), exp)
  }

On staging, that branch consistently evaluated false even for
valid refresh-token requests (handler still ran, so the type
check above it passed — but exp never made it into context).
Result: every refresh attempt 401'd, so users got logged out as
soon as their access token expired (~15 min after login).

The conditional was the only line in code that could explain the
symptom; removing it makes the middleware unconditionally set exp.
Safe to do: no other handler reads ContextKeyExpTime, so setting
it on access-token requests is a no-op for them.

Pinned by table-driven regression test that asserts the key is
present + the value is a future timestamp for both AuthMiddleware
(access) and AuthMiddlewareWithType(..., "refresh", ...).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): add GET /trainers/me + /trainers/me/sessions (#214)

* feat(trainers): add GET /trainers/me + /trainers/me/sessions

So far the FE couldn't fetch a trainer's own profile without already
knowing the trainer.id. Login returns users.id; trainers.id is a
separate UUID. Without an endpoint that maps JWT user_id ->
trainers.id, the FE was 404ing every /trainers/{id} call right after
login.

New endpoints:
  GET /trainers/me          -> the calling trainer's profile, same
                               shape as /trainers/{id} but resolves
                               trainer from the JWT.
  GET /trainers/me/sessions -> convenience variant of
                               /trainers/sessions?trainer_id=... that
                               also resolves trainer from JWT, so a
                               trainer never has to look up its own
                               trainer.id.

Both 404 cleanly when the calling user has no trainers row (e.g. a
plain client hitting a trainer-only endpoint).

Shared resolveTrainerIDFromJWT helper maps JWT user_id -> trainers.id
so the two /me handlers return identical 401 / 404 / 500 contracts.
GET /trainers/{id} keeps working — it just delegates to the renamed
renderTrainerProfileByID helper now.

Integration tests pin the contract: /trainers/me returns trainers.id
(not users.id) which was the whole point; 404 for non-trainer
callers; 401 unauthenticated; /me/sessions returns the expected
booking count.

* fix(spec): document name + email on Trainer schema

CodeRabbit noted GET /trainers/me's description claims the response
includes name + email, but the Trainer schema didn't declare those
fields. The fields are real — renderTrainerProfileByID writes them
from the users join — but they were undocumented, so generated
clients couldn't see them as part of the contract.

Adding both fields to the Trainer schema as the right fix because the
same join-and-set pattern is used by GET /trainers (paginated list)
and GET /trainers/{id} too. Description notes when they're populated
(user-joined endpoints) vs when they may be absent (raw trainers-row
responses like POST/PATCH /trainers).

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(auth): aggregate role-specific IDs (trainer_id) into login response (#216)

After login the FE got back users.id but had no way to learn its own
trainers.id without a follow-up lookup. Every trainer-specific call
(GET /trainers/{id}, GET /trainers/sessions?trainer_id=...) takes
trainers.id, not users.id, so the FE either had to call /trainers/me
first or use the /trainers/me/* convenience routes.

This puts the role-specific ID directly on the login response:

  data.user.trainer_id  (uuid, omitted for non-trainers)

Populated by the four login flows that issue AuthUser:
  - POST /auth/login
  - POST /auth/verify-email (auto-login)
  - GET  /auth/google/callback
  - POST /auth/google/mobile

The schema is open-ended: today trainer is the only role with a
separate ID table, but adding more (e.g. client_id if a clients table
ever appears) is a non-breaking field addition on AuthUser + one more
entry in RoleIDs.

New shared helper buildAuthUser (in auth/user_type.go) keeps the four
handlers identical so a future field on AuthUser only needs one edit.
The role-ID lookup goes through a new UserRepository.LookupRoleIDs
method that's silent on no-trainer — most users aren't trainers, so
absence isn't an error.

Failure mode: if the role-ID lookup hits a real DB error, login still
succeeds with trainer_id omitted (logged at Warn). The user has a
working JWT and can fall back to GET /trainers/me to recover the
trainer.id — better than failing the login over an optional field.

Test coverage in user_type_test.go pins the contract: trainer login
includes trainer_id; client/admin login omits it (not null — actually
absent from the JSON).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
…vite (#219) (#220)

Admins were re-invoking POST /trainers when a trainer needed their
setup email re-sent (lost the link, hit the SMTP quota, etc.). That
worked because UpsertTrainerUser is idempotent on email, but it
forced re-entering specializations / benefits / display picture and
ran the full multipart parser for a one-line ask.

New endpoint takes just the email:

  POST /trainers/resend-setup
  { "email": "trainer@example.com" }

Identified by email (not trainer.id) because the admin asking "send
the invite again" already knows the email, never the internal UUID.

Behavior
- Validates email format (400 on malformed)
- Resolves email -> user -> trainer profile. Both "no such email" and
  "user exists but isn't a trainer" return the same 404 so an admin
  can't trivially probe which emails are trainers vs clients.
- Rejects with 409 when the trainer has already activated
  (consumed_at is set). The recovery path for an activated trainer
  who can't log in is forgot-password, not another setup link.
- Calls the existing accountSetup.IssueAndSend, which rotates the
  token via UpsertToken (idempotent on user_id) and sends the link.
  No new SQL, no new repo methods.

Admin/super_admin only via the existing TrainersAdminOnly middleware
(non-GET /trainers/* requires admin).

Tests cover happy path (super_admin + plain admin), 401, 403,
malformed email, unknown email, non-trainer email (same 404),
already-activated trainer (409 — seeded via a direct INSERT into
account_setup_tokens with consumed_at set).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* feat(trainers): add POST /trainers/resend-setup for re-issuing the invite (#219)

Admins were re-invoking POST /trainers when a trainer needed their
setup email re-sent (lost the link, hit the SMTP quota, etc.). That
worked because UpsertTrainerUser is idempotent on email, but it
forced re-entering specializations / benefits / display picture and
ran the full multipart parser for a one-line ask.

New endpoint takes just the email:

  POST /trainers/resend-setup
  { "email": "trainer@example.com" }

Identified by email (not trainer.id) because the admin asking "send
the invite again" already knows the email, never the internal UUID.

Behavior
- Validates email format (400 on malformed)
- Resolves email -> user -> trainer profile. Both "no such email" and
  "user exists but isn't a trainer" return the same 404 so an admin
  can't trivially probe which emails are trainers vs clients.
- Rejects with 409 when the trainer has already activated
  (consumed_at is set). The recovery path for an activated trainer
  who can't log in is forgot-password, not another setup link.
- Calls the existing accountSetup.IssueAndSend, which rotates the
  token via UpsertToken (idempotent on user_id) and sends the link.
  No new SQL, no new repo methods.

Admin/super_admin only via the existing TrainersAdminOnly middleware
(non-GET /trainers/* requires admin).

Tests cover happy path (super_admin + plain admin), 401, 403,
malformed email, unknown email, non-trainer email (same 404),
already-activated trainer (409 — seeded via a direct INSERT into
account_setup_tokens with consumed_at set).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): accept and return gender + phone_number on create (#222)

* feat(trainers): accept and return gender + phone_number on create

Admins were asking for trainer profile fields that the schema didn't
yet capture. Both belong on the underlying users row, not trainers,
because they're personal identity attributes any role could use later
(users.gender already existed; phone_number is new).

Migration 000047
- ALTER TABLE users ADD COLUMN phone_number TEXT (nullable)
- Normalize stray users.gender values to NULL, then add a CHECK
  constraint locking gender to NULL / male / female / other /
  prefer_not_to_say. Existing rows with non-conforming values would
  have failed the ALTER without the normalize step.

API surface
- POST /trainers multipart form gains two optional fields:
    gender        (enum: male, female, other, prefer_not_to_say)
    phone_number  (E.164, ^\+[1-9]\d{6,14}$ — same shape the
                   discovery-call phone_callback field uses)
- Trainer response schema gains the same two as nullable fields.
- GET /trainers/{id}, GET /trainers/me, GET /trainers (list), and
  the POST /trainers 201 response all surface them, returning JSON
  null when not set.

SQL
- UpsertTrainerUser takes gender + phone_number. NULLIF + COALESCE
  means: empty string -> NULL on insert; a re-invite that omits
  either field keeps the existing stored value rather than wiping it.
- GetTrainerWithUserByID and ListTrainers SELECT u.gender +
  u.phone_number alongside the already-joined u.name / u.email.

Validation
- Invalid gender (not in the enum) -> 400, no row written. Even if
  the handler missed it the users_gender_valid CHECK would fail the
  TX commit — belt-and-braces.
- Invalid phone (not E.164) -> 400, no row written. Validated via
  regex in the handler.

Tests (RUN_INTEGRATION_TESTS=1) round-trip both fields through
create -> GET, prove omission produces JSON null, and pin the two
400 paths.

* fix(trainers): PATCH /trainers/{id} returns full joined trainer profile

CodeRabbit flagged that UpdateTrainer responded with
trainerToMap(updated) — built from the bare trainers row — so the
gender + phone_number fields the previous commit added to GET +
create + list responses were silently absent from PATCH. Anything
the contract advertises on the Trainer schema needs to come out of
PATCH too.

Refactor: extract buildTrainerProfilePayload from
renderTrainerProfileByID so both endpoints (GET /trainers/{id} and
the new PATCH path) build their response through the same code. The
builder does the user join (GetTrainerWithUserByID) plus benefits
fetch and returns the payload map; the writers pick their own
success message ("TRAINER_FETCHED" vs "TRAINER_UPDATED"). On the
PATCH side this means one extra SELECT after the UPDATE — fair
tradeoff for keeping the two response shapes in lockstep.

Failure mode: if the post-update reload fails (e.g. trainer deleted
between UPDATE and SELECT) the response distinguishes that from the
write failure via a warn log + the builder's error response — caller
knows the update landed but the reload didn't.

Test added: PATCH /trainers/{id} with a small bio change and
asserts gender + phone_number survive in the response (seeded from
the earlier create subtest). Regression pin for the shared builder.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* feat(trainers): add POST /trainers/resend-setup for re-issuing the invite (#219)

Admins were re-invoking POST /trainers when a trainer needed their
setup email re-sent (lost the link, hit the SMTP quota, etc.). That
worked because UpsertTrainerUser is idempotent on email, but it
forced re-entering specializations / benefits / display picture and
ran the full multipart parser for a one-line ask.

New endpoint takes just the email:

  POST /trainers/resend-setup
  { "email": "trainer@example.com" }

Identified by email (not trainer.id) because the admin asking "send
the invite again" already knows the email, never the internal UUID.

Behavior
- Validates email format (400 on malformed)
- Resolves email -> user -> trainer profile. Both "no such email" and
  "user exists but isn't a trainer" return the same 404 so an admin
  can't trivially probe which emails are trainers vs clients.
- Rejects with 409 when the trainer has already activated
  (consumed_at is set). The recovery path for an activated trainer
  who can't log in is forgot-password, not another setup link.
- Calls the existing accountSetup.IssueAndSend, which rotates the
  token via UpsertToken (idempotent on user_id) and sends the link.
  No new SQL, no new repo methods.

Admin/super_admin only via the existing TrainersAdminOnly middleware
(non-GET /trainers/* requires admin).

Tests cover happy path (super_admin + plain admin), 401, 403,
malformed email, unknown email, non-trainer email (same 404),
already-activated trainer (409 — seeded via a direct INSERT into
account_setup_tokens with consumed_at set).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): accept and return gender + phone_number on create (#222)

* feat(trainers): accept and return gender + phone_number on create

Admins were asking for trainer profile fields that the schema didn't
yet capture. Both belong on the underlying users row, not trainers,
because they're personal identity attributes any role could use later
(users.gender already existed; phone_number is new).

Migration 000047
- ALTER TABLE users ADD COLUMN phone_number TEXT (nullable)
- Normalize stray users.gender values to NULL, then add a CHECK
  constraint locking gender to NULL / male / female / other /
  prefer_not_to_say. Existing rows with non-conforming values would
  have failed the ALTER without the normalize step.

API surface
- POST /trainers multipart form gains two optional fields:
    gender        (enum: male, female, other, prefer_not_to_say)
    phone_number  (E.164, ^\+[1-9]\d{6,14}$ — same shape the
                   discovery-call phone_callback field uses)
- Trainer response schema gains the same two as nullable fields.
- GET /trainers/{id}, GET /trainers/me, GET /trainers (list), and
  the POST /trainers 201 response all surface them, returning JSON
  null when not set.

SQL
- UpsertTrainerUser takes gender + phone_number. NULLIF + COALESCE
  means: empty string -> NULL on insert; a re-invite that omits
  either field keeps the existing stored value rather than wiping it.
- GetTrainerWithUserByID and ListTrainers SELECT u.gender +
  u.phone_number alongside the already-joined u.name / u.email.

Validation
- Invalid gender (not in the enum) -> 400, no row written. Even if
  the handler missed it the users_gender_valid CHECK would fail the
  TX commit — belt-and-braces.
- Invalid phone (not E.164) -> 400, no row written. Validated via
  regex in the handler.

Tests (RUN_INTEGRATION_TESTS=1) round-trip both fields through
create -> GET, prove omission produces JSON null, and pin the two
400 paths.

* fix(trainers): PATCH /trainers/{id} returns full joined trainer profile

CodeRabbit flagged that UpdateTrainer responded with
trainerToMap(updated) — built from the bare trainers row — so the
gender + phone_number fields the previous commit added to GET +
create + list responses were silently absent from PATCH. Anything
the contract advertises on the Trainer schema needs to come out of
PATCH too.

Refactor: extract buildTrainerProfilePayload from
renderTrainerProfileByID so both endpoints (GET /trainers/{id} and
the new PATCH path) build their response through the same code. The
builder does the user join (GetTrainerWithUserByID) plus benefits
fetch and returns the payload map; the writers pick their own
success message ("TRAINER_FETCHED" vs "TRAINER_UPDATED"). On the
PATCH side this means one extra SELECT after the UPDATE — fair
tradeoff for keeping the two response shapes in lockstep.

Failure mode: if the post-update reload fails (e.g. trainer deleted
between UPDATE and SELECT) the response distinguishes that from the
write failure via a warn log + the builder's error response — caller
knows the update landed but the reload didn't.

Test added: PATCH /trainers/{id} with a small bio change and
asserts gender + phone_number survive in the response (seeded from
the earlier create subtest). Regression pin for the shared builder.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): add GET /trainers/me/clients endpoint (#224)

---------

Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* feat(trainers): add POST /trainers/resend-setup for re-issuing the invite (#219)

Admins were re-invoking POST /trainers when a trainer needed their
setup email re-sent (lost the link, hit the SMTP quota, etc.). That
worked because UpsertTrainerUser is idempotent on email, but it
forced re-entering specializations / benefits / display picture and
ran the full multipart parser for a one-line ask.

New endpoint takes just the email:

  POST /trainers/resend-setup
  { "email": "trainer@example.com" }

Identified by email (not trainer.id) because the admin asking "send
the invite again" already knows the email, never the internal UUID.

Behavior
- Validates email format (400 on malformed)
- Resolves email -> user -> trainer profile. Both "no such email" and
  "user exists but isn't a trainer" return the same 404 so an admin
  can't trivially probe which emails are trainers vs clients.
- Rejects with 409 when the trainer has already activated
  (consumed_at is set). The recovery path for an activated trainer
  who can't log in is forgot-password, not another setup link.
- Calls the existing accountSetup.IssueAndSend, which rotates the
  token via UpsertToken (idempotent on user_id) and sends the link.
  No new SQL, no new repo methods.

Admin/super_admin only via the existing TrainersAdminOnly middleware
(non-GET /trainers/* requires admin).

Tests cover happy path (super_admin + plain admin), 401, 403,
malformed email, unknown email, non-trainer email (same 404),
already-activated trainer (409 — seeded via a direct INSERT into
account_setup_tokens with consumed_at set).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): accept and return gender + phone_number on create (#222)

* feat(trainers): accept and return gender + phone_number on create

Admins were asking for trainer profile fields that the schema didn't
yet capture. Both belong on the underlying users row, not trainers,
because they're personal identity attributes any role could use later
(users.gender already existed; phone_number is new).

Migration 000047
- ALTER TABLE users ADD COLUMN phone_number TEXT (nullable)
- Normalize stray users.gender values to NULL, then add a CHECK
  constraint locking gender to NULL / male / female / other /
  prefer_not_to_say. Existing rows with non-conforming values would
  have failed the ALTER without the normalize step.

API surface
- POST /trainers multipart form gains two optional fields:
    gender        (enum: male, female, other, prefer_not_to_say)
    phone_number  (E.164, ^\+[1-9]\d{6,14}$ — same shape the
                   discovery-call phone_callback field uses)
- Trainer response schema gains the same two as nullable fields.
- GET /trainers/{id}, GET /trainers/me, GET /trainers (list), and
  the POST /trainers 201 response all surface them, returning JSON
  null when not set.

SQL
- UpsertTrainerUser takes gender + phone_number. NULLIF + COALESCE
  means: empty string -> NULL on insert; a re-invite that omits
  either field keeps the existing stored value rather than wiping it.
- GetTrainerWithUserByID and ListTrainers SELECT u.gender +
  u.phone_number alongside the already-joined u.name / u.email.

Validation
- Invalid gender (not in the enum) -> 400, no row written. Even if
  the handler missed it the users_gender_valid CHECK would fail the
  TX commit — belt-and-braces.
- Invalid phone (not E.164) -> 400, no row written. Validated via
  regex in the handler.

Tests (RUN_INTEGRATION_TESTS=1) round-trip both fields through
create -> GET, prove omission produces JSON null, and pin the two
400 paths.

* fix(trainers): PATCH /trainers/{id} returns full joined trainer profile

CodeRabbit flagged that UpdateTrainer responded with
trainerToMap(updated) — built from the bare trainers row — so the
gender + phone_number fields the previous commit added to GET +
create + list responses were silently absent from PATCH. Anything
the contract advertises on the Trainer schema needs to come out of
PATCH too.

Refactor: extract buildTrainerProfilePayload from
renderTrainerProfileByID so both endpoints (GET /trainers/{id} and
the new PATCH path) build their response through the same code. The
builder does the user join (GetTrainerWithUserByID) plus benefits
fetch and returns the payload map; the writers pick their own
success message ("TRAINER_FETCHED" vs "TRAINER_UPDATED"). On the
PATCH side this means one extra SELECT after the UPDATE — fair
tradeoff for keeping the two response shapes in lockstep.

Failure mode: if the post-update reload fails (e.g. trainer deleted
between UPDATE and SELECT) the response distinguishes that from the
write failure via a warn log + the builder's error response — caller
knows the update landed but the reload didn't.

Test added: PATCH /trainers/{id} with a small bio change and
asserts gender + phone_number survive in the response (seeded from
the earlier create subtest). Regression pin for the shared builder.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): add GET /trainers/me/clients endpoint (#224)

* fix: remove 500 error on failed setup link (#226)

* refactor(api.yaml): remove status from BaseResponse (#230)

* refactor(api.yaml): remove status from BaseResponse

* test(waitlist): refactor tests to remove status

* test(admin): refactor login tests to remove status

* fix: rewrite api.NewError function calls (#229)

Rewrote parts of the codebase where api.NewError was called and parameters were passed in the wrong order

* feat(media): organisation-level images + videos library (#234)

Adds a /media endpoint group for org-level media (landing-page hero
content, marketing copy, etc.) — distinct from the trainer-specific
gallery and intro-video flows.

DB
- New table organisation_media with media_type discriminator
  (image | video), status (processing/ready/failed), category as
  free text, uploaded_by audit pointer (ON DELETE SET NULL so admin
  removal doesn't cascade-delete media).
- Composite + partial indexes for the common (type, created_at DESC)
  list scan and the category filter.

Endpoints
- POST   /media/images   admin only; multipart; async pipeline
- POST   /media/videos   admin only; multipart; transcode pipeline
- GET    /media          public; paginated; type/category/status
                         filters (status defaults to 'ready')
- GET    /media/{id}     public
- DELETE /media/{id}     admin only; 409 on processing rows; also
                         removes the MinIO object so storage doesn't
                         drift away from the DB

Upload pipelines
- OrganisationImageUploader mirrors TrainerImageUploader (bytes in
  channel; retry with backoff; status flip ready/failed at terminus).
- OrganisationVideoUploader mirrors VideoUploader (disk-backed temp
  file; ffmpeg transcode to MP4 H.264 faststart; same retry shape).
- Both reuse the existing MinIO client + ffmpeg transcoder.

Storage
- Storage interface gains RemoveObject so the admin DELETE can free
  the underlying MinIO object. Idempotent — missing keys are not an
  error. NoopStorage returns ErrNotConfigured so the call surfaces a
  clean 503 path if storage isn't wired.

Auth
- POST/DELETE gated by handler-level requireMediaAdmin (looks up
  caller's users.role, accepts admin / super_admin). GET endpoints
  use security: [] in the spec — no JWT required.
- No new middleware: the path-prefix gates (TrainersAdminOnly,
  SuperAdminOnly) only fire on /trainers/* and /admin/*; /media
  doesn't intersect either.

Failure modes
- MinIO not configured -> 503 on POST endpoints (handler 503s before
  the request burns memory parsing multipart).
- ffmpeg not configured -> 503 on POST /media/videos specifically;
  images still work.
- Worker exhausts retries -> row stays with status=failed; admin can
  DELETE and re-upload. No silent orphaning.

Tests
- Public GET with no token (200, empty list initially).
- Non-admin POST (403).
- Admin happy path (202 + row id; skips if MinIO not in CI env).
- 409 on DELETE while processing; succeeds once status flipped to
  ready.
- Invalid query enum (400).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(subscriptions): add GET /subscriptions/plans endpoint (#233)

Public endpoint (no auth required) returning the three fixed subscription
tiers — Casual ($20, 1 session), Committed ($80, 12 sessions), Consistent
($120, 18 sessions) — each with a 7-day free trial, Apple/Google IAP
product IDs, and standardised feature highlights.

- Added SubscriptionPlan and SubscriptionPlansResponse schemas to api.yaml
  with required field constraints; added Subscriptions tag to root tags
- Set security: [] so unauthenticated users can browse plans before subscribing
- Removed spurious 401 response from spec (public endpoint never returns 401)
- Hardcoded catalogue in internal/routes/subscriptions.go (no DB needed)
- Regenerated internal/api/gen.go via oapi-codegen

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
* feat(trainers): add POST /trainers/resend-setup for re-issuing the invite (#219)

Admins were re-invoking POST /trainers when a trainer needed their
setup email re-sent (lost the link, hit the SMTP quota, etc.). That
worked because UpsertTrainerUser is idempotent on email, but it
forced re-entering specializations / benefits / display picture and
ran the full multipart parser for a one-line ask.

New endpoint takes just the email:

  POST /trainers/resend-setup
  { "email": "trainer@example.com" }

Identified by email (not trainer.id) because the admin asking "send
the invite again" already knows the email, never the internal UUID.

Behavior
- Validates email format (400 on malformed)
- Resolves email -> user -> trainer profile. Both "no such email" and
  "user exists but isn't a trainer" return the same 404 so an admin
  can't trivially probe which emails are trainers vs clients.
- Rejects with 409 when the trainer has already activated
  (consumed_at is set). The recovery path for an activated trainer
  who can't log in is forgot-password, not another setup link.
- Calls the existing accountSetup.IssueAndSend, which rotates the
  token via UpsertToken (idempotent on user_id) and sends the link.
  No new SQL, no new repo methods.

Admin/super_admin only via the existing TrainersAdminOnly middleware
(non-GET /trainers/* requires admin).

Tests cover happy path (super_admin + plain admin), 401, 403,
malformed email, unknown email, non-trainer email (same 404),
already-activated trainer (409 — seeded via a direct INSERT into
account_setup_tokens with consumed_at set).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): accept and return gender + phone_number on create (#222)

* feat(trainers): accept and return gender + phone_number on create

Admins were asking for trainer profile fields that the schema didn't
yet capture. Both belong on the underlying users row, not trainers,
because they're personal identity attributes any role could use later
(users.gender already existed; phone_number is new).

Migration 000047
- ALTER TABLE users ADD COLUMN phone_number TEXT (nullable)
- Normalize stray users.gender values to NULL, then add a CHECK
  constraint locking gender to NULL / male / female / other /
  prefer_not_to_say. Existing rows with non-conforming values would
  have failed the ALTER without the normalize step.

API surface
- POST /trainers multipart form gains two optional fields:
    gender        (enum: male, female, other, prefer_not_to_say)
    phone_number  (E.164, ^\+[1-9]\d{6,14}$ — same shape the
                   discovery-call phone_callback field uses)
- Trainer response schema gains the same two as nullable fields.
- GET /trainers/{id}, GET /trainers/me, GET /trainers (list), and
  the POST /trainers 201 response all surface them, returning JSON
  null when not set.

SQL
- UpsertTrainerUser takes gender + phone_number. NULLIF + COALESCE
  means: empty string -> NULL on insert; a re-invite that omits
  either field keeps the existing stored value rather than wiping it.
- GetTrainerWithUserByID and ListTrainers SELECT u.gender +
  u.phone_number alongside the already-joined u.name / u.email.

Validation
- Invalid gender (not in the enum) -> 400, no row written. Even if
  the handler missed it the users_gender_valid CHECK would fail the
  TX commit — belt-and-braces.
- Invalid phone (not E.164) -> 400, no row written. Validated via
  regex in the handler.

Tests (RUN_INTEGRATION_TESTS=1) round-trip both fields through
create -> GET, prove omission produces JSON null, and pin the two
400 paths.

* fix(trainers): PATCH /trainers/{id} returns full joined trainer profile

CodeRabbit flagged that UpdateTrainer responded with
trainerToMap(updated) — built from the bare trainers row — so the
gender + phone_number fields the previous commit added to GET +
create + list responses were silently absent from PATCH. Anything
the contract advertises on the Trainer schema needs to come out of
PATCH too.

Refactor: extract buildTrainerProfilePayload from
renderTrainerProfileByID so both endpoints (GET /trainers/{id} and
the new PATCH path) build their response through the same code. The
builder does the user join (GetTrainerWithUserByID) plus benefits
fetch and returns the payload map; the writers pick their own
success message ("TRAINER_FETCHED" vs "TRAINER_UPDATED"). On the
PATCH side this means one extra SELECT after the UPDATE — fair
tradeoff for keeping the two response shapes in lockstep.

Failure mode: if the post-update reload fails (e.g. trainer deleted
between UPDATE and SELECT) the response distinguishes that from the
write failure via a warn log + the builder's error response — caller
knows the update landed but the reload didn't.

Test added: PATCH /trainers/{id} with a small bio change and
asserts gender + phone_number survive in the response (seeded from
the earlier create subtest). Regression pin for the shared builder.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): add GET /trainers/me/clients endpoint (#224)

* fix: remove 500 error on failed setup link (#226)

* refactor(api.yaml): remove status from BaseResponse (#230)

* refactor(api.yaml): remove status from BaseResponse

* test(waitlist): refactor tests to remove status

* test(admin): refactor login tests to remove status

* fix: rewrite api.NewError function calls (#229)

Rewrote parts of the codebase where api.NewError was called and parameters were passed in the wrong order

* feat(media): organisation-level images + videos library (#234)

Adds a /media endpoint group for org-level media (landing-page hero
content, marketing copy, etc.) — distinct from the trainer-specific
gallery and intro-video flows.

DB
- New table organisation_media with media_type discriminator
  (image | video), status (processing/ready/failed), category as
  free text, uploaded_by audit pointer (ON DELETE SET NULL so admin
  removal doesn't cascade-delete media).
- Composite + partial indexes for the common (type, created_at DESC)
  list scan and the category filter.

Endpoints
- POST   /media/images   admin only; multipart; async pipeline
- POST   /media/videos   admin only; multipart; transcode pipeline
- GET    /media          public; paginated; type/category/status
                         filters (status defaults to 'ready')
- GET    /media/{id}     public
- DELETE /media/{id}     admin only; 409 on processing rows; also
                         removes the MinIO object so storage doesn't
                         drift away from the DB

Upload pipelines
- OrganisationImageUploader mirrors TrainerImageUploader (bytes in
  channel; retry with backoff; status flip ready/failed at terminus).
- OrganisationVideoUploader mirrors VideoUploader (disk-backed temp
  file; ffmpeg transcode to MP4 H.264 faststart; same retry shape).
- Both reuse the existing MinIO client + ffmpeg transcoder.

Storage
- Storage interface gains RemoveObject so the admin DELETE can free
  the underlying MinIO object. Idempotent — missing keys are not an
  error. NoopStorage returns ErrNotConfigured so the call surfaces a
  clean 503 path if storage isn't wired.

Auth
- POST/DELETE gated by handler-level requireMediaAdmin (looks up
  caller's users.role, accepts admin / super_admin). GET endpoints
  use security: [] in the spec — no JWT required.
- No new middleware: the path-prefix gates (TrainersAdminOnly,
  SuperAdminOnly) only fire on /trainers/* and /admin/*; /media
  doesn't intersect either.

Failure modes
- MinIO not configured -> 503 on POST endpoints (handler 503s before
  the request burns memory parsing multipart).
- ffmpeg not configured -> 503 on POST /media/videos specifically;
  images still work.
- Worker exhausts retries -> row stays with status=failed; admin can
  DELETE and re-upload. No silent orphaning.

Tests
- Public GET with no token (200, empty list initially).
- Non-admin POST (403).
- Admin happy path (202 + row id; skips if MinIO not in CI env).
- 409 on DELETE while processing; succeeds once status flipped to
  ready.
- Invalid query enum (400).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(subscriptions): add GET /subscriptions/plans endpoint (#233)

Public endpoint (no auth required) returning the three fixed subscription
tiers — Casual ($20, 1 session), Committed ($80, 12 sessions), Consistent
($120, 18 sessions) — each with a 7-day free trial, Apple/Google IAP
product IDs, and standardised feature highlights.

- Added SubscriptionPlan and SubscriptionPlansResponse schemas to api.yaml
  with required field constraints; added Subscriptions tag to root tags
- Set security: [] so unauthenticated users can browse plans before subscribing
- Removed spurious 401 response from spec (public endpoint never returns 401)
- Hardcoded catalogue in internal/routes/subscriptions.go (no DB needed)
- Regenerated internal/api/gen.go via oapi-codegen

* fix: add onboarding status check (#228)

* fix: add onboarding status check

* chore: use generated function for status check

* feat(notification): created notification system and endpoints (#232)

* feat(notification): created notification system and endpoints

* feat(notification): created notification system and endpoints - new branch

* fix(bugs): fixed multiple validation bugs and merge conflicts

* fix(test): waitlist test expected 200, got 400

* fix(conflict): pulled from dev and added FCM cred to .env.example

* fix(migrations): renamed migration to a unique number

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

* Feat/subscriptions (#237)

* feat(subscriptions): add GET /subscriptions/plans and POST /subscriptions

Plans endpoint (GET /subscriptions/plans):
- Public endpoint (no auth) returning 3 fixed tiers — Casual ($20/1 session),
  Committed ($80/12 sessions), Consistent ($120/18 sessions)
- Each plan includes 7-day trial, Apple/Google IAP product IDs, and features
- Added SubscriptionPlan + SubscriptionPlansResponse schemas to api.yaml

Create subscription endpoint (POST /subscriptions):
- Verifies Apple App Store receipt or Google Play purchase token before
  persisting; set IAP_SKIP_VERIFICATION=true to bypass in dev/test
- Returns 409 on duplicate receipt/token or if client already has an active
  subscription with the same trainer
- HTTP client uses 15s timeout for Apple/Google verification calls
- Migration 000048 adds plan_id, platform, trial_ends_at,
  apple_original_transaction_id, google_purchase_token to subscriptions table

* feat(subscriptions): add GET /me, GET /me/usage, POST /client/cancel/subscription

- GET /subscriptions/me: returns client active subscription
- GET /subscriptions/me/usage: returns sessions used/remaining for billing period
- POST /client/cancel/subscription: cancels active subscription (409 if already cancelled)
- Remove free trial fields (trial_days, trial_ends_at) from schema and handler
- Add required fields to Subscription schema
- Add index on (client_id, status) for subscription queries
- Log IAP verification errors internally instead of leaking to client

* fix(subscriptions): address CodeRabbit review comments

- Validate platform explicitly with switch (reject unknown platforms)
- Enforce product_id <-> plan_id consistency before granting entitlements
- Reject Google paymentState=0 (payment pending) as invalid
- Fix ExpiresDateMS comparison to use time.Time not string ordering
- Resolve Google JWT aud claim from tokenURI before building claims
- Return error on non-2xx Google OAuth token responses
- Set cancelled_at = NOW() when cancelling a subscription
- Add DB CHECK constraint for platform/token consistency
- Block IAP_SKIP_VERIFICATION=true in production at boot
- Require APPLE_SHARED_SECRET and GOOGLE_SERVICE_ACCOUNT_JSON at boot when verification is enabled
- Document 409 response on POST /client/cancel/subscription

* fix(iap): address errcheck lint failures

- Wrap defer resp.Body.Close() in func to discard error explicitly
- Check fmt.Sscanf return value in msToTime helper

* fix(migrations): renumber IAP migrations to 000051-000052 to avoid conflicts with dev

* feat(auth): email-only login returns tokens immediately (#239)

POST /auth/login accepts email only and issues access + refresh tokens directly.
No OTP step required. Returns 401 if account not found or inactive.

* Feat/zoom trainer host and sdk (#240)

* feat(zoom): per-trainer hosting + in-app Meeting SDK joins behind feature flags

Adds two orthogonal flags so the existing org-account / link-in-email flow
keeps working untouched while we roll out:

  ZOOM_MEETING_HOST=org|trainer   who hosts the call
  ZOOM_JOIN_MODE=link|sdk         what the email "Join" button opens

Per-trainer OAuth lives in user_zoom_credentials with AES-256-GCM tokens
at rest (pkg/cryptoutil) and rolling-refresh-token races serialised
per-user (internal/zoomflow.CredentialStore). meeting.Selector picks the
provider per call and silently falls back to org when a trainer hasn't
connected yet, so adopters don't break overnight.

In-app joins: GET /sessions/{id}/join-info returns a short-lived
HS256-signed Meeting SDK JWT. /config/zoom + AASA + assetlinks.json files
let mobile claim universal/app links pointing at /sessions/*/join.

Mobile integration recipe in docs/ZOOM_INTEGRATION.md.

* chore(zoom): document new env vars in .env.example

Adds the per-trainer OAuth, AES-256-GCM key, Meeting SDK creds, feature
flags, and universal-link envs for the Zoom integration introduced in
the previous commit. All optional with sane defaults that keep the
existing org-account flow working.

* chore(zoom): renumber zoom-credentials migration to 000053

origin/dev landed 000051_add_iap_fields_to_subscriptions.sql + 000052
while this branch was in flight, both occupying the slots my Zoom
credentials migration claimed. Pushing it to the next free number
keeps goose's ordering unambiguous.

* fix(zoom): satisfy golangci-lint on join-info handler

ineffassign + staticcheck QF1002 — the initial role assignment was
dead (the switch always reassigns) and the switch reads cleaner as a
tagged switch on userID.

* chore: update to env

* fix(zoom): address CodeRabbit review feedback

- store.go: preserve ErrNotConnected contract on the locked re-read path
  so the selector falls back to org cleanly when a row is deleted mid-flight
- user_provider.go: nil-token guard on CreateMeeting/DeleteMeeting + reject
  empty id/join_url so a quiet 200 doesn't persist a useless meeting
- sdk_signature.go: refuse to sign anything outside SDKRole{0,1}; Zoom
  rejects it anyway, fail loud server-side
- sdk_signature_test.go: stop swallowing base64/JSON decode errors and
  cover the new role-validation guard
- zoom_joininfo.go: authorise before any state-revealing branch; defer the
  trainer-details JOIN to only the trainer path so a failing lookup can't
  500 a legitimate client; restructured as if/else to please staticcheck
- routes.go: require the FULL org Zoom credential set + the OAuth
  redirect URL before enabling each provider — partial config now warns
  loudly and stays at NoOp instead of building a provider that can never
  authenticate
- bookings: extract JoinLinkBuilder, share it between the initial
  confirmation and the paid-reschedule emails so an SDK-mode user doesn't
  get a universal link on confirmation + a raw Zoom URL on reschedule
- bookings handler: deleteWithOrgFallback retries old-meeting deletes via
  the org provider when the trainer provider can't find it — covers the
  trainer-connected-after-booking case where the original meeting lives
  under the org account

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/subscriptions (#244)

* feat(subscriptions): add GET /me, GET /me/usage, POST /client/cancel/subscription

- GET /subscriptions/me: returns client active subscription
- GET /subscriptions/me/usage: returns sessions used/remaining for billing period
- POST /client/cancel/subscription: cancels active subscription (409 if already cancelled)
- Remove free trial fields (trial_days, trial_ends_at) from schema and handler
- Add required fields to Subscription schema
- Add index on (client_id, status) for subscription queries
- Log IAP verification errors internally instead of leaking to client

* fix(subscriptions): address CodeRabbit review comments

- Validate platform explicitly with switch (reject unknown platforms)
- Enforce product_id <-> plan_id consistency before granting entitlements
- Reject Google paymentState=0 (payment pending) as invalid
- Fix ExpiresDateMS comparison to use time.Time not string ordering
- Resolve Google JWT aud claim from tokenURI before building claims
- Return error on non-2xx Google OAuth token responses
- Set cancelled_at = NOW() when cancelling a subscription
- Add DB CHECK constraint for platform/token consistency
- Block IAP_SKIP_VERIFICATION=true in production at boot
- Require APPLE_SHARED_SECRET and GOOGLE_SERVICE_ACCOUNT_JSON at boot when verification is enabled
- Document 409 response on POST /client/cancel/subscription

* fix(migrations): renumber IAP migrations to 000051-000052 to avoid conflicts with dev

* chore: untrack docker-compose.yml and add to .gitignore

* feat(webhooks): add Apple and Google IAP webhook handlers

POST /webhooks/apple — handles App Store Server Notifications V2.
Decodes the signed JWS payload, extracts the originalTransactionId and
notificationType, looks up the subscription, and updates status/period_end.

POST /webhooks/google — handles Play Real-Time Developer Notifications
via Pub/Sub push. Base64-decodes message.data, extracts purchaseToken and
notificationType, and updates the subscription accordingly.

Also adds UpdateSubscriptionStatus query (used by both handlers to set
status + current_period_end atomically) and wires the new oapi-codegen
interface methods into the routes package.

* fix(webhooks): preserve current_period_end on expiry/cancel to avoid check constraint violation

Updating current_period_end to Apple's past expiresDate could set it
before current_period_start, violating the DB check constraint.
Expiry and cancellation events now keep the existing period_end and
only update the status. Renewals still fetch and update the period_end.

Also updates dev token endpoint to accept optional user_id query param.

* fix(review): address self-review issues

- gofmt formatting on subscriptions.go (indentation in error blocks)
- validate user_id query param as UUID in dev token endpoint
- map Google ON_HOLD (type 5) to active not expired — grace period
  still gives the client time to recover payment before type 13 fires

* chore: add IAP env vars to .env.example and webhook sim script

* fix(lint): check resp.Body.Close error return in webhook_sim.go

* fix(review): address CodeRabbit comments — cancelled_at stamping, log masking, duplicate status check, filename in usage string

* feat(activities): recent-activity feed for trainers + admin (#242)

Two hand-wired endpoints, both cursor-paginated:

  GET /api/v1/trainers/me/activities   — trainer-scoped (auth required, trainer-only)
  GET /api/v1/admin/activities         — system-wide (admin+)

The feed is derived at read time from the existing source-of-truth
tables (bookings, paid_booking_reschedule_history, discovery_bookings,
booking_reschedule_history, reviews) via UNION ALL. No new tables,
no write-path instrumentation — adding an event type is one branch in
the union plus one entry in the summary template.

Event types shipped: booking_created, booking_cancelled,
booking_rescheduled, discovery_booked, discovery_rescheduled,
review_received. Each row carries an opaque cursor pair
(occurred_at, activity_id) so a busy minute doesn't drop/duplicate
rows across pages.

Trainer scope filters via a CTE on trainers.user_id = $caller; admin
scope drops that join and adds trainer info to each row so the
dashboard can render who the event belonged to. Two near-duplicate
queries rather than a runtime-toggled WHERE because the planner
produces noticeably better plans when the trainer filter is a CTE
join vs an OR-able predicate.

Admin route added to adminReadablePaths so plain admin (not just
super_admin) can read it — ops uses this daily.

Endpoint shape + pagination contract documented in docs/ACTIVITIES.md.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(docker): restore docker-compose.yml with correct port 5432 (#245)

* fix(server): bump HTTP timeouts so slow uploads don't get the socket closed (#249)

ReadTimeout was 15s — anything where the request BODY takes longer
than that to arrive (a 30 MB video on a 4 Mbps mobile uplink alone is
~60s) had its connection closed mid-stream by net/http. The frontend
saw it as a generic "network error" because that's what it was at the
socket level. Affected every upload route — profile picture, trainer
images, trainer intro video, org media images + videos.

New shape:
  ReadHeaderTimeout  10s          unchanged behaviour (slow-loris)
  ReadTimeout       10m           covers ~500 MiB body on slow mobile
  WriteTimeout      10m           must move with ReadTimeout (ticks
                                  from header-read time)
  IdleTimeout        60s          unchanged

Per-handler context deadlines are still the right tool for tightening
individual routes; the server timeout is just the outer envelope.

Note for ops: if traffic sits behind nginx/Cloudflare/ALB, their
own timeouts (and body-size limits) need the same treatment —
client_body_timeout, proxy_read_timeout, ALB idle_timeout. Cloudflare
free tier caps at 100s and can't be raised; uploads larger than that
need to bypass it or move to direct-to-storage signed URLs.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add active subscriptions count and revenue snapshot endpoints (#247)

- GET /admin/subscriptions/count — active subscription count (Subscriptions tag)
- GET /admin/revenue — total/subscription/one-time revenue + latest payment
- Expose dashboard endpoints to admin role via adminReadablePaths
- Cast revenue aggregates to BIGINT so sqlc generates int64
- Add integration tests for all 3 dashboard endpoints
- Fix goose headers in migration 000052; add migration 000054 for plan_type FK

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
* feat(trainers): add POST /trainers/resend-setup for re-issuing the invite (#219)

Admins were re-invoking POST /trainers when a trainer needed their
setup email re-sent (lost the link, hit the SMTP quota, etc.). That
worked because UpsertTrainerUser is idempotent on email, but it
forced re-entering specializations / benefits / display picture and
ran the full multipart parser for a one-line ask.

New endpoint takes just the email:

  POST /trainers/resend-setup
  { "email": "trainer@example.com" }

Identified by email (not trainer.id) because the admin asking "send
the invite again" already knows the email, never the internal UUID.

Behavior
- Validates email format (400 on malformed)
- Resolves email -> user -> trainer profile. Both "no such email" and
  "user exists but isn't a trainer" return the same 404 so an admin
  can't trivially probe which emails are trainers vs clients.
- Rejects with 409 when the trainer has already activated
  (consumed_at is set). The recovery path for an activated trainer
  who can't log in is forgot-password, not another setup link.
- Calls the existing accountSetup.IssueAndSend, which rotates the
  token via UpsertToken (idempotent on user_id) and sends the link.
  No new SQL, no new repo methods.

Admin/super_admin only via the existing TrainersAdminOnly middleware
(non-GET /trainers/* requires admin).

Tests cover happy path (super_admin + plain admin), 401, 403,
malformed email, unknown email, non-trainer email (same 404),
already-activated trainer (409 — seeded via a direct INSERT into
account_setup_tokens with consumed_at set).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): accept and return gender + phone_number on create (#222)

* feat(trainers): accept and return gender + phone_number on create

Admins were asking for trainer profile fields that the schema didn't
yet capture. Both belong on the underlying users row, not trainers,
because they're personal identity attributes any role could use later
(users.gender already existed; phone_number is new).

Migration 000047
- ALTER TABLE users ADD COLUMN phone_number TEXT (nullable)
- Normalize stray users.gender values to NULL, then add a CHECK
  constraint locking gender to NULL / male / female / other /
  prefer_not_to_say. Existing rows with non-conforming values would
  have failed the ALTER without the normalize step.

API surface
- POST /trainers multipart form gains two optional fields:
    gender        (enum: male, female, other, prefer_not_to_say)
    phone_number  (E.164, ^\+[1-9]\d{6,14}$ — same shape the
                   discovery-call phone_callback field uses)
- Trainer response schema gains the same two as nullable fields.
- GET /trainers/{id}, GET /trainers/me, GET /trainers (list), and
  the POST /trainers 201 response all surface them, returning JSON
  null when not set.

SQL
- UpsertTrainerUser takes gender + phone_number. NULLIF + COALESCE
  means: empty string -> NULL on insert; a re-invite that omits
  either field keeps the existing stored value rather than wiping it.
- GetTrainerWithUserByID and ListTrainers SELECT u.gender +
  u.phone_number alongside the already-joined u.name / u.email.

Validation
- Invalid gender (not in the enum) -> 400, no row written. Even if
  the handler missed it the users_gender_valid CHECK would fail the
  TX commit — belt-and-braces.
- Invalid phone (not E.164) -> 400, no row written. Validated via
  regex in the handler.

Tests (RUN_INTEGRATION_TESTS=1) round-trip both fields through
create -> GET, prove omission produces JSON null, and pin the two
400 paths.

* fix(trainers): PATCH /trainers/{id} returns full joined trainer profile

CodeRabbit flagged that UpdateTrainer responded with
trainerToMap(updated) — built from the bare trainers row — so the
gender + phone_number fields the previous commit added to GET +
create + list responses were silently absent from PATCH. Anything
the contract advertises on the Trainer schema needs to come out of
PATCH too.

Refactor: extract buildTrainerProfilePayload from
renderTrainerProfileByID so both endpoints (GET /trainers/{id} and
the new PATCH path) build their response through the same code. The
builder does the user join (GetTrainerWithUserByID) plus benefits
fetch and returns the payload map; the writers pick their own
success message ("TRAINER_FETCHED" vs "TRAINER_UPDATED"). On the
PATCH side this means one extra SELECT after the UPDATE — fair
tradeoff for keeping the two response shapes in lockstep.

Failure mode: if the post-update reload fails (e.g. trainer deleted
between UPDATE and SELECT) the response distinguishes that from the
write failure via a warn log + the builder's error response — caller
knows the update landed but the reload didn't.

Test added: PATCH /trainers/{id} with a small bio change and
asserts gender + phone_number survive in the response (seeded from
the earlier create subtest). Regression pin for the shared builder.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): add GET /trainers/me/clients endpoint (#224)

* fix: remove 500 error on failed setup link (#226)

* refactor(api.yaml): remove status from BaseResponse (#230)

* refactor(api.yaml): remove status from BaseResponse

* test(waitlist): refactor tests to remove status

* test(admin): refactor login tests to remove status

* fix: rewrite api.NewError function calls (#229)

Rewrote parts of the codebase where api.NewError was called and parameters were passed in the wrong order

* feat(media): organisation-level images + videos library (#234)

Adds a /media endpoint group for org-level media (landing-page hero
content, marketing copy, etc.) — distinct from the trainer-specific
gallery and intro-video flows.

DB
- New table organisation_media with media_type discriminator
  (image | video), status (processing/ready/failed), category as
  free text, uploaded_by audit pointer (ON DELETE SET NULL so admin
  removal doesn't cascade-delete media).
- Composite + partial indexes for the common (type, created_at DESC)
  list scan and the category filter.

Endpoints
- POST   /media/images   admin only; multipart; async pipeline
- POST   /media/videos   admin only; multipart; transcode pipeline
- GET    /media          public; paginated; type/category/status
                         filters (status defaults to 'ready')
- GET    /media/{id}     public
- DELETE /media/{id}     admin only; 409 on processing rows; also
                         removes the MinIO object so storage doesn't
                         drift away from the DB

Upload pipelines
- OrganisationImageUploader mirrors TrainerImageUploader (bytes in
  channel; retry with backoff; status flip ready/failed at terminus).
- OrganisationVideoUploader mirrors VideoUploader (disk-backed temp
  file; ffmpeg transcode to MP4 H.264 faststart; same retry shape).
- Both reuse the existing MinIO client + ffmpeg transcoder.

Storage
- Storage interface gains RemoveObject so the admin DELETE can free
  the underlying MinIO object. Idempotent — missing keys are not an
  error. NoopStorage returns ErrNotConfigured so the call surfaces a
  clean 503 path if storage isn't wired.

Auth
- POST/DELETE gated by handler-level requireMediaAdmin (looks up
  caller's users.role, accepts admin / super_admin). GET endpoints
  use security: [] in the spec — no JWT required.
- No new middleware: the path-prefix gates (TrainersAdminOnly,
  SuperAdminOnly) only fire on /trainers/* and /admin/*; /media
  doesn't intersect either.

Failure modes
- MinIO not configured -> 503 on POST endpoints (handler 503s before
  the request burns memory parsing multipart).
- ffmpeg not configured -> 503 on POST /media/videos specifically;
  images still work.
- Worker exhausts retries -> row stays with status=failed; admin can
  DELETE and re-upload. No silent orphaning.

Tests
- Public GET with no token (200, empty list initially).
- Non-admin POST (403).
- Admin happy path (202 + row id; skips if MinIO not in CI env).
- 409 on DELETE while processing; succeeds once status flipped to
  ready.
- Invalid query enum (400).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(subscriptions): add GET /subscriptions/plans endpoint (#233)

Public endpoint (no auth required) returning the three fixed subscription
tiers — Casual ($20, 1 session), Committed ($80, 12 sessions), Consistent
($120, 18 sessions) — each with a 7-day free trial, Apple/Google IAP
product IDs, and standardised feature highlights.

- Added SubscriptionPlan and SubscriptionPlansResponse schemas to api.yaml
  with required field constraints; added Subscriptions tag to root tags
- Set security: [] so unauthenticated users can browse plans before subscribing
- Removed spurious 401 response from spec (public endpoint never returns 401)
- Hardcoded catalogue in internal/routes/subscriptions.go (no DB needed)
- Regenerated internal/api/gen.go via oapi-codegen

* fix: add onboarding status check (#228)

* fix: add onboarding status check

* chore: use generated function for status check

* feat(notification): created notification system and endpoints (#232)

* feat(notification): created notification system and endpoints

* feat(notification): created notification system and endpoints - new branch

* fix(bugs): fixed multiple validation bugs and merge conflicts

* fix(test): waitlist test expected 200, got 400

* fix(conflict): pulled from dev and added FCM cred to .env.example

* fix(migrations): renamed migration to a unique number

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

* Feat/subscriptions (#237)

* feat(subscriptions): add GET /subscriptions/plans and POST /subscriptions

Plans endpoint (GET /subscriptions/plans):
- Public endpoint (no auth) returning 3 fixed tiers — Casual ($20/1 session),
  Committed ($80/12 sessions), Consistent ($120/18 sessions)
- Each plan includes 7-day trial, Apple/Google IAP product IDs, and features
- Added SubscriptionPlan + SubscriptionPlansResponse schemas to api.yaml

Create subscription endpoint (POST /subscriptions):
- Verifies Apple App Store receipt or Google Play purchase token before
  persisting; set IAP_SKIP_VERIFICATION=true to bypass in dev/test
- Returns 409 on duplicate receipt/token or if client already has an active
  subscription with the same trainer
- HTTP client uses 15s timeout for Apple/Google verification calls
- Migration 000048 adds plan_id, platform, trial_ends_at,
  apple_original_transaction_id, google_purchase_token to subscriptions table

* feat(subscriptions): add GET /me, GET /me/usage, POST /client/cancel/subscription

- GET /subscriptions/me: returns client active subscription
- GET /subscriptions/me/usage: returns sessions used/remaining for billing period
- POST /client/cancel/subscription: cancels active subscription (409 if already cancelled)
- Remove free trial fields (trial_days, trial_ends_at) from schema and handler
- Add required fields to Subscription schema
- Add index on (client_id, status) for subscription queries
- Log IAP verification errors internally instead of leaking to client

* fix(subscriptions): address CodeRabbit review comments

- Validate platform explicitly with switch (reject unknown platforms)
- Enforce product_id <-> plan_id consistency before granting entitlements
- Reject Google paymentState=0 (payment pending) as invalid
- Fix ExpiresDateMS comparison to use time.Time not string ordering
- Resolve Google JWT aud claim from tokenURI before building claims
- Return error on non-2xx Google OAuth token responses
- Set cancelled_at = NOW() when cancelling a subscription
- Add DB CHECK constraint for platform/token consistency
- Block IAP_SKIP_VERIFICATION=true in production at boot
- Require APPLE_SHARED_SECRET and GOOGLE_SERVICE_ACCOUNT_JSON at boot when verification is enabled
- Document 409 response on POST /client/cancel/subscription

* fix(iap): address errcheck lint failures

- Wrap defer resp.Body.Close() in func to discard error explicitly
- Check fmt.Sscanf return value in msToTime helper

* fix(migrations): renumber IAP migrations to 000051-000052 to avoid conflicts with dev

* feat(auth): email-only login returns tokens immediately (#239)

POST /auth/login accepts email only and issues access + refresh tokens directly.
No OTP step required. Returns 401 if account not found or inactive.

* Feat/zoom trainer host and sdk (#240)

* feat(zoom): per-trainer hosting + in-app Meeting SDK joins behind feature flags

Adds two orthogonal flags so the existing org-account / link-in-email flow
keeps working untouched while we roll out:

  ZOOM_MEETING_HOST=org|trainer   who hosts the call
  ZOOM_JOIN_MODE=link|sdk         what the email "Join" button opens

Per-trainer OAuth lives in user_zoom_credentials with AES-256-GCM tokens
at rest (pkg/cryptoutil) and rolling-refresh-token races serialised
per-user (internal/zoomflow.CredentialStore). meeting.Selector picks the
provider per call and silently falls back to org when a trainer hasn't
connected yet, so adopters don't break overnight.

In-app joins: GET /sessions/{id}/join-info returns a short-lived
HS256-signed Meeting SDK JWT. /config/zoom + AASA + assetlinks.json files
let mobile claim universal/app links pointing at /sessions/*/join.

Mobile integration recipe in docs/ZOOM_INTEGRATION.md.

* chore(zoom): document new env vars in .env.example

Adds the per-trainer OAuth, AES-256-GCM key, Meeting SDK creds, feature
flags, and universal-link envs for the Zoom integration introduced in
the previous commit. All optional with sane defaults that keep the
existing org-account flow working.

* chore(zoom): renumber zoom-credentials migration to 000053

origin/dev landed 000051_add_iap_fields_to_subscriptions.sql + 000052
while this branch was in flight, both occupying the slots my Zoom
credentials migration claimed. Pushing it to the next free number
keeps goose's ordering unambiguous.

* fix(zoom): satisfy golangci-lint on join-info handler

ineffassign + staticcheck QF1002 — the initial role assignment was
dead (the switch always reassigns) and the switch reads cleaner as a
tagged switch on userID.

* chore: update to env

* fix(zoom): address CodeRabbit review feedback

- store.go: preserve ErrNotConnected contract on the locked re-read path
  so the selector falls back to org cleanly when a row is deleted mid-flight
- user_provider.go: nil-token guard on CreateMeeting/DeleteMeeting + reject
  empty id/join_url so a quiet 200 doesn't persist a useless meeting
- sdk_signature.go: refuse to sign anything outside SDKRole{0,1}; Zoom
  rejects it anyway, fail loud server-side
- sdk_signature_test.go: stop swallowing base64/JSON decode errors and
  cover the new role-validation guard
- zoom_joininfo.go: authorise before any state-revealing branch; defer the
  trainer-details JOIN to only the trainer path so a failing lookup can't
  500 a legitimate client; restructured as if/else to please staticcheck
- routes.go: require the FULL org Zoom credential set + the OAuth
  redirect URL before enabling each provider — partial config now warns
  loudly and stays at NoOp instead of building a provider that can never
  authenticate
- bookings: extract JoinLinkBuilder, share it between the initial
  confirmation and the paid-reschedule emails so an SDK-mode user doesn't
  get a universal link on confirmation + a raw Zoom URL on reschedule
- bookings handler: deleteWithOrgFallback retries old-meeting deletes via
  the org provider when the trainer provider can't find it — covers the
  trainer-connected-after-booking case where the original meeting lives
  under the org account

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/subscriptions (#244)

* feat(subscriptions): add GET /me, GET /me/usage, POST /client/cancel/subscription

- GET /subscriptions/me: returns client active subscription
- GET /subscriptions/me/usage: returns sessions used/remaining for billing period
- POST /client/cancel/subscription: cancels active subscription (409 if already cancelled)
- Remove free trial fields (trial_days, trial_ends_at) from schema and handler
- Add required fields to Subscription schema
- Add index on (client_id, status) for subscription queries
- Log IAP verification errors internally instead of leaking to client

* fix(subscriptions): address CodeRabbit review comments

- Validate platform explicitly with switch (reject unknown platforms)
- Enforce product_id <-> plan_id consistency before granting entitlements
- Reject Google paymentState=0 (payment pending) as invalid
- Fix ExpiresDateMS comparison to use time.Time not string ordering
- Resolve Google JWT aud claim from tokenURI before building claims
- Return error on non-2xx Google OAuth token responses
- Set cancelled_at = NOW() when cancelling a subscription
- Add DB CHECK constraint for platform/token consistency
- Block IAP_SKIP_VERIFICATION=true in production at boot
- Require APPLE_SHARED_SECRET and GOOGLE_SERVICE_ACCOUNT_JSON at boot when verification is enabled
- Document 409 response on POST /client/cancel/subscription

* fix(migrations): renumber IAP migrations to 000051-000052 to avoid conflicts with dev

* chore: untrack docker-compose.yml and add to .gitignore

* feat(webhooks): add Apple and Google IAP webhook handlers

POST /webhooks/apple — handles App Store Server Notifications V2.
Decodes the signed JWS payload, extracts the originalTransactionId and
notificationType, looks up the subscription, and updates status/period_end.

POST /webhooks/google — handles Play Real-Time Developer Notifications
via Pub/Sub push. Base64-decodes message.data, extracts purchaseToken and
notificationType, and updates the subscription accordingly.

Also adds UpdateSubscriptionStatus query (used by both handlers to set
status + current_period_end atomically) and wires the new oapi-codegen
interface methods into the routes package.

* fix(webhooks): preserve current_period_end on expiry/cancel to avoid check constraint violation

Updating current_period_end to Apple's past expiresDate could set it
before current_period_start, violating the DB check constraint.
Expiry and cancellation events now keep the existing period_end and
only update the status. Renewals still fetch and update the period_end.

Also updates dev token endpoint to accept optional user_id query param.

* fix(review): address self-review issues

- gofmt formatting on subscriptions.go (indentation in error blocks)
- validate user_id query param as UUID in dev token endpoint
- map Google ON_HOLD (type 5) to active not expired — grace period
  still gives the client time to recover payment before type 13 fires

* chore: add IAP env vars to .env.example and webhook sim script

* fix(lint): check resp.Body.Close error return in webhook_sim.go

* fix(review): address CodeRabbit comments — cancelled_at stamping, log masking, duplicate status check, filename in usage string

* feat(activities): recent-activity feed for trainers + admin (#242)

Two hand-wired endpoints, both cursor-paginated:

  GET /api/v1/trainers/me/activities   — trainer-scoped (auth required, trainer-only)
  GET /api/v1/admin/activities         — system-wide (admin+)

The feed is derived at read time from the existing source-of-truth
tables (bookings, paid_booking_reschedule_history, discovery_bookings,
booking_reschedule_history, reviews) via UNION ALL. No new tables,
no write-path instrumentation — adding an event type is one branch in
the union plus one entry in the summary template.

Event types shipped: booking_created, booking_cancelled,
booking_rescheduled, discovery_booked, discovery_rescheduled,
review_received. Each row carries an opaque cursor pair
(occurred_at, activity_id) so a busy minute doesn't drop/duplicate
rows across pages.

Trainer scope filters via a CTE on trainers.user_id = $caller; admin
scope drops that join and adds trainer info to each row so the
dashboard can render who the event belonged to. Two near-duplicate
queries rather than a runtime-toggled WHERE because the planner
produces noticeably better plans when the trainer filter is a CTE
join vs an OR-able predicate.

Admin route added to adminReadablePaths so plain admin (not just
super_admin) can read it — ops uses this daily.

Endpoint shape + pagination contract documented in docs/ACTIVITIES.md.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(docker): restore docker-compose.yml with correct port 5432 (#245)

* fix(server): bump HTTP timeouts so slow uploads don't get the socket closed (#249)

ReadTimeout was 15s — anything where the request BODY takes longer
than that to arrive (a 30 MB video on a 4 Mbps mobile uplink alone is
~60s) had its connection closed mid-stream by net/http. The frontend
saw it as a generic "network error" because that's what it was at the
socket level. Affected every upload route — profile picture, trainer
images, trainer intro video, org media images + videos.

New shape:
  ReadHeaderTimeout  10s          unchanged behaviour (slow-loris)
  ReadTimeout       10m           covers ~500 MiB body on slow mobile
  WriteTimeout      10m           must move with ReadTimeout (ticks
                                  from header-read time)
  IdleTimeout        60s          unchanged

Per-handler context deadlines are still the right tool for tightening
individual routes; the server timeout is just the outer envelope.

Note for ops: if traffic sits behind nginx/Cloudflare/ALB, their
own timeouts (and body-size limits) need the same treatment —
client_body_timeout, proxy_read_timeout, ALB idle_timeout. Cloudflare
free tier caps at 100s and can't be raised; uploads larger than that
need to bypass it or move to direct-to-storage signed URLs.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add active subscriptions count and revenue snapshot endpoints (#247)

- GET /admin/subscriptions/count — active subscription count (Subscriptions tag)
- GET /admin/revenue — total/subscription/one-time revenue + latest payment
- Expose dashboard endpoints to admin role via adminReadablePaths
- Cast revenue aggregates to BIGINT so sqlc generates int64
- Add integration tests for all 3 dashboard endpoints
- Fix goose headers in migration 000052; add migration 000054 for plan_type FK

* Feat/top trainers (#248)

* feat(admin): top trainers

* Update config.go

* fix: bug fixes and improvements

* fix:

* fix(config): missing IAP credentials must not crash boot (#251)

The IAP feature shipped with a hard-fail in config.Load when
APPLE_SHARED_SECRET or GOOGLE_SERVICE_ACCOUNT_JSON were absent. This
took down every environment (staging, local dev, CI) that doesn't run
the Apple/Google billing flows — boot dies before the rest of the API
gets a chance to come up, so unrelated changes are bricked too.

Match the pattern already used for MinIO / Zoom / ffmpeg: log a loud
warn at boot, let the per-request subscription handlers reject with
their existing 400 path when their backing secret is empty. The Apple
+ Google verification calls in subscriptions.go and webhooks.go already
handle the failed-verify case gracefully — empty secret → Apple/Google
returns auth error → handler 400s the request.

The production guard against IAP_SKIP_VERIFICATION=true stays fatal —
silently bypassing receipt verification in prod is a real risk
(malicious client claims to have paid without paying); the missing-
secret case is just config-not-yet-provisioned.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/notifications (#252)

* feat(notifications): add tests to notification and user_device system

* fix(notification_test): fixed notification test bugs, fixed pkg notification bugs)

* fix(handler_test): included require.NoError(t,err) to check json.Unmarshal error

* fix(conflicts): fixed conflicts and merged with dev

* fix(coderabbit_convo): added more test to user_device and notifications

* fix(dead nil): removed possible nil pointer dereference

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

* feat(notification_websocket): added websocket integration to notification for non-clients (#255)

* feat(notifications): add tests to notification and user_device system

* fix(notification_test): fixed notification test bugs, fixed pkg notification bugs)

* fix(conflicts): fixed conflicts and merged with dev

* fix(coderabbit_convo): added more test to user_device and notifications

* fix(dead nil): removed possible nil pointer dereference

* feat(notifications): added websocket integration for web interface

* feat(notifications): added websocket integration for web interface

* fix(coderabbit): fixed bugs identified by coderabbit

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
* feat(trainers): add POST /trainers/resend-setup for re-issuing the invite (#219)

Admins were re-invoking POST /trainers when a trainer needed their
setup email re-sent (lost the link, hit the SMTP quota, etc.). That
worked because UpsertTrainerUser is idempotent on email, but it
forced re-entering specializations / benefits / display picture and
ran the full multipart parser for a one-line ask.

New endpoint takes just the email:

  POST /trainers/resend-setup
  { "email": "trainer@example.com" }

Identified by email (not trainer.id) because the admin asking "send
the invite again" already knows the email, never the internal UUID.

Behavior
- Validates email format (400 on malformed)
- Resolves email -> user -> trainer profile. Both "no such email" and
  "user exists but isn't a trainer" return the same 404 so an admin
  can't trivially probe which emails are trainers vs clients.
- Rejects with 409 when the trainer has already activated
  (consumed_at is set). The recovery path for an activated trainer
  who can't log in is forgot-password, not another setup link.
- Calls the existing accountSetup.IssueAndSend, which rotates the
  token via UpsertToken (idempotent on user_id) and sends the link.
  No new SQL, no new repo methods.

Admin/super_admin only via the existing TrainersAdminOnly middleware
(non-GET /trainers/* requires admin).

Tests cover happy path (super_admin + plain admin), 401, 403,
malformed email, unknown email, non-trainer email (same 404),
already-activated trainer (409 — seeded via a direct INSERT into
account_setup_tokens with consumed_at set).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): accept and return gender + phone_number on create (#222)

* feat(trainers): accept and return gender + phone_number on create

Admins were asking for trainer profile fields that the schema didn't
yet capture. Both belong on the underlying users row, not trainers,
because they're personal identity attributes any role could use later
(users.gender already existed; phone_number is new).

Migration 000047
- ALTER TABLE users ADD COLUMN phone_number TEXT (nullable)
- Normalize stray users.gender values to NULL, then add a CHECK
  constraint locking gender to NULL / male / female / other /
  prefer_not_to_say. Existing rows with non-conforming values would
  have failed the ALTER without the normalize step.

API surface
- POST /trainers multipart form gains two optional fields:
    gender        (enum: male, female, other, prefer_not_to_say)
    phone_number  (E.164, ^\+[1-9]\d{6,14}$ — same shape the
                   discovery-call phone_callback field uses)
- Trainer response schema gains the same two as nullable fields.
- GET /trainers/{id}, GET /trainers/me, GET /trainers (list), and
  the POST /trainers 201 response all surface them, returning JSON
  null when not set.

SQL
- UpsertTrainerUser takes gender + phone_number. NULLIF + COALESCE
  means: empty string -> NULL on insert; a re-invite that omits
  either field keeps the existing stored value rather than wiping it.
- GetTrainerWithUserByID and ListTrainers SELECT u.gender +
  u.phone_number alongside the already-joined u.name / u.email.

Validation
- Invalid gender (not in the enum) -> 400, no row written. Even if
  the handler missed it the users_gender_valid CHECK would fail the
  TX commit — belt-and-braces.
- Invalid phone (not E.164) -> 400, no row written. Validated via
  regex in the handler.

Tests (RUN_INTEGRATION_TESTS=1) round-trip both fields through
create -> GET, prove omission produces JSON null, and pin the two
400 paths.

* fix(trainers): PATCH /trainers/{id} returns full joined trainer profile

CodeRabbit flagged that UpdateTrainer responded with
trainerToMap(updated) — built from the bare trainers row — so the
gender + phone_number fields the previous commit added to GET +
create + list responses were silently absent from PATCH. Anything
the contract advertises on the Trainer schema needs to come out of
PATCH too.

Refactor: extract buildTrainerProfilePayload from
renderTrainerProfileByID so both endpoints (GET /trainers/{id} and
the new PATCH path) build their response through the same code. The
builder does the user join (GetTrainerWithUserByID) plus benefits
fetch and returns the payload map; the writers pick their own
success message ("TRAINER_FETCHED" vs "TRAINER_UPDATED"). On the
PATCH side this means one extra SELECT after the UPDATE — fair
tradeoff for keeping the two response shapes in lockstep.

Failure mode: if the post-update reload fails (e.g. trainer deleted
between UPDATE and SELECT) the response distinguishes that from the
write failure via a warn log + the builder's error response — caller
knows the update landed but the reload didn't.

Test added: PATCH /trainers/{id} with a small bio change and
asserts gender + phone_number survive in the response (seeded from
the earlier create subtest). Regression pin for the shared builder.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(trainers): add GET /trainers/me/clients endpoint (#224)

* fix: remove 500 error on failed setup link (#226)

* refactor(api.yaml): remove status from BaseResponse (#230)

* refactor(api.yaml): remove status from BaseResponse

* test(waitlist): refactor tests to remove status

* test(admin): refactor login tests to remove status

* fix: rewrite api.NewError function calls (#229)

Rewrote parts of the codebase where api.NewError was called and parameters were passed in the wrong order

* feat(media): organisation-level images + videos library (#234)

Adds a /media endpoint group for org-level media (landing-page hero
content, marketing copy, etc.) — distinct from the trainer-specific
gallery and intro-video flows.

DB
- New table organisation_media with media_type discriminator
  (image | video), status (processing/ready/failed), category as
  free text, uploaded_by audit pointer (ON DELETE SET NULL so admin
  removal doesn't cascade-delete media).
- Composite + partial indexes for the common (type, created_at DESC)
  list scan and the category filter.

Endpoints
- POST   /media/images   admin only; multipart; async pipeline
- POST   /media/videos   admin only; multipart; transcode pipeline
- GET    /media          public; paginated; type/category/status
                         filters (status defaults to 'ready')
- GET    /media/{id}     public
- DELETE /media/{id}     admin only; 409 on processing rows; also
                         removes the MinIO object so storage doesn't
                         drift away from the DB

Upload pipelines
- OrganisationImageUploader mirrors TrainerImageUploader (bytes in
  channel; retry with backoff; status flip ready/failed at terminus).
- OrganisationVideoUploader mirrors VideoUploader (disk-backed temp
  file; ffmpeg transcode to MP4 H.264 faststart; same retry shape).
- Both reuse the existing MinIO client + ffmpeg transcoder.

Storage
- Storage interface gains RemoveObject so the admin DELETE can free
  the underlying MinIO object. Idempotent — missing keys are not an
  error. NoopStorage returns ErrNotConfigured so the call surfaces a
  clean 503 path if storage isn't wired.

Auth
- POST/DELETE gated by handler-level requireMediaAdmin (looks up
  caller's users.role, accepts admin / super_admin). GET endpoints
  use security: [] in the spec — no JWT required.
- No new middleware: the path-prefix gates (TrainersAdminOnly,
  SuperAdminOnly) only fire on /trainers/* and /admin/*; /media
  doesn't intersect either.

Failure modes
- MinIO not configured -> 503 on POST endpoints (handler 503s before
  the request burns memory parsing multipart).
- ffmpeg not configured -> 503 on POST /media/videos specifically;
  images still work.
- Worker exhausts retries -> row stays with status=failed; admin can
  DELETE and re-upload. No silent orphaning.

Tests
- Public GET with no token (200, empty list initially).
- Non-admin POST (403).
- Admin happy path (202 + row id; skips if MinIO not in CI env).
- 409 on DELETE while processing; succeeds once status flipped to
  ready.
- Invalid query enum (400).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(subscriptions): add GET /subscriptions/plans endpoint (#233)

Public endpoint (no auth required) returning the three fixed subscription
tiers — Casual ($20, 1 session), Committed ($80, 12 sessions), Consistent
($120, 18 sessions) — each with a 7-day free trial, Apple/Google IAP
product IDs, and standardised feature highlights.

- Added SubscriptionPlan and SubscriptionPlansResponse schemas to api.yaml
  with required field constraints; added Subscriptions tag to root tags
- Set security: [] so unauthenticated users can browse plans before subscribing
- Removed spurious 401 response from spec (public endpoint never returns 401)
- Hardcoded catalogue in internal/routes/subscriptions.go (no DB needed)
- Regenerated internal/api/gen.go via oapi-codegen

* fix: add onboarding status check (#228)

* fix: add onboarding status check

* chore: use generated function for status check

* feat(notification): created notification system and endpoints (#232)

* feat(notification): created notification system and endpoints

* feat(notification): created notification system and endpoints - new branch

* fix(bugs): fixed multiple validation bugs and merge conflicts

* fix(test): waitlist test expected 200, got 400

* fix(conflict): pulled from dev and added FCM cred to .env.example

* fix(migrations): renamed migration to a unique number

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

* Feat/subscriptions (#237)

* feat(subscriptions): add GET /subscriptions/plans and POST /subscriptions

Plans endpoint (GET /subscriptions/plans):
- Public endpoint (no auth) returning 3 fixed tiers — Casual ($20/1 session),
  Committed ($80/12 sessions), Consistent ($120/18 sessions)
- Each plan includes 7-day trial, Apple/Google IAP product IDs, and features
- Added SubscriptionPlan + SubscriptionPlansResponse schemas to api.yaml

Create subscription endpoint (POST /subscriptions):
- Verifies Apple App Store receipt or Google Play purchase token before
  persisting; set IAP_SKIP_VERIFICATION=true to bypass in dev/test
- Returns 409 on duplicate receipt/token or if client already has an active
  subscription with the same trainer
- HTTP client uses 15s timeout for Apple/Google verification calls
- Migration 000048 adds plan_id, platform, trial_ends_at,
  apple_original_transaction_id, google_purchase_token to subscriptions table

* feat(subscriptions): add GET /me, GET /me/usage, POST /client/cancel/subscription

- GET /subscriptions/me: returns client active subscription
- GET /subscriptions/me/usage: returns sessions used/remaining for billing period
- POST /client/cancel/subscription: cancels active subscription (409 if already cancelled)
- Remove free trial fields (trial_days, trial_ends_at) from schema and handler
- Add required fields to Subscription schema
- Add index on (client_id, status) for subscription queries
- Log IAP verification errors internally instead of leaking to client

* fix(subscriptions): address CodeRabbit review comments

- Validate platform explicitly with switch (reject unknown platforms)
- Enforce product_id <-> plan_id consistency before granting entitlements
- Reject Google paymentState=0 (payment pending) as invalid
- Fix ExpiresDateMS comparison to use time.Time not string ordering
- Resolve Google JWT aud claim from tokenURI before building claims
- Return error on non-2xx Google OAuth token responses
- Set cancelled_at = NOW() when cancelling a subscription
- Add DB CHECK constraint for platform/token consistency
- Block IAP_SKIP_VERIFICATION=true in production at boot
- Require APPLE_SHARED_SECRET and GOOGLE_SERVICE_ACCOUNT_JSON at boot when verification is enabled
- Document 409 response on POST /client/cancel/subscription

* fix(iap): address errcheck lint failures

- Wrap defer resp.Body.Close() in func to discard error explicitly
- Check fmt.Sscanf return value in msToTime helper

* fix(migrations): renumber IAP migrations to 000051-000052 to avoid conflicts with dev

* feat(auth): email-only login returns tokens immediately (#239)

POST /auth/login accepts email only and issues access + refresh tokens directly.
No OTP step required. Returns 401 if account not found or inactive.

* Feat/zoom trainer host and sdk (#240)

* feat(zoom): per-trainer hosting + in-app Meeting SDK joins behind feature flags

Adds two orthogonal flags so the existing org-account / link-in-email flow
keeps working untouched while we roll out:

  ZOOM_MEETING_HOST=org|trainer   who hosts the call
  ZOOM_JOIN_MODE=link|sdk         what the email "Join" button opens

Per-trainer OAuth lives in user_zoom_credentials with AES-256-GCM tokens
at rest (pkg/cryptoutil) and rolling-refresh-token races serialised
per-user (internal/zoomflow.CredentialStore). meeting.Selector picks the
provider per call and silently falls back to org when a trainer hasn't
connected yet, so adopters don't break overnight.

In-app joins: GET /sessions/{id}/join-info returns a short-lived
HS256-signed Meeting SDK JWT. /config/zoom + AASA + assetlinks.json files
let mobile claim universal/app links pointing at /sessions/*/join.

Mobile integration recipe in docs/ZOOM_INTEGRATION.md.

* chore(zoom): document new env vars in .env.example

Adds the per-trainer OAuth, AES-256-GCM key, Meeting SDK creds, feature
flags, and universal-link envs for the Zoom integration introduced in
the previous commit. All optional with sane defaults that keep the
existing org-account flow working.

* chore(zoom): renumber zoom-credentials migration to 000053

origin/dev landed 000051_add_iap_fields_to_subscriptions.sql + 000052
while this branch was in flight, both occupying the slots my Zoom
credentials migration claimed. Pushing it to the next free number
keeps goose's ordering unambiguous.

* fix(zoom): satisfy golangci-lint on join-info handler

ineffassign + staticcheck QF1002 — the initial role assignment was
dead (the switch always reassigns) and the switch reads cleaner as a
tagged switch on userID.

* chore: update to env

* fix(zoom): address CodeRabbit review feedback

- store.go: preserve ErrNotConnected contract on the locked re-read path
  so the selector falls back to org cleanly when a row is deleted mid-flight
- user_provider.go: nil-token guard on CreateMeeting/DeleteMeeting + reject
  empty id/join_url so a quiet 200 doesn't persist a useless meeting
- sdk_signature.go: refuse to sign anything outside SDKRole{0,1}; Zoom
  rejects it anyway, fail loud server-side
- sdk_signature_test.go: stop swallowing base64/JSON decode errors and
  cover the new role-validation guard
- zoom_joininfo.go: authorise before any state-revealing branch; defer the
  trainer-details JOIN to only the trainer path so a failing lookup can't
  500 a legitimate client; restructured as if/else to please staticcheck
- routes.go: require the FULL org Zoom credential set + the OAuth
  redirect URL before enabling each provider — partial config now warns
  loudly and stays at NoOp instead of building a provider that can never
  authenticate
- bookings: extract JoinLinkBuilder, share it between the initial
  confirmation and the paid-reschedule emails so an SDK-mode user doesn't
  get a universal link on confirmation + a raw Zoom URL on reschedule
- bookings handler: deleteWithOrgFallback retries old-meeting deletes via
  the org provider when the trainer provider can't find it — covers the
  trainer-connected-after-booking case where the original meeting lives
  under the org account

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/subscriptions (#244)

* feat(subscriptions): add GET /me, GET /me/usage, POST /client/cancel/subscription

- GET /subscriptions/me: returns client active subscription
- GET /subscriptions/me/usage: returns sessions used/remaining for billing period
- POST /client/cancel/subscription: cancels active subscription (409 if already cancelled)
- Remove free trial fields (trial_days, trial_ends_at) from schema and handler
- Add required fields to Subscription schema
- Add index on (client_id, status) for subscription queries
- Log IAP verification errors internally instead of leaking to client

* fix(subscriptions): address CodeRabbit review comments

- Validate platform explicitly with switch (reject unknown platforms)
- Enforce product_id <-> plan_id consistency before granting entitlements
- Reject Google paymentState=0 (payment pending) as invalid
- Fix ExpiresDateMS comparison to use time.Time not string ordering
- Resolve Google JWT aud claim from tokenURI before building claims
- Return error on non-2xx Google OAuth token responses
- Set cancelled_at = NOW() when cancelling a subscription
- Add DB CHECK constraint for platform/token consistency
- Block IAP_SKIP_VERIFICATION=true in production at boot
- Require APPLE_SHARED_SECRET and GOOGLE_SERVICE_ACCOUNT_JSON at boot when verification is enabled
- Document 409 response on POST /client/cancel/subscription

* fix(migrations): renumber IAP migrations to 000051-000052 to avoid conflicts with dev

* chore: untrack docker-compose.yml and add to .gitignore

* feat(webhooks): add Apple and Google IAP webhook handlers

POST /webhooks/apple — handles App Store Server Notifications V2.
Decodes the signed JWS payload, extracts the originalTransactionId and
notificationType, looks up the subscription, and updates status/period_end.

POST /webhooks/google — handles Play Real-Time Developer Notifications
via Pub/Sub push. Base64-decodes message.data, extracts purchaseToken and
notificationType, and updates the subscription accordingly.

Also adds UpdateSubscriptionStatus query (used by both handlers to set
status + current_period_end atomically) and wires the new oapi-codegen
interface methods into the routes package.

* fix(webhooks): preserve current_period_end on expiry/cancel to avoid check constraint violation

Updating current_period_end to Apple's past expiresDate could set it
before current_period_start, violating the DB check constraint.
Expiry and cancellation events now keep the existing period_end and
only update the status. Renewals still fetch and update the period_end.

Also updates dev token endpoint to accept optional user_id query param.

* fix(review): address self-review issues

- gofmt formatting on subscriptions.go (indentation in error blocks)
- validate user_id query param as UUID in dev token endpoint
- map Google ON_HOLD (type 5) to active not expired — grace period
  still gives the client time to recover payment before type 13 fires

* chore: add IAP env vars to .env.example and webhook sim script

* fix(lint): check resp.Body.Close error return in webhook_sim.go

* fix(review): address CodeRabbit comments — cancelled_at stamping, log masking, duplicate status check, filename in usage string

* feat(activities): recent-activity feed for trainers + admin (#242)

Two hand-wired endpoints, both cursor-paginated:

  GET /api/v1/trainers/me/activities   — trainer-scoped (auth required, trainer-only)
  GET /api/v1/admin/activities         — system-wide (admin+)

The feed is derived at read time from the existing source-of-truth
tables (bookings, paid_booking_reschedule_history, discovery_bookings,
booking_reschedule_history, reviews) via UNION ALL. No new tables,
no write-path instrumentation — adding an event type is one branch in
the union plus one entry in the summary template.

Event types shipped: booking_created, booking_cancelled,
booking_rescheduled, discovery_booked, discovery_rescheduled,
review_received. Each row carries an opaque cursor pair
(occurred_at, activity_id) so a busy minute doesn't drop/duplicate
rows across pages.

Trainer scope filters via a CTE on trainers.user_id = $caller; admin
scope drops that join and adds trainer info to each row so the
dashboard can render who the event belonged to. Two near-duplicate
queries rather than a runtime-toggled WHERE because the planner
produces noticeably better plans when the trainer filter is a CTE
join vs an OR-able predicate.

Admin route added to adminReadablePaths so plain admin (not just
super_admin) can read it — ops uses this daily.

Endpoint shape + pagination contract documented in docs/ACTIVITIES.md.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(docker): restore docker-compose.yml with correct port 5432 (#245)

* fix(server): bump HTTP timeouts so slow uploads don't get the socket closed (#249)

ReadTimeout was 15s — anything where the request BODY takes longer
than that to arrive (a 30 MB video on a 4 Mbps mobile uplink alone is
~60s) had its connection closed mid-stream by net/http. The frontend
saw it as a generic "network error" because that's what it was at the
socket level. Affected every upload route — profile picture, trainer
images, trainer intro video, org media images + videos.

New shape:
  ReadHeaderTimeout  10s          unchanged behaviour (slow-loris)
  ReadTimeout       10m           covers ~500 MiB body on slow mobile
  WriteTimeout      10m           must move with ReadTimeout (ticks
                                  from header-read time)
  IdleTimeout        60s          unchanged

Per-handler context deadlines are still the right tool for tightening
individual routes; the server timeout is just the outer envelope.

Note for ops: if traffic sits behind nginx/Cloudflare/ALB, their
own timeouts (and body-size limits) need the same treatment —
client_body_timeout, proxy_read_timeout, ALB idle_timeout. Cloudflare
free tier caps at 100s and can't be raised; uploads larger than that
need to bypass it or move to direct-to-storage signed URLs.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add active subscriptions count and revenue snapshot endpoints (#247)

- GET /admin/subscriptions/count — active subscription count (Subscriptions tag)
- GET /admin/revenue — total/subscription/one-time revenue + latest payment
- Expose dashboard endpoints to admin role via adminReadablePaths
- Cast revenue aggregates to BIGINT so sqlc generates int64
- Add integration tests for all 3 dashboard endpoints
- Fix goose headers in migration 000052; add migration 000054 for plan_type FK

* Feat/top trainers (#248)

* feat(admin): top trainers

* Update config.go

* fix: bug fixes and improvements

* fix:

* fix(config): missing IAP credentials must not crash boot (#251)

The IAP feature shipped with a hard-fail in config.Load when
APPLE_SHARED_SECRET or GOOGLE_SERVICE_ACCOUNT_JSON were absent. This
took down every environment (staging, local dev, CI) that doesn't run
the Apple/Google billing flows — boot dies before the rest of the API
gets a chance to come up, so unrelated changes are bricked too.

Match the pattern already used for MinIO / Zoom / ffmpeg: log a loud
warn at boot, let the per-request subscription handlers reject with
their existing 400 path when their backing secret is empty. The Apple
+ Google verification calls in subscriptions.go and webhooks.go already
handle the failed-verify case gracefully — empty secret → Apple/Google
returns auth error → handler 400s the request.

The production guard against IAP_SKIP_VERIFICATION=true stays fatal —
silently bypassing receipt verification in prod is a real risk
(malicious client claims to have paid without paying); the missing-
secret case is just config-not-yet-provisioned.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/notifications (#252)

* feat(notifications): add tests to notification and user_device system

* fix(notification_test): fixed notification test bugs, fixed pkg notification bugs)

* fix(handler_test): included require.NoError(t,err) to check json.Unmarshal error

* fix(conflicts): fixed conflicts and merged with dev

* fix(coderabbit_convo): added more test to user_device and notifications

* fix(dead nil): removed possible nil pointer dereference

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

* feat(notification_websocket): added websocket integration to notification for non-clients (#255)

* feat(notifications): add tests to notification and user_device system

* fix(notification_test): fixed notification test bugs, fixed pkg notification bugs)

* fix(conflicts): fixed conflicts and merged with dev

* fix(coderabbit_convo): added more test to user_device and notifications

* fix(dead nil): removed possible nil pointer dereference

* feat(notifications): added websocket integration for web interface

* feat(notifications): added websocket integration for web interface

* fix(coderabbit): fixed bugs identified by coderabbit

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

* Docs/swagger catchup zoom activities (#257)

* docs(swagger): catch up api.yaml with hand-wired Zoom + activities routes

Adds OpenAPI entries so Swagger UI renders the routes shipped in the
Zoom (#240) and activities (#242) PRs. Runtime is unchanged — handlers
remain hand-wired in internal/routes/zoom_*.go and internal/activities/.

Routes added to the spec:
  GET    /trainers/me/zoom/connect
  GET    /trainers/me/zoom/callback
  GET    /trainers/me/zoom/status
  DELETE /trainers/me/zoom
  GET    /sessions/{id}/join-info
  GET    /config/zoom
  GET    /trainers/me/activities
  GET    /admin/activities
  GET    /.well-known/apple-app-site-association  (root-served via per-op `servers` override)
  GET    /.well-known/assetlinks.json             (same)

oapi-codegen.yaml now lists these operationIds under
output-options.exclude-operation-ids so `make codegen` does NOT grow
gen.go and break the build by adding ServerInterface methods nothing
implements. Verified locally: re-running oapi-codegen produces a
content-identical gen.go (only line-ending churn on Windows).

* docs(swagger): apply CodeRabbit review feedback

Four fixes on the Zoom + activities Swagger catch-up:

api.yaml — /trainers/me/zoom/callback: explicit `security: []`.
  Without this the document-level bearerAuth default applies, which
  contradicts the handler reality (Zoom redirects the browser here
  directly; identity is recovered from the single-use state token, not
  a bearer). Was misleading every generated client.

api.yaml — /sessions/{id}/join-info: document the 500 response that
  the handler returns on session/booking lookup failures or signature-
  generation failures. Spec previously stopped at 503, leaving the
  error contract incomplete.

api.yaml — /.well-known/{apple-app-site-association,assetlinks.json}:
  explicit `security: []` (iOS/Android fetch them with no auth header),
  and document the 404 response when the env vars aren't set. The spec
  previously claimed 200 with an empty body in the unconfigured case;
  the handler in zoom_config.go actually returns 404 with `{}` / `[]`.
  Fixed the description to match.

zoom_oauth.go — file-header docstring said
  `GET /trainers/me/zoom/connect → 302 to Zoom authorize URL` but the
  handler returns 200 JSON with {authorize_url}. The 200-JSON shape
  was deliberate (lets the mobile app open the URL in an in-app
  browser of its choice rather than letting the OS handle a 302).
  Updated the comment to match reality, and added a note explaining
  WHY 200 instead of 302 so the next reader doesn't "fix" it.

No runtime changes — every fix is docs / file-header. oapi-codegen
re-run produces a content-identical gen.go.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix: waitlist email validation and subscription plan pricing (#261)

BUG-M1-004: accept emails up to 254 characters (RFC maximum) — add
explicit length check before DB insert to avoid 500 on valid emails

BUG-M1-005: return 400 with a clear message for emails > 254 chars
instead of propagating a DB constraint error as a 500

BUG-M1-006: detect unique constraint violations in AddEmail and surface
them as ErrDuplicate; handler returns 409 instead of 500 when a race
condition bypasses the pre-insert duplicate check. Also fix GetByEmail
to only map sql.ErrNoRows to ErrNotFound and propagate real DB errors

BUG-M1-007: add migration 000056 to correct subscription_plans amounts
and display names to match PRD (Single $12, Standard Monthly $100,
Premium Monthly $150)

* Feat/session reminder (#263)

* feat(reminder): add 1-hour session reminder background worker

Adds a background worker that ticks every minute and sends push notifications
plus emails to both client and trainer for any confirmed session starting in
59-61 minutes. Idempotency keys prevent double-sends across ticks.

- internal/reminder/worker.go: new Worker with Start/Stop; main SELECT joins
  trainers to resolve trainer_user_id directly (no extra per-booking query);
  push message formatted in booking timezone; rows.Err() checked after loop
- pkg/email: adds SendSessionReminder + SendSessionReminderTrainer to the
  Mailer interface with SMTP, Resend, and LogMailer implementations
- internal/routes/routes.go: wires the worker (reminderNotifSender adapter +
  reminder.New + Start); stops it gracefully in Close()
- test fakes updated to satisfy the expanded Mailer interface

* fix(reminder): address CodeRabbit review issues

- worker: narrow query window to [59,60) half-open interval so each booking
  is matched by at most one tick, preventing duplicate email sends
- worker: wrap rows.Close() in deferred closure to satisfy errcheck linter
- email: use UTC as timezone label (not original string) when LoadLocation
  fails in sessionReminderClientHTML and sessionReminderTrainerHTML

* Fix(notification integration): integrated notification system into endpoints (#264)

* feat(notifications): add tests to notification and user_device system

* fix(notification_test): fixed notification test bugs, fixed pkg notification bugs)

* fix(conflicts): fixed conflicts and merged with dev

* fix(coderabbit_convo): added more test to user_device and notifications

* fix(dead nil): removed possible nil pointer dereference

* feat(notifications): added websocket integration for web interface

* feat(notifications): added websocket integration for web interface

* fix(coderabbit): fixed bugs identified by coderabbit

* fix(notification): integrated notifications into endpoints

* fix(admin_login): reverted service back to default

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

* chore(trainers): add onboarding_status query (#265)

* Fix/notification integration (#266)

* feat(notifications): integrate push notifications, websocket, and FCM

* fix(.env.example): updated FCM_CREDENTIAL_FILE to FCM_CREDENTIAL_JSON

* fix(notification): validated duplicate idempotency key

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
fix(auth): admin login was locking out plain admins + plain super_adm…
) (#273)

Five hand-wired routes backing the Settings page mockup:

  GET    /api/v1/admin/settings           — read settings + categories
  PUT    /api/v1/admin/settings           — partial update of the four scalars
  POST   /api/v1/admin/categories         — add a category (super_admin)
  DELETE /api/v1/admin/categories/{id}    — remove a category (super_admin)
  GET    /api/v1/categories               — client-facing list

Storage
-------
Migration 000057 adds two tables:

  - admin_settings: single-row, enforced by a UNIQUE singleton_lock
    column. Updates target the row by `WHERE singleton_lock =
    'singleton'` so the handler never has to read first. CHECK
    constraints bound the integers (5..480 minutes, 1..100 trainers
    listed) and are mirrored by 400-returning validation in the
    handler so the FE sees a friendlier message before Postgres does.
    Seeded with sensible defaults on first apply.

  - categories: id + display name + URL-safe slug, both UNIQUE.
    Seeded with the seven shown on the mockup (Strength, Yoga, HIIT,
    Pilates, Endurance, Weight loss, Mobility).

  Trainer specializations are intentionally NOT migrated to reference
  this table yet — they still validate against the hardcoded CHECK
  constraint from migration 000037. Doing both in one PR would mean
  rewriting trainer signup, and the Settings page works just as well
  with categories living independently. Follow-up.

Update semantics
----------------
PUT /admin/settings uses pointer-typed fields in the request DTO and
sqlc COALESCE in the UPDATE so absent fields are left unchanged.
Lets the FE flip a single toggle without re-sending the whole form,
and avoids confusing "set to zero/false" with "no change".

Authorization
-------------
GET /admin/settings is added to middleware.adminReadablePaths so
plain admins can view current values (customer-care answering
"why is the default 60 min" without paging the founders). All
mutating routes require super_admin via the existing SuperAdminOnly
middleware.

api.yaml entries are doc-only via oapi-codegen.yaml's exclude-
operation-ids list; gen.go is content-identical after re-running
codegen.

Tests cover slug derivation (the only non-trivial logic in the
handler), the slugify-output-matches-validator invariant, and the
sql.NullX pointer plumbing that distinguishes "leave alone" from
"set to zero".

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* feat(settings): admin Settings page endpoints + client /categories (#272)

Five hand-wired routes backing the Settings page mockup:

  GET    /api/v1/admin/settings           — read settings + categories
  PUT    /api/v1/admin/settings           — partial update of the four scalars
  POST   /api/v1/admin/categories         — add a category (super_admin)
  DELETE /api/v1/admin/categories/{id}    — remove a category (super_admin)
  GET    /api/v1/categories               — client-facing list

Storage
-------
Migration 000057 adds two tables:

  - admin_settings: single-row, enforced by a UNIQUE singleton_lock
    column. Updates target the row by `WHERE singleton_lock =
    'singleton'` so the handler never has to read first. CHECK
    constraints bound the integers (5..480 minutes, 1..100 trainers
    listed) and are mirrored by 400-returning validation in the
    handler so the FE sees a friendlier message before Postgres does.
    Seeded with sensible defaults on first apply.

  - categories: id + display name + URL-safe slug, both UNIQUE.
    Seeded with the seven shown on the mockup (Strength, Yoga, HIIT,
    Pilates, Endurance, Weight loss, Mobility).

  Trainer specializations are intentionally NOT migrated to reference
  this table yet — they still validate against the hardcoded CHECK
  constraint from migration 000037. Doing both in one PR would mean
  rewriting trainer signup, and the Settings page works just as well
  with categories living independently. Follow-up.

Update semantics
----------------
PUT /admin/settings uses pointer-typed fields in the request DTO and
sqlc COALESCE in the UPDATE so absent fields are left unchanged.
Lets the FE flip a single toggle without re-sending the whole form,
and avoids confusing "set to zero/false" with "no change".

Authorization
-------------
GET /admin/settings is added to middleware.adminReadablePaths so
plain admins can view current values (customer-care answering
"why is the default 60 min" without paging the founders). All
mutating routes require super_admin via the existing SuperAdminOnly
middleware.

api.yaml entries are doc-only via oapi-codegen.yaml's exclude-
operation-ids list; gen.go is content-identical after re-running
codegen.

Tests cover slug derivation (the only non-trivial logic in the
handler), the slugify-output-matches-validator invariant, and the
sql.NullX pointer plumbing that distinguishes "leave alone" from
"set to zero".

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add GET /admin/transactions endpoint (#274)

Paginated list of all client subscriptions with client and trainer
details for super-admin review.

- CountAdminTransactions and ListAdminTransactions queries in subscriptions.sql.go
- Typed transactionItem response struct (no map[string]interface{})
- Returns 400 on invalid ?page/?limit instead of silently falling back
- int64 arithmetic for offset to avoid int32 overflow on large pages
- Route always registered; superAdminOnly checked inline so DB-down
  returns 503 not 404

---------

Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* feat(settings): admin Settings page endpoints + client /categories (#272)

Five hand-wired routes backing the Settings page mockup:

  GET    /api/v1/admin/settings           — read settings + categories
  PUT    /api/v1/admin/settings           — partial update of the four scalars
  POST   /api/v1/admin/categories         — add a category (super_admin)
  DELETE /api/v1/admin/categories/{id}    — remove a category (super_admin)
  GET    /api/v1/categories               — client-facing list

Storage
-------
Migration 000057 adds two tables:

  - admin_settings: single-row, enforced by a UNIQUE singleton_lock
    column. Updates target the row by `WHERE singleton_lock =
    'singleton'` so the handler never has to read first. CHECK
    constraints bound the integers (5..480 minutes, 1..100 trainers
    listed) and are mirrored by 400-returning validation in the
    handler so the FE sees a friendlier message before Postgres does.
    Seeded with sensible defaults on first apply.

  - categories: id + display name + URL-safe slug, both UNIQUE.
    Seeded with the seven shown on the mockup (Strength, Yoga, HIIT,
    Pilates, Endurance, Weight loss, Mobility).

  Trainer specializations are intentionally NOT migrated to reference
  this table yet — they still validate against the hardcoded CHECK
  constraint from migration 000037. Doing both in one PR would mean
  rewriting trainer signup, and the Settings page works just as well
  with categories living independently. Follow-up.

Update semantics
----------------
PUT /admin/settings uses pointer-typed fields in the request DTO and
sqlc COALESCE in the UPDATE so absent fields are left unchanged.
Lets the FE flip a single toggle without re-sending the whole form,
and avoids confusing "set to zero/false" with "no change".

Authorization
-------------
GET /admin/settings is added to middleware.adminReadablePaths so
plain admins can view current values (customer-care answering
"why is the default 60 min" without paging the founders). All
mutating routes require super_admin via the existing SuperAdminOnly
middleware.

api.yaml entries are doc-only via oapi-codegen.yaml's exclude-
operation-ids list; gen.go is content-identical after re-running
codegen.

Tests cover slug derivation (the only non-trivial logic in the
handler), the slugify-output-matches-validator invariant, and the
sql.NullX pointer plumbing that distinguishes "leave alone" from
"set to zero".

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add GET /admin/transactions endpoint (#274)

Paginated list of all client subscriptions with client and trainer
details for super-admin review.

- CountAdminTransactions and ListAdminTransactions queries in subscriptions.sql.go
- Typed transactionItem response struct (no map[string]interface{})
- Returns 400 on invalid ?page/?limit instead of silently falling back
- int64 arithmetic for offset to avoid int32 overflow on large pages
- Route always registered; superAdminOnly checked inline so DB-down
  returns 503 not 404

* docs(swagger): add GET /admin/transactions to api.yaml under Admin tag (#276)

* docs(swagger): add GET /admin/transactions to api.yaml under Admin tag

* fix(swagger): add missing status field to GET /admin/transactions example

---------

Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
…ing-settings-admin-subs

# Conflicts:
#	internal/repository/db/subscriptions.sql.go
#	internal/routes/routes.go
…ttings-admin-subs

Chore/sync dev to staging settings admin subs
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

---------

Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

---------

Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

* Feat/trainer password reset (#291)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* Feat/trainer soft delete (#293)

* feat: convert DELETE /trainers/{id} from hard delete to soft delete

- Replace hard DELETE FROM trainers with DeactivateTrainer query that sets users.is_active = false
- Trainer record and all associated data preserved; trainer cannot log in or appear in active listings
- Returns 200 with body (was 204 no-content), 409 if already deactivated, 404 if trainer not found
- Disambiguates 404 vs 409 when DeactivateTrainer returns ErrNoRows via GetTrainerByID lookup
- Add DeactivateTrainer SQL query to trainers.sql and trainers.sql.go

* fix: handle non-ErrNoRows from GetTrainerByID disambiguate; add 503 to api.yaml

- If GetTrainerByID returns a non-not-found error during ErrNoRows disambiguation, return 500 instead of wrong 409
- Add 503 Service Unavailable to DELETE /trainers/{id} spec (returned when trainers store is nil)

* fix: distinguish all three ErrNoRows cases in DeactivateTrainer disambiguate path

After DeactivateTrainer returns ErrNoRows, check:
- trainer row missing → 404
- linked user row missing → 500 (data integrity)
- linked user has unexpected role → 500 (data integrity)
- user exists with role=trainer but is_active=false → 409 (already deactivated)

Previously all three non-404 cases collapsed into 409.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

* Feat/trainer password reset (#291)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* Feat/trainer soft delete (#293)

* feat: convert DELETE /trainers/{id} from hard delete to soft delete

- Replace hard DELETE FROM trainers with DeactivateTrainer query that sets users.is_active = false
- Trainer record and all associated data preserved; trainer cannot log in or appear in active listings
- Returns 200 with body (was 204 no-content), 409 if already deactivated, 404 if trainer not found
- Disambiguates 404 vs 409 when DeactivateTrainer returns ErrNoRows via GetTrainerByID lookup
- Add DeactivateTrainer SQL query to trainers.sql and trainers.sql.go

* fix: handle non-ErrNoRows from GetTrainerByID disambiguate; add 503 to api.yaml

- If GetTrainerByID returns a non-not-found error during ErrNoRows disambiguation, return 500 instead of wrong 409
- Add 503 Service Unavailable to DELETE /trainers/{id} spec (returned when trainers store is nil)

* fix: distinguish all three ErrNoRows cases in DeactivateTrainer disambiguate path

After DeactivateTrainer returns ErrNoRows, check:
- trainer row missing → 404
- linked user row missing → 500 (data integrity)
- linked user has unexpected role → 500 (data integrity)
- user exists with role=trainer but is_active=false → 409 (already deactivated)

Previously all three non-404 cases collapsed into 409.

* fix(auth): restore bcrypt password check on POST /auth/login (#296)

CRITICAL — SECURITY FIX

PR #239 ("feat(auth): email-only login returns tokens immediately")
deliberately deleted the bcrypt password verification from SignIn,
shipping an auth bypass that affects EVERY account on the platform.
Any caller who knows a registered email address could mint a fresh
access + refresh token pair for that account:

    POST /auth/login
    { "email": "victim@example.com" }
    → 200 with valid tokens

User-reported as "my trainer can login with any password" but the
scope is wider: every role (client, trainer, admin, super_admin) is
exposed. The bypass has been live since #239 merged on May 25.

Fix
---
- Restore the bcrypt password check using the existing CheckPassword
  helper. Same failure path as the original implementation: any
  credential issue (wrong password, unknown email, OAuth-only account
  with no password, inactive user) collapses to a single 401 with
  the generic "invalid email or password" message. Distinct messages
  would let an attacker enumerate registered emails by diffing
  responses.
- Empty-password input is 400 (client mistake), so the FE can show
  "field required" instead of "wrong password".
- api.yaml updated to add the required `password` field and a
  description matching the actual flow. (The old description claimed
  an OTP step the handler didn't implement.)

Why a local request struct instead of regenerating gen.go
---------------------------------------------------------
PR #286 added /admin/sessions/{id}/cancel to api.yaml without
re-running codegen, so the on-disk gen.go is already out of sync
with the spec — re-running oapi-codegen now surfaces unrelated
breakage (ServerInterface signature mismatch on AdminCancelSession).
This is a deliberate scope-limit: ship the security fix first,
codegen cleanup is its own PR.

Bound the password from a small inline struct local to the handler.
Spec + runtime stay correct; gen.go is unchanged.

Tests (internal/auth/sign_in_test.go)
-------------------------------------
10 new tests, all of which FAIL against the pre-fix code:

  TestSignIn_EmailOnlyMustNotIssueTokens    regression guard for #239
  TestSignIn_CorrectPasswordSucceeds        happy path
  TestSignIn_WrongPasswordRejected          401 + generic message
  TestSignIn_MissingPasswordField           400
  TestSignIn_EmptyPasswordString            400
  TestSignIn_OAuthOnlyAccountRejected       401, no password set
  TestSignIn_UnknownEmailRejected           401, generic message
  TestSignIn_InactiveUserRejected           401, generic message (no enum)
  TestSignIn_OverLongPasswordRejected       401 on >72-byte input
  TestSignIn_MalformedJSONRejected          400

Verified by stashing the fix and re-running the new tests against
dev's current code — every one fails as expected (returns 200 where
401/400 is required), then all pass after restoring the fix.

Deploy ASAP. Any account on staging/prod has been logable without
credentials since May 25.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/trainer password reset (#297)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

---------

Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

* Feat/trainer password reset (#291)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* Feat/trainer soft delete (#293)

* feat: convert DELETE /trainers/{id} from hard delete to soft delete

- Replace hard DELETE FROM trainers with DeactivateTrainer query that sets users.is_active = false
- Trainer record and all associated data preserved; trainer cannot log in or appear in active listings
- Returns 200 with body (was 204 no-content), 409 if already deactivated, 404 if trainer not found
- Disambiguates 404 vs 409 when DeactivateTrainer returns ErrNoRows via GetTrainerByID lookup
- Add DeactivateTrainer SQL query to trainers.sql and trainers.sql.go

* fix: handle non-ErrNoRows from GetTrainerByID disambiguate; add 503 to api.yaml

- If GetTrainerByID returns a non-not-found error during ErrNoRows disambiguation, return 500 instead of wrong 409
- Add 503 Service Unavailable to DELETE /trainers/{id} spec (returned when trainers store is nil)

* fix: distinguish all three ErrNoRows cases in DeactivateTrainer disambiguate path

After DeactivateTrainer returns ErrNoRows, check:
- trainer row missing → 404
- linked user row missing → 500 (data integrity)
- linked user has unexpected role → 500 (data integrity)
- user exists with role=trainer but is_active=false → 409 (already deactivated)

Previously all three non-404 cases collapsed into 409.

* fix(auth): restore bcrypt password check on POST /auth/login (#296)

CRITICAL — SECURITY FIX

PR #239 ("feat(auth): email-only login returns tokens immediately")
deliberately deleted the bcrypt password verification from SignIn,
shipping an auth bypass that affects EVERY account on the platform.
Any caller who knows a registered email address could mint a fresh
access + refresh token pair for that account:

    POST /auth/login
    { "email": "victim@example.com" }
    → 200 with valid tokens

User-reported as "my trainer can login with any password" but the
scope is wider: every role (client, trainer, admin, super_admin) is
exposed. The bypass has been live since #239 merged on May 25.

Fix
---
- Restore the bcrypt password check using the existing CheckPassword
  helper. Same failure path as the original implementation: any
  credential issue (wrong password, unknown email, OAuth-only account
  with no password, inactive user) collapses to a single 401 with
  the generic "invalid email or password" message. Distinct messages
  would let an attacker enumerate registered emails by diffing
  responses.
- Empty-password input is 400 (client mistake), so the FE can show
  "field required" instead of "wrong password".
- api.yaml updated to add the required `password` field and a
  description matching the actual flow. (The old description claimed
  an OTP step the handler didn't implement.)

Why a local request struct instead of regenerating gen.go
---------------------------------------------------------
PR #286 added /admin/sessions/{id}/cancel to api.yaml without
re-running codegen, so the on-disk gen.go is already out of sync
with the spec — re-running oapi-codegen now surfaces unrelated
breakage (ServerInterface signature mismatch on AdminCancelSession).
This is a deliberate scope-limit: ship the security fix first,
codegen cleanup is its own PR.

Bound the password from a small inline struct local to the handler.
Spec + runtime stay correct; gen.go is unchanged.

Tests (internal/auth/sign_in_test.go)
-------------------------------------
10 new tests, all of which FAIL against the pre-fix code:

  TestSignIn_EmailOnlyMustNotIssueTokens    regression guard for #239
  TestSignIn_CorrectPasswordSucceeds        happy path
  TestSignIn_WrongPasswordRejected          401 + generic message
  TestSignIn_MissingPasswordField           400
  TestSignIn_EmptyPasswordString            400
  TestSignIn_OAuthOnlyAccountRejected       401, no password set
  TestSignIn_UnknownEmailRejected           401, generic message
  TestSignIn_InactiveUserRejected           401, generic message (no enum)
  TestSignIn_OverLongPasswordRejected       401 on >72-byte input
  TestSignIn_MalformedJSONRejected          400

Verified by stashing the fix and re-running the new tests against
dev's current code — every one fails as expected (returns 200 where
401/400 is required), then all pass after restoring the fix.

Deploy ASAP. Any account on staging/prod has been logable without
credentials since May 25.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/trainer password reset (#297)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* Feat/trainer password reset (#300)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

* Feat/trainer password reset (#291)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* Feat/trainer soft delete (#293)

* feat: convert DELETE /trainers/{id} from hard delete to soft delete

- Replace hard DELETE FROM trainers with DeactivateTrainer query that sets users.is_active = false
- Trainer record and all associated data preserved; trainer cannot log in or appear in active listings
- Returns 200 with body (was 204 no-content), 409 if already deactivated, 404 if trainer not found
- Disambiguates 404 vs 409 when DeactivateTrainer returns ErrNoRows via GetTrainerByID lookup
- Add DeactivateTrainer SQL query to trainers.sql and trainers.sql.go

* fix: handle non-ErrNoRows from GetTrainerByID disambiguate; add 503 to api.yaml

- If GetTrainerByID returns a non-not-found error during ErrNoRows disambiguation, return 500 instead of wrong 409
- Add 503 Service Unavailable to DELETE /trainers/{id} spec (returned when trainers store is nil)

* fix: distinguish all three ErrNoRows cases in DeactivateTrainer disambiguate path

After DeactivateTrainer returns ErrNoRows, check:
- trainer row missing → 404
- linked user row missing → 500 (data integrity)
- linked user has unexpected role → 500 (data integrity)
- user exists with role=trainer but is_active=false → 409 (already deactivated)

Previously all three non-404 cases collapsed into 409.

* fix(auth): restore bcrypt password check on POST /auth/login (#296)

CRITICAL — SECURITY FIX

PR #239 ("feat(auth): email-only login returns tokens immediately")
deliberately deleted the bcrypt password verification from SignIn,
shipping an auth bypass that affects EVERY account on the platform.
Any caller who knows a registered email address could mint a fresh
access + refresh token pair for that account:

    POST /auth/login
    { "email": "victim@example.com" }
    → 200 with valid tokens

User-reported as "my trainer can login with any password" but the
scope is wider: every role (client, trainer, admin, super_admin) is
exposed. The bypass has been live since #239 merged on May 25.

Fix
---
- Restore the bcrypt password check using the existing CheckPassword
  helper. Same failure path as the original implementation: any
  credential issue (wrong password, unknown email, OAuth-only account
  with no password, inactive user) collapses to a single 401 with
  the generic "invalid email or password" message. Distinct messages
  would let an attacker enumerate registered emails by diffing
  responses.
- Empty-password input is 400 (client mistake), so the FE can show
  "field required" instead of "wrong password".
- api.yaml updated to add the required `password` field and a
  description matching the actual flow. (The old description claimed
  an OTP step the handler didn't implement.)

Why a local request struct instead of regenerating gen.go
---------------------------------------------------------
PR #286 added /admin/sessions/{id}/cancel to api.yaml without
re-running codegen, so the on-disk gen.go is already out of sync
with the spec — re-running oapi-codegen now surfaces unrelated
breakage (ServerInterface signature mismatch on AdminCancelSession).
This is a deliberate scope-limit: ship the security fix first,
codegen cleanup is its own PR.

Bound the password from a small inline struct local to the handler.
Spec + runtime stay correct; gen.go is unchanged.

Tests (internal/auth/sign_in_test.go)
-------------------------------------
10 new tests, all of which FAIL against the pre-fix code:

  TestSignIn_EmailOnlyMustNotIssueTokens    regression guard for #239
  TestSignIn_CorrectPasswordSucceeds        happy path
  TestSignIn_WrongPasswordRejected          401 + generic message
  TestSignIn_MissingPasswordField           400
  TestSignIn_EmptyPasswordString            400
  TestSignIn_OAuthOnlyAccountRejected       401, no password set
  TestSignIn_UnknownEmailRejected           401, generic message
  TestSignIn_InactiveUserRejected           401, generic message (no enum)
  TestSignIn_OverLongPasswordRejected       401 on >72-byte input
  TestSignIn_MalformedJSONRejected          400

Verified by stashing the fix and re-running the new tests against
dev's current code — every one fails as expected (returns 200 where
401/400 is required), then all pass after restoring the fix.

Deploy ASAP. Any account on staging/prod has been logable without
credentials since May 25.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/trainer password reset (#297)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* Feat/trainer password reset (#300)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

* Feat/trainer password reset (#291)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* Feat/trainer soft delete (#293)

* feat: convert DELETE /trainers/{id} from hard delete to soft delete

- Replace hard DELETE FROM trainers with DeactivateTrainer query that sets users.is_active = false
- Trainer record and all associated data preserved; trainer cannot log in or appear in active listings
- Returns 200 with body (was 204 no-content), 409 if already deactivated, 404 if trainer not found
- Disambiguates 404 vs 409 when DeactivateTrainer returns ErrNoRows via GetTrainerByID lookup
- Add DeactivateTrainer SQL query to trainers.sql and trainers.sql.go

* fix: handle non-ErrNoRows from GetTrainerByID disambiguate; add 503 to api.yaml

- If GetTrainerByID returns a non-not-found error during ErrNoRows disambiguation, return 500 instead of wrong 409
- Add 503 Service Unavailable to DELETE /trainers/{id} spec (returned when trainers store is nil)

* fix: distinguish all three ErrNoRows cases in DeactivateTrainer disambiguate path

After DeactivateTrainer returns ErrNoRows, check:
- trainer row missing → 404
- linked user row missing → 500 (data integrity)
- linked user has unexpected role → 500 (data integrity)
- user exists with role=trainer but is_active=false → 409 (already deactivated)

Previously all three non-404 cases collapsed into 409.

* fix(auth): restore bcrypt password check on POST /auth/login (#296)

CRITICAL — SECURITY FIX

PR #239 ("feat(auth): email-only login returns tokens immediately")
deliberately deleted the bcrypt password verification from SignIn,
shipping an auth bypass that affects EVERY account on the platform.
Any caller who knows a registered email address could mint a fresh
access + refresh token pair for that account:

    POST /auth/login
    { "email": "victim@example.com" }
    → 200 with valid tokens

User-reported as "my trainer can login with any password" but the
scope is wider: every role (client, trainer, admin, super_admin) is
exposed. The bypass has been live since #239 merged on May 25.

Fix
---
- Restore the bcrypt password check using the existing CheckPassword
  helper. Same failure path as the original implementation: any
  credential issue (wrong password, unknown email, OAuth-only account
  with no password, inactive user) collapses to a single 401 with
  the generic "invalid email or password" message. Distinct messages
  would let an attacker enumerate registered emails by diffing
  responses.
- Empty-password input is 400 (client mistake), so the FE can show
  "field required" instead of "wrong password".
- api.yaml updated to add the required `password` field and a
  description matching the actual flow. (The old description claimed
  an OTP step the handler didn't implement.)

Why a local request struct instead of regenerating gen.go
---------------------------------------------------------
PR #286 added /admin/sessions/{id}/cancel to api.yaml without
re-running codegen, so the on-disk gen.go is already out of sync
with the spec — re-running oapi-codegen now surfaces unrelated
breakage (ServerInterface signature mismatch on AdminCancelSession).
This is a deliberate scope-limit: ship the security fix first,
codegen cleanup is its own PR.

Bound the password from a small inline struct local to the handler.
Spec + runtime stay correct; gen.go is unchanged.

Tests (internal/auth/sign_in_test.go)
-------------------------------------
10 new tests, all of which FAIL against the pre-fix code:

  TestSignIn_EmailOnlyMustNotIssueTokens    regression guard for #239
  TestSignIn_CorrectPasswordSucceeds        happy path
  TestSignIn_WrongPasswordRejected          401 + generic message
  TestSignIn_MissingPasswordField           400
  TestSignIn_EmptyPasswordString            400
  TestSignIn_OAuthOnlyAccountRejected       401, no password set
  TestSignIn_UnknownEmailRejected           401, generic message
  TestSignIn_InactiveUserRejected           401, generic message (no enum)
  TestSignIn_OverLongPasswordRejected       401 on >72-byte input
  TestSignIn_MalformedJSONRejected          400

Verified by stashing the fix and re-running the new tests against
dev's current code — every one fails as expected (returns 200 where
401/400 is required), then all pass after restoring the fix.

Deploy ASAP. Any account on staging/prod has been logable without
credentials since May 25.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/trainer password reset (#297)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* Feat/trainer password reset (#300)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* chore(seed): add staging environment support (#304)

* chore(seed): add staging environment support

* Update main.go

* Fix/admin cancel session codegen (#305)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* fix: update AdminCancelSession signature to match codegen interface

Add id openapi_types.UUID param to match ServerInterface generated by oapi-codegen.
Replace manual c.Param("id") extraction with the passed UUID parameter.

* fix: fix routes.go callers and add missing openapi_types import

Update AdminCancelSession and AdminRescheduleSession manual route
callers in routes.go to extract id from URL and pass as openapi_types.UUID.
Add openapi_types import to routes.go.

* Fix/admin cancel session codegen (#306)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* fix: update AdminCancelSession signature to match codegen interface

Add id openapi_types.UUID param to match ServerInterface generated by oapi-codegen.
Replace manual c.Param("id") extraction with the passed UUID parameter.

* fix: fix routes.go callers and add missing openapi_types import

Update AdminCancelSession and AdminRescheduleSession manual route
callers in routes.go to extract id from URL and pass as openapi_types.UUID.
Add openapi_types import to routes.go.

* fix: update DeleteAdminClient signature to match codegen interface

Add id openapi_types.UUID param to DeleteAdminClient in admin.go.
Update manual route caller in routes.go to extract and pass UUID.
go build ./... passes clean.

* fix: run codegen and fix all handler interface mismatches (#307)

* Fix/codegen interface compliance (#308)

* fix: run codegen and fix all handler interface mismatches

* fix: remove duplicate route registrations for admin/transactions and admin/subscriptions

* feat(bookings): Google Meet (org account) + Messenger contact channel (#309)

Two new options on the booking platform picker — `google_meet` and
`messenger` — across both paid bookings and discovery calls. The
existing `zoom` and `phone_callback` flows are unchanged.

Google Meet
-----------
- New pkg/googlemeet: OAuth refresh-token client + Provider that mints
  Meet rooms via the v2 Spaces REST API (`POST /v2/spaces`).
- ONE Workspace user (e.g. meet-bot@yourdomain) hosts every booking;
  no per-trainer OAuth. Meet's `spaces.create` has no per-creator
  concurrency cap, so the single-account model scales cleanly.
- New cmd/meet-bootstrap: small CLI that runs OAuth once against the
  Workspace account, prints a refresh token to paste into env. Ops
  runs this per environment and forgets.
- pkg/meeting.Selector grows a `platform` parameter; new
  MultiPlatformSelector dispatches Zoom → existing zoomflow selector,
  Meet → single org provider. Zoomflow's selector ignores the
  platform arg (it only handles Zoom).
- Master switch MEET_ENABLED=false by default. Until flipped, the
  platform is hidden client-side and any inbound `google_meet`
  booking returns 503 with "google meet is not configured."

Messenger
---------
- Not a meeting provider — there's no Messenger API for rooms. It's a
  contact channel: client supplies their handle at booking time
  (Facebook profile slug, m.me link, numeric ID — anything), the
  server stores it on the booking row, the trainer follows up
  manually via Facebook.
- Mechanically symmetric with phone_callback: handler skips meeting
  creation, just persists the handle.

Schema (migration 000058)
-------------------------
- Widen bookings.session_platform CHECK to (zoom, google_meet,
  messenger). Dropped the dead `whatsapp` value from migration 000012
  that no handler ever implemented — leaving it would let clients
  pick a platform that 5xxs immediately.
- Widen discovery_bookings.contact_mode CHECK to add (google_meet,
  messenger).
- Add nullable messenger_handle column to both tables.
- sqlc queries (CreateBooking, RETURNING, etc.) updated to include
  the new column so the generated Booking struct stays canonical
  (without this update each query returned a row-specific type and
  broke the bookings repository's *Booking return signatures).

Side fix: DeactivateClient query (hand-added to the generated
internal/repository/db/users.sql.go in PR #283 without an SQL
source) backfilled into internal/models/queries/users.sql so
`sqlc generate` no longer wipes it.

Side fix: DeactivateTrainer SQL aliased to disambiguate column refs
that sqlc's parser couldn't resolve.

Email templates
---------------
- The discovery reschedule template now treats zoom_meeting and
  google_meet identically (both produce a clickable URL in
  ZoomLink). Label renamed to "Meeting Link" so it's platform-neutral.
- Mailer interface stayed unchanged for this PR. Messenger emails
  don't yet render the handle in the email body — trainers see it
  via the existing in-app notification path. Surfacing the handle in
  email requires extending all three mailer signatures (Log, SMTP,
  Resend) and is a follow-up.

Config + env
------------
Five new vars, all optional with sane defaults:
  MEET_ENABLED           (master switch, default false)
  MEET_OAUTH_CLIENT_ID
  MEET_OAUTH_CLIENT_SECRET
  MEET_REFRESH_TOKEN     (from cmd/meet-bootstrap)
  MEET_HOST_EMAIL        (logs only)

Spec
----
api.yaml updated for both BookDiscoveryCallRequest.contact_mode and
the paid booking session_platform enums. `whatsapp` removed from
the spec to match the new CHECK constraint. messenger_handle field
documented as required-when-mode-matches.

Tests
-----
- pkg/googlemeet: 13 tests across OAuth + Provider — including
  invalid_grant → ErrTokenRevoked sentinel, access-token caching,
  Spaces.create happy path + empty-response defence, DeleteMeeting
  prefix normalisation + 404/idle-conference tolerance.
- pkg/meeting: MultiPlatformSelector dispatch + nil-fields-NoOp +
  StaticSelector platform-agnostic.

Docs
----
docs/MEET_INTEGRATION.md — full operator runbook covering setup
(Workspace + GCP project + bootstrap), env vars, smoke test, day-2
ops, rollback story, and why per-trainer Meet was explicitly
rejected (cost/complexity vs zero user benefit).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore/dev token setup (#310)

* chore: api setup

* feat(dev): add refresh and access tokens

* chore: add proper validation

* chore: generate openai contract

* chore(session-cancel): set parameters to required

* Feat/account management (#315)

* feat: user account deactivation, reactivation, hard delete and active sessions

* feat: user account deactivation, reactivation, hard delete and active sessions

* fix: CodeRabbit review — nullable fields, execrows, deactivate exemption, admin readable path

* feat: move hard delete to user self-service (DELETE /users/me)

* fix: CodeRabbit review — deactivation middleware for hand-wired routes, role check, pagination, payment cascade

* fix: CodeRabbit round 2 — middleware order, fail-closed deactivation, active sessions pagination

* feat: add status filter to GET /admin/discovery-bookings (#316)

* Chore/dev to staging (#319)

* sync dev to staging (#118)

* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Chore/merge dev to staging (#122)

* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targe…
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

* Feat/trainer password reset (#291)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* Feat/trainer soft delete (#293)

* feat: convert DELETE /trainers/{id} from hard delete to soft delete

- Replace hard DELETE FROM trainers with DeactivateTrainer query that sets users.is_active = false
- Trainer record and all associated data preserved; trainer cannot log in or appear in active listings
- Returns 200 with body (was 204 no-content), 409 if already deactivated, 404 if trainer not found
- Disambiguates 404 vs 409 when DeactivateTrainer returns ErrNoRows via GetTrainerByID lookup
- Add DeactivateTrainer SQL query to trainers.sql and trainers.sql.go

* fix: handle non-ErrNoRows from GetTrainerByID disambiguate; add 503 to api.yaml

- If GetTrainerByID returns a non-not-found error during ErrNoRows disambiguation, return 500 instead of wrong 409
- Add 503 Service Unavailable to DELETE /trainers/{id} spec (returned when trainers store is nil)

* fix: distinguish all three ErrNoRows cases in DeactivateTrainer disambiguate path

After DeactivateTrainer returns ErrNoRows, check:
- trainer row missing → 404
- linked user row missing → 500 (data integrity)
- linked user has unexpected role → 500 (data integrity)
- user exists with role=trainer but is_active=false → 409 (already deactivated)

Previously all three non-404 cases collapsed into 409.

* fix(auth): restore bcrypt password check on POST /auth/login (#296)

CRITICAL — SECURITY FIX

PR #239 ("feat(auth): email-only login returns tokens immediately")
deliberately deleted the bcrypt password verification from SignIn,
shipping an auth bypass that affects EVERY account on the platform.
Any caller who knows a registered email address could mint a fresh
access + refresh token pair for that account:

    POST /auth/login
    { "email": "victim@example.com" }
    → 200 with valid tokens

User-reported as "my trainer can login with any password" but the
scope is wider: every role (client, trainer, admin, super_admin) is
exposed. The bypass has been live since #239 merged on May 25.

Fix
---
- Restore the bcrypt password check using the existing CheckPassword
  helper. Same failure path as the original implementation: any
  credential issue (wrong password, unknown email, OAuth-only account
  with no password, inactive user) collapses to a single 401 with
  the generic "invalid email or password" message. Distinct messages
  would let an attacker enumerate registered emails by diffing
  responses.
- Empty-password input is 400 (client mistake), so the FE can show
  "field required" instead of "wrong password".
- api.yaml updated to add the required `password` field and a
  description matching the actual flow. (The old description claimed
  an OTP step the handler didn't implement.)

Why a local request struct instead of regenerating gen.go
---------------------------------------------------------
PR #286 added /admin/sessions/{id}/cancel to api.yaml without
re-running codegen, so the on-disk gen.go is already out of sync
with the spec — re-running oapi-codegen now surfaces unrelated
breakage (ServerInterface signature mismatch on AdminCancelSession).
This is a deliberate scope-limit: ship the security fix first,
codegen cleanup is its own PR.

Bound the password from a small inline struct local to the handler.
Spec + runtime stay correct; gen.go is unchanged.

Tests (internal/auth/sign_in_test.go)
-------------------------------------
10 new tests, all of which FAIL against the pre-fix code:

  TestSignIn_EmailOnlyMustNotIssueTokens    regression guard for #239
  TestSignIn_CorrectPasswordSucceeds        happy path
  TestSignIn_WrongPasswordRejected          401 + generic message
  TestSignIn_MissingPasswordField           400
  TestSignIn_EmptyPasswordString            400
  TestSignIn_OAuthOnlyAccountRejected       401, no password set
  TestSignIn_UnknownEmailRejected           401, generic message
  TestSignIn_InactiveUserRejected           401, generic message (no enum)
  TestSignIn_OverLongPasswordRejected       401 on >72-byte input
  TestSignIn_MalformedJSONRejected          400

Verified by stashing the fix and re-running the new tests against
dev's current code — every one fails as expected (returns 200 where
401/400 is required), then all pass after restoring the fix.

Deploy ASAP. Any account on staging/prod has been logable without
credentials since May 25.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/trainer password reset (#297)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* Feat/trainer password reset (#300)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* chore(seed): add staging environment support (#304)

* chore(seed): add staging environment support

* Update main.go

* Fix/admin cancel session codegen (#305)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* fix: update AdminCancelSession signature to match codegen interface

Add id openapi_types.UUID param to match ServerInterface generated by oapi-codegen.
Replace manual c.Param("id") extraction with the passed UUID parameter.

* fix: fix routes.go callers and add missing openapi_types import

Update AdminCancelSession and AdminRescheduleSession manual route
callers in routes.go to extract id from URL and pass as openapi_types.UUID.
Add openapi_types import to routes.go.

* Fix/admin cancel session codegen (#306)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* fix: update AdminCancelSession signature to match codegen interface

Add id openapi_types.UUID param to match ServerInterface generated by oapi-codegen.
Replace manual c.Param("id") extraction with the passed UUID parameter.

* fix: fix routes.go callers and add missing openapi_types import

Update AdminCancelSession and AdminRescheduleSession manual route
callers in routes.go to extract id from URL and pass as openapi_types.UUID.
Add openapi_types import to routes.go.

* fix: update DeleteAdminClient signature to match codegen interface

Add id openapi_types.UUID param to DeleteAdminClient in admin.go.
Update manual route caller in routes.go to extract and pass UUID.
go build ./... passes clean.

* fix: run codegen and fix all handler interface mismatches (#307)

* Fix/codegen interface compliance (#308)

* fix: run codegen and fix all handler interface mismatches

* fix: remove duplicate route registrations for admin/transactions and admin/subscriptions

* feat(bookings): Google Meet (org account) + Messenger contact channel (#309)

Two new options on the booking platform picker — `google_meet` and
`messenger` — across both paid bookings and discovery calls. The
existing `zoom` and `phone_callback` flows are unchanged.

Google Meet
-----------
- New pkg/googlemeet: OAuth refresh-token client + Provider that mints
  Meet rooms via the v2 Spaces REST API (`POST /v2/spaces`).
- ONE Workspace user (e.g. meet-bot@yourdomain) hosts every booking;
  no per-trainer OAuth. Meet's `spaces.create` has no per-creator
  concurrency cap, so the single-account model scales cleanly.
- New cmd/meet-bootstrap: small CLI that runs OAuth once against the
  Workspace account, prints a refresh token to paste into env. Ops
  runs this per environment and forgets.
- pkg/meeting.Selector grows a `platform` parameter; new
  MultiPlatformSelector dispatches Zoom → existing zoomflow selector,
  Meet → single org provider. Zoomflow's selector ignores the
  platform arg (it only handles Zoom).
- Master switch MEET_ENABLED=false by default. Until flipped, the
  platform is hidden client-side and any inbound `google_meet`
  booking returns 503 with "google meet is not configured."

Messenger
---------
- Not a meeting provider — there's no Messenger API for rooms. It's a
  contact channel: client supplies their handle at booking time
  (Facebook profile slug, m.me link, numeric ID — anything), the
  server stores it on the booking row, the trainer follows up
  manually via Facebook.
- Mechanically symmetric with phone_callback: handler skips meeting
  creation, just persists the handle.

Schema (migration 000058)
-------------------------
- Widen bookings.session_platform CHECK to (zoom, google_meet,
  messenger). Dropped the dead `whatsapp` value from migration 000012
  that no handler ever implemented — leaving it would let clients
  pick a platform that 5xxs immediately.
- Widen discovery_bookings.contact_mode CHECK to add (google_meet,
  messenger).
- Add nullable messenger_handle column to both tables.
- sqlc queries (CreateBooking, RETURNING, etc.) updated to include
  the new column so the generated Booking struct stays canonical
  (without this update each query returned a row-specific type and
  broke the bookings repository's *Booking return signatures).

Side fix: DeactivateClient query (hand-added to the generated
internal/repository/db/users.sql.go in PR #283 without an SQL
source) backfilled into internal/models/queries/users.sql so
`sqlc generate` no longer wipes it.

Side fix: DeactivateTrainer SQL aliased to disambiguate column refs
that sqlc's parser couldn't resolve.

Email templates
---------------
- The discovery reschedule template now treats zoom_meeting and
  google_meet identically (both produce a clickable URL in
  ZoomLink). Label renamed to "Meeting Link" so it's platform-neutral.
- Mailer interface stayed unchanged for this PR. Messenger emails
  don't yet render the handle in the email body — trainers see it
  via the existing in-app notification path. Surfacing the handle in
  email requires extending all three mailer signatures (Log, SMTP,
  Resend) and is a follow-up.

Config + env
------------
Five new vars, all optional with sane defaults:
  MEET_ENABLED           (master switch, default false)
  MEET_OAUTH_CLIENT_ID
  MEET_OAUTH_CLIENT_SECRET
  MEET_REFRESH_TOKEN     (from cmd/meet-bootstrap)
  MEET_HOST_EMAIL        (logs only)

Spec
----
api.yaml updated for both BookDiscoveryCallRequest.contact_mode and
the paid booking session_platform enums. `whatsapp` removed from
the spec to match the new CHECK constraint. messenger_handle field
documented as required-when-mode-matches.

Tests
-----
- pkg/googlemeet: 13 tests across OAuth + Provider — including
  invalid_grant → ErrTokenRevoked sentinel, access-token caching,
  Spaces.create happy path + empty-response defence, DeleteMeeting
  prefix normalisation + 404/idle-conference tolerance.
- pkg/meeting: MultiPlatformSelector dispatch + nil-fields-NoOp +
  StaticSelector platform-agnostic.

Docs
----
docs/MEET_INTEGRATION.md — full operator runbook covering setup
(Workspace + GCP project + bootstrap), env vars, smoke test, day-2
ops, rollback story, and why per-trainer Meet was explicitly
rejected (cost/complexity vs zero user benefit).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore/dev token setup (#310)

* chore: api setup

* feat(dev): add refresh and access tokens

* chore: add proper validation

* chore: generate openai contract

* chore(session-cancel): set parameters to required

* Feat/account management (#315)

* feat: user account deactivation, reactivation, hard delete and active sessions

* feat: user account deactivation, reactivation, hard delete and active sessions

* fix: CodeRabbit review — nullable fields, execrows, deactivate exemption, admin readable path

* feat: move hard delete to user self-service (DELETE /users/me)

* fix: CodeRabbit review — deactivation middleware for hand-wired routes, role check, pagination, payment cascade

* fix: CodeRabbit round 2 — middleware order, fail-closed deactivation, active sessions pagination

* feat: add status filter to GET /admin/discovery-bookings (#316)

* Chore/dev to staging (#319)

* sync dev to staging (#118)

* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Chore/merge dev to staging (#122)

* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally…
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

* Feat/trainer password reset (#291)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* Feat/trainer soft delete (#293)

* feat: convert DELETE /trainers/{id} from hard delete to soft delete

- Replace hard DELETE FROM trainers with DeactivateTrainer query that sets users.is_active = false
- Trainer record and all associated data preserved; trainer cannot log in or appear in active listings
- Returns 200 with body (was 204 no-content), 409 if already deactivated, 404 if trainer not found
- Disambiguates 404 vs 409 when DeactivateTrainer returns ErrNoRows via GetTrainerByID lookup
- Add DeactivateTrainer SQL query to trainers.sql and trainers.sql.go

* fix: handle non-ErrNoRows from GetTrainerByID disambiguate; add 503 to api.yaml

- If GetTrainerByID returns a non-not-found error during ErrNoRows disambiguation, return 500 instead of wrong 409
- Add 503 Service Unavailable to DELETE /trainers/{id} spec (returned when trainers store is nil)

* fix: distinguish all three ErrNoRows cases in DeactivateTrainer disambiguate path

After DeactivateTrainer returns ErrNoRows, check:
- trainer row missing → 404
- linked user row missing → 500 (data integrity)
- linked user has unexpected role → 500 (data integrity)
- user exists with role=trainer but is_active=false → 409 (already deactivated)

Previously all three non-404 cases collapsed into 409.

* fix(auth): restore bcrypt password check on POST /auth/login (#296)

CRITICAL — SECURITY FIX

PR #239 ("feat(auth): email-only login returns tokens immediately")
deliberately deleted the bcrypt password verification from SignIn,
shipping an auth bypass that affects EVERY account on the platform.
Any caller who knows a registered email address could mint a fresh
access + refresh token pair for that account:

    POST /auth/login
    { "email": "victim@example.com" }
    → 200 with valid tokens

User-reported as "my trainer can login with any password" but the
scope is wider: every role (client, trainer, admin, super_admin) is
exposed. The bypass has been live since #239 merged on May 25.

Fix
---
- Restore the bcrypt password check using the existing CheckPassword
  helper. Same failure path as the original implementation: any
  credential issue (wrong password, unknown email, OAuth-only account
  with no password, inactive user) collapses to a single 401 with
  the generic "invalid email or password" message. Distinct messages
  would let an attacker enumerate registered emails by diffing
  responses.
- Empty-password input is 400 (client mistake), so the FE can show
  "field required" instead of "wrong password".
- api.yaml updated to add the required `password` field and a
  description matching the actual flow. (The old description claimed
  an OTP step the handler didn't implement.)

Why a local request struct instead of regenerating gen.go
---------------------------------------------------------
PR #286 added /admin/sessions/{id}/cancel to api.yaml without
re-running codegen, so the on-disk gen.go is already out of sync
with the spec — re-running oapi-codegen now surfaces unrelated
breakage (ServerInterface signature mismatch on AdminCancelSession).
This is a deliberate scope-limit: ship the security fix first,
codegen cleanup is its own PR.

Bound the password from a small inline struct local to the handler.
Spec + runtime stay correct; gen.go is unchanged.

Tests (internal/auth/sign_in_test.go)
-------------------------------------
10 new tests, all of which FAIL against the pre-fix code:

  TestSignIn_EmailOnlyMustNotIssueTokens    regression guard for #239
  TestSignIn_CorrectPasswordSucceeds        happy path
  TestSignIn_WrongPasswordRejected          401 + generic message
  TestSignIn_MissingPasswordField           400
  TestSignIn_EmptyPasswordString            400
  TestSignIn_OAuthOnlyAccountRejected       401, no password set
  TestSignIn_UnknownEmailRejected           401, generic message
  TestSignIn_InactiveUserRejected           401, generic message (no enum)
  TestSignIn_OverLongPasswordRejected       401 on >72-byte input
  TestSignIn_MalformedJSONRejected          400

Verified by stashing the fix and re-running the new tests against
dev's current code — every one fails as expected (returns 200 where
401/400 is required), then all pass after restoring the fix.

Deploy ASAP. Any account on staging/prod has been logable without
credentials since May 25.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/trainer password reset (#297)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* Feat/trainer password reset (#300)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* chore(seed): add staging environment support (#304)

* chore(seed): add staging environment support

* Update main.go

* Fix/admin cancel session codegen (#305)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* fix: update AdminCancelSession signature to match codegen interface

Add id openapi_types.UUID param to match ServerInterface generated by oapi-codegen.
Replace manual c.Param("id") extraction with the passed UUID parameter.

* fix: fix routes.go callers and add missing openapi_types import

Update AdminCancelSession and AdminRescheduleSession manual route
callers in routes.go to extract id from URL and pass as openapi_types.UUID.
Add openapi_types import to routes.go.

* Fix/admin cancel session codegen (#306)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* fix: update AdminCancelSession signature to match codegen interface

Add id openapi_types.UUID param to match ServerInterface generated by oapi-codegen.
Replace manual c.Param("id") extraction with the passed UUID parameter.

* fix: fix routes.go callers and add missing openapi_types import

Update AdminCancelSession and AdminRescheduleSession manual route
callers in routes.go to extract id from URL and pass as openapi_types.UUID.
Add openapi_types import to routes.go.

* fix: update DeleteAdminClient signature to match codegen interface

Add id openapi_types.UUID param to DeleteAdminClient in admin.go.
Update manual route caller in routes.go to extract and pass UUID.
go build ./... passes clean.

* fix: run codegen and fix all handler interface mismatches (#307)

* Fix/codegen interface compliance (#308)

* fix: run codegen and fix all handler interface mismatches

* fix: remove duplicate route registrations for admin/transactions and admin/subscriptions

* feat(bookings): Google Meet (org account) + Messenger contact channel (#309)

Two new options on the booking platform picker — `google_meet` and
`messenger` — across both paid bookings and discovery calls. The
existing `zoom` and `phone_callback` flows are unchanged.

Google Meet
-----------
- New pkg/googlemeet: OAuth refresh-token client + Provider that mints
  Meet rooms via the v2 Spaces REST API (`POST /v2/spaces`).
- ONE Workspace user (e.g. meet-bot@yourdomain) hosts every booking;
  no per-trainer OAuth. Meet's `spaces.create` has no per-creator
  concurrency cap, so the single-account model scales cleanly.
- New cmd/meet-bootstrap: small CLI that runs OAuth once against the
  Workspace account, prints a refresh token to paste into env. Ops
  runs this per environment and forgets.
- pkg/meeting.Selector grows a `platform` parameter; new
  MultiPlatformSelector dispatches Zoom → existing zoomflow selector,
  Meet → single org provider. Zoomflow's selector ignores the
  platform arg (it only handles Zoom).
- Master switch MEET_ENABLED=false by default. Until flipped, the
  platform is hidden client-side and any inbound `google_meet`
  booking returns 503 with "google meet is not configured."

Messenger
---------
- Not a meeting provider — there's no Messenger API for rooms. It's a
  contact channel: client supplies their handle at booking time
  (Facebook profile slug, m.me link, numeric ID — anything), the
  server stores it on the booking row, the trainer follows up
  manually via Facebook.
- Mechanically symmetric with phone_callback: handler skips meeting
  creation, just persists the handle.

Schema (migration 000058)
-------------------------
- Widen bookings.session_platform CHECK to (zoom, google_meet,
  messenger). Dropped the dead `whatsapp` value from migration 000012
  that no handler ever implemented — leaving it would let clients
  pick a platform that 5xxs immediately.
- Widen discovery_bookings.contact_mode CHECK to add (google_meet,
  messenger).
- Add nullable messenger_handle column to both tables.
- sqlc queries (CreateBooking, RETURNING, etc.) updated to include
  the new column so the generated Booking struct stays canonical
  (without this update each query returned a row-specific type and
  broke the bookings repository's *Booking return signatures).

Side fix: DeactivateClient query (hand-added to the generated
internal/repository/db/users.sql.go in PR #283 without an SQL
source) backfilled into internal/models/queries/users.sql so
`sqlc generate` no longer wipes it.

Side fix: DeactivateTrainer SQL aliased to disambiguate column refs
that sqlc's parser couldn't resolve.

Email templates
---------------
- The discovery reschedule template now treats zoom_meeting and
  google_meet identically (both produce a clickable URL in
  ZoomLink). Label renamed to "Meeting Link" so it's platform-neutral.
- Mailer interface stayed unchanged for this PR. Messenger emails
  don't yet render the handle in the email body — trainers see it
  via the existing in-app notification path. Surfacing the handle in
  email requires extending all three mailer signatures (Log, SMTP,
  Resend) and is a follow-up.

Config + env
------------
Five new vars, all optional with sane defaults:
  MEET_ENABLED           (master switch, default false)
  MEET_OAUTH_CLIENT_ID
  MEET_OAUTH_CLIENT_SECRET
  MEET_REFRESH_TOKEN     (from cmd/meet-bootstrap)
  MEET_HOST_EMAIL        (logs only)

Spec
----
api.yaml updated for both BookDiscoveryCallRequest.contact_mode and
the paid booking session_platform enums. `whatsapp` removed from
the spec to match the new CHECK constraint. messenger_handle field
documented as required-when-mode-matches.

Tests
-----
- pkg/googlemeet: 13 tests across OAuth + Provider — including
  invalid_grant → ErrTokenRevoked sentinel, access-token caching,
  Spaces.create happy path + empty-response defence, DeleteMeeting
  prefix normalisation + 404/idle-conference tolerance.
- pkg/meeting: MultiPlatformSelector dispatch + nil-fields-NoOp +
  StaticSelector platform-agnostic.

Docs
----
docs/MEET_INTEGRATION.md — full operator runbook covering setup
(Workspace + GCP project + bootstrap), env vars, smoke test, day-2
ops, rollback story, and why per-trainer Meet was explicitly
rejected (cost/complexity vs zero user benefit).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore/dev token setup (#310)

* chore: api setup

* feat(dev): add refresh and access tokens

* chore: add proper validation

* chore: generate openai contract

* chore(session-cancel): set parameters to required

* Feat/account management (#315)

* feat: user account deactivation, reactivation, hard delete and active sessions

* feat: user account deactivation, reactivation, hard delete and active sessions

* fix: CodeRabbit review — nullable fields, execrows, deactivate exemption, admin readable path

* feat: move hard delete to user self-service (DELETE /users/me)

* fix: CodeRabbit review — deactivation middleware for hand-wired routes, role check, pagination, payment cascade

* fix: CodeRabbit round 2 — middleware order, fail-closed deactivation, active sessions pagination

* feat: add status filter to GET /admin/discovery-bookings (#316)

* Chore/dev to staging (#319)

* sync dev to staging (#118)

* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Chore/merge dev to staging (#122)

* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targ…
* feat(notifications): admin broadcasts + hook into 5 more events (#282)

* feat(notifications): admin broadcasts + hook into 5 more events

Adds notifications for events the original PR (#252) didn't cover:

  Discovery booked       → all admins
  Discovery rescheduled  → all admins
  Subscription created   → client + all admins (trainer was already wired)
  Subscription cancelled → client + all admins (trainer was already wired)
  Zoom OAuth connected   → the trainer + all admins

Admin broadcasts
----------------
New NotificationService.SendNotificationToAdmins(ctx, title, message,
idempotencyKeyBase) fans out one DB row per user holding either the
`admin` or `super_admin` role. Each row's idempotency_key is suffixed
with the recipient's user_id (`<base>-admin-<uuid>`) so the table-wide
UNIQUE constraint doesn't collide on the same event for multiple
admins.

Per-admin failures are logged and the loop continues — one admin's
missing device token won't block notifying the others. Duplicate-key
errors are treated as "already delivered" not "failed", which matters
because handlers retried by FE clients would otherwise spam the
warn log.

Backing query: ListAdminUserIDs (admin OR super_admin, DISTINCT
because a user could hold both roles).

Side fix: backfilled CountAdminTransactions / ListAdminTransactions /
CountAdminSubscriptions / ListAdminSubscriptions from
internal/repository/db/subscriptions.sql.go (where PR #274 + #279
hand-added them) into internal/models/queries/subscriptions.sql so
`sqlc generate` no longer wipes them. Pure tech-debt cleanup; the
generated functions and their SQL are unchanged.

Routes.go reshuffle: notification service is now constructed at the
top of the if-db block instead of mid-way down, so the Zoom OAuth
init + discovery handler + the existing booking handlers all share
the same notification service without an init-order dance.

Tests
-----
5 new tests in internal/notification/admin_broadcast_test.go:
  - OnePerAdmin_KeySuffixed     covers fan-out + key uniqueness
  - NoAdminsIsZero              early-stage system, no panic
  - ListErrorPropagates         DB outage short-circuits
  - PartialFailureLoopContinues one admin fails, rest still notified
  - DuplicateKeyTreatedAsAlreadyDelivered  retried-event idempotency

* feat(notifications): admin broadcast on trainer creation

POST /trainers now fans out an in-app notification to every admin /
super_admin so the staff dashboard surfaces new trainer onboarding
without anyone refreshing.

Idempotency keyed on the trainer record id (`trainer-created-<id>`),
not the user id — that's stable across re-invites that flow through
UpsertTrainerUser, so retrying the same trainer won't double-notify.
The broadcast helper appends `-admin-<adminUUID>` per recipient so
the table-wide UNIQUE on idempotency_key doesn't collide.

Follows the same `if s.notificationService != nil { ... }` guard
the other event sites use, so a server booted without the
notification service still serves /trainers.

* docs(swagger): document /notifications/ws so FE doesn't have to poll

The WebSocket already exists and serves real-time notifications to
trainers + admins (and replays pending notifications to clients on
connect). It just wasn't in api.yaml, so the FE team has been
defaulting to polling GET /notifications.

OpenAPI has no first-class WebSocket primitive, so the entry is
documented as a GET with the upgrade described in `description` —
same pattern other API specs use. operationId is excluded from
oapi-codegen so gen.go stays unchanged (verified locally).

The description covers everything an FE engineer needs to pick up
without reading the Go code:

- which roles get pushes (trainer/admin; clients still on FCM today)
- the connection URL + how to authenticate (Authorization header OR
  ?token=, because browser WebSocket APIs can't set headers)
- the JSON message shape on the wire — { id, title, message, type,
  created_at } — including that `type` is always "notification"
- the replay-on-connect behaviour (pending notifications stream
  immediately on every reconnect; clients should de-dupe on `id`
  until a future mark-as-read endpoint lands)
- keepalive + reconnect guidance (30s ping cadence, exp backoff)
- a copy-pasteable RN/browser snippet
- what the WS does NOT replace (REST GET still wanted for paginated
  history; FCM still the system-tray channel for mobile clients)

Plus three response codes (101 Switching Protocols on upgrade
success, 401 on bad token, 500 on hub/DB failure) so FE error
handling has a contract.

* fix(notifications): make Zoom-reconnect + repeat-reschedule keys distinct

Two CodeRabbit findings; both cases where the original idempotency key
was constant across legitimately-repeatable events, so the
table-wide UNIQUE(idempotency_key) constraint would silently drop
subsequent fires as "already delivered."

discovery rescheduled
---------------------
A booking can be rescheduled up to maxReschedules (3) times. The key
was `discovery-rescheduled-<bookingID>` for every one of them, so
only the first reschedule notified admins. Fix: append
`updated.RescheduleCount` — the SQL increments the counter BEFORE
RETURNING, so the value is the 1-based reschedule sequence number
and distinct on every successful call.

zoom connected
--------------
A trainer can DELETE /trainers/me/zoom then reconnect later. The key
was `zoom-connected-<userID>` (and `-admin-<userID>` for the broadcast
suffix), so the reconnect wouldn't notify. Fix: append
`tokens.ExpiresAt.Unix()` — pkg/zoom recomputes ExpiresAt as
`time.Now() + expires_in - 60s` on every token exchange, so it's
naturally distinct per (re)connect.

A single replayed OAuth callback (browser refresh) can't reach the
notification code either way — ExchangeCode 502s first because Zoom
invalidates auth codes after use, so the suffix doesn't weaken the
"don't double-notify on accidental replay" property.

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint (#283)

* feat(admin): add DELETE /admin/clients/{id} soft delete endpoint

* fix(admin): handle race condition, 409 for already-deactivated, clean up

* Feat/admin session actions (#286)

* feat: add admin cancel and reschedule session endpoints

- PUT /admin/sessions/:id/cancel — super_admin cancels any booking with a reason; returns 409 if already cancelled/completed
- PUT /admin/sessions/:id/reschedule — super_admin reschedules any booking with new start/end; bypasses the 3-reschedule client limit; returns 409 if cancelled/completed
- Add AdminRescheduleBooking SQL query (no reschedule_count cap) to bookings.sql and bookings.sql.go
- Add OpenAPI specs for both endpoints in api.yaml

* fix: use transactions with FOR UPDATE lock in admin session handlers; add 503 to api.yaml

- AdminCancelSession and AdminRescheduleSession now use BeginTx + GetBookingByIDForUpdate to prevent TOCTOU race conditions
- Add 503 Service Unavailable response to both endpoints in api.yaml

* fix: address CodeRabbit review on admin session endpoints

- Add minLength:1 to cancel reason in api.yaml schema
- AdminRescheduleBooking now nulls zoom_meeting_link/id — admin cannot provision Zoom; stale links are cleared
- AdminCancelSession now calls ReleaseBookingSlot to free the trainer slot after cancel
- Both handlers send best-effort push notifications to client and trainer after cancel/reschedule

* Feat/trainer password reset (#291)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* Feat/trainer soft delete (#293)

* feat: convert DELETE /trainers/{id} from hard delete to soft delete

- Replace hard DELETE FROM trainers with DeactivateTrainer query that sets users.is_active = false
- Trainer record and all associated data preserved; trainer cannot log in or appear in active listings
- Returns 200 with body (was 204 no-content), 409 if already deactivated, 404 if trainer not found
- Disambiguates 404 vs 409 when DeactivateTrainer returns ErrNoRows via GetTrainerByID lookup
- Add DeactivateTrainer SQL query to trainers.sql and trainers.sql.go

* fix: handle non-ErrNoRows from GetTrainerByID disambiguate; add 503 to api.yaml

- If GetTrainerByID returns a non-not-found error during ErrNoRows disambiguation, return 500 instead of wrong 409
- Add 503 Service Unavailable to DELETE /trainers/{id} spec (returned when trainers store is nil)

* fix: distinguish all three ErrNoRows cases in DeactivateTrainer disambiguate path

After DeactivateTrainer returns ErrNoRows, check:
- trainer row missing → 404
- linked user row missing → 500 (data integrity)
- linked user has unexpected role → 500 (data integrity)
- user exists with role=trainer but is_active=false → 409 (already deactivated)

Previously all three non-404 cases collapsed into 409.

* fix(auth): restore bcrypt password check on POST /auth/login (#296)

CRITICAL — SECURITY FIX

PR #239 ("feat(auth): email-only login returns tokens immediately")
deliberately deleted the bcrypt password verification from SignIn,
shipping an auth bypass that affects EVERY account on the platform.
Any caller who knows a registered email address could mint a fresh
access + refresh token pair for that account:

    POST /auth/login
    { "email": "victim@example.com" }
    → 200 with valid tokens

User-reported as "my trainer can login with any password" but the
scope is wider: every role (client, trainer, admin, super_admin) is
exposed. The bypass has been live since #239 merged on May 25.

Fix
---
- Restore the bcrypt password check using the existing CheckPassword
  helper. Same failure path as the original implementation: any
  credential issue (wrong password, unknown email, OAuth-only account
  with no password, inactive user) collapses to a single 401 with
  the generic "invalid email or password" message. Distinct messages
  would let an attacker enumerate registered emails by diffing
  responses.
- Empty-password input is 400 (client mistake), so the FE can show
  "field required" instead of "wrong password".
- api.yaml updated to add the required `password` field and a
  description matching the actual flow. (The old description claimed
  an OTP step the handler didn't implement.)

Why a local request struct instead of regenerating gen.go
---------------------------------------------------------
PR #286 added /admin/sessions/{id}/cancel to api.yaml without
re-running codegen, so the on-disk gen.go is already out of sync
with the spec — re-running oapi-codegen now surfaces unrelated
breakage (ServerInterface signature mismatch on AdminCancelSession).
This is a deliberate scope-limit: ship the security fix first,
codegen cleanup is its own PR.

Bound the password from a small inline struct local to the handler.
Spec + runtime stay correct; gen.go is unchanged.

Tests (internal/auth/sign_in_test.go)
-------------------------------------
10 new tests, all of which FAIL against the pre-fix code:

  TestSignIn_EmailOnlyMustNotIssueTokens    regression guard for #239
  TestSignIn_CorrectPasswordSucceeds        happy path
  TestSignIn_WrongPasswordRejected          401 + generic message
  TestSignIn_MissingPasswordField           400
  TestSignIn_EmptyPasswordString            400
  TestSignIn_OAuthOnlyAccountRejected       401, no password set
  TestSignIn_UnknownEmailRejected           401, generic message
  TestSignIn_InactiveUserRejected           401, generic message (no enum)
  TestSignIn_OverLongPasswordRejected       401 on >72-byte input
  TestSignIn_MalformedJSONRejected          400

Verified by stashing the fix and re-running the new tests against
dev's current code — every one fails as expected (returns 200 where
401/400 is required), then all pass after restoring the fix.

Deploy ASAP. Any account on staging/prod has been logable without
credentials since May 25.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/trainer password reset (#297)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* Feat/trainer password reset (#300)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* chore(seed): add staging environment support (#304)

* chore(seed): add staging environment support

* Update main.go

* Fix/admin cancel session codegen (#305)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* fix: update AdminCancelSession signature to match codegen interface

Add id openapi_types.UUID param to match ServerInterface generated by oapi-codegen.
Replace manual c.Param("id") extraction with the passed UUID parameter.

* fix: fix routes.go callers and add missing openapi_types import

Update AdminCancelSession and AdminRescheduleSession manual route
callers in routes.go to extract id from URL and pass as openapi_types.UUID.
Add openapi_types import to routes.go.

* Fix/admin cancel session codegen (#306)

* feat: extend password reset to trainers and add verify-otp step

- /auth/forgot-password and /auth/reset-password now accept trainer role in addition to admin
- Add POST /auth/verify-reset-code — validates OTP without consuming it so mobile can confirm the code before showing the new-password screen
- Add VerifyCode to PasswordResetRepository backed by VerifyPasswordResetCode SQL query
- Add trainerRoleName constant

* fix: add rate limiting to HandleVerifyResetCode; add 429 to api.yaml

- Apply resetIPLimiter (per-IP) and resetLimiter (per-email) to the verify-reset-code endpoint — same limits as HandleResetPassword — to prevent brute-force guessing of the 6-digit OTP
- Document 429 response in api.yaml spec for /auth/verify-reset-code

* fix: address CodeRabbit review on verify-reset-code endpoint

- Reset per-email rate limit bucket after a successful verify so the preflight does not consume attempts the caller needs for the actual reset step
- Add eligibility gate to HandleVerifyResetCode mirroring HandleResetPassword: check account is active, uses local auth, and has admin or trainer role — prevents false-positive 200 for accounts the reset step would reject
- Add comment to hand-wired route explaining why it is outside gen.go

* fix: use users.role instead of user_roles table for password reset eligibility check

UpsertTrainerUser and UpsertAdminUser write role to the users.role column but
do not insert into user_roles. UserHasRole queries user_roles, so all trainer
and admin password reset requests were silently dropped.

Switch all three handlers (processForgotPassword, HandleResetPassword,
HandleVerifyResetCode) to check user.Role directly from the FindByEmailAndProvider
result — one fewer DB round-trip and works correctly for both flows.

* fix: use users.role directly for password reset eligibility (re-apply after merge conflict)

* fix: update AdminCancelSession signature to match codegen interface

Add id openapi_types.UUID param to match ServerInterface generated by oapi-codegen.
Replace manual c.Param("id") extraction with the passed UUID parameter.

* fix: fix routes.go callers and add missing openapi_types import

Update AdminCancelSession and AdminRescheduleSession manual route
callers in routes.go to extract id from URL and pass as openapi_types.UUID.
Add openapi_types import to routes.go.

* fix: update DeleteAdminClient signature to match codegen interface

Add id openapi_types.UUID param to DeleteAdminClient in admin.go.
Update manual route caller in routes.go to extract and pass UUID.
go build ./... passes clean.

* fix: run codegen and fix all handler interface mismatches (#307)

* Fix/codegen interface compliance (#308)

* fix: run codegen and fix all handler interface mismatches

* fix: remove duplicate route registrations for admin/transactions and admin/subscriptions

* feat(bookings): Google Meet (org account) + Messenger contact channel (#309)

Two new options on the booking platform picker — `google_meet` and
`messenger` — across both paid bookings and discovery calls. The
existing `zoom` and `phone_callback` flows are unchanged.

Google Meet
-----------
- New pkg/googlemeet: OAuth refresh-token client + Provider that mints
  Meet rooms via the v2 Spaces REST API (`POST /v2/spaces`).
- ONE Workspace user (e.g. meet-bot@yourdomain) hosts every booking;
  no per-trainer OAuth. Meet's `spaces.create` has no per-creator
  concurrency cap, so the single-account model scales cleanly.
- New cmd/meet-bootstrap: small CLI that runs OAuth once against the
  Workspace account, prints a refresh token to paste into env. Ops
  runs this per environment and forgets.
- pkg/meeting.Selector grows a `platform` parameter; new
  MultiPlatformSelector dispatches Zoom → existing zoomflow selector,
  Meet → single org provider. Zoomflow's selector ignores the
  platform arg (it only handles Zoom).
- Master switch MEET_ENABLED=false by default. Until flipped, the
  platform is hidden client-side and any inbound `google_meet`
  booking returns 503 with "google meet is not configured."

Messenger
---------
- Not a meeting provider — there's no Messenger API for rooms. It's a
  contact channel: client supplies their handle at booking time
  (Facebook profile slug, m.me link, numeric ID — anything), the
  server stores it on the booking row, the trainer follows up
  manually via Facebook.
- Mechanically symmetric with phone_callback: handler skips meeting
  creation, just persists the handle.

Schema (migration 000058)
-------------------------
- Widen bookings.session_platform CHECK to (zoom, google_meet,
  messenger). Dropped the dead `whatsapp` value from migration 000012
  that no handler ever implemented — leaving it would let clients
  pick a platform that 5xxs immediately.
- Widen discovery_bookings.contact_mode CHECK to add (google_meet,
  messenger).
- Add nullable messenger_handle column to both tables.
- sqlc queries (CreateBooking, RETURNING, etc.) updated to include
  the new column so the generated Booking struct stays canonical
  (without this update each query returned a row-specific type and
  broke the bookings repository's *Booking return signatures).

Side fix: DeactivateClient query (hand-added to the generated
internal/repository/db/users.sql.go in PR #283 without an SQL
source) backfilled into internal/models/queries/users.sql so
`sqlc generate` no longer wipes it.

Side fix: DeactivateTrainer SQL aliased to disambiguate column refs
that sqlc's parser couldn't resolve.

Email templates
---------------
- The discovery reschedule template now treats zoom_meeting and
  google_meet identically (both produce a clickable URL in
  ZoomLink). Label renamed to "Meeting Link" so it's platform-neutral.
- Mailer interface stayed unchanged for this PR. Messenger emails
  don't yet render the handle in the email body — trainers see it
  via the existing in-app notification path. Surfacing the handle in
  email requires extending all three mailer signatures (Log, SMTP,
  Resend) and is a follow-up.

Config + env
------------
Five new vars, all optional with sane defaults:
  MEET_ENABLED           (master switch, default false)
  MEET_OAUTH_CLIENT_ID
  MEET_OAUTH_CLIENT_SECRET
  MEET_REFRESH_TOKEN     (from cmd/meet-bootstrap)
  MEET_HOST_EMAIL        (logs only)

Spec
----
api.yaml updated for both BookDiscoveryCallRequest.contact_mode and
the paid booking session_platform enums. `whatsapp` removed from
the spec to match the new CHECK constraint. messenger_handle field
documented as required-when-mode-matches.

Tests
-----
- pkg/googlemeet: 13 tests across OAuth + Provider — including
  invalid_grant → ErrTokenRevoked sentinel, access-token caching,
  Spaces.create happy path + empty-response defence, DeleteMeeting
  prefix normalisation + 404/idle-conference tolerance.
- pkg/meeting: MultiPlatformSelector dispatch + nil-fields-NoOp +
  StaticSelector platform-agnostic.

Docs
----
docs/MEET_INTEGRATION.md — full operator runbook covering setup
(Workspace + GCP project + bootstrap), env vars, smoke test, day-2
ops, rollback story, and why per-trainer Meet was explicitly
rejected (cost/complexity vs zero user benefit).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore/dev token setup (#310)

* chore: api setup

* feat(dev): add refresh and access tokens

* chore: add proper validation

* chore: generate openai contract

* chore(session-cancel): set parameters to required

* Feat/account management (#315)

* feat: user account deactivation, reactivation, hard delete and active sessions

* feat: user account deactivation, reactivation, hard delete and active sessions

* fix: CodeRabbit review — nullable fields, execrows, deactivate exemption, admin readable path

* feat: move hard delete to user self-service (DELETE /users/me)

* fix: CodeRabbit review — deactivation middleware for hand-wired routes, role check, pagination, payment cascade

* fix: CodeRabbit round 2 — middleware order, fail-closed deactivation, active sessions pagination

* feat: add status filter to GET /admin/discovery-bookings (#316)

* Chore/dev to staging (#319)

* sync dev to staging (#118)

* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(booking): implement BE-BOOKING-001 discovery call booking (#80)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* Feat/user onboarding profile (#82)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(onboarding): add user profile onboarding endpoints

* Merge pull request #75 from hngprojects/refactor/waitlist-table

Fix(waitlist): 500 error (#83)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* chore: build and deployment pipeline

* Feat/review route flow (#64) (#66)

* feat(api): add review route contract and generated handlers

* feat(db): add bookings and reviews schema for review flow

* chore(sqlc): generate booking and review queries

* feat(reviews): implement review submission and trainer review listing

* test(reviews): cover validation ownership duplicates and pagination

* fix(routes): remove merge-conflict middleware leftovers

* refactor(bookings): align booking schema with scheduling requirements

* fix(db): add subscriptions migration and handle review duplicate conflicts

Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>

* fix: remove duplicate contact migration 000010 (#72) (#73)

Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Taterbro <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>

* Feat/discovery call (#85)

* feat(booking): implement BE-BOOKING-001 discovery call booking
- Add migrations for booking_slots, discovery_bookings, customer_care role,
  and booking_reschedule_history tables
- Add SQL queries and sqlc-generated Go code for all booking operations
- Add POST /bookings/discovery public endpoint with slot validation,
  conflict detection, Zoom meeting creation, and email confirmation
- Add CRUD endpoints for /booking-slots (admin/customer_care only)
- Add pkg/zoom Zoom Server-to-Server OAuth client (stub when unconfigured)
- Update Mailer interface with SendDiscoveryBookingConfirmation
- Wire discovery handler into routes; add ZoomAccountID/ClientID/Secret to config
- Fix waitlist package after migration 000014 changed columns to NOT NULL

* refactor(meeting): extract MeetingProvider interface for pluggable video backends
- Add pkg/meeting.Provider interface (IsConfigured, CreateMeeting)
- Add meeting.NoOp for when no credentials are configured
- Zoom client now implements meeting.Provider
- Discovery handler depends on meeting.Provider, not *zoom.Client
- Routes wire NoOp by default; Zoom is used only when ZOOM_ACCOUNT_ID is set

* fix(discovery): address code review issues
- Remove unused clientTZ param from validateAgainstSlots
- Add zoom_meeting_id to booking response
- Validate IANA timezone on booking and slot creation
- Fix CheckSlotConflict to block ±30 min window instead of exact match
- Remove COALESCE from UpdateBookingSlot (full replace semantics)
- Add context.Context to meeting.Provider interface and Zoom client
- Fix silent json.Marshal error in zoom.go
- Validate timezone param in GetBookingSlots silently falls back

* fix(zoom): handle deferred Body.Close error to satisfy errcheck linter

* feat(discovery): book discovery call endpoint — auth, Zoom, email confirmation, slot lock, booking limits

* Ci scanner (#90)

* Add forbidden pattern scan script

This script scans for forbidden patterns in repository files and reports any matches.

* Add security scan workflow with two scanning jobs

This workflow defines two jobs for security scans: a forbidden pattern scan and a Lazarus scanner, triggered on push, pull request, or manually.

* feat(discovery): implement reschedule discovery call (BE-BOOKING-002) (#86)

* Feat/add dev token (#89)

* chore(auth): delete login and register

* feat: add refresh route

* revert: add local_test.go

* feat: add test token

* chore: generate gen.go

* fix: remove empty if check

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(bookings): implement GET /bookings/upcoming (BE-BOOKING-006) (#98)

* Feat/booking session (#91)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Feat/cancel booking (#101)

* feat: add Cancel Booking API schema and endpoint (BE-BOOKING-004)

Phase 1: API Layer implementation

Added schemas:
- CancelBookingRequest: reason and optional notes
- CancelBookingResponse: status, refund amount, refund reason, notification status
- DiscoveryBookingResponse: fixed pre-existing missing schema

Added endpoint:
- PUT /bookings/{id}/cancel
- Requires bearer token authentication
- Returns 200 on success with refund details
- Returns 400 for validation errors
- Returns 403 for authorization errors
- Returns 404 if booking not found
- Returns 409 for conflict (already cancelled or session started)
- Returns 500 for server errors

Regenerated API code with make codegen

* feat: add SQL queries for booking cancellation (BE-BOOKING-004)

Phase 2: SQL Queries implementation

Added to bookings.sql:
- CancelBooking: Update booking status to cancelled with reason and timestamp
- ReleaseBookingSlot: Mark booking slot as available again (set is_active=true)

Created subscriptions.sql with:
- GetSubscriptionByID: Fetch subscription details
- GetActiveSubscriptionForClient: Get active subscription for client-trainer pair
- RefundSessionCredit: Decrement sessions_used_this_month for credit refund

Generated SQL layer with make sqlc

* docs: update endpoint reference to use Gin syntax

Changed endpoint reference from {id} to :id syntax in description.
The OpenAPI spec uses {id} (standard), but Gin routes use :id (runtime).
Comments should refer to the actual Gin endpoint syntax.

* docs: fix CancelBookingResponse schema and add PR documentation

- Make refund_reason and notification_sent required in CancelBookingResponse schema for stable client contracts
- Add comprehensive PR documentation for BE-BOOKING-004 feature

* fix: scope booking slot release to specific trainer

- Add trainer_id column to booking_slots table via migration
- Update ReleaseBookingSlot query to filter by trainer_id to prevent cross-trainer slot conflicts
- Ensures slots are only released for the booking's assigned trainer

* chore: update postgres db port mapping from 5433 to 5432

* fix: address code review issues in cancel booking implementation

- Make trainer_id NOT NULL in booking_slots migration for stronger guarantees
- Change ReleaseBookingSlot to :execrows and validate exactly 1 row affected
- Add cancellation reason validation
- Move booking state checks into transaction with FOR UPDATE lock to prevent race conditions
- Add subscription active status check before refunding credits
- Regenerate api/gen.go to ensure required fields don't have omitempty tags

* fix: enhance cancellation reason validation to check enum values

- Validate that cancellation reason is one of the allowed enum values
- Reject both empty and unknown reason values
- Prevents invalid/unsupported cancellation reasons from being persisted

* feat(bookings): implement reschedule paid session (BE-BOOKING-005) (#102)

- Add migration 000023: zoom_meeting_link, zoom_meeting_id, reschedule_count to bookings; paid_booking_reschedule_history table
- Add SQL queries: ReschedulePaidBooking, CheckPaidBookingConflict, CreatePaidRescheduleHistory
- Regenerate SQLC models with updated Booking struct (3 new fields)
- Create internal/bookings package with repository and handler
- Unified PUT /bookings/{id}/reschedule: checks paid session first, falls back to discovery call
- Add paid session reschedule email methods to SMTPMailer, LogMailer, ResendMailer
- Enforces 12-hour lock window, 3-reschedule max, trainer conflict check, Zoom delete+recreate

* Feat/availability (#99)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets whose current role isn't already
    admin/super_admin (403) — this endpoint is not a backdoor for
    promoting clients/trainers

Supporting changes
  - new sqlc queries UpsertAdminUser + UpdateUserRole
  - UserRepository gains UpsertAdmin, UpdateRole, GetByID
  - new Mailer.SendAdminCredentials method + HTML template on both
    SMTPMailer and LogMailer (test fakes updated)
  - new SuperAdminOnly middleware (path-prefix gated to /api/v1/admin,
    mirrors TrainersAdminOnly's pattern; uses ValidateAccessToken to
    enforce the JWT type claim)

Known follow-up: dev's /auth/login is OTP-only. The generated admin
password is stored but unusable until a password-login endpoint is
added — separate ticket.

* chore(admin): drop unused admin_invites migration

The previous admin-invite design used a tokenized invite-link flow with
an admin_invites table. The current implementation (POST /admin/add)
creates accounts directly with a generated password, so this table is
unreferenced. Removing the migration to keep the PR diff focused.

* feat: Implement the Admin-invite

* fix: resolve merge conflict issues from dev branch

* fix: add missing methods to test mocks

* refactor: split AdminUserRepository from UserRepository and merge password files

* feat: implement PUT /admin/trainers/{id}/approve endpoint

Adds a new admin-only endpoint to approve trainer profiles. Sets onboarding_status
to 'approved', making the trainer profile live. The endpoint:
- Requires super_admin authentication (enforced by SuperAdminOnly middleware)
- Returns 404 if trainer not found
- Returns 200 with updated trainer data on success

Implementation includes:
- New ApproveTrainer SQL query in trainers.sql
- Regenerated DB layer (trainers.sql.go) with ApproveTrainer method
- Added PUT /admin/trainers/{id}/approve to OpenAPI spec
- Regenerated API layer (gen.go) with AdminApproveTrainer interface method
- Implemented handler in routes/admin.go

* refactor: update endpoint syntax to use Gin path parameters

Replace OpenAPI syntax {id} with Gin framework syntax :id in endpoint definitions for consistency with codebase conventions.

* feat: Set-Trainer-Availability-Endpoint

* feat: Set-Trainer-Availability-Endpoint

* fix: handle tx.Rollback() error in saveAvailabilitySlots

Explicitly ignore Rollback error in defer to pass golangci-lint errcheck linter.
The Rollback call will fail if Commit succeeds or if we've already returned
with an error, but it's still called for cleanup. Ignoring the error is the
standard pattern for transaction defer cleanup.

* fix: make admin creation atomicity more robust against TOCTOU races

Addressed Code Rabbit finding: removed the separate FindByEmail check which
was non-atomic with the UpsertAdminUser operation. Added error handling for
conflict scenarios and null user results. Database UNIQUE constraint on
(email, auth_provider) provides the final safety net. Maps conflict errors
to HTTP 409 instead of 500 for better client experience.

* fix: address Code Rabbit security and schema review findings

1. Redact verification code from LogMailer (prevent secret logging)
   - Match pattern used in SendPasswordResetCode
   - Only log metadata (to, subject, expiry), not the code itself

2. Add regex pattern validation to API schema
   - HH:MM 24-hour format pattern for start_time and end_time
   - Pattern: ^([01]\d|2[0-3]):[0-5]\d$
   - Enforces format in OpenAPI contract

3. Add unique constraint to trainer_availability migration
   - Prevent duplicate slots: (trainer_id, day_of_week, start_time, end_time)
   - Ensures database consistency

4. Improve trainers_admin_only middleware
   - Add explicit checks for exact /trainers/me and /api/v1/trainers/me paths
   - Verify user_id in context before allowing me/* bypass
   - More defensive authentication check

* chore: remove PR documentation files

These files are not needed in the repository. PR content should be created
directly on GitHub when submitting the pull request.

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* chore: added image storage and processing (#108)

* chore: added image storage and processing

* fix(uploads): rename QueueFull -> ErrQueueFull (ST1012)

* fix(uploads): address review (split key/URL, race-safe Stop, persist DB-link failures, :execrows for missing-user, attempts CHECK, idempotent bucket create)

* fix(uploads): validate image dimensions before decode to prevent OOM

* fix(uploads): close stopCh before wg.Wait so backoff sleeps cancel on shutdown

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): renumber conflicting 000022/000023 to 000026/000027 (#111)

Two pairs of migrations collided on the same version number because PRs
were developed in parallel and each picked the next-available number from
its base. Goose refuses to run with duplicate versions and panics on
deploy (seen in staging deploy: 'duplicate version 22 detected').

Renumber the later-merged duplicates so the sequence is unique:

- 000022_create_trainer_availability_table.sql (#99) -> 000026
- 000023_add_trainer_id_to_booking_slots.sql (#101)   -> 000027

The earlier-merged ones (#86 reschedule_discovery_call at 000022, #91
booking_session at 000023) keep their original numbers — they had the
legitimate claim.

The file CONTENT is unchanged (git renames at 100% similarity); only the
filename version prefix changes. Anyone with goose_db_version rows for
the original 22/23 should DELETE those rows so goose re-applies the
renumbered versions; production hasn't run them (goose panicked before
inserting any row).

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* Feat/booking creation (#104)

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* refactor(admin_login): Fixed bugs and conversations

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(admin_login): Adding test for admin login

* fix(docs): arranged docs properly

* fix(makefile): changed CGO_ENABLED to default

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* fix(docs): arranged docs properly

* fix(docs): arranged docs properly

* feat(test): Added test for admin login

* feat(test): Added test for admin login

* fix/fixed tag issue

* feat(booking_session): booking session endpoint

* fix: fixing merge conflict

* fix: fixing merge conflict

* feat(booking_session): booking session endpoint

* fix: solved coderabbit convo

* feat(booking): Added booking creation endpoint

* ci: add gitleaks secret scan job, gate ci aggregator on it (#95)

* ci: add gitleaks secret scan job, gate ci aggregator on it

* ci: fixed api.yml example secrets

* ci(secrets): verify gitleaks tarball checksum, migrate to 'git' subcommand

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(booking): Added booking creation endpoint

* feat(test): Added test for admin login

* feat(booking_session): booking session endpoint

* feat(booking): Added booking creation endpoint

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* fix(coderabbit): fixed coderabbit and merge conflicts

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* feat(email): Added email confirmation

* fix(merge): fixed merge conflicts

---------

Co-authored-by: Olatise Oluwatobiloba <olatise oluwatobiloba>
Co-authored-by: Ukeme Ikot <ukemeetim2222@gmail.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(api): regenerate gen.go to register profile picture upload route (#115)

The api.yaml at /users/me/profile/picture was added in #108 (image storage
and processing) but the regenerated gen.go did not land with it — likely
forgotten in the final commit or stripped during a rebase. Without the
handler in gen.go's ServerInterface, RegisterHandlersWithOptions skips
the route and every POST returns 404.

Pure regen of internal/api/gen.go from the current api.yaml. No source
or behavioural change beyond making the existing endpoint actually
addressable.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* fix(migrations): resolve version + intent collisions; add CI guards (#117)

Migrations directory had two classes of collision that goose panics on:

1. Three files all named *_add_trainer_id_to_booking_slots.sql at versions
   000013, 000024, 000027. The first runs fine; the others either error
   (column already exists) or no-op redundantly. Deleted 000024 and 000027
   — 000013 is the canonical migration for this column.

2. Three version-number collisions where a later-merged PR claimed an
   already-occupied version slot:
     v25: failed_avatar_uploads (earlier) keeps 25; booking_session -> 29
     v26: trainer_availability   (earlier) keeps 26; reschedule_discovery_call -> 30
     v27: reschedule_paid_booking keeps 27 after the trainer_id orphan deleted

Renumbered UP (not into the v22 gap) so existing migration run order is
preserved — anything that ran in any environment still runs at the same
relative position.

Added two CI guards in .github/workflows/ci.yml so the next instance of
either class fails at PR time instead of after a staging deploy:
  - Unique version numbers across migrations/*.sql
  - Unique migration intent (no duplicate name suffixes)

Local-dev impact: anyone who already ran migrations 24 or 27 (the
trainer_id duplicates) should DELETE those rows from their goose_db_version
table so goose doesn't trip on the missing files. Production/staging
weren't able to deploy these, so no cleanup needed there.

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

---------

Co-authored-by: Victor Alfa <135230103+Taterbro@users.noreply.github.com>
Co-authored-by: taberah <52472112+nonso7@users.noreply.github.com>
Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>
Co-authored-by: Mairo Gospel <128805659+Gospelmairo@users.noreply.github.com>
Co-authored-by: Usman <108248000+maxixo@users.noreply.github.com>
Co-authored-by: Tobi <olatiseoluwatobiloba@gmail.com>
Co-authored-by: SodiqBinAliu <127252936+Sodiqbinaliu@users.noreply.github.com>
Co-authored-by: cycy <160864573+CynthiaWahome@users.noreply.github.com>

* Chore/merge dev to staging (#122)

* Refactor/waitlist table (#76)

* refactor(waitlist): add table migrations

* chore(waitlist): remove old migration columns

* Feat/ Implement PUT /admin/trainers/:id/approve Endpoint (#77)

* feat: implemented admin inviting a fellow admin

* fix: email normalization (#48)

* chore(admin-invite): adopt dev as truth, address review findings

Merged origin/dev (committed earlier) and reset feat/Admin-Invite to use
dev as source of truth. Removed admin-invite scaffolding (admininvite
package, admin_invites route, related migrations/queries) pending the
implementation rework agreed with the lead — the simpler "super_admin
posts email -> backend creates account with generated password" flow
will be reintroduced in a follow-up.

Review findings addressed:
- auth: JWT secret now injected via auth.Configure() at startup; hot
  path no longer reads JWT_SECRET from env; ValidateAccessToken /
  ValidateRefreshToken enforce the "type" claim and HMAC method.
- auth/google: profile_complete flag corrected (!isNewUser).
- auth/password: enforce 72-byte bcrypt input limit with explicit error.
- middleware/logger: log matched route pattern (c.FullPath()) instead
  of raw path so secret URL segments don't leak into logs.
- migrations/000002: add idx_sessions_user_id with matching down step.
- models/user.go: removed (dead code, no remaining importers).

Findings already addressed on dev (verified, no change needed):
- main.go godotenv non-fatal load.
- api/response.go safe payload handling (Data is *interface{}).
- auth: explicit JWT generation error checks in local.go SignIn.

Findings not applicable post-merge (skipped with reason in PR notes):
- Role.CratedAt typo: dev uses a `role` column on users, no Role struct.
- PasswordHash nil-deref guard: dev's SignIn is OTP-based.
- admin_invites.go logging / error distinction: file removed.

Pre-existing unrelated failure on dev:
TestHandleAddWaitlist_EmailNormalization in internal/waitlist — flagged
to the waitlist owner, not in scope here.

* Feat/forget password (#38)

* chore: added forget password endpoint

* chore: added forget password endpoint

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* docs: document forgot/reset password endpoints and Resend mailer

* feat: added the endpoints for forget password and reset password. Gated by the admin user role

* chore: untrack .claude/, rename NoopLimiter to AllowAllLimiter

* fix(auth): cap reset password to bcrypt's 72-byte limit

* fix: password length issue

* fix(auth): align local_test.go with NewLocalHandler signature post-rebase

* ci: add lint, test and Trivy security pipeline

* ci: bump golangci-lint-action to v7, pin trivy-action to 0.24.0

* chore: fix all errcheck violations for clean lint

* fix: update to repo

* ci: add aggregator job named 'ci' to satisfy branch protection

---------

Co-authored-by: Technology Developer 3 <techdev3@tehcoop.com>

* feat(admin): super_admin endpoints to add admin + change role

Implements the two endpoints requested by the lead, sitting on top of
dev's existing OTP/role-column-on-users foundation.

POST /admin/add (super_admin only)
  - body: {email, name}
  - generates a 16-char random password (excludes confusable chars),
    bcrypt-hashes it, and upserts the target user as auth_provider=local
    with role=admin
  - emails the plaintext password via mailer.SendAdminCredentials; it is
    never logged or persisted in plaintext
  - idempotent on the same (email, local) pair — repeats rotate the
    password rather than creating duplicates

PUT /admin/{id}/role (super_admin only)
  - body: {role: "admin" | "super_admin"}
  - intentionally rejects targets wh…
@coderabbitai

coderabbitai Bot commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Apple Sign-In handler and JWT verifier, Google Meet OAuth/provider with bootstrap CLI, multi-platform meeting routing, deactivated-user middleware, new admin session ops, extended booking/contact modes, self-account lifecycle endpoints, notifications fan-out, and comprehensive schema/SQL/codegen updates.

Changes

Unified Auth, Meetings, and Admin Ops

Layer / File(s) Summary
Apple Sign-In handler and verifier
internal/auth/apple.go, pkg/apple/*, internal/routes/auth.go, api.yaml
Implements Apple JWT verification, user find/create by sub, session issuance; routes POST /auth/apple.
Google Meet integration
pkg/googlemeet/*, docs/MEET_INTEGRATION.md, cmd/meet-bootstrap/main.go, internal/config/config.go
Adds Meet OAuth client, provider for spaces create/end, bootstrap CLI, env/config wiring and runbook.
Meeting platform routing
pkg/meeting/*, internal/bookings/*, internal/discovery/handler.go, internal/zoomflow/selector.go
Introduces multi-platform selector and platform-aware create/reschedule; adds messenger handle, meet support.
Deactivation and self-account lifecycle
internal/middleware/deactivated.go, internal/routes/profile.go, api.yaml
Middleware blocks deactivated users; adds self deactivate/reactivate/delete endpoints.
Admin sessions and notifications
internal/routes/admin_sessions.go, internal/routes/admin.go, internal/notification/*, api.yaml
Adds admin list/cancel/reschedule sessions, active sessions list, and admin broadcast notifications.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API
  participant MeetingSelector
  participant GoogleMeet as Google Meet API
  participant DB
  Client->>API: POST /bookings {session_platform}
  API->>MeetingSelector: For(trainerID, platform)
  MeetingSelector-->>API: Provider (Meet/Zoom/NoOp)
  alt google_meet
    API->>GoogleMeet: POST /spaces (Bearer AT)
    GoogleMeet-->>API: meetingUri, name
    API->>DB: Insert booking with link/id
  else other/no-op
    API->>DB: Insert booking without link
  end
  API-->>Client: 201 booking payload
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • CynthiaWahome
  • Taterbro
  • ukemeikot

Poem

A rabbit taps in terminal light,
Tokens hop—apple, meet—alight.
Zooms and Meets in platform waltz,
Deactivate, then fix our faults.
Admin winds now softly blow,
Notifications thump—let’s go!
Little paws ship big PRs. 🐇✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch staging

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
cmd/seed/main.go (1)

44-50: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the hint message to include "staging".

The guard now accepts both "development" and "staging", but the hint on Line 47 only mentions "development".

📝 Proposed fix to update the hint
 	if cfg.Env != "development" && cfg.Env != "staging" {
 		slog.Error("seed script cannot run in production",
 			"got_env", cfg.Env,
-			"hint", "set APP_ENV=development if you really mean to run this against your local DB",
+			"hint", "set APP_ENV=development or APP_ENV=staging if you really mean to run this against your DB",
 		)
 		os.Exit(1)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/seed/main.go` around lines 44 - 50, The slog.Error call guarding the seed
script checks cfg.Env for both "development" and "staging" but its hint string
only mentions "development"; update the hint in the slog.Error invocation so it
references both environments (e.g., "set APP_ENV=development or APP_ENV=staging
if you really mean to run this against your local DB") to accurately reflect the
allowed values and help users run the script intentionally.
internal/bookings/handlers.go (1)

160-197: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ignore messenger_handle unless session_platform == "messenger".

The validation comment says non-messenger handles are ignored, but this path still persists any non-empty messenger_handle for Zoom/Google Meet bookings. Because CreateBookingParams is forwarded unchanged into the repository layer, that stale handle will be written to the booking row instead of being dropped.

Suggested fix
 	var messengerNS sql.NullString
-	if messengerHandle != "" {
+	if platformStr == "messenger" && messengerHandle != "" {
 		messengerNS = sql.NullString{Valid: true, String: messengerHandle}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/bookings/handlers.go` around lines 160 - 197, In
HandleCreateBookingSession the messengerHandle is always included in
db.CreateBookingParams (MessengerHandle), so non-messenger bookings can persist
a stale handle; change the assignment so MessengerHandle is only set when
platformStr == "messenger" (i.e., set messengerNS = sql.NullString{Valid:true,
String: messengerHandle} only for platformStr == "messenger"}, otherwise set
MessengerHandle to sql.NullString{Valid:false}) before constructing the
CreateBookingParams to ensure non-messenger platforms do not store the handle.
🧹 Nitpick comments (9)
internal/discovery/handler.go (1)

847-875: ⚡ Quick win

Update error message to reflect multi-platform meeting creation.

The function createMeetingWithRetry now accepts a platform parameter (line 847) and handles both Zoom and Google Meet via the provider selector. However, the final error message at line 873 still reads "zoom meeting creation failed after all retries", which is misleading when the failure occurs for a Google Meet platform. Consider making the message platform-neutral or including the platform name.

♻️ Proposed fix for a generic error message
-	h.log.Error("zoom meeting creation failed after all retries")
+	h.log.Error("meeting creation failed after all retries", "platform", platform)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/discovery/handler.go` around lines 847 - 875, The final log in
createMeetingWithRetry currently hardcodes "zoom meeting creation failed after
all retries"; update the h.log.Error call to be platform-aware by either using a
generic message like "meeting creation failed after all retries" or include the
platform variable (e.g., "meeting creation failed after all retries",
"platform", platform) so the error accurately reflects the provider being used;
locate the h.log.Error at the end of createMeetingWithRetry and replace the
static Zoom text accordingly.
internal/routes/admin.go (1)

118-130: ⚡ Quick win

Prefer the existing parsePagination helper for consistency.

This handler manually parses page and limit with inline defaults and bounds, but the file already has a parsePagination helper (used at line 196) that centralizes the same logic. Using the helper would ensure consistent validation and error responses across all admin endpoints.

♻️ Proposed refactor to use the helper
-	// Parse page/limit from query string manually since this handler has no
-	// oapi-codegen params struct. Defaults: page=1, limit=10, max limit=100.
-	pageVal := 1
-	limitVal := 10
-	if p := c.Query("page"); p != "" {
-		if n, err := strconv.Atoi(p); err == nil && n >= 1 {
-			pageVal = n
-		}
-	}
-	if l := c.Query("limit"); l != "" {
-		if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 100 {
-			limitVal = n
-		}
-	}
-	page, limit := pageVal, limitVal
+	// Parse pagination using the existing helper (returns and writes error response on failure)
+	var pagePtr, limitPtr *int
+	if p := c.Query("page"); p != "" {
+		if n, err := strconv.Atoi(p); err == nil {
+			pagePtr = &n
+		}
+	}
+	if l := c.Query("limit"); l != "" {
+		if n, err := strconv.Atoi(l); err == nil {
+			limitPtr = &n
+		}
+	}
+	page, limit, ok := parsePagination(c, pagePtr, limitPtr, s.logger)
+	if !ok {
+		return
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/routes/admin.go` around lines 118 - 130, This handler duplicates
manual pagination parsing (pageVal/limitVal) instead of reusing the existing
parsePagination helper; replace the inline parsing block that sets
pageVal/limitVal and page, limit with a call to parsePagination(c) and use its
returned (page, limit, err), returning the same error response when err != nil;
this ensures consistent validation and bounds logic across handlers and matches
other uses (see parsePagination usage at line ~196).
internal/notification/service.go (1)

108-149: 💤 Low value

Consider standardizing idempotency key base naming.

The suffix pattern "-admin-" + adminID.String() is correct for avoiding UNIQUE constraint collisions. However, some callers pass base keys that already contain "admin" (e.g., "zoom-connected-admin-<userID>-<ts>" in zoom_oauth.go:207), which produces keys like "zoom-connected-admin-<userID>-<ts>-admin-<adminID>". While functionally correct, the redundant "admin" token is confusing.

Consider establishing a naming convention where base keys for admin broadcasts omit role indicators (e.g., "zoom-connected-<userID>-<ts>" instead of "zoom-connected-admin-<userID>-<ts>"), since the per-admin suffix already provides that context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/notification/service.go` around lines 108 - 149,
SendNotificationToAdmins builds per-admin idempotency keys by appending
"-admin-<userID>" to idempotencyKeyBase, but some callers already include
"admin" in their base (e.g., zoom-connected-admin-...), causing redundant
tokens; update SendNotificationToAdmins to normalize idempotencyKeyBase before
appending by stripping a trailing "-admin" (or any trailing
"-admin-<something>") so the final key is always "<base>-admin-<adminID>";
locate the normalization in SendNotificationToAdmins (which constructs key :=
idempotencyKeyBase + "-admin-" + adminID.String()) and replace it with a small
sanitizer that removes a trailing "-admin" segment from idempotencyKeyBase (or
document and update callers to omit role indicators instead) while preserving
existing duplicate-key handling via ErrDuplicateIdempotencyKey and
SendNotificationToUser.
internal/auth/password_reset.go (2)

414-489: ⚡ Quick win

Consider extracting shared validation and eligibility logic.

HandleVerifyResetCode duplicates validation (email/code format), rate limiting setup, and user eligibility checks from HandleResetPassword. For security-critical password reset flows, keeping these validation rules in sync is important for maintaining consistent behavior.

Extracting the shared logic (email/code validation at lines 427-440, rate limiting at lines 442-456, and eligibility checks at lines 468-480) into helper methods would reduce duplication and ensure consistency when either handler is updated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/password_reset.go` around lines 414 - 489,
HandleVerifyResetCode duplicates email/code validation, rate-limiter checks, and
user eligibility logic found in HandleResetPassword; extract those into shared
helper methods on PasswordResetHandler (e.g., ValidateResetRequest(email, code)
-> ([]api.FieldError, error), EnforceResetRateLimits(ctx, clientIP, email) ->
(allowed bool, err error) or return api response error, and
CheckResetEligibility(ctx, email) -> (*User, error)) and call them from both
handlers; ensure helpers perform the same checks (email/code format,
resetIPLimiter and resetLimiter Allow/Reset behavior, and user role/IsActive
checks against providerLocal/adminRoleName/superAdminRoleName/trainerRoleName)
and return consistent API errors so both HandleVerifyResetCode and
HandleResetPassword remain behaviorally identical.

414-489: ⚖️ Poor tradeoff

Reassess rate-limiter reset impact in /auth/verify-reset-code flow

HandleVerifyResetCode calls h.resetLimiter.Allow(emailAddr) and then—only after VerifyCode(...) succeeds and the same active/admin/trainer role gate passes—resets the per-email bucket via h.resetLimiter.Reset(...) (line ~484). The HandleResetPassword endpoint also consumes the OTP on success (ConsumeCodeAndUpdatePassword), and resets h.resetLimiter only after a successful reset.

Because the reset code is consumed on successful HandleResetPassword, a holder of a valid code effectively gains at most one successful password reset per issued OTP; repeatedly calling verify to “refresh” the limiter doesn’t meaningfully increase the number of successful resets per code. The main concern would be limited edge cases where HandleResetPassword fails without consuming the code—then repeated verify calls could undermine the intended throttle.

Consider using a separate limiter for the verify endpoint (or avoid resetting the reset-password limiter key on verify) if you want the limiter to apply uniformly even across those failure scenarios.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/password_reset.go` around lines 414 - 489,
HandleVerifyResetCode currently calls h.resetLimiter.Allow(...) and then resets
that same limiter via h.resetLimiter.Reset(...) after VerifyCode and role
checks, which can let repeated verifies refresh the allowance and undermine
throttling intended for HandleResetPassword (which consumes codes via
ConsumeCodeAndUpdatePassword). Fix by separating the verify and reset-password
rate limits: introduce and use a distinct limiter for verification (e.g.,
h.verifyLimiter.Allow/Reset) or stop calling h.resetLimiter.Reset in
HandleVerifyResetCode so the password-reset limiter remains authoritative;
update HandleVerifyResetCode to use the new limiter (or remove the reset call)
and ensure HandleResetPassword continues to use h.resetLimiter and calls Reset
only after successful ConsumeCodeAndUpdatePassword.
pkg/googlemeet/oauth.go (1)

134-144: ⚡ Quick win

Parse JSON before checking for "invalid_grant" to avoid false positives.

Line 140 searches for "invalid_grant" in the raw response body string before parsing JSON. This could match the substring in unexpected places (e.g., error messages mentioning "invalid_grant" as an example). Parse the JSON structure first and check the error field properly.

♻️ Proposed fix
 	raw, _ := io.ReadAll(resp.Body)
 	if resp.StatusCode >= 400 {
-		// invalid_grant is the specific failure operators need to act
-		// on (refresh token revoked; bootstrap helper must be re-run).
-		// Distinguish from generic 4xx/5xx so the handler can show a
-		// useful message without parsing JSON.
-		if strings.Contains(string(raw), "invalid_grant") {
-			return "", time.Time{}, ErrTokenRevoked
+		var errResp struct {
+			Error string `json:"error"`
+		}
+		if err := json.Unmarshal(raw, &errResp); err == nil && errResp.Error == "invalid_grant" {
+			return "", time.Time{}, ErrTokenRevoked
 		}
 		return "", time.Time{}, fmt.Errorf("googlemeet: token endpoint %d: %s", resp.StatusCode, string(raw))
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/googlemeet/oauth.go` around lines 134 - 144, The current logic reads
resp.Body into raw and searches the raw string for "invalid_grant" which can
produce false positives; instead, after reading raw (or by decoding resp.Body),
unmarshal the JSON into a small struct (e.g., struct{ Error string
`json:"error"`; ErrorDescription string `json:"error_description"` }) and
inspect the Error field for the value "invalid_grant" before returning
ErrTokenRevoked; if JSON decoding fails, fall back to the existing behavior of
returning a formatted error with resp.StatusCode and the raw body. Update the
error-handling block in pkg/googlemeet/oauth.go where resp.Body is read
(variables raw, resp) and where ErrTokenRevoked is returned.
internal/auth/local.go (1)

266-282: 💤 Low value

Track the codegen synchronization debt.

The local request struct workaround (lines 274-277) avoids codegen drift but bypasses type safety. This pattern should be temporary—consider opening an issue to track re-running codegen after resolving the PR #286 conflicts mentioned in the comment, so future changes benefit from the generated types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/local.go` around lines 266 - 282, The local inline request
struct (var req struct { Email string `json:"email"`; Password string
`json:"password"` }) used before c.ShouldBindJSON in the sign-in handler avoids
codegen drift but should be temporary; create a tracked issue in your repo
referencing the handler (the function containing c.ShouldBindJSON and
h.log.Warn) to re-run the API code generator and restore use of the generated
api.HandleLocalAuthJSONRequestBody type once PR `#286` conflicts are resolved, and
add a TODO comment linking that issue ID next to the inline req declaration so
future contributors know to replace the local struct with the generated type.
pkg/googlemeet/provider.go (2)

80-82: ⚡ Quick win

Silently discarding io.ReadAll errors can mask I/O problems.

The error returned by io.ReadAll is discarded with _ on Line 80 and Line 150. If the read fails (e.g., connection drop mid-response), the error message will contain partial or empty body text, making troubleshooting harder.

🔍 Proposed fix to log read errors
-	raw, _ := io.ReadAll(resp.Body)
+	raw, readErr := io.ReadAll(resp.Body)
+	if readErr != nil {
+		return "", "", fmt.Errorf("googlemeet: create space read error: %w", readErr)
+	}
	if resp.StatusCode >= 400 {
		return "", "", fmt.Errorf("googlemeet: create space %d: %s", resp.StatusCode, string(raw))
	}

Apply the same pattern to Line 150 in DeleteMeeting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/googlemeet/provider.go` around lines 80 - 82, The code in CreateSpace
(the block returning fmt.Errorf("googlemeet: create space...")) and in
DeleteMeeting is discarding the error from io.ReadAll; change the io.ReadAll
call to capture the error (raw, err := io.ReadAll(resp.Body)), check err and
include that error information in the returned error message (or wrap it) so the
final fmt.Errorf for both CreateSpace and DeleteMeeting contains the response
body and any read error details to avoid silencing I/O failures.

151-152: 💤 Low value

String-based error detection is fragile but acceptable for this specific API contract.

The check strings.Contains(string(raw), "no active conference") on Line 151 treats a specific 400 error as success. This is brittle—if Google changes the error message wording, the condition will miss it and return an error instead of silently succeeding. However, since this matches the Zoom provider's defensive DeleteMeeting behavior (comment on Line 114-116), and the consequence of a miss is just a logged warning (no data corruption), this is acceptable.

Consider documenting the exact error string returned by Google in a comment to aid future troubleshooting if the check breaks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/googlemeet/provider.go` around lines 151 - 152, The fragile string check
in the DeleteMeeting flow (the resp.StatusCode == http.StatusBadRequest &&
strings.Contains(string(raw), "no active conference") branch in provider.go)
should be preserved but explicitly documented: add a comment above that
condition noting the exact Google error text being matched ("no active
conference") and why we accept it (mirrors Zoom provider DeleteMeeting behavior
and only results in a warning). Mention the HTTP 400 status and that this is a
defensive check for the provider's API contract so future readers can
troubleshoot if Google changes the wording.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Line 71: There are duplicate declarations of the environment variable
APPLE_SIGN_IN_BUNDLE_IDS which causes the later (often blank) entry to silently
override the earlier one; remove the redundant declaration so only a single
canonical APPLE_SIGN_IN_BUNDLE_IDS entry remains in the .env.example, keep the
intended value/comment in the chosen section, and ensure any related
documentation or example comments reference that single declaration to prevent
accidental overrides.

In `@api.yaml`:
- Around line 7463-7483: The docs currently instruct clients to send the real
access_token via the query parameter named "token" (and to reuse the normal
access_token), which leaks credentials; update the OpenAPI parameters so the
"token" query parameter is removed or reworded to forbid using the long-lived
access_token, and replace guidance with recommending a short-lived WebSocket
ticket or same-site cookie for browser/React Native clients while continuing to
prefer the "Authorization" header for server-side callers; ensure the parameter
descriptions for "token" and "Authorization" (the symbols to change) clearly
state not to send bearer tokens in URLs and show the secure alternatives.
- Around line 4439-4443: The spec is inconsistent: the new "verify reset code"
step describes trainer users using a forgot-password flow but the downstream
POST /auth/reset-password remains documented as admin-only; reconcile by either
(A) updating POST /auth/reset-password to accept the trainer/forgot-password
flow (remove or relax admin-only constraints and document parameters/permissions
for user self-service reset) or (B) restrict the new verify-reset-code
description to admin-initiated resets only; update the descriptive text for the
verify-reset-code step and the POST /auth/reset-password operation so both refer
to the same actor/permission model (reference the verify-reset-code description
block and the POST /auth/reset-password operation name/path when applying the
change).
- Around line 1538-1546: The OpenAPI schema's password property incorrectly uses
maxLength: 72 (characters) but bcrypt limits the password to 72 UTF-8 bytes;
update the password schema (the "password" property) to remove or avoid relying
on maxLength for bytes, amend the description to state "bcrypt truncates to 72
bytes of the UTF-8-encoded password" and instruct callers that the server
validates by UTF-8 byte length, and optionally add a vendor extension (e.g.,
x-max-bytes: 72) or note that clients should validate UTF-8 byte length
themselves so generated clients don't assume a 72-character limit.

In `@cmd/createTrainers/main.go`:
- Around line 154-156: The shared HTTP client "client" used with
generateAccessToken and later trainer-create requests has no timeout and can
hang the whole batch; update the client initialization (the "client" variable)
to include a sensible Timeout (e.g. http.Client{Timeout: 10 * time.Second} or a
configurable value) or ensure each request uses a context with deadline, then
pass that client into generateAccessToken and downstream request functions so
all HTTP calls (login and trainer-create) respect the timeout.
- Around line 295-323: The non-201 branch leaks the HTTP response body for
status codes other than 400/409 because res.Body is only closed in some paths;
ensure res.Body.Close() is called on every non-201 path (including the generic
failure branch after appendIntoFailedTrainer and before continue) and handle its
error correctly (use the close error value, not the wrong variable like err).
Update the logic around ValidationErrorResponse handling and the generic failure
branch in the create trainers loop (references: res.Body.Close,
appendIntoFailedTrainer, ValidationErrorResponse) so every early continue/return
closes the body and reports any close error.

In `@internal/auth/auth_service.go`:
- Around line 63-96: GenerateTestTokens currently assigns a uuid.UUID directly
to the "sub" claim in accessClaims and refreshClaims which will JSON-marshal
incorrectly; convert the UUID to its string form before assignment (e.g., use
userId.String()) so "sub" is a StringOrURI like GenerateJWTToken uses, and
ensure both accessClaims and refreshClaims use the string value.

In `@internal/discovery/handler.go`:
- Around line 115-122: The error message returned when validating contact modes
is missing "imessage"; update the validation error in the branch that calls
h.log.Warn and c.JSON(api.NewError(...)) so the message lists all five accepted
modes: "zoom_meeting, google_meet, phone_callback, messenger, or imessage". Keep
the switch on mode as-is (cases: "zoom_meeting", "phone_callback",
"google_meet", "messenger", "imessage") and only change the text passed to
api.NewError (and any user-facing log if desired) to reflect the complete set of
allowed modes.

In `@internal/models/queries/booking_slots.sql`:
- Around line 14-30: The booking-slots query GetTrainersBookingSlots
(internal/models/queries/booking_slots.sql) correctly filters by t.is_available
to control what is bookable, but the 15-minute cache around
HandleGetTrainersBookingSlots (internal/bookings/handlers.go) causes stale
visible slots after toggling a trainer; fix by invalidating or refreshing that
cache when a trainer's is_available flag changes (e.g., emit cache invalidation
on the trainer update path or reduce TTL), and ensure booking listing queries
(ListBookingsForAdmin, ListActiveBookingsForAdmin, ListBookingsByTrainer in
internal/models/queries/bookings.sql) remain unchanged so existing/past bookings
are still returned.

In `@internal/repository/db/users.sql.go`:
- Around line 476-512: UpdateTrainerUserProfile's SQL (updateTrainerUserProfile)
and its scanner are missing apple_user_id/AppleUserID in the RETURNING and Scan,
causing incomplete User results; update the SQL RETURNING clause to include
apple_user_id and regenerate the queries file, then update the
UpdateTrainerUserProfile method to scan the apple_user_id into the
User.AppleUserID field (match the position/order used by other user queries like
CreateUser/UpdateUserOnboarding/CreateAppleUser) so the returned User struct is
populated consistently.

In `@internal/routes/admin_subscriptions.go`:
- Line 32: GetAdminSubscriptions currently ignores the OpenAPI-bound params
parameter and re-reads query values from gin.Context; update the handler to use
params.Status, params.Page and params.Limit (or pass params into the existing
parse/validation helper) instead of calling c.Query or parseAdminPagination(c),
remove the unused params read duplication and ensure any pagination/validation
logic is centralized (e.g., adapt parseAdminPagination to accept
api.GetAdminSubscriptionsParams or validate params before use) so the function
signature and generated binding are actually used.

In `@internal/routes/admin_transactions.go`:
- Line 33: GetAdminTransactions (and similarly GetAdminSubscriptions) currently
ignores the provided api.GetAdminTransactionsParams /
api.GetAdminSubscriptionsParams and instead reads pagination from c.Query;
update the handlers to use params.Page and params.Limit (or change
parseAdminPagination to accept page/limit) so pagination comes from the
oapi-codegen params. Concretely: in GetAdminTransactions and
GetAdminSubscriptions replace calls that parse c.Query("page")/c.Query("limit")
by passing params.Page and params.Limit into the pagination helper (or update
parseAdminPagination signature to accept these values and call it with
params.Page/params.Limit), and remove any unused references to c.Query and the
unused params warning.

In `@internal/routes/profile.go`:
- Around line 201-210: The current handling of sql.ErrNoRows from
s.users.q.DeactivateSelf is ambiguous because DeactivateSelf filters on
is_active and can return no rows for either a non-existent user or an
already-deactivated user; update the flow to first call
s.users.q.GetUserByID(ctx, userID) when DeactivateSelf returns sql.ErrNoRows,
return 404 Not Found if GetUserByID also returns sql.ErrNoRows, otherwise return
409 Conflict with "account is already deactivated"; keep existing 500 handling
and logging for other DB errors and log contextual info (userID, err) when
returning 500.
- Around line 230-239: The sql.ErrNoRows from ReactivateSelf is ambiguous (could
mean user doesn't exist or is already active); update the handler to distinguish
those cases by, on ErrNoRows, querying user existence explicitly (e.g., call
s.users.q.GetByID or a similar lookup with the same userID) and return 404 Not
Found if that lookup returns sql.ErrNoRows, otherwise return 409 Conflict with
"account is already active"; keep existing logging and 500 handling for other
errors.

In `@internal/routes/trainers.go`:
- Around line 976-1001: The two separate DB calls (s.trainers.q.UpdateTrainer
and s.trainers.q.UpdateTrainerUserProfile) must be executed inside a single
transaction so the whole profile patch is atomic: open a transaction via your
DB/query layer (e.g. start tx or use the generated q.WithTx / Store.BeginTx
helper), run the UpdateTrainer call using the tx-scoped queries, then if
body.PhoneNumber != nil run UpdateTrainerUserProfile also on the same tx; on any
error rollback and return the 500, otherwise commit the tx and return success.
Ensure you use the tx-scoped query methods corresponding to UpdateTrainer and
UpdateTrainerUserProfile and reference trainerID/updated.UserID as before.
- Around line 993-998: The patch writes any trimmed phone string to
UpdateTrainerUserProfile without validating, so add the same E.164 validation
used in CreateTrainer: after computing phoneVal from body.PhoneNumber (in the
block that calls s.trainers.q.UpdateTrainerUserProfile), test phoneVal against
trainerPhoneE164Regex and if it does not match return a 400/validation error
(consistent with CreateTrainer's behavior) instead of calling
UpdateTrainerUserProfile; only call s.trainers.q.UpdateTrainerUserProfile when
the regex check passes.
- Around line 871-883: The PATCH handler currently uses a plain bool in the
request body so a missing is_available binds as false; change the anonymous body
struct's IsAvailable to *bool, keep using c.ShouldBindJSON(&body), then validate
that body.IsAvailable != nil and return a 400 (with the same logging/message) if
nil; when calling trainers.q.ToggleTrainerAvailability use the dereferenced
value for IsAvailable (e.g., *body.IsAvailable) so the update only runs when the
field was provided.

In `@migrations/000058_extend_booking_platforms_messenger_meet.sql`:
- Around line 44-48: Before re-adding the old check constraint in the down
migration, update any rows where bookings.session_platform = 'messenger' to a
permitted value (e.g., 'whatsapp' or 'zoom') so they won't violate the restored
constraint; specifically, add an UPDATE on the bookings table to remap
'messenger' values prior to executing ALTER TABLE bookings ADD CONSTRAINT
bookings_session_platform_check CHECK (session_platform IN ('whatsapp',
'google_meet', 'zoom')), and then drop the messenger_handle column/constraint as
currently written.

In `@pkg/googlemeet/oauth.go`:
- Around line 90-111: The AccessToken method currently holds OAuthClient.mu
across the network call to refresh(), causing serialized callers; change to a
double-checked locking pattern in AccessToken: acquire mu and check
accessToken/expiresAt, if expired then release mu, call c.refresh(ctx) (no
lock), then re-acquire mu and re-check accessToken/expiresAt in case another
goroutine refreshed in the meantime; only then assign c.accessToken and
c.expiresAt (or return the already-updated token) and release mu. Ensure you
still return errors from refresh and preserve the 60s skew logic used when
checking expiry.

---

Outside diff comments:
In `@cmd/seed/main.go`:
- Around line 44-50: The slog.Error call guarding the seed script checks cfg.Env
for both "development" and "staging" but its hint string only mentions
"development"; update the hint in the slog.Error invocation so it references
both environments (e.g., "set APP_ENV=development or APP_ENV=staging if you
really mean to run this against your local DB") to accurately reflect the
allowed values and help users run the script intentionally.

In `@internal/bookings/handlers.go`:
- Around line 160-197: In HandleCreateBookingSession the messengerHandle is
always included in db.CreateBookingParams (MessengerHandle), so non-messenger
bookings can persist a stale handle; change the assignment so MessengerHandle is
only set when platformStr == "messenger" (i.e., set messengerNS =
sql.NullString{Valid:true, String: messengerHandle} only for platformStr ==
"messenger"}, otherwise set MessengerHandle to sql.NullString{Valid:false})
before constructing the CreateBookingParams to ensure non-messenger platforms do
not store the handle.

---

Nitpick comments:
In `@internal/auth/local.go`:
- Around line 266-282: The local inline request struct (var req struct { Email
string `json:"email"`; Password string `json:"password"` }) used before
c.ShouldBindJSON in the sign-in handler avoids codegen drift but should be
temporary; create a tracked issue in your repo referencing the handler (the
function containing c.ShouldBindJSON and h.log.Warn) to re-run the API code
generator and restore use of the generated api.HandleLocalAuthJSONRequestBody
type once PR `#286` conflicts are resolved, and add a TODO comment linking that
issue ID next to the inline req declaration so future contributors know to
replace the local struct with the generated type.

In `@internal/auth/password_reset.go`:
- Around line 414-489: HandleVerifyResetCode duplicates email/code validation,
rate-limiter checks, and user eligibility logic found in HandleResetPassword;
extract those into shared helper methods on PasswordResetHandler (e.g.,
ValidateResetRequest(email, code) -> ([]api.FieldError, error),
EnforceResetRateLimits(ctx, clientIP, email) -> (allowed bool, err error) or
return api response error, and CheckResetEligibility(ctx, email) -> (*User,
error)) and call them from both handlers; ensure helpers perform the same checks
(email/code format, resetIPLimiter and resetLimiter Allow/Reset behavior, and
user role/IsActive checks against
providerLocal/adminRoleName/superAdminRoleName/trainerRoleName) and return
consistent API errors so both HandleVerifyResetCode and HandleResetPassword
remain behaviorally identical.
- Around line 414-489: HandleVerifyResetCode currently calls
h.resetLimiter.Allow(...) and then resets that same limiter via
h.resetLimiter.Reset(...) after VerifyCode and role checks, which can let
repeated verifies refresh the allowance and undermine throttling intended for
HandleResetPassword (which consumes codes via ConsumeCodeAndUpdatePassword). Fix
by separating the verify and reset-password rate limits: introduce and use a
distinct limiter for verification (e.g., h.verifyLimiter.Allow/Reset) or stop
calling h.resetLimiter.Reset in HandleVerifyResetCode so the password-reset
limiter remains authoritative; update HandleVerifyResetCode to use the new
limiter (or remove the reset call) and ensure HandleResetPassword continues to
use h.resetLimiter and calls Reset only after successful
ConsumeCodeAndUpdatePassword.

In `@internal/discovery/handler.go`:
- Around line 847-875: The final log in createMeetingWithRetry currently
hardcodes "zoom meeting creation failed after all retries"; update the
h.log.Error call to be platform-aware by either using a generic message like
"meeting creation failed after all retries" or include the platform variable
(e.g., "meeting creation failed after all retries", "platform", platform) so the
error accurately reflects the provider being used; locate the h.log.Error at the
end of createMeetingWithRetry and replace the static Zoom text accordingly.

In `@internal/notification/service.go`:
- Around line 108-149: SendNotificationToAdmins builds per-admin idempotency
keys by appending "-admin-<userID>" to idempotencyKeyBase, but some callers
already include "admin" in their base (e.g., zoom-connected-admin-...), causing
redundant tokens; update SendNotificationToAdmins to normalize
idempotencyKeyBase before appending by stripping a trailing "-admin" (or any
trailing "-admin-<something>") so the final key is always
"<base>-admin-<adminID>"; locate the normalization in SendNotificationToAdmins
(which constructs key := idempotencyKeyBase + "-admin-" + adminID.String()) and
replace it with a small sanitizer that removes a trailing "-admin" segment from
idempotencyKeyBase (or document and update callers to omit role indicators
instead) while preserving existing duplicate-key handling via
ErrDuplicateIdempotencyKey and SendNotificationToUser.

In `@internal/routes/admin.go`:
- Around line 118-130: This handler duplicates manual pagination parsing
(pageVal/limitVal) instead of reusing the existing parsePagination helper;
replace the inline parsing block that sets pageVal/limitVal and page, limit with
a call to parsePagination(c) and use its returned (page, limit, err), returning
the same error response when err != nil; this ensures consistent validation and
bounds logic across handlers and matches other uses (see parsePagination usage
at line ~196).

In `@pkg/googlemeet/oauth.go`:
- Around line 134-144: The current logic reads resp.Body into raw and searches
the raw string for "invalid_grant" which can produce false positives; instead,
after reading raw (or by decoding resp.Body), unmarshal the JSON into a small
struct (e.g., struct{ Error string `json:"error"`; ErrorDescription string
`json:"error_description"` }) and inspect the Error field for the value
"invalid_grant" before returning ErrTokenRevoked; if JSON decoding fails, fall
back to the existing behavior of returning a formatted error with
resp.StatusCode and the raw body. Update the error-handling block in
pkg/googlemeet/oauth.go where resp.Body is read (variables raw, resp) and where
ErrTokenRevoked is returned.

In `@pkg/googlemeet/provider.go`:
- Around line 80-82: The code in CreateSpace (the block returning
fmt.Errorf("googlemeet: create space...")) and in DeleteMeeting is discarding
the error from io.ReadAll; change the io.ReadAll call to capture the error (raw,
err := io.ReadAll(resp.Body)), check err and include that error information in
the returned error message (or wrap it) so the final fmt.Errorf for both
CreateSpace and DeleteMeeting contains the response body and any read error
details to avoid silencing I/O failures.
- Around line 151-152: The fragile string check in the DeleteMeeting flow (the
resp.StatusCode == http.StatusBadRequest && strings.Contains(string(raw), "no
active conference") branch in provider.go) should be preserved but explicitly
documented: add a comment above that condition noting the exact Google error
text being matched ("no active conference") and why we accept it (mirrors Zoom
provider DeleteMeeting behavior and only results in a warning). Mention the HTTP
400 status and that this is a defensive check for the provider's API contract so
future readers can troubleshoot if Google changes the wording.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b0e85f37-5e7d-42fa-b893-394cbc9a1a4e

📥 Commits

Reviewing files that changed from the base of the PR and between f701e7e and af6df8d.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (80)
  • .env.example
  • .gitignore
  • .gitleaksignore
  • api.yaml
  • cmd/createTrainers/main.go
  • cmd/meet-bootstrap/main.go
  • cmd/seed/main.go
  • docs/MEET_INTEGRATION.md
  • go.mod
  • internal/api/gen.go
  • internal/auth/admin_login_test.go
  • internal/auth/apple.go
  • internal/auth/apple_test.go
  • internal/auth/auth_service.go
  • internal/auth/google_test.go
  • internal/auth/local.go
  • internal/auth/local_test.go
  • internal/auth/password_reset.go
  • internal/auth/repository.go
  • internal/auth/sign_in_test.go
  • internal/bookings/handler.go
  • internal/bookings/handlers.go
  • internal/bookings/service.go
  • internal/config/config.go
  • internal/dev/handler.go
  • internal/discovery/handler.go
  • internal/middleware/admin_only.go
  • internal/middleware/deactivated.go
  • internal/models/queries/booking_slots.sql
  • internal/models/queries/bookings.sql
  • internal/models/queries/discovery.sql
  • internal/models/queries/password_reset.sql
  • internal/models/queries/roles.sql
  • internal/models/queries/subscriptions.sql
  • internal/models/queries/trainers.sql
  • internal/models/queries/users.sql
  • internal/notification/admin_broadcast_test.go
  • internal/notification/helpers_test.go
  • internal/notification/repository.go
  • internal/notification/service.go
  • internal/repository/db/account_setup.sql.go
  • internal/repository/db/booking_slots.sql.go
  • internal/repository/db/bookings.sql.go
  • internal/repository/db/discovery.sql.go
  • internal/repository/db/models.go
  • internal/repository/db/password_reset.sql.go
  • internal/repository/db/reviews.sql.go
  • internal/repository/db/roles.sql.go
  • internal/repository/db/subscriptions.sql.go
  • internal/repository/db/trainers.sql.go
  • internal/repository/db/users.sql.go
  • internal/routes/admin.go
  • internal/routes/admin_sessions.go
  • internal/routes/admin_subscriptions.go
  • internal/routes/admin_transactions.go
  • internal/routes/auth.go
  • internal/routes/dev.go
  • internal/routes/profile.go
  • internal/routes/routes.go
  • internal/routes/subscriptions.go
  • internal/routes/trainers.go
  • internal/routes/zoom_oauth.go
  • internal/zoomflow/selector.go
  • migrations/000058_extend_booking_platforms_messenger_meet.sql
  • migrations/000060_user_account_management.sql
  • migrations/000061_cascade_user_hard_delete.sql
  • migrations/000062_cascade_booking_hard_delete.sql
  • migrations/000063_add_apple_user_id_to_users.sql
  • migrations/000064_add_is_available_to_trainers.sql
  • oapi-codegen.yaml
  • pkg/apple/verifier.go
  • pkg/apple/verifier_test.go
  • pkg/email/email.go
  • pkg/googlemeet/oauth.go
  • pkg/googlemeet/oauth_test.go
  • pkg/googlemeet/provider.go
  • pkg/googlemeet/provider_test.go
  • pkg/meeting/multi_platform_test.go
  • pkg/meeting/selector.go
  • pkg/meeting/selector_test.go

Comment thread .env.example
# Apple's public keys (JWKS) are fetched automatically from
# https://appleid.apple.com/auth/keys — there is no client secret to set
# on the server side.
APPLE_SIGN_IN_BUNDLE_IDS=

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove duplicated APPLE_SIGN_IN_BUNDLE_IDS to avoid silent override.

APPLE_SIGN_IN_BUNDLE_IDS is declared twice (Line 71 and Line 200). In .env parsing, the later key wins, so a value set in the first occurrence can be unintentionally overridden by the second (often blank), breaking Apple sign-in audience validation. Keep exactly one declaration and one canonical section.

Also applies to: 200-200

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example at line 71, There are duplicate declarations of the environment
variable APPLE_SIGN_IN_BUNDLE_IDS which causes the later (often blank) entry to
silently override the earlier one; remove the redundant declaration so only a
single canonical APPLE_SIGN_IN_BUNDLE_IDS entry remains in the .env.example,
keep the intended value/comment in the chosen section, and ensure any related
documentation or example comments reference that single declaration to prevent
accidental overrides.

Comment thread api.yaml
Comment on lines +1538 to +1546
password:
type: string
minLength: 1
maxLength: 72
description: |
The user's password. Capped at 72 bytes because
bcrypt silently truncates beyond that — accepting
longer inputs would let any password sharing the
first 72 bytes authenticate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate api.yaml and show the referenced section with line numbers
ls -la
fd -n 'api.yaml' . 2>/dev/null || true
# Use rg to find the specific maxLength: 72 near bcrypt description
rg -n --fixed-string "maxLength: 72" -S . -g'api.yaml' || true
# Show around the match with line numbers
file=$(fd -n 'api.yaml' . 2>/dev/null | head -n 1 || true)
if [ -z "${file:-}" ]; then
  echo "api.yaml not found via fd -n; falling back to rg results."
  file=$(rg -l --fixed-string "maxLength: 72" . -g'*.yaml' | head -n 1 || true)
fi
echo "Using file: ${file:-<none>}"
if [ -n "${file:-}" ]; then
  # print around the assumed lines 1538-1546
  sed -n '1525,1565p' "$file" | cat -n
  # also show exact lines with numbers from grep
  nl -ba "$file" | sed -n '1530,1560p'
fi

Repository: hngprojects/personal-trainer-be

Length of output: 1984


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate api.yaml and show the referenced section with line numbers
ls -la
fd -n 'api.yaml' . 2>/dev/null || true

# Use rg to find the specific maxLength: 72 near bcrypt description
rg -n --fixed-string "maxLength: 72" -S . -g'api.yaml' || true

# Show around the match with line numbers
file=$(fd -n 'api.yaml' . 2>/dev/null | head -n 1 || true)
if [ -z "${file:-}" ]; then
  echo "api.yaml not found via fd -n; falling back to rg results."
  file=$(rg -l --fixed-string "maxLength: 72" . -g'*.yaml' | head -n 1 || true)
fi
echo "Using file: ${file:-<none>}"
if [ -n "${file:-}" ]; then
  # print around the assumed lines 1538-1546
  sed -n '1525,1565p' "$file" | cat -n
  # also show exact lines with numbers from grep
  nl -ba "$file" | sed -n '1530,1560p'
fi

Repository: hngprojects/personal-trainer-be

Length of output: 1984


🌐 Web query:

OpenAPI Specification maxLength counts characters not bytes UTF-8

💡 Result:

In the OpenAPI Specification, the maxLength keyword counts characters, not bytes [1][2][3]. Because OpenAPI relies on JSON Schema for its validation keywords, maxLength is defined as the number of Unicode code points (logical characters) in a string instance [1][2]. This means that multibyte characters (such as those in UTF-8 that require more than one byte) are counted as a single character rather than their byte-length equivalent [1]. There is a distinct functional difference between character-based length and byte-based length: 1. Character Count (maxLength): This is the standard behavior in OpenAPI for string types, measuring the number of logical characters [1][2]. 2. Byte Count: OpenAPI does not provide a native property to constrain the byte-length of general strings [1]. For scenarios where byte-length validation is required (such as for binary data or specific database constraints), developers must often rely on custom validation, format-specific logic, or specialized extensions (e.g., custom tags used in frameworks like Kubernetes) [4][3][5]. An exception exists for unencoded binary data, where the specification explicitly notes that length is measured in octets (bytes) [6][7][8]. However, for standard string types, implementations that conflate character length with byte length (e.g., in generated client code) are often considered to be handling the specification incorrectly [3].

Citations:


🌐 Web query:

bcrypt truncates password at 72 bytes behavior accepts longer inputs first 72 bytes

💡 Result:

The bcrypt algorithm has a hard limit of 72 bytes for passwords, a constraint originating from the underlying Blowfish cipher's P-box size [1][2][3]. The behavior regarding inputs longer than 72 bytes varies by implementation: 1. Traditional/Reference Behavior: The original OpenBSD implementation and many older libraries silently truncate passwords at the 72-byte mark, using only the first 72 bytes for hashing [4][1][5]. 2. Modern/Stricter Behavior: Many modern libraries (such as pyca/bcrypt, golang/crypto, and others) have updated their behavior to explicitly reject inputs exceeding 72 bytes by throwing an error (e.g., ValueError or a specific error object) [4][3][6][5]. This change was introduced to prevent user confusion and ensure that developers are aware that trailing characters are not contributing to the security of the hash [5]. Important Technical Considerations: - Byte vs. Character: The 72-byte limit applies to the number of bytes after UTF-8 encoding, not the number of characters [7]. Multibyte characters (such as emojis or certain non-Latin scripts) can cause a string to exceed 72 bytes even if it contains fewer than 72 characters [7]. - Security Recommendation: To securely handle passwords longer than 72 bytes, a common best practice is to pre-hash the password using a cryptographic hash function (like SHA-256) and then pass the resulting digest (encoded as a string or base64) to bcrypt [4][1][2]. This ensures the entire input is accounted for while remaining within the bcrypt byte limit. Top results: [4][1][7][3][6][5]

Citations:


maxLength: 72 misrepresents bcrypt’s 72-byte UTF-8 limit

OpenAPI’s maxLength constrains string length by characters (code points), not UTF-8 bytes. bcrypt’s 72-byte cutoff is based on the byte length of the UTF-8-encoded password, so inputs with <72 characters but >72 bytes can still be truncated (or rejected depending on the bcrypt library), while generated clients may accept them—contradicting the behavior described in the schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api.yaml` around lines 1538 - 1546, The OpenAPI schema's password property
incorrectly uses maxLength: 72 (characters) but bcrypt limits the password to 72
UTF-8 bytes; update the password schema (the "password" property) to remove or
avoid relying on maxLength for bytes, amend the description to state "bcrypt
truncates to 72 bytes of the UTF-8-encoded password" and instruct callers that
the server validates by UTF-8 byte length, and optionally add a vendor extension
(e.g., x-max-bytes: 72) or note that clients should validate UTF-8 byte length
themselves so generated clients don't assume a 72-character limit.

Comment thread api.yaml
Comment on lines +4439 to +4443
description: |
Confirms that the 6-digit OTP code sent to an email address is valid
and has not expired, without consuming it. Use this between the
forgot-password and reset-password steps so clients can validate the
code before asking the user to enter a new password.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The published reset flow is now internally contradictory.

This new step positions trainer users inside the forgot-password flow, but the downstream POST /auth/reset-password docs still describe admin-only resets. As written, the spec tells trainer clients to verify a reset code they apparently cannot use in the final step.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api.yaml` around lines 4439 - 4443, The spec is inconsistent: the new "verify
reset code" step describes trainer users using a forgot-password flow but the
downstream POST /auth/reset-password remains documented as admin-only; reconcile
by either (A) updating POST /auth/reset-password to accept the
trainer/forgot-password flow (remove or relax admin-only constraints and
document parameters/permissions for user self-service reset) or (B) restrict the
new verify-reset-code description to admin-initiated resets only; update the
descriptive text for the verify-reset-code step and the POST
/auth/reset-password operation so both refer to the same actor/permission model
(reference the verify-reset-code description block and the POST
/auth/reset-password operation name/path when applying the change).

Comment thread api.yaml
Comment on lines +7463 to +7483
parameters:
- name: token
in: query
required: false
description: |
Access token (the same `access_token` returned by login).
Used when the client cannot set the Authorization header on
the WebSocket upgrade request — primarily browsers and React
Native. Server-side clients SHOULD use the Authorization
header instead.
schema:
type: string
- name: Authorization
in: header
required: false
description: |
`Bearer <access_token>`. Alternative to the `token` query
parameter. The middleware tries the Authorization header
first, then falls back to `token`.
schema:
type: string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't ask clients to send the real access token in the WebSocket URL.

Putting a bearer token in ?token= leaks it into reverse-proxy logs, APM traces, browser history, and crash reports. Because the docs explicitly tell clients to reuse the normal access_token, anyone with log access can replay a live session until expiry. Prefer a short-lived WS ticket or same-site cookie for browser/RN clients, and keep header auth for server-side callers.

Also applies to: 7496-7550

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api.yaml` around lines 7463 - 7483, The docs currently instruct clients to
send the real access_token via the query parameter named "token" (and to reuse
the normal access_token), which leaks credentials; update the OpenAPI parameters
so the "token" query parameter is removed or reworded to forbid using the
long-lived access_token, and replace guidance with recommending a short-lived
WebSocket ticket or same-site cookie for browser/React Native clients while
continuing to prefer the "Authorization" header for server-side callers; ensure
the parameter descriptions for "token" and "Authorization" (the symbols to
change) clearly state not to send bearer tokens in URLs and show the secure
alternatives.

Comment on lines +154 to +156
client := &http.Client{}
accessToken, err := generateAccessToken(BASE_URL, client, email, password)
if err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Set a timeout on the shared HTTP client.

This client is reused for login and every trainer-create request, but with no timeout a single stalled connection can hang the whole batch indefinitely.

Suggested fix
-	client := &http.Client{}
+	client := &http.Client{
+		Timeout: 30 * time.Second,
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
client := &http.Client{}
accessToken, err := generateAccessToken(BASE_URL, client, email, password)
if err != nil {
client := &http.Client{
Timeout: 30 * time.Second,
}
accessToken, err := generateAccessToken(BASE_URL, client, email, password)
if err != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/createTrainers/main.go` around lines 154 - 156, The shared HTTP client
"client" used with generateAccessToken and later trainer-create requests has no
timeout and can hang the whole batch; update the client initialization (the
"client" variable) to include a sensible Timeout (e.g. http.Client{Timeout: 10 *
time.Second} or a configurable value) or ensure each request uses a context with
deadline, then pass that client into generateAccessToken and downstream request
functions so all HTTP calls (login and trainer-create) respect the timeout.

Comment on lines +871 to +883
var body struct {
IsAvailable bool `json:"is_available"`
}
if err := c.ShouldBindJSON(&body); err != nil {
s.logger.Warn("toggle trainer availability: invalid request body", "err", err)
c.JSON(http.StatusBadRequest, api.NewError("invalid request body", api.CodeBadRequest))
return
}

result, err := s.trainers.q.ToggleTrainerAvailability(c.Request.Context(), db.ToggleTrainerAvailabilityParams{
ID: trainerID,
IsAvailable: body.IsAvailable,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require is_available to be present in the PATCH body.

With a plain bool, {} binds successfully as false, so a missing field will deactivate the trainer instead of being rejected as an invalid request.

Suggested fix
 	var body struct {
-		IsAvailable bool `json:"is_available"`
+		IsAvailable *bool `json:"is_available"`
 	}
 	if err := c.ShouldBindJSON(&body); err != nil {
 		s.logger.Warn("toggle trainer availability: invalid request body", "err", err)
 		c.JSON(http.StatusBadRequest, api.NewError("invalid request body", api.CodeBadRequest))
 		return
 	}
+	if body.IsAvailable == nil {
+		c.JSON(http.StatusBadRequest, api.NewValidationError([]api.FieldError{
+			{Field: "is_available", Message: "is_available is required"},
+		}))
+		return
+	}
 
 	result, err := s.trainers.q.ToggleTrainerAvailability(c.Request.Context(), db.ToggleTrainerAvailabilityParams{
 		ID:          trainerID,
-		IsAvailable: body.IsAvailable,
+		IsAvailable: *body.IsAvailable,
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/routes/trainers.go` around lines 871 - 883, The PATCH handler
currently uses a plain bool in the request body so a missing is_available binds
as false; change the anonymous body struct's IsAvailable to *bool, keep using
c.ShouldBindJSON(&body), then validate that body.IsAvailable != nil and return a
400 (with the same logging/message) if nil; when calling
trainers.q.ToggleTrainerAvailability use the dereferenced value for IsAvailable
(e.g., *body.IsAvailable) so the update only runs when the field was provided.

Comment on lines +976 to +1001
updated, err := s.trainers.q.UpdateTrainer(ctx, db.UpdateTrainerParams{
ID: trainerID,
Specializations: specializations,
TrainingStyles: existing.TrainingStyles,
Bio: bio,
YearsOfExperience: years,
IntroVideoUrl: existing.IntroVideoUrl,
DisplayPicture: displayPicture,
OnboardingStatus: sql.NullString{},
})
if err != nil {
s.logger.Error("patch trainers me: update trainer failed", "trainerID", trainerID, "err", err)
c.JSON(http.StatusInternalServerError, api.NewError("failed to update profile", api.CodeServerError))
return
}

// Update phone_number on the users table if supplied.
if body.PhoneNumber != nil {
phoneVal := strings.TrimSpace(*body.PhoneNumber)
if _, err := s.trainers.q.UpdateTrainerUserProfile(ctx, db.UpdateTrainerUserProfileParams{
ID: updated.UserID,
PhoneNumber: phoneVal,
}); err != nil {
s.logger.Error("patch trainers me: update user phone failed", "userID", updated.UserID, "err", err)
c.JSON(http.StatusInternalServerError, api.NewError("failed to update phone number", api.CodeServerError))
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the trainer/user profile patch atomic.

UpdateTrainer commits before UpdateTrainerUserProfile runs. If the phone-number write fails, this endpoint returns 500 after already persisting the trainer-row edits, so the client sees a failure even though part of the patch landed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/routes/trainers.go` around lines 976 - 1001, The two separate DB
calls (s.trainers.q.UpdateTrainer and s.trainers.q.UpdateTrainerUserProfile)
must be executed inside a single transaction so the whole profile patch is
atomic: open a transaction via your DB/query layer (e.g. start tx or use the
generated q.WithTx / Store.BeginTx helper), run the UpdateTrainer call using the
tx-scoped queries, then if body.PhoneNumber != nil run UpdateTrainerUserProfile
also on the same tx; on any error rollback and return the 500, otherwise commit
the tx and return success. Ensure you use the tx-scoped query methods
corresponding to UpdateTrainer and UpdateTrainerUserProfile and reference
trainerID/updated.UserID as before.

Comment on lines +993 to +998
if body.PhoneNumber != nil {
phoneVal := strings.TrimSpace(*body.PhoneNumber)
if _, err := s.trainers.q.UpdateTrainerUserProfile(ctx, db.UpdateTrainerUserProfileParams{
ID: updated.UserID,
PhoneNumber: phoneVal,
}); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate phone_number with the same E.164 rule used on create.

CreateTrainer rejects invalid numbers via trainerPhoneE164Regex, but this self-service patch writes any trimmed string straight into UpdateTrainerUserProfile. That lets invalid phone data bypass the existing contract.

Suggested fix
 	if body.PhoneNumber != nil {
 		phoneVal := strings.TrimSpace(*body.PhoneNumber)
+		if phoneVal != "" && !trainerPhoneE164Regex.MatchString(phoneVal) {
+			c.JSON(http.StatusBadRequest, api.NewError("phone_number must be in E.164 format (e.g. +2348012345678)", api.CodeBadRequest))
+			return
+		}
 		if _, err := s.trainers.q.UpdateTrainerUserProfile(ctx, db.UpdateTrainerUserProfileParams{
 			ID:          updated.UserID,
 			PhoneNumber: phoneVal,
 		}); err != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/routes/trainers.go` around lines 993 - 998, The patch writes any
trimmed phone string to UpdateTrainerUserProfile without validating, so add the
same E.164 validation used in CreateTrainer: after computing phoneVal from
body.PhoneNumber (in the block that calls
s.trainers.q.UpdateTrainerUserProfile), test phoneVal against
trainerPhoneE164Regex and if it does not match return a 400/validation error
(consistent with CreateTrainer's behavior) instead of calling
UpdateTrainerUserProfile; only call s.trainers.q.UpdateTrainerUserProfile when
the regex check passes.

Comment on lines +44 to +48
ALTER TABLE bookings DROP COLUMN IF EXISTS messenger_handle;
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_session_platform_check;
ALTER TABLE bookings
ADD CONSTRAINT bookings_session_platform_check
CHECK (session_platform IN ('whatsapp', 'google_meet', 'zoom'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Down migration will fail if messenger bookings exist.

The down migration drops the messenger_handle column and restores the old session_platform constraint allowing only whatsapp, google_meet, and zoom. However, if any bookings with session_platform = 'messenger' exist in production (created after this migration runs), they will violate the restored constraint and cause the rollback to fail.

The up migration correctly remaps legacy whatsapp rows to zoom (line 10-11) before applying the new constraint. The down migration should similarly remap messenger rows to a valid value before restoring the old constraint.

🔄 Proposed fix to remap messenger rows before restoring constraint
 ALTER TABLE bookings DROP COLUMN IF EXISTS messenger_handle;
 ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_session_platform_check;
+
+-- Remap messenger rows to zoom before applying the old constraint
+UPDATE bookings SET session_platform = 'zoom'
+WHERE session_platform = 'messenger';
+
 ALTER TABLE bookings
     ADD CONSTRAINT bookings_session_platform_check
     CHECK (session_platform IN ('whatsapp', 'google_meet', 'zoom'));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ALTER TABLE bookings DROP COLUMN IF EXISTS messenger_handle;
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_session_platform_check;
ALTER TABLE bookings
ADD CONSTRAINT bookings_session_platform_check
CHECK (session_platform IN ('whatsapp', 'google_meet', 'zoom'));
ALTER TABLE bookings DROP COLUMN IF EXISTS messenger_handle;
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_session_platform_check;
-- Remap messenger rows to zoom before applying the old constraint
UPDATE bookings SET session_platform = 'zoom'
WHERE session_platform = 'messenger';
ALTER TABLE bookings
ADD CONSTRAINT bookings_session_platform_check
CHECK (session_platform IN ('whatsapp', 'google_meet', 'zoom'));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@migrations/000058_extend_booking_platforms_messenger_meet.sql` around lines
44 - 48, Before re-adding the old check constraint in the down migration, update
any rows where bookings.session_platform = 'messenger' to a permitted value
(e.g., 'whatsapp' or 'zoom') so they won't violate the restored constraint;
specifically, add an UPDATE on the bookings table to remap 'messenger' values
prior to executing ALTER TABLE bookings ADD CONSTRAINT
bookings_session_platform_check CHECK (session_platform IN ('whatsapp',
'google_meet', 'zoom')), and then drop the messenger_handle column/constraint as
currently written.

Comment thread pkg/googlemeet/oauth.go
Comment on lines +90 to +111
func (c *OAuthClient) AccessToken(ctx context.Context) (string, error) {
if !c.IsConfigured() {
return "", errors.New("googlemeet: OAuthClient not configured")
}

c.mu.Lock()
defer c.mu.Unlock()

// 60s skew so an in-flight request can't race the clock and expire
// mid-call (same buffer as the Zoom oauth client uses).
if c.accessToken != "" && time.Now().Before(c.expiresAt.Add(-60*time.Second)) {
return c.accessToken, nil
}

tok, exp, err := c.refresh(ctx)
if err != nil {
return "", err
}
c.accessToken = tok
c.expiresAt = exp
return tok, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Release mutex during token refresh HTTP call to prevent lock contention.

The mutex is acquired at line 95 and held through the entire refresh call (line 104), which performs a network request with a 10-second timeout. Concurrent requests for AccessToken will serialize and wait, potentially causing cascading delays. Consider double-checked locking: check expiry, release lock, refresh, re-acquire lock, verify again before storing.

♻️ Suggested refactoring pattern
 func (c *OAuthClient) AccessToken(ctx context.Context) (string, error) {
 	if !c.IsConfigured() {
 		return "", errors.New("googlemeet: OAuthClient not configured")
 	}
 
 	c.mu.Lock()
-	defer c.mu.Unlock()
-
-	// 60s skew so an in-flight request can't race the clock and expire
-	// mid-call (same buffer as the Zoom oauth client uses).
 	if c.accessToken != "" && time.Now().Before(c.expiresAt.Add(-60*time.Second)) {
-		return c.accessToken, nil
+		tok := c.accessToken
+		c.mu.Unlock()
+		return tok, nil
 	}
+	c.mu.Unlock()
 
+	// Refresh without holding lock
 	tok, exp, err := c.refresh(ctx)
 	if err != nil {
 		return "", err
 	}
+	
+	c.mu.Lock()
+	// Double-check: another goroutine may have refreshed while we were unlocked
+	if c.accessToken != "" && time.Now().Before(c.expiresAt.Add(-60*time.Second)) {
+		tok := c.accessToken
+		c.mu.Unlock()
+		return tok, nil
+	}
 	c.accessToken = tok
 	c.expiresAt = exp
+	c.mu.Unlock()
 	return tok, nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/googlemeet/oauth.go` around lines 90 - 111, The AccessToken method
currently holds OAuthClient.mu across the network call to refresh(), causing
serialized callers; change to a double-checked locking pattern in AccessToken:
acquire mu and check accessToken/expiresAt, if expired then release mu, call
c.refresh(ctx) (no lock), then re-acquire mu and re-check accessToken/expiresAt
in case another goroutine refreshed in the meantime; only then assign
c.accessToken and c.expiresAt (or return the already-updated token) and release
mu. Ensure you still return errors from refresh and preserve the 60s skew logic
used when checking expiry.

@Gospelmairo Gospelmairo closed this Jun 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants