Staging - #347
Staging#347Gospelmairo wants to merge 67 commits into
Conversation
* 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
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
sync dev to staging
* 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>
* 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>
syncing dev to staging
…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>
sync dev to staging
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…
📝 WalkthroughWalkthroughAdds 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. ChangesUnified Auth, Meetings, and Admin Ops
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches📝 Generate docstrings
⚔️ Resolve merge conflicts
|
There was a problem hiding this comment.
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 winUpdate 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 winIgnore
messenger_handleunlesssession_platform == "messenger".The validation comment says non-messenger handles are ignored, but this path still persists any non-empty
messenger_handlefor Zoom/Google Meet bookings. BecauseCreateBookingParamsis 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 winUpdate error message to reflect multi-platform meeting creation.
The function
createMeetingWithRetrynow accepts aplatformparameter (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 winPrefer the existing
parsePaginationhelper for consistency.This handler manually parses
pageandlimitwith inline defaults and bounds, but the file already has aparsePaginationhelper (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 valueConsider 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 winConsider extracting shared validation and eligibility logic.
HandleVerifyResetCodeduplicates validation (email/code format), rate limiting setup, and user eligibility checks fromHandleResetPassword. 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 tradeoffReassess rate-limiter reset impact in
/auth/verify-reset-codeflow
HandleVerifyResetCodecallsh.resetLimiter.Allow(emailAddr)and then—only afterVerifyCode(...)succeeds and the same active/admin/trainer role gate passes—resets the per-email bucket viah.resetLimiter.Reset(...)(line ~484). TheHandleResetPasswordendpoint also consumes the OTP on success (ConsumeCodeAndUpdatePassword), and resetsh.resetLimiteronly 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 whereHandleResetPasswordfails 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 winParse 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 theerrorfield 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 valueTrack 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
#286conflicts 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 winSilently discarding
io.ReadAllerrors can mask I/O problems.The error returned by
io.ReadAllis 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 valueString-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
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (80)
.env.example.gitignore.gitleaksignoreapi.yamlcmd/createTrainers/main.gocmd/meet-bootstrap/main.gocmd/seed/main.godocs/MEET_INTEGRATION.mdgo.modinternal/api/gen.gointernal/auth/admin_login_test.gointernal/auth/apple.gointernal/auth/apple_test.gointernal/auth/auth_service.gointernal/auth/google_test.gointernal/auth/local.gointernal/auth/local_test.gointernal/auth/password_reset.gointernal/auth/repository.gointernal/auth/sign_in_test.gointernal/bookings/handler.gointernal/bookings/handlers.gointernal/bookings/service.gointernal/config/config.gointernal/dev/handler.gointernal/discovery/handler.gointernal/middleware/admin_only.gointernal/middleware/deactivated.gointernal/models/queries/booking_slots.sqlinternal/models/queries/bookings.sqlinternal/models/queries/discovery.sqlinternal/models/queries/password_reset.sqlinternal/models/queries/roles.sqlinternal/models/queries/subscriptions.sqlinternal/models/queries/trainers.sqlinternal/models/queries/users.sqlinternal/notification/admin_broadcast_test.gointernal/notification/helpers_test.gointernal/notification/repository.gointernal/notification/service.gointernal/repository/db/account_setup.sql.gointernal/repository/db/booking_slots.sql.gointernal/repository/db/bookings.sql.gointernal/repository/db/discovery.sql.gointernal/repository/db/models.gointernal/repository/db/password_reset.sql.gointernal/repository/db/reviews.sql.gointernal/repository/db/roles.sql.gointernal/repository/db/subscriptions.sql.gointernal/repository/db/trainers.sql.gointernal/repository/db/users.sql.gointernal/routes/admin.gointernal/routes/admin_sessions.gointernal/routes/admin_subscriptions.gointernal/routes/admin_transactions.gointernal/routes/auth.gointernal/routes/dev.gointernal/routes/profile.gointernal/routes/routes.gointernal/routes/subscriptions.gointernal/routes/trainers.gointernal/routes/zoom_oauth.gointernal/zoomflow/selector.gomigrations/000058_extend_booking_platforms_messenger_meet.sqlmigrations/000060_user_account_management.sqlmigrations/000061_cascade_user_hard_delete.sqlmigrations/000062_cascade_booking_hard_delete.sqlmigrations/000063_add_apple_user_id_to_users.sqlmigrations/000064_add_is_available_to_trainers.sqloapi-codegen.yamlpkg/apple/verifier.gopkg/apple/verifier_test.gopkg/email/email.gopkg/googlemeet/oauth.gopkg/googlemeet/oauth_test.gopkg/googlemeet/provider.gopkg/googlemeet/provider_test.gopkg/meeting/multi_platform_test.gopkg/meeting/selector.gopkg/meeting/selector_test.go
| # 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= |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
🧩 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'
fiRepository: 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'
fiRepository: 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:
- 1: https://www.learnjsonschema.com/draft7/validation/maxlength/
- 2: What is meaning of maxLength or minimum OAI/OpenAPI-Specification#1754
- 3: Byte length not calculated correctly in python OpenAPITools/openapi-generator#450
- 4: [Declarative Validation] Bring
k8s:maxLengthtag in line with OpenAPImaxLengthvalidation semantics kubernetes/kubernetes#137291 - 5: Add +k8s:maxBytes and +k8s:minBytes OpenAPI representation kubernetes/kube-openapi#578
- 6: https://swagger.io/specification/v3/
- 7: https://spec.openapis.org/oas/v3.0.html
- 8: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md
🌐 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:
- 1: https://security.stackexchange.com/questions/39849/does-bcrypt-have-a-maximum-password-length
- 2: https://crypto.stackexchange.com/questions/24993/is-there-a-way-to-use-bcrypt-with-passwords-longer-than-72-bytes-securely
- 3: https://github.com/bcgit/bc-java/blob/master/core/src/main/java/org/bouncycastle/crypto/generators/BCrypt.java
- 4: https://github.com/pyca/bcrypt/blob/main/README.rst
- 5: https://go.googlesource.com/crypto.git/+/bc7d1d1eb54b3530da4f5ec31625c95d7df40231
- 6: https://github.com/golang/crypto/blob/bc7d1d1eb54b3530da4f5ec31625c95d7df40231/bcrypt/bcrypt.go
- 7: https://github.com/kelektiv/node.bcrypt.js/blob/master/README.md
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.
| 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. |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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.
| client := &http.Client{} | ||
| accessToken, err := generateAccessToken(BASE_URL, client, email, password) | ||
| if err != nil { |
There was a problem hiding this comment.
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.
| 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.
| 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, | ||
| }) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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 { |
There was a problem hiding this comment.
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.
| 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')); |
There was a problem hiding this comment.
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.
| 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.
| 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 | ||
| } |
There was a problem hiding this comment.
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.
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
Checklist
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes