Repository: moasq/production-saas-starter Branch: main Commit: 701bff660c62 Files: 479 Total size: 1.8 MB Directory structure: gitextract__wbx865y/ ├── .editorconfig ├── .gitignore ├── CLAUDE.md ├── Caddyfile ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SETUP.md ├── docker-compose.production.yml ├── go-b2b-starter/ │ ├── .air.toml │ ├── .claude/ │ │ └── CLAUDE.md │ ├── .dockerignore │ ├── .gitignore │ ├── .gitlab-ci.yml │ ├── Dockerfile │ ├── Makefile │ ├── README.md │ ├── cmd/ │ │ └── api/ │ │ └── main.go │ ├── deps/ │ │ ├── Dockerfile │ │ └── docker-compose.yml │ ├── docs/ │ │ ├── README.md │ │ ├── adding-a-module.md │ │ ├── api-development.md │ │ ├── architecture.md │ │ ├── authentication.md │ │ ├── billing.md │ │ ├── database.md │ │ ├── event-bus.md │ │ └── file-manager.md │ ├── example.env │ ├── go.mod │ ├── go.sum │ ├── internal/ │ │ ├── api/ │ │ │ └── provider.go │ │ ├── bootstrap/ │ │ │ ├── init_mods.go │ │ │ └── root.go │ │ ├── db/ │ │ │ ├── README.md │ │ │ ├── adapters/ │ │ │ │ ├── cognitive_store.go │ │ │ │ ├── document_store.go │ │ │ │ ├── file_asset_store.go │ │ │ │ ├── organization_store.go │ │ │ │ └── subscription_store.go │ │ │ ├── cmd/ │ │ │ │ ├── init.go │ │ │ │ └── providers.go │ │ │ ├── core/ │ │ │ │ ├── connection.go │ │ │ │ ├── errors.go │ │ │ │ └── transaction.go │ │ │ ├── helpers/ │ │ │ │ └── helpers.go │ │ │ ├── inject.go │ │ │ └── postgres/ │ │ │ ├── adapter_impl/ │ │ │ │ ├── cognitive_store.go │ │ │ │ ├── document_store.go │ │ │ │ ├── file_asset_store.go │ │ │ │ ├── organization_store.go │ │ │ │ └── subscription_store.go │ │ │ ├── connection.go │ │ │ ├── db_config.go │ │ │ ├── init.go │ │ │ ├── postgres_manager.go │ │ │ ├── retry.go │ │ │ ├── sqlc/ │ │ │ │ ├── gen/ │ │ │ │ │ ├── cognitive.sql.go │ │ │ │ │ ├── db.go │ │ │ │ │ ├── documents.sql.go │ │ │ │ │ ├── error.go │ │ │ │ │ ├── example_resource.sql.go │ │ │ │ │ ├── exec.go │ │ │ │ │ ├── file_manager.sql.go │ │ │ │ │ ├── models.go │ │ │ │ │ ├── organizations.sql.go │ │ │ │ │ ├── querier.go │ │ │ │ │ ├── resource_embeddings.sql.go │ │ │ │ │ ├── store.go │ │ │ │ │ └── subscription_billing.sql.go │ │ │ │ ├── migrations/ │ │ │ │ │ ├── 000001_create_file_manager_schema.down.sql │ │ │ │ │ ├── 000001_create_file_manager_schema.up.sql │ │ │ │ │ ├── 000002_create_organizations_schema.down.sql │ │ │ │ │ ├── 000002_create_organizations_schema.up.sql │ │ │ │ │ ├── 000003_enforce_role_enum.down.sql │ │ │ │ │ ├── 000003_enforce_role_enum.up.sql │ │ │ │ │ ├── 000004_create_subscription_billing_schema.down.sql │ │ │ │ │ ├── 000004_create_subscription_billing_schema.up.sql │ │ │ │ │ ├── 000005_update_quota_tracking_schema.down.sql │ │ │ │ │ ├── 000005_update_quota_tracking_schema.up.sql │ │ │ │ │ ├── 000006_create_example_resources.down.sql │ │ │ │ │ ├── 000006_create_example_resources.up.sql │ │ │ │ │ ├── 000007_create_resource_embeddings.down.sql │ │ │ │ │ ├── 000007_create_resource_embeddings.up.sql │ │ │ │ │ ├── 000008_create_documents_schema.down.sql │ │ │ │ │ ├── 000008_create_documents_schema.up.sql │ │ │ │ │ ├── 000009_create_cognitive_schema.down.sql │ │ │ │ │ └── 000009_create_cognitive_schema.up.sql │ │ │ │ ├── query/ │ │ │ │ │ ├── cognitive.sql │ │ │ │ │ ├── documents.sql │ │ │ │ │ ├── example_resource.sql │ │ │ │ │ ├── file_manager.sql │ │ │ │ │ ├── organizations.sql │ │ │ │ │ └── subscription_billing.sql │ │ │ │ └── sqlc.yml │ │ │ └── types_transform.go │ │ ├── docs/ │ │ │ ├── api/ │ │ │ │ ├── handler.go │ │ │ │ └── routes.go │ │ │ ├── cmd/ │ │ │ │ └── init.go │ │ │ └── gen/ │ │ │ ├── docs.go │ │ │ ├── swagger.json │ │ │ └── swagger.yaml │ │ ├── modules/ │ │ │ ├── auth/ │ │ │ │ ├── README.md │ │ │ │ ├── adapters/ │ │ │ │ │ └── stytch/ │ │ │ │ │ ├── adapter.go │ │ │ │ │ ├── config.go │ │ │ │ │ ├── jwks_cache.go │ │ │ │ │ ├── jwt_parser.go │ │ │ │ │ ├── mock_adapter.go │ │ │ │ │ ├── rbac_policy.go │ │ │ │ │ └── token_verifier.go │ │ │ │ ├── auth.go │ │ │ │ ├── cmd/ │ │ │ │ │ └── init.go │ │ │ │ ├── context.go │ │ │ │ ├── errors.go │ │ │ │ ├── handler.go │ │ │ │ ├── middleware.go │ │ │ │ ├── permissions.go │ │ │ │ ├── provider.go │ │ │ │ ├── rbac.go │ │ │ │ ├── resolvers.go │ │ │ │ ├── roles.go │ │ │ │ └── routes.go │ │ │ ├── billing/ │ │ │ │ ├── README.md │ │ │ │ ├── app/ │ │ │ │ │ └── services/ │ │ │ │ │ ├── check_quota_availability_service.go │ │ │ │ │ ├── consume_invoice_quota_service.go │ │ │ │ │ ├── get_billing_status_service.go │ │ │ │ │ ├── module.go │ │ │ │ │ ├── process_webhook_event_service.go │ │ │ │ │ ├── refresh_subscription_status_service.go │ │ │ │ │ ├── subscription_service_dec.go │ │ │ │ │ ├── sync_subscription_service.go │ │ │ │ │ ├── verify_and_consume_quota_service.go │ │ │ │ │ └── verify_payment_service.go │ │ │ │ ├── cmd/ │ │ │ │ │ ├── init.go │ │ │ │ │ └── provider.go │ │ │ │ ├── domain/ │ │ │ │ │ ├── errors.go │ │ │ │ │ ├── repository.go │ │ │ │ │ └── types.go │ │ │ │ ├── handler.go │ │ │ │ ├── infra/ │ │ │ │ │ ├── adapters/ │ │ │ │ │ │ └── status_provider.go │ │ │ │ │ ├── polar/ │ │ │ │ │ │ └── polar_adapter.go │ │ │ │ │ └── repositories/ │ │ │ │ │ ├── organization_adapter.go │ │ │ │ │ └── subscription_repository.go │ │ │ │ ├── provider.go │ │ │ │ └── routes.go │ │ │ ├── cognitive/ │ │ │ │ ├── app/ │ │ │ │ │ └── services/ │ │ │ │ │ ├── document_listener.go │ │ │ │ │ ├── embedding_service.go │ │ │ │ │ ├── interface.go │ │ │ │ │ └── rag_service.go │ │ │ │ ├── cmd/ │ │ │ │ │ └── init.go │ │ │ │ ├── domain/ │ │ │ │ │ ├── ai_provider.go │ │ │ │ │ ├── entity.go │ │ │ │ │ ├── errors.go │ │ │ │ │ └── repository.go │ │ │ │ ├── handler.go │ │ │ │ ├── infra/ │ │ │ │ │ ├── ai/ │ │ │ │ │ │ ├── assistant_provider.go │ │ │ │ │ │ └── text_vectorizer.go │ │ │ │ │ └── repositories/ │ │ │ │ │ ├── chat_repository.go │ │ │ │ │ ├── embedding_repository.go │ │ │ │ │ └── helpers.go │ │ │ │ ├── module.go │ │ │ │ ├── provider.go │ │ │ │ └── routes.go │ │ │ ├── documents/ │ │ │ │ ├── app/ │ │ │ │ │ └── services/ │ │ │ │ │ ├── document_service.go │ │ │ │ │ └── interface.go │ │ │ │ ├── cmd/ │ │ │ │ │ └── init.go │ │ │ │ ├── domain/ │ │ │ │ │ ├── entity.go │ │ │ │ │ ├── errors.go │ │ │ │ │ ├── events/ │ │ │ │ │ │ └── document_events.go │ │ │ │ │ └── repository.go │ │ │ │ ├── handler.go │ │ │ │ ├── infra/ │ │ │ │ │ └── repositories/ │ │ │ │ │ └── document_repository.go │ │ │ │ ├── module.go │ │ │ │ ├── provider.go │ │ │ │ └── routes.go │ │ │ ├── files/ │ │ │ │ ├── README.md │ │ │ │ ├── cmd/ │ │ │ │ │ ├── init.go │ │ │ │ │ └── provider.go │ │ │ │ ├── config/ │ │ │ │ │ └── config.go │ │ │ │ ├── constants.go │ │ │ │ ├── domain/ │ │ │ │ │ ├── entity.go │ │ │ │ │ ├── helpers.go │ │ │ │ │ ├── repository.go │ │ │ │ │ ├── service.go │ │ │ │ │ └── validator.go │ │ │ │ ├── infra/ │ │ │ │ │ └── file_metadata_repository.go │ │ │ │ └── internal/ │ │ │ │ └── infra/ │ │ │ │ ├── composite_repository.go │ │ │ │ ├── db_repository.go │ │ │ │ ├── mock_r2_repository.go │ │ │ │ └── r2_repository.go │ │ │ ├── organizations/ │ │ │ │ ├── account_handler.go │ │ │ │ ├── app/ │ │ │ │ │ └── services/ │ │ │ │ │ ├── member_service.go │ │ │ │ │ ├── member_service_impl.go │ │ │ │ │ ├── organization_service.go │ │ │ │ │ └── organization_service_interface.go │ │ │ │ ├── cmd/ │ │ │ │ │ └── init.go │ │ │ │ ├── domain/ │ │ │ │ │ ├── auth_provider.go │ │ │ │ │ ├── entity.go │ │ │ │ │ ├── errors.go │ │ │ │ │ ├── events/ │ │ │ │ │ │ └── organization_events.go │ │ │ │ │ └── repository.go │ │ │ │ ├── infra/ │ │ │ │ │ └── repositories/ │ │ │ │ │ ├── account_repository.go │ │ │ │ │ ├── organization_repository.go │ │ │ │ │ ├── slug_generator.go │ │ │ │ │ ├── stytch_member_repository.go │ │ │ │ │ ├── stytch_organization_repository.go │ │ │ │ │ └── stytch_role_repository.go │ │ │ │ ├── member_handler.go │ │ │ │ ├── module.go │ │ │ │ ├── organization_handler.go │ │ │ │ ├── provider.go │ │ │ │ └── routes.go │ │ │ └── paywall/ │ │ │ ├── README.md │ │ │ ├── cmd/ │ │ │ │ └── init.go │ │ │ ├── context.go │ │ │ ├── errors.go │ │ │ ├── middleware.go │ │ │ ├── provider.go │ │ │ └── subscription.go │ │ └── platform/ │ │ ├── eventbus/ │ │ │ ├── bus.go │ │ │ ├── cmd/ │ │ │ │ ├── init.go │ │ │ │ └── provider.go │ │ │ ├── event.go │ │ │ ├── events.go │ │ │ └── middleware.go │ │ ├── llm/ │ │ │ ├── README.md │ │ │ ├── cmd/ │ │ │ │ └── init.go │ │ │ ├── domain/ │ │ │ │ ├── errors.go │ │ │ │ └── service.go │ │ │ └── infra/ │ │ │ └── openai_client.go │ │ ├── logger/ │ │ │ ├── cmd/ │ │ │ │ ├── init.go │ │ │ │ └── provider.go │ │ │ ├── domain/ │ │ │ │ ├── logger.go │ │ │ │ └── options.go │ │ │ ├── factory.go │ │ │ └── internal/ │ │ │ └── zerologger/ │ │ │ ├── factory.go │ │ │ └── logger.go │ │ ├── ocr/ │ │ │ ├── README.md │ │ │ ├── cmd/ │ │ │ │ └── init.go │ │ │ ├── domain/ │ │ │ │ ├── entity.go │ │ │ │ ├── errors.go │ │ │ │ └── service.go │ │ │ └── infra/ │ │ │ ├── config.go │ │ │ ├── mistral_ocr_client.go │ │ │ └── mock_ocr_client.go │ │ ├── polar/ │ │ │ ├── client.go │ │ │ ├── cmd/ │ │ │ │ └── init.go │ │ │ ├── config.go │ │ │ ├── inject.go │ │ │ └── webhook.go │ │ ├── redis/ │ │ │ ├── README.md │ │ │ ├── cmd/ │ │ │ │ ├── init.go │ │ │ │ └── provider.go │ │ │ ├── config.go │ │ │ ├── init.go │ │ │ ├── redis.go │ │ │ └── store.go │ │ ├── server/ │ │ │ ├── cmd/ │ │ │ │ ├── di.go │ │ │ │ └── init.go │ │ │ ├── config/ │ │ │ │ └── config.go │ │ │ ├── domain/ │ │ │ │ ├── health.go │ │ │ │ ├── http_server.go │ │ │ │ ├── middleware.go │ │ │ │ ├── middleware_resolver.go │ │ │ │ ├── server.go │ │ │ │ └── server_.go │ │ │ ├── errors/ │ │ │ │ └── errors.go │ │ │ ├── gin/ │ │ │ │ └── gin.go │ │ │ ├── logging/ │ │ │ │ ├── logger.go │ │ │ │ └── security_logger.go │ │ │ ├── metrics/ │ │ │ │ └── prometheus.go │ │ │ └── middleware/ │ │ │ ├── cors.go │ │ │ ├── ip_protection.go │ │ │ ├── ratelimit.go │ │ │ ├── recovery.go │ │ │ ├── request_id.go │ │ │ ├── request_size_limit.go │ │ │ ├── sanatization.go │ │ │ ├── security_headers.go │ │ │ ├── timeout.go │ │ │ └── validator.go │ │ └── stytch/ │ │ ├── client.go │ │ ├── cmd/ │ │ │ └── provider.go │ │ ├── config.go │ │ ├── errors.go │ │ ├── inject.go │ │ └── rbac_policy.go │ ├── pkg/ │ │ ├── httperr/ │ │ │ ├── errors.go │ │ │ └── http_error.go │ │ ├── pagination/ │ │ │ ├── pagination.go │ │ │ ├── pramas.go │ │ │ └── util.go │ │ ├── response/ │ │ │ └── response.go │ │ └── slugify/ │ │ └── slugify.go │ └── scripts/ │ ├── migrate_down.sh │ ├── migrate_up.sh │ └── run_tests_with_coverage.sh ├── next_b2b_starter/ │ ├── .claude/ │ │ └── CLAUDE.md │ ├── .dockerignore │ ├── .env.example │ ├── .eslintrc.json │ ├── .npmrc │ ├── Dockerfile │ ├── README.md │ ├── STYTCH_CONFIGURATION.md │ ├── app/ │ │ ├── api/ │ │ │ ├── auth/ │ │ │ │ └── session/ │ │ │ │ └── refresh/ │ │ │ │ └── route.ts │ │ │ └── billing/ │ │ │ └── webhook/ │ │ │ └── route.ts │ │ ├── auth/ │ │ │ └── page.tsx │ │ ├── authenticate/ │ │ │ └── page.tsx │ │ ├── dashboard/ │ │ │ ├── knowledge/ │ │ │ │ ├── components/ │ │ │ │ │ ├── chat-interface.tsx │ │ │ │ │ ├── chat-message.tsx │ │ │ │ │ ├── document-list.tsx │ │ │ │ │ ├── document-sources.tsx │ │ │ │ │ ├── document-upload.tsx │ │ │ │ │ ├── knowledge-content.tsx │ │ │ │ │ └── knowledge-sidebar.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── settings/ │ │ │ ├── components/ │ │ │ │ ├── invite-member.tsx │ │ │ │ ├── member-list.tsx │ │ │ │ ├── profile-section.tsx │ │ │ │ ├── profile-tab.tsx │ │ │ │ ├── settings-content.tsx │ │ │ │ └── subscription-tab.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── globals.css │ │ ├── head.tsx │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── robots.ts │ │ ├── signup/ │ │ │ └── page.tsx │ │ └── sitemap.ts │ ├── components/ │ │ ├── auth/ │ │ │ ├── can.tsx │ │ │ ├── permission-gate.tsx │ │ │ └── stytch-provider.tsx │ │ ├── billing/ │ │ │ ├── plans-modal.tsx │ │ │ ├── subscription-alerts.tsx │ │ │ └── subscription-paywall.tsx │ │ ├── common/ │ │ │ └── obfuscated-email.tsx │ │ ├── layout/ │ │ │ ├── dashboard-layout.tsx │ │ │ ├── header.tsx │ │ │ ├── sidebar.tsx │ │ │ └── user-menu.tsx │ │ ├── seo/ │ │ │ └── jsonld.tsx │ │ └── ui/ │ │ ├── accordion.tsx │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── date-picker.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet-header.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ └── textarea.tsx │ ├── components.json │ ├── docs/ │ │ ├── 01-getting-started.md │ │ ├── 02-authentication.md │ │ ├── 03-permissions-and-roles.md │ │ ├── 04-payments-and-billing.md │ │ ├── 05-making-api-requests.md │ │ ├── 06-creating-pages.md │ │ ├── 07-creating-apis.md │ │ ├── 08-using-hooks.md │ │ ├── 09-adding-a-feature.md │ │ ├── 10-server-actions.md │ │ ├── 11-feature-guards.md │ │ ├── 12-subscription-patterns.md │ │ ├── API-LOGGING.md │ │ └── README.md │ ├── hooks/ │ │ ├── use-signup-flow.ts │ │ └── use-toast.ts │ ├── lib/ │ │ ├── actions/ │ │ │ ├── auth/ │ │ │ │ ├── consume-magic-link.ts │ │ │ │ ├── logout.ts │ │ │ │ └── send-magic-link.ts │ │ │ └── billing/ │ │ │ ├── cancel-subscription.ts │ │ │ ├── create-checkout.ts │ │ │ ├── get-products.ts │ │ │ ├── get-subscription-status.ts │ │ │ └── verify-payment.ts │ │ ├── api/ │ │ │ └── api/ │ │ │ ├── client/ │ │ │ │ └── api-client.ts │ │ │ ├── dto/ │ │ │ │ ├── auth.dto.ts │ │ │ │ ├── cognitive.dto.ts │ │ │ │ ├── document.dto.ts │ │ │ │ ├── member.dto.ts │ │ │ │ ├── organization.dto.ts │ │ │ │ ├── profile.dto.ts │ │ │ │ └── rbac.dto.ts │ │ │ └── repositories/ │ │ │ ├── cognitive-repository.ts │ │ │ ├── document-repository.ts │ │ │ ├── member-repository.ts │ │ │ ├── profile-repository.ts │ │ │ ├── rbac-repository.ts │ │ │ └── signup-repository.ts │ │ ├── auth/ │ │ │ ├── README.md │ │ │ ├── bootstrap.ts │ │ │ ├── config-types.ts │ │ │ ├── constants.ts │ │ │ ├── permission-utils.ts │ │ │ ├── permissions.ts │ │ │ ├── server-constants.ts │ │ │ ├── server-permissions.ts │ │ │ ├── stytch/ │ │ │ │ └── server.ts │ │ │ ├── stytch-client.ts │ │ │ ├── stytch-server.ts │ │ │ ├── stytch.ts │ │ │ ├── subscription.ts │ │ │ └── token-utils.ts │ │ ├── contexts/ │ │ │ ├── auth-context.tsx │ │ │ └── stytch-config-context.tsx │ │ ├── hooks/ │ │ │ ├── mutations/ │ │ │ │ ├── use-chat.ts │ │ │ │ ├── use-delete-document.ts │ │ │ │ ├── use-invite-member.ts │ │ │ │ ├── use-remove-member.ts │ │ │ │ ├── use-resend-invitation.ts │ │ │ │ ├── use-update-profile.ts │ │ │ │ └── use-upload-document.ts │ │ │ ├── queries/ │ │ │ │ ├── query-keys.ts │ │ │ │ ├── use-documents-query.ts │ │ │ │ ├── use-members-query.ts │ │ │ │ ├── use-products-query.ts │ │ │ │ ├── use-profile-query.ts │ │ │ │ ├── use-sessions-query.ts │ │ │ │ └── use-subscription-query.ts │ │ │ └── use-permissions.ts │ │ ├── models/ │ │ │ ├── cognitive.model.ts │ │ │ ├── document.model.ts │ │ │ ├── member.model.ts │ │ │ └── signup.model.ts │ │ ├── polar/ │ │ │ ├── client.ts │ │ │ ├── config.ts │ │ │ ├── current-subscription.ts │ │ │ ├── plans.ts │ │ │ ├── server-meters.ts │ │ │ ├── server-products.ts │ │ │ ├── subscription.ts │ │ │ └── usage.ts │ │ ├── providers/ │ │ │ └── query-provider.tsx │ │ ├── stores/ │ │ │ └── sidebar-store.ts │ │ ├── types/ │ │ │ └── loadable.ts │ │ ├── utils/ │ │ │ ├── api-logger.ts │ │ │ ├── password-generator.ts │ │ │ └── server-action-helpers.ts │ │ └── utils.ts │ ├── lint/ │ │ └── README.md │ ├── next-env.d.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── scripts/ │ │ ├── convert-svg-to-png.js │ │ ├── generate-favicons.js │ │ └── generate-og-images.js │ ├── stores/ │ │ └── ui-store.ts │ ├── tailwind.config.ts │ └── tsconfig.json └── setup.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 4 charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.go] indent_style = tab [*.{js,ts,tsx,json,yml,yaml}] indent_style = space indent_size = 2 ================================================ FILE: .gitignore ================================================ # --- Global OS Files --- .DS_Store Thumbs.db # --- Global Editor Files --- .idea/ .vscode/ *.swp *.swo # --- Secrets (Safety Net) --- # We ignore these here just in case someone accidentally # creates an .env file in the root. .env .env.* # But allow .env.example and example.env files (templates for users) !.env.example !**/.env.example !**/example.env *.pem *.key # --- Claude Code --- # Ignore .claude/ contents but allow CLAUDE.md files .claude/* !.claude/CLAUDE.md **/.claude/* !**/.claude/CLAUDE.md # --- Logs --- *.log npm-debug.log* yarn-debug.log* yarn-error.log* # --- Docker --- # If you mount volumes locally .docker/ postgres_data/ redis_data/ # --- Additional Safety Nets --- # These provide defense-in-depth, even though subdirectory # .gitignore files may already cover these patterns tmp/ temp/ # Additional IDE files *.sublime-* .vscode-test/ # ============================================================================== # PROJECT-SPECIFIC IGNORES # ============================================================================== # --- Go Backend (go-b2b-starter/) --- # Go Environment Files go-b2b-starter/.env go-b2b-starter/.env.* go-b2b-starter/app.env # Binaries and Build Artifacts go-b2b-starter/bin/ go-b2b-starter/dist/ go-b2b-starter/*.exe go-b2b-starter/*.exe~ go-b2b-starter/*.dll go-b2b-starter/*.so go-b2b-starter/*.dylib go-b2b-starter/*.test go-b2b-starter/main go-b2b-starter/src/main/main go-b2b-starter/src/bin/main # Go Temporary Files go-b2b-starter/tmp/ go-b2b-starter/temp/ # Go Test Coverage go-b2b-starter/coverage.out go-b2b-starter/coverage.html go-b2b-starter/coverage.txt go-b2b-starter/coverage/ # Go Vendor go-b2b-starter/vendor/ # --- Next.js Frontend (next_b2b_starter/) --- # Next.js Environment Files next_b2b_starter/.env.local next_b2b_starter/.env.production next_b2b_starter/.env.development next_b2b_starter/.env # Dependencies next_b2b_starter/node_modules/ # Next.js Build Output next_b2b_starter/.next/ next_b2b_starter/out/ next_b2b_starter/build/ # TypeScript Build Info next_b2b_starter/*.tsbuildinfo next_b2b_starter/.turbo/ ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md Guidance for Claude Code when working with this monorepo. ## Monorepo Structure Production SaaS Starter - Enterprise-grade B2B SaaS boilerplate with Next.js 16 frontend and Go backend. ``` production-saas-starter/ ├── go-b2b-starter/ # Go backend (API, auth, billing, AI/RAG) ├── next_b2b_starter/ # Next.js frontend (React 19, TypeScript) ├── setup.sh # One-line setup script ├── DEVELOPMENT.md # Development workflow guide └── README.md # Project overview ``` ## Quick Navigation **Working on Backend?** - See: `go-b2b-starter/.claude/CLAUDE.md` - Key commands: `make server`, `make dev`, `make sqlc`, `make test` - Tech: Go, Gin, PostgreSQL, SQLC, Stytch, Polar.sh **Working on Frontend?** - See: `next_b2b_starter/.claude/CLAUDE.md` - Key commands: `pnpm dev`, `pnpm build`, `pnpm lint` - Tech: Next.js 16, React 19, TypeScript, Tailwind, shadcn/ui, TanStack Query ## Getting Started ```bash # One-line setup chmod +x setup.sh && ./setup.sh # Or manual start: # Backend cd go-b2b-starter make run-deps # Start PostgreSQL make migrateup # Apply migrations make dev # Run with hot reload # Frontend cd next_b2b_starter pnpm install pnpm dev # Start Next.js dev server ``` ## Architecture Overview | Component | Technology | Purpose | |-----------|------------|---------| | **Authentication** | Stytch B2B | Magic links, RBAC, multi-tenant | | **Billing** | Polar.sh | Subscriptions, webhooks, usage metering | | **Database** | PostgreSQL + SQLC | Type-safe queries, pgvector | | **Backend** | Go + Gin | Clean Architecture, DI (uber-go/dig) | | **Frontend** | Next.js 16 + React 19 | Server Actions, TanStack Query | | **Styling** | Tailwind + shadcn/ui | Consistent design system | ## Key Patterns ### Backend (Go) - **Clean Architecture**: domain -> app -> infra layers - **Repository Pattern**: Domain interfaces implemented by SQLC-backed repos - **Event Bus**: Loose coupling between modules - **RBAC Middleware**: Permission-based route protection ### Frontend (Next.js) - **Server Actions**: For mutations with auth/permission guards - **TanStack Query**: Server state management with caching - **Repository Pattern**: API client abstractions - **Type Safety**: Strict TypeScript throughout ## Documentation - `DEVELOPMENT.md` - Development workflow and setup - `SETUP.md` - Initial project configuration - `go-b2b-starter/docs/` - Backend documentation - `next_b2b_starter/docs/` - Frontend documentation ================================================ FILE: Caddyfile ================================================ # Caddyfile - Production reverse proxy configuration # # Routes traffic between Next.js frontend and Go backend: # - Next.js internal API routes stay in frontend container # - Go backend API routes go to backend container # - Everything else (pages, assets) goes to frontend # # Usage: # Set DOMAIN environment variable or it defaults to localhost # For production: DOMAIN=yourdomain.com docker compose up {$DOMAIN:localhost} { # ========================================================================= # Next.js internal API routes (must go to frontend) # These are handled by Next.js API routes, not the Go backend # ========================================================================= # Auth session management (handled by Next.js) handle /api/auth/session/* { reverse_proxy frontend:3000 } # Billing webhook (handled by Next.js for Polar.sh) handle /api/billing/webhook { reverse_proxy frontend:3000 } # Next.js health check handle /api/health { reverse_proxy frontend:3000 } # ========================================================================= # Go Backend API routes # All other /api/* routes go to the Go backend # ========================================================================= handle /api/* { reverse_proxy backend:8080 } # Backend health check (optional, for container orchestration) handle /health { reverse_proxy backend:8080 } # ========================================================================= # Frontend - Everything else (pages, assets, etc.) # ========================================================================= handle { reverse_proxy frontend:3000 } } ================================================ FILE: DEVELOPMENT.md ================================================ # 🛠️ Local Development Setup Complete guide to running the Production SaaS Starter Kit locally. --- ## 📋 Prerequisites Install these tools before starting: | Tool | Version | Purpose | |------|---------|---------| | **Docker Desktop** | Latest | Runs PostgreSQL + Redis containers | | **Go** | 1.25+ | Backend server | | **Node.js** | 20+ | Frontend build | | **pnpm** | 9+ | Frontend package manager | > **Note:** You do NOT need to install PostgreSQL or Redis directly — Docker handles them. --- ### 🐳 Install Docker Desktop Docker runs PostgreSQL and Redis in containers so you don't need to install them directly. **macOS:** ```bash # Option 1: Homebrew brew install --cask docker # Option 2: Direct download # https://www.docker.com/products/docker-desktop/ ``` **Windows:** 1. Download from https://www.docker.com/products/docker-desktop/ 2. Run installer 3. Enable WSL 2 when prompted **Linux (Ubuntu/Debian):** ```bash # Add Docker's official GPG key sudo apt-get update sudo apt-get install ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc # Add the repository echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker sudo apt-get update sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # Add your user to docker group (logout/login required) sudo usermod -aG docker $USER ``` After installing, **open Docker Desktop** and wait for it to start before proceeding. --- ### 🐹 Install Go The backend requires Go 1.25 or higher. **macOS:** ```bash # Option 1: Homebrew (recommended) brew install go # Option 2: Direct download # https://go.dev/dl/ ``` **Windows:** 1. Download from https://go.dev/dl/ 2. Run the MSI installer 3. Restart terminal after install **Linux:** ```bash # Download latest (check https://go.dev/dl/ for current version) wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz # Remove old version and extract new sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz # Add to PATH (add to ~/.bashrc or ~/.zshrc) export PATH=$PATH:/usr/local/go/bin ``` --- ### 📦 Install Node.js The frontend requires Node.js 20 or higher. We recommend using **nvm** (Node Version Manager) to manage Node versions. **macOS/Linux — Install nvm:** ```bash # Install nvm curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash # Restart terminal, then install Node 20 nvm install 20 nvm use 20 nvm alias default 20 ``` **Windows — Install nvm-windows:** 1. Download from https://github.com/coreybutler/nvm-windows/releases 2. Run the installer 3. Open new terminal: ```bash nvm install 20 nvm use 20 ``` **Alternative — Direct install (without nvm):** ```bash # macOS brew install node@20 # Or download from https://nodejs.org/ ``` --- ### 📦 Install pnpm pnpm is our package manager for the frontend (faster than npm, saves disk space). ```bash # After Node is installed npm install -g pnpm ``` Or use Corepack (built into Node 16.13+): ```bash corepack enable corepack prepare pnpm@latest --activate ``` --- ### ✅ Verify Installation Run these commands to confirm everything is installed correctly: ```bash docker --version # Docker version 24+ go version # go1.25+ node --version # v20+ pnpm --version # 9+ ``` All four should return version numbers. If any command fails, revisit the install steps above. --- ## 📁 Project Structure ``` production-saas-starter/ ├── go-b2b-starter/ # Go backend ├── next_b2b_starter/ # Next.js frontend ├── deps/ # Docker Compose files └── setup.sh # Automated setup script ``` --- ## 🚀 First-Time Setup ### 1. Clone the Repository ```bash git clone cd production-saas-starter ``` ### 2. Start Backend Services ```bash cd go-b2b-starter # Start PostgreSQL + Redis containers make run-deps # Wait for containers to be healthy, then run migrations make migrateup ``` ### 3. Configure Frontend Environment ```bash cd next_b2b_starter # Copy environment template cp .env.example .env.local # Edit .env.local and fill in: # - STYTCH_PROJECT_ID # - STYTCH_SECRET # - NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN # - POLAR_ACCESS_TOKEN (if using billing) ``` ### 4. Install Frontend Dependencies ```bash pnpm install ``` ### 5. Start Development Servers **Terminal 1 — Backend:** ```bash cd go-b2b-starter make dev ``` **Terminal 2 — Frontend:** ```bash cd next_b2b_starter pnpm dev ``` ### 6. Access the App | Service | URL | |---------|-----| | Frontend | http://localhost:3000 | | Backend API | http://localhost:8080 | | API Docs (Swagger) | http://localhost:8080/swagger/index.html | --- ## 📅 Daily Workflow ### Starting Your Day ```bash # Terminal 1: Start database containers (if not running) cd go-b2b-starter make run-deps # Terminal 2: Start backend with hot reload cd go-b2b-starter make dev # Terminal 3: Start frontend cd next_b2b_starter pnpm dev ``` ### Ending Your Day ```bash # Stop the Go server: Ctrl+C in Terminal 2 # Stop the Next.js server: Ctrl+C in Terminal 3 # Optional: Stop database containers (data persists) cd go-b2b-starter make stop-deps ``` --- ## 📋 Commands Cheat Sheet ### Backend (Go) Run from `go-b2b-starter/`: | Command | Description | |---------|-------------| | `make run-deps` | Start PostgreSQL + Redis containers | | `make stop-deps` | Stop and remove containers | | `make dev` | Start server with hot reload (Air) | | `make server` | Start server without hot reload | | `make migrateup` | Apply database migrations | | `make migratedown` | Rollback migrations | | `make create-migration MIGRATION_NAME=add_users` | Create new migration | | `make sqlc` | Generate Go code from SQL queries | | `make test` | Run tests | | `make swagger` | Regenerate Swagger docs | | `make build` | Build production binary | ### Frontend (Next.js) Run from `next_b2b_starter/`: | Command | Description | |---------|-------------| | `pnpm dev` | Start development server | | `pnpm build` | Build for production | | `pnpm start` | Start production server | | `pnpm lint` | Run ESLint | --- ## 🔌 Service Ports | Service | Port | Notes | |---------|------|-------| | Next.js Frontend | 3000 | Turbopack hot reload | | Go Backend | 8080 | Air hot reload | | PostgreSQL | 5432 | pgvector enabled | | Redis | 6379 | For caching/sessions | --- ## 🗄️ Database Access ### Connect via psql ```bash psql -h localhost -p 5432 -U user -d mydatabase # Password: password ``` ### Connect via GUI (TablePlus, DBeaver, etc.) | Field | Value | |-------|-------| | Host | localhost | | Port | 5432 | | User | user | | Password | password | | Database | mydatabase | ### Database Credentials Defined in `go-b2b-starter/deps/docker-compose.yml`: ```yaml POSTGRES_DB: mydatabase POSTGRES_USER: user POSTGRES_PASSWORD: password ``` --- ## 🔐 Environment Variables ### Backend The backend uses Docker environment variables defined in `deps/docker-compose.yml`. No `.env` file needed for local dev. ### Frontend Required variables in `.env.local`: ```bash # App URLs APP_BASE_URL=http://localhost:3000 NEXT_PUBLIC_APP_BASE_URL=http://localhost:3000 # Stytch B2B Auth (required) STYTCH_PROJECT_ID=project-test-xxx STYTCH_SECRET=secret-test-xxx STYTCH_PROJECT_ENV=test NEXT_PUBLIC_STYTCH_PROJECT_ENV=test NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=public-token-test-xxx # Polar Billing (optional for dev) POLAR_ACCESS_TOKEN= POLAR_WEBHOOK_SECRET= ``` Get Stytch credentials from: https://stytch.com/dashboard --- ## 🔧 Troubleshooting ### Docker containers won't start ```bash # Check if Docker is running docker info # Check container status docker ps -a # View container logs docker compose -f deps/docker-compose.yml logs postgres docker compose -f deps/docker-compose.yml logs redis # Nuclear option: remove everything and start fresh make stop-deps docker volume rm deps_postgres_data deps_redis_data make run-deps ``` ### Port already in use ```bash # Find what's using the port lsof -i :5432 # PostgreSQL lsof -i :8080 # Backend lsof -i :3000 # Frontend # Kill the process kill -9 ``` ### Migration errors ```bash # Check migration status docker compose -f deps/docker-compose.yml run --rm cli migrate -path ./internal/db/postgres/sqlc/migrations -database "postgresql://user:password@postgres:5432/mydatabase?sslmode=disable" version # Force to specific version if stuck docker compose -f deps/docker-compose.yml run --rm cli migrate -path ./internal/db/postgres/sqlc/migrations -database "postgresql://user:password@postgres:5432/mydatabase?sslmode=disable" force ``` ### SQLC generation fails ```bash # Make sure containers are running make run-deps # Check sqlc.yaml configuration cat internal/db/postgres/sqlc/sqlc.yaml # Run sqlc with verbose output docker compose -f deps/docker-compose.yml run --rm -w /workspace/internal/db/postgres/sqlc cli sqlc generate ``` ### Hot reload not working (Backend) Air watches for file changes. If it's not working: ```bash # Check Air is installed in container docker compose -f deps/docker-compose.yml run --rm cli which air # Restart with fresh build make stop-deps make run-deps make dev ``` ### Hot reload not working (Frontend) ```bash # Clear Next.js cache rm -rf .next # Restart pnpm dev ``` ### Can't connect to backend from frontend 1. Check backend is running: http://localhost:8080/health 2. Check CORS settings in backend 3. Verify API URL in frontend matches backend port --- ## 💡 Tips ### VS Code Extensions Recommended for this project: - **Go** — Go language support - **ESLint** — JavaScript/TypeScript linting - **Tailwind CSS IntelliSense** — Tailwind autocomplete - **Docker** — Docker file support - **PostgreSQL** — SQL syntax highlighting ### Multiple Terminals Use a terminal multiplexer or VS Code's integrated terminals: ``` ┌─────────────────────────────────────────┐ │ Terminal 1: make run-deps (background) │ ├─────────────────────────────────────────┤ │ Terminal 2: make dev (backend) │ ├─────────────────────────────────────────┤ │ Terminal 3: pnpm dev (frontend) │ └─────────────────────────────────────────┘ ``` ### Fast Iteration Cycle 1. Make code changes 2. Save file (hot reload triggers) 3. Test in browser 4. Repeat No manual restart needed thanks to Air (Go) and Turbopack (Next.js). ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Mohammed Salim Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ⭐ Production SaaS Starter Kit The Enterprise-Grade SaaS boilerplate for serious founders. Built with **Next.js 16** and **Go 1.25**. [![Go Report Card](https://goreportcard.com/badge/github.com/moasq/production-saas-starter)](https://goreportcard.com/report/github.com/moasq/production-saas-starter) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) ![Dashboard Preview](docs/dashboard.png) ## 🛠️ Built With ### Frontend Stack - **[Next.js 16](https://nextjs.org)** (v16.0.10) Modern React framework with App Router and API routes. - **[React 19](https://react.dev)** (v19.2.3) Latest React with improved performance and concurrent features. - **[TypeScript](https://www.typescriptlang.org)** (v5.7.3) Type-safe JavaScript for enhanced developer experience. - **[Tailwind CSS](https://tailwindcss.com)** (v3.4.17) Utility-first CSS framework for rapid UI development. - **[shadcn/ui](https://ui.shadcn.com)** + **Radix UI** Accessible component library with 29+ pre-built components. - **[TanStack Query](https://tanstack.com/query)** (v5.90.5) Powerful data fetching and state management. - **[Zustand](https://zustand-demo.pmnd.rs)** (v5.0.8) Lightweight state management for UI state. - **[react-hook-form](https://react-hook-form.com)** + **[Zod](https://zod.dev)** Type-safe forms with schema validation. - **[Stytch](https://stytch.com)** Enterprise authentication with magic links, OAuth, and SSO. - **[Polar.sh](https://polar.sh)** Billing integration and subscription management. - **[Recharts](https://recharts.org)** Composable charting library for data visualization. ### Backend Stack - **[Go 1.25](https://go.dev)** High-performance, concurrent backend with excellent tooling. - **[Gin](https://gin-gonic.com)** Fast HTTP web framework with middleware support. - **[PostgreSQL](https://www.postgresql.org)** with **[pgvector](https://github.com/pgvector/pgvector)** Reliable relational database with vector similarity search. - **[SQLC](https://sqlc.dev)** Type-safe SQL compiler for Go (no ORM). - **[Stytch B2B](https://stytch.com)** Enterprise authentication, SSO, and RBAC. - **[Polar.sh](https://polar.sh)** Merchant of Record for subscriptions, invoicing, and global tax compliance. - **[OpenAI API](https://openai.com)** LLM integration with RAG pipeline and vector embeddings. - **[Mistral AI](https://mistral.ai)** OCR service for document data extraction. - **[Cloudflare R2](https://www.cloudflare.com/products/r2/)** Object storage for file management. - **[Docker](https://www.docker.com)** + **Docker Compose** Containerization for consistent environments. ## 🥇 Features - **Authentication**: Sign in with Magic Link, Google OAuth, and Enterprise SSO. - **Multi-Tenancy**: Built-in Organization support with strict data isolation. - **Roles & Permissions**: Granular RBAC system with 3 roles (Member, Manager, Admin) and 7 permission types. - **Billing & Subscriptions**: Complete integration with Polar.sh for SaaS pricing models. - **AI & RAG**: Ready-to-use vector embeddings pipeline for AI features. - **OCR Service**: Extract structured data from valid documents instantly. - **Team Management**: Invite members, manage roles, and update settings. - **Responsive Design**: Mobile-first UI built with Tailwind CSS and shadcn/ui. - **Type Safety**: End-to-end type safety from database (SQLC) to frontend (TypeScript). ## ➡️ Coming Soon - **Audit Logs**: Complete audit logging system for tracking user activities. - **Webhooks UI**: Customer-facing webhook configuration. - **Advanced Analytics**: Built-in charts and usage tracking. ## ✨ Getting Started Please follow these simple steps to get a local copy up and running. ### Prerequisites - **Docker** & **Docker Compose** - **Go 1.25+** - **Node.js 20+** & **pnpm** ### The One-Line Setup Run this command to configure your keys and start the infrastructure: ```bash chmod +x setup.sh && ./setup.sh ``` **Manual Start:** 1. **Backend:** `cd go-b2b-starter && make dev` 2. **Frontend:** `cd next_b2b_starter && pnpm dev` 3. **Visit:** [http://localhost:3000](http://localhost:3000) > [!IMPORTANT] > See **[SETUP.md](./SETUP.md)** for quick setup or **[DEVELOPMENT.md](./DEVELOPMENT.md)** for comprehensive guidance including multi-platform prerequisites, troubleshooting, and daily workflow tips. ## 🛡️ License [MIT License](./LICENSE) ## 👯 Consulting & Services Although this kit is self-service, I help ambitious founders move faster. **I can help you with:** 1. **Managed Config:** I sets up your AWS/GCP production environment so you never touch DevOps. 2. **Custom Features:** Need SAML SSO or complex RAG flows? I'll build them directly into your repo. 3. **Code Audits:** Migrating from Node/Python? I'll review your architecture for scale. **[m.salim@apflowhq.com](mailto:m.salim@apflowhq.com)** • [**@foundmod**](https://x.com/foundmod) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Use this section to tell people about which versions of your project are currently being supported with security updates. | Version | Supported | | ------- | ------------------ | | 1.0.x | :white_check_mark: | | < 1.0 | :x: | ## Reporting a Vulnerability We take the security of this starter kit seriously. If you find a vulnerability, please **DO NOT** open a public issue. ================================================ FILE: SETUP.md ================================================ # 🛠️ Setup Guide > **Looking for detailed setup?** See **[DEVELOPMENT.md](./DEVELOPMENT.md)** for comprehensive guidance including multi-platform prerequisites, troubleshooting, database access, and daily workflow tips. This document covers the manual steps to verify your environment if `setup.sh` is not sufficient. ## 1. Environment Variables The kit comes with example files. You need to copy them to the "live" filenames. ### Backend (`go-b2b-starter`) ```bash cp go-b2b-starter/example.env go-b2b-starter/app.env ``` Open `app.env` and fill in the keys: * `DB_SOURCE`: Your Postgres connection string. * `STYTCH_PROJECT_ID`: From Stytch Dashboard. * `POLAR_ACCESS_TOKEN`: From Polar.sh. ### Frontend (`next_b2b_starter`) ```bash cp next_b2b_starter/.env.example next_b2b_starter/.env.local ``` Update `.env.local` with your public API keys. ## 2. Docker Dependencies If you prefer running dependencies manually (without `setup.sh`): ```bash cd go-b2b-starter docker compose -f deps/docker-compose.yml up -d postgres redis ``` ## 3. Database Migrations Once Docker is running, you must apply the schema: ```bash cd go-b2b-starter make migrateup ``` ## 4. Troubleshooting If the backend fails to start, verify that Redis is reachable on port `6379`. ================================================ FILE: docker-compose.production.yml ================================================ # Production Docker Compose # Copy to docker-compose.yml and configure with your .env file # # Required environment variables in .env: # - POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB # - STYTCH_PROJECT_ID, STYTCH_SECRET, STYTCH_PROJECT_ENV # - NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN, NEXT_PUBLIC_STYTCH_PROJECT_ID # - NEXT_PUBLIC_APP_BASE_URL # - POLAR_ACCESS_TOKEN (if using billing) services: # ============================================================================= # Reverse Proxy - Routes traffic between frontend and backend # ============================================================================= caddy: image: caddy:2-alpine ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config environment: - DOMAIN=${DOMAIN:-localhost} depends_on: - frontend - backend restart: unless-stopped # ============================================================================= # Frontend - Next.js application # ============================================================================= frontend: build: context: ./next_b2b_starter dockerfile: Dockerfile args: - NEXT_PUBLIC_APP_BASE_URL=${NEXT_PUBLIC_APP_BASE_URL} - NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-/api} - NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=${NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN} - NEXT_PUBLIC_STYTCH_PROJECT_ID=${NEXT_PUBLIC_STYTCH_PROJECT_ID} - NEXT_PUBLIC_STYTCH_PROJECT_ENV=${NEXT_PUBLIC_STYTCH_PROJECT_ENV:-test} - NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN=${NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN:-} - NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN=${NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN:-} environment: # Runtime environment variables for Server Actions - API_BASE_URL_INTERNAL=http://backend:8080/api - STYTCH_PROJECT_ID=${STYTCH_PROJECT_ID} - STYTCH_SECRET=${STYTCH_SECRET} - STYTCH_PROJECT_ENV=${STYTCH_PROJECT_ENV:-test} expose: - "3000" depends_on: - backend restart: unless-stopped # ============================================================================= # Backend - Go API server # ============================================================================= backend: build: context: ./go-b2b-starter dockerfile: Dockerfile environment: - POSTGRES_HOST=postgres - POSTGRES_PORT=5432 - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} - REDIS_HOST=redis - REDIS_PORT=6379 - STYTCH_PROJECT_ID=${STYTCH_PROJECT_ID} - STYTCH_SECRET=${STYTCH_SECRET} - STYTCH_PROJECT_ENV=${STYTCH_PROJECT_ENV:-test} - POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-} expose: - "8080" depends_on: postgres: condition: service_healthy redis: condition: service_started restart: unless-stopped # ============================================================================= # Database - PostgreSQL with pgvector # ============================================================================= postgres: image: pgvector/pgvector:pg17 environment: - POSTGRES_DB=${POSTGRES_DB} - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped # ============================================================================= # Cache - Redis # ============================================================================= redis: image: redis:alpine volumes: - redis_data:/data restart: unless-stopped volumes: postgres_data: redis_data: caddy_data: caddy_config: ================================================ FILE: go-b2b-starter/.air.toml ================================================ root = "." testdata_dir = "testdata" tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" cmd = "go build -o ./tmp/main ./cmd/api/main.go" delay = 1000 exclude_dir = ["tmp", "vendor", "testdata", "frontend-starter", "next_b2b_starter", "deps", "src"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html"] include_file = [] kill_delay = "0s" log = "build-errors.log" poll = false poll_interval = 0 rerun = false rerun_delay = 500 send_interrupt = false stop_on_error = false [color] app = "" build = "yellow" main = "magenta" runner = "green" watcher = "cyan" [log] main_only = false time = false [misc] clean_on_exit = true [screen] clear_on_rebuild = false keep_scroll = true ================================================ FILE: go-b2b-starter/.claude/CLAUDE.md ================================================ # CLAUDE.md AI instructions for working with this Go B2B SaaS Starter Kit codebase. ## Project Overview Go B2B SaaS Starter Kit - Invoice-to-pay lifecycle automation with AI-powered data extraction, duplicate detection, and payment optimization via Polar.sh integration. **Architecture**: Modular monolith following Clean Architecture with clear module boundaries. **Layers**: - `internal/modules/*/` - Feature modules (domain/app/infra layers per module) - `internal/platform/` - Cross-cutting concerns (logger, server, etc.) - `internal/db/` - Database layer (SQLC adapters, DI registration) - `internal/bootstrap/` - Application initialization - `pkg/` - Shared utility packages (httperr, response) - `cmd/api/` - Application entry point **Core Patterns**: - Clean Architecture (domain → app → infra) - Dependency Injection (uber-go/dig) - Repository Pattern (domain interfaces → store adapters) - Adapter Pattern (limit SQLC exposure) ## Commands ```bash # Development make server # Run dev server make build # Build binary make deps # Update Go dependencies # Database make run-deps # Start PostgreSQL in Docker make migrateup # Apply migrations make migratedown # Rollback migrations make sqlc # Generate Go code from SQL # Testing make test # Run tests with coverage # Docker make run-stack # Start full stack make restart-app # Restart app container ``` ## Database Layer (internal/db/) **Structure**: ``` internal/db/ ├── adapters/ # Legacy adapter interfaces (being phased out) ├── postgres/ │ ├── sqlc/ │ │ ├── migrations/ # SQL migration files │ │ ├── query/ # SQL queries with SQLC annotations │ │ └── gen/ # Generated code (DO NOT EDIT) │ ├── adapter_impl/ # Legacy adapter implementations │ └── postgres.go # DB connection and pooling └── inject.go # DI registration for all domain repositories ``` **Key Principle**: Domain interfaces (defined in `internal/modules/*/domain/`) are implemented by repositories in `internal/modules/*/infra/repositories/`. The `internal/db/inject.go` registers these implementations in the DI container. **SQLC Workflow**: ``` internal/db/postgres/sqlc/ ├── migrations/ # SQL migration files ├── query/ # SQL queries with SQLC annotations └── gen/ # Generated code (DO NOT EDIT) ``` ### Adding Database Operations **1. Define domain interface** in your module (`internal/modules/{module}/domain/repository.go`): ```go package domain type UserRepository interface { GetByID(ctx context.Context, orgID, userID int32) (*User, error) Create(ctx context.Context, user *User) (*User, error) Update(ctx context.Context, user *User) (*User, error) Delete(ctx context.Context, orgID, userID int32) error } ``` **2. Write SQL query** (`internal/db/postgres/sqlc/query/{domain}.sql`): ```sql -- name: GetUserByID :one SELECT * FROM users WHERE organization_id = $1 AND id = $2; -- name: CreateUser :one INSERT INTO users (organization_id, email, full_name) VALUES ($1, $2, $3) RETURNING *; ``` **3. Generate SQLC code**: ```bash make sqlc ``` **4. Implement repository** (`internal/modules/{module}/infra/repositories/{domain}_repository.go`): ```go package repositories import ( "github.com/moasq/go-b2b-starter/internal/modules/{module}/domain" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) type userRepository struct { store sqlc.Store } func NewUserRepository(store sqlc.Store) domain.UserRepository { return &userRepository{store: store} } func (r *userRepository) GetByID(ctx context.Context, orgID, userID int32) (*domain.User, error) { dbUser, err := r.store.GetUserByID(ctx, sqlc.GetUserByIDParams{ OrganizationID: orgID, ID: userID, }) if err != nil { return nil, err } // Map SQLC type to domain type return &domain.User{ ID: dbUser.ID, OrganizationID: dbUser.OrganizationID, Email: dbUser.Email, FullName: dbUser.FullName, }, nil } ``` **5. Register in DI** (`internal/db/inject.go`): ```go import ( userDomain "github.com/moasq/go-b2b-starter/internal/modules/users/domain" userRepos "github.com/moasq/go-b2b-starter/internal/modules/users/infra/repositories" ) // In registerDomainStores function: if err := container.Provide(func(sqlcStore sqlc.Store) userDomain.UserRepository { return userRepos.NewUserRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide user repository: %w", err) } ``` **Why This Architecture**: - Domain defines interfaces (Dependency Inversion Principle) - Infra implements these interfaces using SQLC - SQLC types never leak out of the infra layer - Easy to mock for testing (depend on interface, not implementation) - Clear separation of concerns **Error Handling** (`core/errors.go`): - `ErrNoRows`, `ErrTxClosed`, `ErrPoolClosed`, `ErrInvalidConnection`, `ErrTimeout` - Helpers: `IsNoRowsError()`, `IsConstraintError()`, `IsTimeoutError()` ## Authentication (internal/modules/auth/) **Provider-agnostic auth with Stytch integration**. Type-safe middleware for JWT verification, RBAC, and multi-tenant org context. **Core Types**: - `Identity` - User info from auth provider (email, roles, permissions) - `RequestContext` - Resolved database IDs (OrganizationID, AccountID) - `Permission` - Format `"resource:action"` (e.g., `"invoice:create"`) **Middleware Setup**: ```go authMiddleware := auth.NewMiddleware(authProvider, orgResolver, accResolver, nil) // Apply middleware router.Use(authMiddleware.RequireAuth()) // Verify JWT router.Use(authMiddleware.RequireOrganization()) // Resolve org/account IDs ``` **Route Protection**: ```go // Permission-based router.POST("/invoices", auth.RequirePermissionFunc("invoice", "create"), handler.CreateInvoice) // Role-based router.DELETE("/orgs/:id", authMiddleware.RequireRole(auth.RoleAdmin), handler.DeleteOrg) ``` **Handler Context Access**: ```go func (h *Handler) MyHandler(c *gin.Context) { reqCtx := auth.GetRequestContext(c) orgID := reqCtx.OrganizationID // int32 accountID := reqCtx.AccountID // int32 email := reqCtx.Identity.Email // string // Or use convenience functions orgID := auth.GetOrganizationID(c) // Safe: returns 0 if not set accountID := auth.GetAccountID(c) // Safe: returns 0 if not set } ``` **Common Permissions** (`permissions.go`): ```go auth.PermInvoiceCreate // "invoice:create" auth.PermInvoiceView // "invoice:view" auth.PermInvoiceDelete // "invoice:delete" auth.PermOrgView // "org:view" auth.PermOrgManage // "org:manage" ``` **Configuration** (environment variables): ```env STYTCH_PROJECT_ID=project-test-xxx-xxx # Required STYTCH_SECRET=secret-test-xxx # Required STYTCH_ENV=test # Optional: "test" or "live" ``` **See**: `internal/modules/auth/README.md` for detailed usage patterns and examples. ## File Manager (internal/modules/files/) **Dual architecture**: Cloudflare R2 (object storage) + PostgreSQL (searchable metadata). **Components**: - `FileRepository` - Combined operations (upload, download, delete, search) - `R2Repository` - R2 object storage operations - `FileMetadataRepository` - Database metadata operations - `FileService` - Business logic with validation **Upload with Entity Linking**: ```go req := &domain.FileUploadRequest{ Filename: "invoice_001.pdf", ContentType: "application/pdf", Context: file_manager.ContextInvoice, } file := &domain.FileAsset{ EntityType: "invoice", EntityID: invoiceID, } uploadedFile, err := fileService.UploadFile(ctx, req, fileReader) ``` **Search Operations**: ```go files, err := fileRepo.GetByEntity(ctx, "invoice", invoiceID) documents, err := fileRepo.GetByCategory(ctx, file_manager.CategoryDocument, 10, 0) receipts, err := fileRepo.GetByContext(ctx, file_manager.ContextReceipt, 20, 0) ``` **Atomic Transactions**: 1. Save metadata to DB (get ID) 2. Upload file to R2 (using DB ID in key) 3. Update metadata with storage path 4. Automatic rollback on failure ## Go Coding Standards ### Core Rules **Use `any` instead of `interface{}`** (Go 1.18+): ```go // ✅ Good func ProcessData(data any) error type Request struct { Metadata map[string]any } // ❌ Bad func ProcessData(data interface{}) error ``` **Error Wrapping**: ```go // ✅ Good if err := repo.Create(ctx, invoice); err != nil { return fmt.Errorf("failed to create invoice %d: %w", invoice.ID, err) } ``` **Context First**: ```go // ✅ Good func (s *service) ProcessInvoice(ctx context.Context, invoiceID int32) error ``` **Naming**: - Packages: lowercase, single word (`invoice`, not `invoice_mgmt`) - Interfaces: noun/adjective + "er" (`Repository`, `Handler`) - Structs: PascalCase (`InvoiceService`, `PaymentRequest`) - Methods: PascalCase verbs (`CreateInvoice`, `ValidateData`) ### Struct Organization ```go type Invoice struct { // Identifiers first ID int32 `json:"id" db:"id"` InvoiceNumber string `json:"invoice_number"` // Core business data Amount decimal.Decimal `json:"amount"` DueDate time.Time `json:"due_date"` // References VendorID int32 `json:"vendor_id"` // Timestamps last CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } ``` ### Dependency Injection ```go // ✅ Constructor returns interface func NewInvoiceService( repo domain.InvoiceRepository, logger logger.Logger, ) domain.InvoiceService { return &invoiceService{repo: repo, logger: logger} } // ✅ Register in DI container.Provide(NewInvoiceService) ``` ### Event-Driven Patterns ```go // ✅ Events are past tense, include all data type InvoiceCreatedEvent struct { BaseEvent InvoiceID int32 `json:"invoice_id"` Amount decimal.Decimal `json:"amount"` CreatedAt time.Time `json:"created_at"` } ``` ### Testing ```go // ✅ Table-driven tests func TestInvoiceValidation(t *testing.T) { tests := []struct { name string invoice *Invoice wantErr bool }{ {"valid", &Invoice{Amount: decimal.NewFromInt(100)}, false}, {"negative", &Invoice{Amount: decimal.NewFromInt(-100)}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.invoice.Validate() if (err != nil) != tt.wantErr { t.Errorf("got error = %v, want %v", err, tt.wantErr) } }) } } ``` ## API Development Pattern **ALWAYS follow this pattern** for consistency and Clean Architecture compliance. ### Implementation Steps **1. Database Layer** (if new data needed): - Add SQLC queries: `internal/db/postgres/sqlc/query/{domain}.sql` - Run `make sqlc` - Define repository interface in `internal/modules/{module}/domain/repository.go` - Implement repository in `internal/modules/{module}/infra/repositories/{domain}_repository.go` - Register in DI: `internal/db/inject.go` **2. Domain Layer** (`internal/modules/{module}/domain/`): - Create entities with business types - Define repository interfaces - Add validation methods **3. Infrastructure** (`internal/modules/{module}/infra/repositories/`): - Implement repository interfaces using SQLC - Map SQLC types ↔ domain types (never expose SQLC outside infra) - Handle transactions **4. Application** (`internal/modules/{module}/app/services/`): - Define request/response DTOs - Add service interface - Implement business logic **5. API Layer** (`internal/modules/{module}/`): - Add handler with validation (`handler.go`) - Add Swagger annotations (see Swagger Best Practices below) - Register routes (`routes.go`) - Wire dependencies in module initialization ### Required Handler Pattern ```go func (h *Handler) OperationName(c *gin.Context) { // 1. Extract path params var entityID int32 if _, err := fmt.Sscanf(c.Param("id"), "%d", &entityID); err != nil { c.JSON(400, httperr.NewHTTPError(400, "invalid_id", "Invalid ID")) return } // 2. Get auth context reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(401, httperr.NewHTTPError(401, "unauthorized", "Auth required")) return } // 3. Bind request (if needed) var req models.RequestDto if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, httperr.NewHTTPError(400, "invalid_request", err.Error())) return } // 4. Call service response, err := h.service.Operation(c.Request.Context(), reqCtx.OrganizationID, req) if err != nil { c.JSON(500, httperr.NewHTTPError(500, "operation_failed", err.Error())) return } // 5. Return response c.JSON(200, response) } ``` ### Swagger Best Practices **CRITICAL**: Always use local type references in swagger annotations. Never use full package paths. **✅ Correct**: ```go // @Success 200 {object} domain.User "User details" // @Success 201 {object} services.CreateUserResponse "Created user" // @Failure 400 {object} httperr.HTTPError "Bad request" // @Param request body services.CreateUserRequest true "User data" ``` **❌ Wrong**: ```go // @Success 200 {object} github_com_moasq_go-b2b-starter_internal_modules_users_domain.User // @Failure 400 {object} errors.HTTPError // Wrong package name ``` **Common Patterns**: ```go // Handler in internal/modules/users/handler.go import ( "github.com/moasq/go-b2b-starter/internal/modules/users/domain" "github.com/moasq/go-b2b-starter/internal/modules/users/app/services" "github.com/moasq/go-b2b-starter/pkg/httperr" ) // @Summary Create user // @Description Creates a new user in the organization // @Tags users // @Accept json // @Produce json // @Param request body services.CreateUserRequest true "User data" // @Success 201 {object} domain.User "Created user" // @Failure 400 {object} httperr.HTTPError "Invalid request" // @Failure 500 {object} httperr.HTTPError "Internal error" // @Router /api/users [post] func (h *Handler) CreateUser(c *gin.Context) { // Implementation } ``` **Docker Compose Best Practices**: When using docker-compose for CLI tools, use `${PWD}` for volume mounts: ```yaml cli: volumes: - ${PWD}:/workspace # ✅ Correct - uses current directory # - ../:/workspace # ❌ Wrong - relative paths don't work consistently working_dir: /workspace ``` ### Required Service Pattern ```go func (s *service) Operation(ctx context.Context, orgID int32, req *Request) (*Response, error) { // 1. Validate if err := req.Validate(); err != nil { return nil, err } // 2. Execute business logic result, err := s.repo.Operation(ctx, orgID, req) if err != nil { return nil, fmt.Errorf("operation failed: %w", err) } // 3. Return return result, nil } ``` ### Required Middleware Pattern ```go // In routes.go apiGroup := router.Group("/{domain}") apiGroup.Use( authMiddleware.RequireAuth(), authMiddleware.RequireOrganization(), ) { apiGroup.POST("/path", auth.RequirePermissionFunc("{resource}", "{action}"), h.HandlerMethod) } ``` ### Mandatory Requirements - **Context**: First parameter in all I/O operations - **Error Wrapping**: `fmt.Errorf("context: %w", err)` - **Validation**: At both entity and service levels - **Logging**: Structured logging for operations - **Middleware**: Auth, org context, permissions - **Swagger**: Complete API documentation - **Transactions**: Atomic operations where needed ### Testing Requirements - Unit tests with mocked dependencies - Integration tests with database - Validation tests for all error scenarios - Permission tests for access control ## Dependency Management **Rule**: Use interface abstractions when dependencies aren't ready. ```go // ✅ Good - Depend on interface type OCRService interface { ExtractData(ctx context.Context, fileID int32) (map[string]any, error) } // ✅ Good - Event-driven integration func NewInvoiceService(eventBus eventbus.EventBus) InvoiceService { // Publish events; other modules subscribe when ready } // ❌ Bad - Don't inject concrete types that don't exist func NewInvoiceService(ocrService *OCRServiceImpl) InvoiceService { // Fails if OCRServiceImpl doesn't exist } ``` ## Project Structure ``` go-b2b-starter/ ├── cmd/api/ # Application entry point ├── internal/ │ ├── bootstrap/ # App initialization and wiring │ ├── db/ # Database layer (SQLC, DI registration) │ │ ├── postgres/sqlc/ # SQLC queries and generated code │ │ ├── adapters/ # Legacy adapters (being phased out) │ │ └── inject.go # Repository DI registration │ ├── modules/ # Feature modules │ │ ├── {module}/ │ │ │ ├── domain/ # Entities, interfaces, validation │ │ │ ├── app/services/ # Business logic (use cases) │ │ │ ├── infra/ # Repository implementations │ │ │ ├── handler.go # HTTP handlers │ │ │ ├── routes.go # Route definitions │ │ │ └── module.go # Module DI setup │ │ ├── auth/ # Authentication & RBAC │ │ ├── billing/ # Polar.sh subscriptions │ │ ├── organizations/ # Multi-tenant org management │ │ ├── documents/ # PDF document management │ │ ├── cognitive/ # RAG and embeddings │ │ ├── files/ # File storage (R2 + metadata) │ │ └── paywall/ # Subscription access gating │ └── platform/ # Cross-cutting concerns │ ├── logger/ # Structured logging │ ├── server/ # HTTP server │ ├── eventbus/ # Event pub-sub │ ├── llm/ # LLM client │ ├── ocr/ # OCR service │ ├── redis/ # Redis client │ └── stytch/ # Stytch B2B client └── pkg/ # Public shared utilities ├── httperr/ # HTTP error responses ├── pagination/ # Pagination helpers ├── response/ # Standard API responses └── slugify/ # Slug generation utilities ``` ## Billing & Paywall (internal/modules/billing/) **Polar.sh integration** with hybrid sync for subscriptions. **Sync Strategy**: 1. **Webhooks** (primary) - Real-time updates from Polar.sh 2. **Active Verification** - Poll after checkout redirect 3. **Lazy Guarding** - Verify with API if local data suggests expired **Paywall Middleware**: ```go // Require active subscription premiumGroup := router.Group("/premium") premiumGroup.Use( resolver.Get("auth"), resolver.Get("org_context"), resolver.Get("paywall"), // RequireActiveSubscription ) // Optional subscription info publicGroup.Use(resolver.Get("paywall_optional")) ``` **Quota Management**: ```go // Check and consume quota status, err := billingService.ConsumeInvoiceQuota(ctx, orgID) if err == domain.ErrInsufficientQuota { // Handle quota exhausted } ``` ## Event Bus (internal/platform/eventbus/) **In-memory event bus** for loose coupling between modules. **Define Event**: ```go type DocumentUploadedEvent struct { eventbus.BaseEvent DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` } func NewDocumentUploadedEvent(docID, orgID int32) *DocumentUploadedEvent { return &DocumentUploadedEvent{ BaseEvent: eventbus.NewBaseEvent("document.uploaded"), DocumentID: docID, OrganizationID: orgID, } } ``` **Publish**: ```go event := NewDocumentUploadedEvent(doc.ID, orgID) eventBus.Publish(ctx, event) // Fire-and-forget ``` **Subscribe**: ```go eventBus.Subscribe("document.uploaded", func(ctx context.Context, event eventbus.Event) error { docEvent := event.(*DocumentUploadedEvent) return embeddingService.GenerateForDocument(ctx, docEvent.DocumentID) }) ``` ## Module Initialization Order **File**: `internal/bootstrap/bootstrap.go` Order matters due to dependencies: ```go // Phase 1: Infrastructure (no dependencies) logger.Inject(container) server.Inject(container) db.Inject(container) // Registers all domain repositories // Phase 2: Platform Services redis.Inject(container) llm.Inject(container) ocr.Inject(container) polar.Inject(container) eventbus.Inject(container) // Phase 3: Module Dependencies (order critical!) files.SetupDependencies(container) // File storage auth.SetupDependencies(container) // Auth, RBAC, resolvers organizations.RegisterDependencies(container) billing.Configure(container) cognitive.RegisterDependencies(container) documents.RegisterDependencies(container) // Phase 4: Event Subscriptions cognitive.SetupEventSubscriptions(container) // Phase 5: HTTP Server Setup server.SetupMiddleware(container) ``` ## Named Middlewares Access middleware by name in routes: ```go func (r *Routes) Routes(router *gin.RouterGroup, resolver server.MiddlewareResolver) { group := router.Group("/api") group.Use( resolver.Get("auth"), // RequireAuth resolver.Get("org_context"), // RequireOrganization resolver.Get("paywall"), // RequireActiveSubscription resolver.Get("paywall_optional"), // OptionalSubscriptionStatus resolver.Get("subscription"), // Deprecated alias ) } ``` ## Configuration Environment-based configuration using `app.env` and `example.env`. Docker Compose for local dependencies. ## Documentation Comprehensive documentation available in `docs/`: - `docs/README.md` - Overview and quick start - `docs/architecture.md` - Clean Architecture patterns - `docs/database.md` - SQLC workflow and migrations - `docs/authentication.md` - Auth, RBAC, Stytch integration - `docs/billing.md` - Polar.sh and paywall - `docs/file-manager.md` - R2 file storage - `docs/event-bus.md` - Event-driven patterns - `docs/api-development.md` - Step-by-step API guide - `docs/modules/` - Module-specific documentation ================================================ FILE: go-b2b-starter/.dockerignore ================================================ # Environment and configuration files app.env .env .air.toml example.env # Version control .git .gitignore .gitlab-ci.yml # IDE and editor files .idea .vscode .claude # Project files Makefile README.md # Directories docs/ deps/ deployment/ scripts/ tmp/ bin/ storage/ .scannerwork/ coverage/ /src/pkg/db/postgres/seed /src/pkg/db/postgres/sqlc/migrations /src/pkg/db/postgres/sqlc/query /src/pkg/db/postgres/sqlc.yml # Build artifacts *.log *.out *.env *.sql # Docker files Dockerfile docker-compose.yml ================================================ FILE: go-b2b-starter/.gitignore ================================================ /api ================================================ FILE: go-b2b-starter/.gitlab-ci.yml ================================================ stages: - test - build run-tests: stage: test image: golang:1.22.4 script: - mkdir -p coverage - bash scripts/run_tests_with_coverage.sh artifacts: paths: - coverage/ reports: coverage_report: coverage_format: cobertura path: coverage/coverage.xml coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/' build-image: stage: build image: docker:latest services: - docker:dind script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest - docker push $CI_REGISTRY_IMAGE:latest rules: - if: $CI_COMMIT_BRANCH == "main" when: on_success ================================================ FILE: go-b2b-starter/Dockerfile ================================================ # Builder stage FROM golang:1.25-alpine3.20 AS builder WORKDIR /app # Install build dependencies RUN apk add --no-cache git # Copy go mod and sum files first for better caching COPY go.mod go.sum ./ RUN go mod download # Copy the source code COPY . . # Build the application with additional flags for production RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /main ./cmd/api/main.go # Final stage - using Alpine for smaller image with necessary system files FROM alpine:3.20 # Install necessary packages and clean up RUN apk add --no-cache ca-certificates tzdata && \ rm -rf /var/cache/apk/* # Create non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app # Copy only the binary from builder COPY --from=builder /main /app/main # Set proper permissions RUN chown -R appuser:appgroup /app && \ chmod +x /app/main # Use non-root user USER appuser # Image metadata LABEL org.opencontainers.image.title="B2B SaaS Starter Backend" \ org.opencontainers.image.description="Go backend for B2B SaaS Starter" \ org.opencontainers.image.vendor="B2B SaaS Starter" \ org.opencontainers.image.version="1.0.0" \ org.opencontainers.image.source="https://github.com/yourusername/b2b-saas-starter" # Expose the port your app runs on EXPOSE 8080 # Command to run the application ENTRYPOINT ["/app/main"] ================================================ FILE: go-b2b-starter/Makefile ================================================ COMPOSE_FILE := deps/docker-compose.yml MIGRATION_PATH ?= schema/migration POSTGRES_HOST ?= localhost POSTGRES_PORT ?= 5432 POSTGRES_DB ?= mydatabase POSTGRES_USER ?= user POSTGRES_PASSWORD ?= password CONTAINER_NAME ?= deps-postgis-1 MIGRATION_NAME ?= init_schema MIGRATION_DIR ?= ./internal/db/postgres/sqlc/migrations SQLC_DIR ?= internal/db/postgres/sqlc # Start the necessary docker containers run-deps: docker compose -f $(COMPOSE_FILE) up --build -d # Stop and remove docker containers stop-deps: docker compose -f $(COMPOSE_FILE) down -v # Create a new database migration file create-migration: @docker compose -f $(COMPOSE_FILE) run --rm cli migrate create -ext sql -dir $(MIGRATION_DIR) -seq $(MIGRATION_NAME) @echo "Migration created in $(MIGRATION_DIR) with name $(MIGRATION_NAME)" # Apply all up migrations # Apply all up migrations migrateup: @docker compose -f $(COMPOSE_FILE) run --rm cli migrate -path $(MIGRATION_DIR) -database "postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable" -verbose up # Apply all down migrations migratedown: @docker compose -f $(COMPOSE_FILE) run --rm cli migrate -path $(MIGRATION_DIR) -database "postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable" -verbose down sqlc: @docker compose -f $(COMPOSE_FILE) run --rm -w /workspace/$(SQLC_DIR) cli sqlc generate # Create a new module create-module: bash scripts/create_module.sh $(type) $(name) if [ "$(db)" = "postgres" ]; then bash scripts/setup_db.sh $(type) $(name); fi # Run the server server: go run ./cmd/api/main.go # build the app build: go build -o bin/api ./cmd/api/main.go # install dependencies deps: go mod tidy # swagger swagger: @docker compose -f $(COMPOSE_FILE) run --rm cli swag init -g cmd/api/main.go -d . --parseDependency --parseInternal -o internal/docs/gen # Run the server with Air (Live Reload) dev: @docker compose -f $(COMPOSE_FILE) run --rm -T --service-ports cli air reload-profile: @source ~/.bashrc test: @bash scripts/run_tests_with_coverage.sh # Clear RBAC and JWKS caches from Redis clear-rbac-cache: @echo "Clearing RBAC and JWKS Redis caches..." @redis-cli DEL "stytch:rbac:policy" || echo " ✗ Failed to clear stytch:rbac:policy (may not exist)" @redis-cli DEL "stytch:jwks:cache" || echo " ✗ Failed to clear stytch:jwks:cache (may not exist)" @echo "✓ Cache clearing attempted (caches will auto-expire if not manually cleared)" .PHONY: \ build \ clear-rbac-cache \ create-migration \ create-module \ create-seed-country \ deps \ generate-seed-file \ generate-changed-seed-file \ generate-migrations-file \ generate-down-migrations-file \ migratedown \ migrateup \ push-to-do \ reload-profile \ run-deps \ seed-db \ server \ sonar-scanner \ sqlc \ swagger \ test ================================================ FILE: go-b2b-starter/README.md ================================================ # Go B2B Starter Backend Professional Modular Monolith backend for B2B SaaS using idiomatic Go project layout. ## ⚡️ Quick Start ```bash # 1. Start dependencies (Postgres, Redis) cd deps && docker-compose up -d postgres redis # 2. Copy environment config cp example.env app.env # 3. Run migrations make migrateup # 4. Start server with live reload make dev ``` ## 🏗 Project Layout (Go Standard 2026) ``` go-b2b-starter/ ├── cmd/ │ └── api/ # Application entry point │ └── main.go │ ├── internal/ # Private application code │ ├── bootstrap/ # App initialization & DI wiring │ ├── api/ # API route registration │ │ │ ├── auth/ # Authentication & RBAC │ ├── billing/ # Subscription & billing │ ├── organizations/ # Multi-tenant org management │ ├── documents/ # PDF document handling │ ├── cognitive/ # AI/RAG chat features │ │ │ ├── db/ # Database connections & SQLC │ ├── server/ # HTTP server & middleware │ ├── redis/ # Redis client │ └── stytch/ # Stytch B2B auth adapter │ ├── pkg/ # Public reusable packages │ ├── httperr/ # HTTP error types │ ├── pagination/ # Pagination helpers │ ├── response/ # API response helpers │ └── slugify/ # String utilities │ ├── deps/ # Docker Compose for dependencies ├── docs/ # Documentation └── go.mod # Single module (consolidated) ``` ## 📚 Documentation - **[Architecture Guide](./docs/01-architecture.md)** - Understand the layers - **[Adding a Feature](./docs/02-adding-a-module.md)** - How to create new features - **[API & Auth](./docs/03-api-and-auth.md)** - Security and Request flow ## 🛠 Key Commands | Command | Description | |---------|-------------| | `make dev` | Start server with Air (Live Reload) | | `make server` | Run server directly | | `make build` | Build binary to `bin/api` | | `make migrateup` | Apply DB migrations | | `make sqlc` | Generate type-safe DB code | | `make swagger` | Generate Swagger docs | ## 🔧 Module Structure Each feature module in `internal/` follows **Clean Architecture**: ``` internal/billing/ ├── cmd/ # Module initialization (DI) │ └── init.go ├── app/ # Application layer (use cases) │ └── services/ ├── domain/ # Core business logic & interfaces ├── infra/ # External integrations │ └── repositories/ ├── handler.go # HTTP handlers ├── routes.go # Route registration └── provider.go # Dependency injection ``` ## 🚀 API Endpoints The server exposes these API groups: - `/api/auth/*` - Authentication & member management - `/api/organizations/*` - Organization CRUD - `/api/accounts/*` - Account management - `/api/rbac/*` - Role & permission discovery - `/api/subscriptions/*` - Billing status - `/api/example_documents/*` - PDF upload/management - `/api/example_cognitive/*` - AI chat sessions - `/swagger/*` - API documentation - `/health` - Health check ================================================ FILE: go-b2b-starter/cmd/api/main.go ================================================ // Package main provides the entry point for the B2B SaaS Starter // // @title B2B SaaS Starter API // @version 1.0 // @description This is the API server for B2B SaaS Starter. // @termsOfService http://swagger.io/terms/ // // @contact.name API Support // @contact.url http://www.swagger.io/support // @contact.email support@swagger.io // // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html // // @host localhost:8080 // @BasePath /api // // @securityDefinitions.basic BasicAuth // // @externalDocs.description OpenAPI // @externalDocs.url https://swagger.io/resources/open-api/ package main import "github.com/moasq/go-b2b-starter/internal/bootstrap" func main() { bootstrap.Execute() } ================================================ FILE: go-b2b-starter/deps/Dockerfile ================================================ FROM golang:1.25.5-alpine WORKDIR /workspace # Install system dependencies RUN apk add --no-cache git make bash curl # Install Go tools RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest && \ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \ go install github.com/swaggo/swag/cmd/swag@latest && \ go install github.com/air-verse/air@latest CMD ["bash"] ================================================ FILE: go-b2b-starter/deps/docker-compose.yml ================================================ services: postgres: image: pgvector/pgvector:pg17 environment: POSTGRES_DB: mydatabase POSTGRES_USER: user POSTGRES_PASSWORD: password ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user -d mydatabase"] interval: 10s timeout: 5s retries: 5 redis: image: redis:alpine platform: linux/arm64 ports: - "6379:6379" volumes: - redis_data:/data cli: build: context: . dockerfile: Dockerfile image: go-b2b-starter-cli volumes: - ${PWD}:/workspace working_dir: /workspace environment: - POSTGRES_HOST=postgres - POSTGRES_PORT=5432 - POSTGRES_USER=user - POSTGRES_PASSWORD=password - POSTGRES_DB=mydatabase - REDIS_HOST=redis - REDIS_PORT=6379 ports: - "8080:8080" depends_on: postgres: condition: service_healthy volumes: postgres_data: redis_data: ================================================ FILE: go-b2b-starter/docs/README.md ================================================ # Go B2B SaaS Starter Kit A production-ready Go backend for B2B SaaS applications with multi-tenant architecture, authentication, billing, and file management. ## Quick Start ```bash make run-deps # Start PostgreSQL & Redis make migrateup # Run migrations make dev # Start dev server with hot reload ``` ## Documentation ### Core Systems - **[Architecture](./architecture.md)** - Clean Architecture, dependency injection, module patterns - **[Database](./database.md)** - SQLC workflow, migrations, store adapters - **[Authentication](./authentication.md)** - Stytch integration, RBAC, middleware - **[Billing](./billing.md)** - Polar.sh integration, subscriptions, paywall ### Infrastructure - **[File Manager](./file-manager.md)** - R2 storage and file operations - **[Event Bus](./event-bus.md)** - Event-driven architecture patterns - **[API Development](./api-development.md)** - Guide to building new endpoints ## Project Structure The codebase follows Clean Architecture with three main layers: **API Layer** (`internal/`) - HTTP handlers and routes **Application Layer** (`internal/`) - Business logic organized by modules **Shared Layer** (`internal/`) - Reusable infrastructure packages Each application module contains: - `domain/` - Entities, interfaces, business rules - `app/` - Services (use cases) - `infra/` - Repository implementations - `module.go` - Dependency injection setup ## Common Commands ```bash # Development make dev # Run dev server with hot reload (Air) make server # Run server without hot reload make build # Build production binary # Dependencies make run-deps # Start PostgreSQL & Redis in Docker make stop-deps # Stop and remove Docker containers # Database make migrateup # Apply all migrations make migratedown # Rollback migrations make sqlc # Generate code from SQL make create-migration # Create new migration file # Code Generation make swagger # Generate Swagger docs # Testing make test # Run tests with coverage # Utilities make clear-rbac-cache # Clear RBAC and JWKS caches from Redis ``` ## Tech Stack - **Language**: Go 1.25+ - **HTTP**: Gin framework - **Database**: PostgreSQL with SQLC - **Auth**: Stytch B2B - **Payments**: Polar.sh - **Storage**: Cloudflare R2 - **DI**: uber-go/dig ## Environment Setup Copy `example.env` to `app.env` and configure: ```env # Database DATABASE_HOST=localhost DATABASE_NAME=b2b_starter # Authentication STYTCH_PROJECT_ID=your-project-id STYTCH_SECRET=your-secret # Billing POLAR_ACCESS_TOKEN=your-token POLAR_WEBHOOK_SECRET=your-secret # File Storage R2_ACCOUNT_ID=your-account R2_ACCESS_KEY_ID=your-key R2_SECRET_ACCESS_KEY=your-secret R2_BUCKET_NAME=files ``` See `example.env` for all configuration options. ## Getting Started 1. **Understand the architecture**: Read [Architecture](./architecture.md) 2. **Set up the database**: Follow [Database](./database.md) 3. **Configure authentication**: See [Authentication](./authentication.md) 4. **Build your first API**: Follow [API Development](./api-development.md) ================================================ FILE: go-b2b-starter/docs/adding-a-module.md ================================================ # Adding a New Module This guide shows how to create a new feature module following Clean Architecture and the idiomatic Go project layout. ## Modules vs Platform Decision Before creating a new module, determine whether it should be a **feature module** or a **platform component**. ### Create a Module (`internal/modules/`) when: - Implementing a business domain feature - Has domain entities with business rules - Exposes API endpoints - Contains use cases and workflows - **Examples**: billing, documents, organizations, invoices, products ### Create a Platform Component (`internal/platform/`) when: - Infrastructure or cross-cutting concern - Used by multiple modules - No business logic (pure infrastructure) - Provides technical capability - **Examples**: logger, eventbus, redis, http server ### Decision Tree ``` Is it a business domain feature? ├─ Yes → Create in internal/modules/{name}/ └─ No → Is it used by multiple modules? ├─ Yes → Create in internal/platform/{name}/ └─ No → Should it be part of an existing module? ``` ## Module Location All feature modules live in `internal/modules/` which enforces Go's import boundary: ``` internal/ ├── modules/ # Feature modules (business domains) │ ├── auth/ # Authentication & RBAC │ ├── billing/ # Subscription & billing │ ├── organizations/ # Multi-tenant organizations │ ├── documents/ # Document management │ ├── cognitive/ # AI/RAG features │ ├── files/ # File storage │ ├── paywall/ # Subscription middleware │ └── products/ # ← Your new module here │ ├── platform/ # Cross-cutting infrastructure │ ├── server/ # HTTP server │ ├── eventbus/ # Event pub/sub │ ├── logger/ # Structured logging │ ├── redis/ # Redis client │ └── ... │ ├── db/ # Database layer └── bootstrap/ # App initialization ``` ## Module Structure Each module follows **Clean Architecture** with these layers: ``` internal/modules/products/ ├── cmd/ # Module initialization (DI wiring) │ └── init.go │ ├── app/ # Application Layer (Use Cases) │ └── services/ │ └── product_service.go │ ├── domain/ # Domain Layer (Core Business Logic) │ ├── entity.go # Data structures │ └── repository.go # Interface definitions │ ├── infra/ # Infrastructure Layer (External) │ └── repositories/ │ └── product_repository.go │ ├── handler.go # HTTP handlers (Delivery Layer) ├── routes.go # Route registration └── module.go # Dependency injection setup ``` ## Step-by-Step Guide ### 1. Define the Entity (`domain/entity.go`) Start with your core business objects: ```go package domain import "time" type Product struct { ID int32 `json:"id"` Name string `json:"name"` Description string `json:"description"` Price float64 `json:"price"` OrganizationID int32 `json:"organization_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Validate validates the product data func (p *Product) Validate() error { if p.Name == "" { return ErrInvalidProductName } if p.Price < 0 { return ErrInvalidPrice } return nil } ``` ### 2. Define the Repository Interface (`domain/repository.go`) Define what operations your module needs: ```go package domain import "context" type ProductRepository interface { Create(ctx context.Context, p *Product) (*Product, error) GetByID(ctx context.Context, orgID, id int32) (*Product, error) ListByOrganization(ctx context.Context, orgID int32, limit, offset int32) ([]*Product, error) Update(ctx context.Context, p *Product) error Delete(ctx context.Context, orgID, id int32) error } ``` **Key Points:** - Interface uses **domain types**, not database types - Defined in the domain layer (where it's used) - Independent of implementation details ### 3. Implement the Repository (`infra/repositories/product_repository.go`) Implement the interface using SQLC: ```go package repositories import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/modules/products/domain" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) type productRepository struct { store sqlc.Store } func NewProductRepository(store sqlc.Store) domain.ProductRepository { return &productRepository{store: store} } func (r *productRepository) Create(ctx context.Context, p *domain.Product) (*domain.Product, error) { dbProduct, err := r.store.CreateProduct(ctx, sqlc.CreateProductParams{ Name: p.Name, Description: p.Description, Price: p.Price, OrganizationID: p.OrganizationID, }) if err != nil { return nil, fmt.Errorf("failed to create product: %w", err) } // Map SQLC type to domain type return &domain.Product{ ID: dbProduct.ID, Name: dbProduct.Name, Description: dbProduct.Description, Price: dbProduct.Price, OrganizationID: dbProduct.OrganizationID, CreatedAt: dbProduct.CreatedAt, UpdatedAt: dbProduct.UpdatedAt, }, nil } func (r *productRepository) GetByID(ctx context.Context, orgID, id int32) (*domain.Product, error) { dbProduct, err := r.store.GetProductByID(ctx, sqlc.GetProductByIDParams{ OrganizationID: orgID, ID: id, }) if err != nil { return nil, fmt.Errorf("failed to get product: %w", err) } return &domain.Product{ ID: dbProduct.ID, Name: dbProduct.Name, Description: dbProduct.Description, Price: dbProduct.Price, OrganizationID: dbProduct.OrganizationID, CreatedAt: dbProduct.CreatedAt, UpdatedAt: dbProduct.UpdatedAt, }, nil } // ... implement other methods ``` ### 4. Create the Service (`app/services/product_service.go`) Business logic lives here: ```go package services import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/modules/products/domain" ) type ProductService interface { Create(ctx context.Context, orgID int32, req *CreateProductRequest) (*domain.Product, error) GetByID(ctx context.Context, orgID, id int32) (*domain.Product, error) ListByOrganization(ctx context.Context, orgID int32, limit, offset int32) ([]*domain.Product, error) } type productService struct { repo domain.ProductRepository } func NewProductService(repo domain.ProductRepository) ProductService { return &productService{repo: repo} } type CreateProductRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Price float64 `json:"price" binding:"required,min=0"` } func (s *productService) Create(ctx context.Context, orgID int32, req *CreateProductRequest) (*domain.Product, error) { product := &domain.Product{ Name: req.Name, Description: req.Description, Price: req.Price, OrganizationID: orgID, } // Validate if err := product.Validate(); err != nil { return nil, err } // Create created, err := s.repo.Create(ctx, product) if err != nil { return nil, fmt.Errorf("failed to create product: %w", err) } return created, nil } ``` ### 5. Create the Handler (`handler.go`) HTTP request handling: ```go package products import ( "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/modules/products/app/services" "github.com/moasq/go-b2b-starter/pkg/httperr" ) type Handler struct { service services.ProductService } func NewHandler(service services.ProductService) *Handler { return &Handler{service: service} } // @Summary Create product // @Description Creates a new product in the organization // @Tags products // @Accept json // @Produce json // @Param request body services.CreateProductRequest true "Product data" // @Success 201 {object} domain.Product "Created product" // @Failure 400 {object} httperr.HTTPError "Invalid request" // @Failure 500 {object} httperr.HTTPError "Internal error" // @Router /api/products [post] func (h *Handler) Create(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusUnauthorized, httperr.NewHTTPError( http.StatusUnauthorized, "unauthorized", "Authentication required", )) return } var req services.CreateProductRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "invalid_request", err.Error(), )) return } product, err := h.service.Create(c.Request.Context(), reqCtx.OrganizationID, &req) if err != nil { c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "creation_failed", err.Error(), )) return } c.JSON(http.StatusCreated, product) } // @Summary Get product // @Description Gets a product by ID // @Tags products // @Produce json // @Param id path int true "Product ID" // @Success 200 {object} domain.Product "Product details" // @Failure 400 {object} httperr.HTTPError "Invalid ID" // @Failure 404 {object} httperr.HTTPError "Product not found" // @Router /api/products/{id} [get] func (h *Handler) GetByID(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusUnauthorized, httperr.NewHTTPError( http.StatusUnauthorized, "unauthorized", "Authentication required", )) return } var productID int32 if _, err := fmt.Sscanf(c.Param("id"), "%d", &productID); err != nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "invalid_id", "Product ID must be a number", )) return } product, err := h.service.GetByID(c.Request.Context(), reqCtx.OrganizationID, productID) if err != nil { c.JSON(http.StatusNotFound, httperr.NewHTTPError( http.StatusNotFound, "not_found", err.Error(), )) return } c.JSON(http.StatusOK, product) } ``` ### 6. Define Routes (`routes.go`) Register your endpoints: ```go package products import ( "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) type Routes struct { handler *Handler } func NewRoutes(handler *Handler) *Routes { return &Routes{handler: handler} } func (r *Routes) Routes(router *gin.RouterGroup, resolver domain.MiddlewareResolver) { products := router.Group("/products") products.Use( resolver.Get("auth"), // RequireAuth resolver.Get("org_context"), // RequireOrganization ) { products.POST("", r.handler.Create) products.GET("/:id", r.handler.GetByID) products.GET("", r.handler.List) products.PUT("/:id", r.handler.Update) products.DELETE("/:id", r.handler.Delete) } } ``` ### 7. Wire Dependencies (`cmd/init.go`) Set up dependency injection: ```go package cmd import ( "fmt" "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/products" "github.com/moasq/go-b2b-starter/internal/modules/products/app/services" "github.com/moasq/go-b2b-starter/internal/modules/products/domain" "github.com/moasq/go-b2b-starter/internal/modules/products/infra/repositories" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) func RegisterDependencies(container *dig.Container) error { // Repository - registered in internal/db/inject.go // (See step 8 below) // Service if err := container.Provide(services.NewProductService); err != nil { return fmt.Errorf("failed to provide product service: %w", err) } // Handler if err := container.Provide(products.NewHandler); err != nil { return fmt.Errorf("failed to provide product handler: %w", err) } // Routes if err := container.Provide(products.NewRoutes); err != nil { return fmt.Errorf("failed to provide product routes: %w", err) } return nil } ``` ### 8. Register Repository in Database Layer **IMPORTANT**: Repositories are registered in `internal/db/inject.go`, not in the module's `cmd/init.go`. Add to `internal/db/inject.go`: ```go import ( productDomain "github.com/moasq/go-b2b-starter/internal/modules/products/domain" productRepos "github.com/moasq/go-b2b-starter/internal/modules/products/infra/repositories" ) // In registerDomainStores function: if err := container.Provide(func(sqlcStore sqlc.Store) productDomain.ProductRepository { return productRepos.NewProductRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide product repository: %w", err) } ``` ### 9. Register in Bootstrap Add your module to `internal/bootstrap/init_mods.go`: ```go import productsCmd "github.com/moasq/go-b2b-starter/internal/modules/products/cmd" func InitMods(container *dig.Container) error { // ... existing modules ... // Products module if err := productsCmd.RegisterDependencies(container); err != nil { return fmt.Errorf("failed to register products dependencies: %w", err) } return nil } ``` ### 10. Register Routes in API Routes are auto-registered via DI. Ensure your module's Routes struct is provided in step 7. The `internal/api/provider.go` will automatically discover and register all route groups. ## Database Setup ### 1. Create Migration Create a migration for your new table: ```bash cd internal/db/postgres/sqlc/migrations # Create files manually with next sequence number ``` **Up migration** (`000015_create_products.up.sql`): ```sql CREATE TABLE app.products ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, price DECIMAL(10, 2) NOT NULL, organization_id INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_organization FOREIGN KEY (organization_id) REFERENCES app.organizations(id) ON DELETE CASCADE ); CREATE INDEX idx_products_organization_id ON app.products(organization_id); CREATE INDEX idx_products_name ON app.products(name); ``` **Down migration** (`000015_create_products.down.sql`): ```sql DROP TABLE IF EXISTS app.products; ``` ### 2. Create SQLC Queries Create `internal/db/postgres/sqlc/query/products.sql`: ```sql -- name: CreateProduct :one INSERT INTO products (name, description, price, organization_id) VALUES ($1, $2, $3, $4) RETURNING *; -- name: GetProductByID :one SELECT * FROM products WHERE organization_id = $1 AND id = $2; -- name: ListProductsByOrganization :many SELECT * FROM products WHERE organization_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3; -- name: UpdateProduct :one UPDATE products SET name = $2, description = $3, price = $4, updated_at = NOW() WHERE organization_id = $1 AND id = $5 RETURNING *; -- name: DeleteProduct :exec DELETE FROM products WHERE organization_id = $1 AND id = $2; ``` ### 3. Run Migrations and Generate Code ```bash make migrateup # Apply migrations make sqlc # Generate type-safe Go code ``` ## Testing ### Unit Test Example ```go package services_test import ( "context" "testing" "github.com/moasq/go-b2b-starter/internal/modules/products/domain" "github.com/moasq/go-b2b-starter/internal/modules/products/app/services" ) type mockProductRepository struct { createFunc func(ctx context.Context, p *domain.Product) (*domain.Product, error) } func (m *mockProductRepository) Create(ctx context.Context, p *domain.Product) (*domain.Product, error) { return m.createFunc(ctx, p) } func TestProductService_Create(t *testing.T) { mockRepo := &mockProductRepository{ createFunc: func(ctx context.Context, p *domain.Product) (*domain.Product, error) { p.ID = 1 return p, nil }, } service := services.NewProductService(mockRepo) req := &services.CreateProductRequest{ Name: "Test Product", Price: 99.99, } product, err := service.Create(context.Background(), 1, req) if err != nil { t.Fatalf("expected no error, got %v", err) } if product.ID != 1 { t.Errorf("expected product ID 1, got %d", product.ID) } } ``` ## Best Practices 1. **Domain Layer is Pure**: No external dependencies in `domain/` 2. **Interfaces in Domain**: Repository interfaces defined where they're used 3. **Services Return Domain Types**: Not database types 4. **Handlers Are Thin**: Validation, auth, delegate to service 5. **Context First**: Always pass `context.Context` as first parameter 6. **Use `httperr.HTTPError`**: For consistent API error responses 7. **Register Repositories Centrally**: In `internal/db/inject.go`, not module init 8. **Map Database Types**: Convert SQLC types to domain types in repositories ## Common Pitfalls ### Import Cycles ```go // ❌ Bad - Creates import cycle // internal/modules/products/domain/entity.go import "github.com/moasq/go-b2b-starter/internal/modules/products/app/services" // ✅ Good - Domain has no dependencies // internal/modules/products/domain/entity.go package domain ``` ### Wrong Repository Registration ```go // ❌ Bad - Registering repository in module init // internal/modules/products/cmd/init.go container.Provide(repositories.NewProductRepository) // ✅ Good - Register in database layer // internal/db/inject.go container.Provide(func(sqlcStore sqlc.Store) productDomain.ProductRepository { return productRepos.NewProductRepository(sqlcStore) }) ``` ### Exposing SQLC Types ```go // ❌ Bad - Service returns SQLC types func (s *service) GetProduct(ctx context.Context, id int32) (*sqlc.Product, error) // ✅ Good - Service returns domain types func (s *service) GetProduct(ctx context.Context, id int32) (*domain.Product, error) ``` ## File Checklist After creating a new module, you should have: - [ ] `domain/entity.go` - Domain entities - [ ] `domain/repository.go` - Repository interfaces - [ ] `infra/repositories/{entity}_repository.go` - Repository implementation - [ ] `app/services/{entity}_service.go` - Service interface and implementation - [ ] `handler.go` - HTTP handlers with Swagger docs - [ ] `routes.go` - Route registration - [ ] `cmd/init.go` - DI setup for service, handler, routes - [ ] SQL queries in `internal/db/postgres/sqlc/query/{entity}.sql` - [ ] Repository registration in `internal/db/inject.go` - [ ] Module registration in `internal/bootstrap/init_mods.go` - [ ] Database migrations in `internal/db/postgres/sqlc/migrations/` ## Next Steps - **Architecture Details**: See [Architecture Guide](./architecture.md) - **Database Operations**: See [Database Guide](./database.md) - **API Development**: See [API Development Guide](./api-development.md) ================================================ FILE: go-b2b-starter/docs/api-development.md ================================================ # API Development Guide Step-by-step guide to building new API endpoints following Clean Architecture patterns. ## Overview Building an API endpoint involves these layers: 1. **Domain** - Entity and repository interface 2. **Infrastructure** - Repository implementation 3. **Application** - Service with business logic 4. **API** - HTTP handler and routes ## Step 1: Database Layer ### Create Migration Add migration files in `internal/db/postgres/sqlc/migrations/`: ```sql -- 000015_create_resources.up.sql CREATE TABLE app.resources ( id SERIAL PRIMARY KEY, organization_id INT NOT NULL, name VARCHAR(255) NOT NULL, status VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_resources_org ON app.resources(organization_id); ``` ### Write SQL Queries In `internal/db/postgres/sqlc/query/resources.sql`: ```sql -- name: GetResourceByID :one SELECT * FROM app.resources WHERE id = $1; -- name: CreateResource :one INSERT INTO app.resources (organization_id, name, status) VALUES ($1, $2, $3) RETURNING *; -- name: ListResources :many SELECT * FROM app.resources WHERE organization_id = $1 ORDER BY created_at DESC; ``` ### Generate Code ```bash make sqlc ``` ### Create Store Interface In `internal/db/adapters/resource_store.go`: ```go type ResourceStore interface { GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error) CreateResource(ctx context.Context, arg sqlc.CreateResourceParams) (sqlc.Resource, error) ListResources(ctx context.Context, orgID int32) ([]sqlc.Resource, error) } ``` ### Implement Adapter In `internal/db/postgres/adapter_impl/resource_store.go`: ```go type resourceStore struct { store sqlc.Store } func NewResourceStore(store sqlc.Store) adapters.ResourceStore { return &resourceStore{store: store} } func (s *resourceStore) GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error) { return s.store.GetResourceByID(ctx, id) } ``` ### Register in DI In `internal/db/inject.go`: ```go container.Provide(func(sqlcStore sqlc.Store) adapters.ResourceStore { return adapter_impl.NewResourceStore(sqlcStore) }) ``` ## Step 2: Domain Layer ### Create Entity In `internal/resources/domain/entity.go`: ```go type Resource struct { ID int32 OrganizationID int32 Name string Status string CreatedAt time.Time UpdatedAt time.Time } func (r *Resource) Validate() error { if r.Name == "" { return ErrResourceNameRequired } return nil } ``` ### Define Repository Interface In `internal/resources/domain/repository.go`: ```go type ResourceRepository interface { Create(ctx context.Context, resource *Resource) (*Resource, error) GetByID(ctx context.Context, id int32) (*Resource, error) List(ctx context.Context, orgID int32) ([]*Resource, error) } ``` ## Step 3: Infrastructure Layer ### Implement Repository In `internal/resources/infra/repositories/resource_repository.go`: ```go type resourceRepository struct { store adapters.ResourceStore } func NewResourceRepository(store adapters.ResourceStore) domain.ResourceRepository { return &resourceRepository{store: store} } func (r *resourceRepository) Create(ctx context.Context, resource *domain.Resource) (*domain.Resource, error) { params := sqlc.CreateResourceParams{ OrganizationID: resource.OrganizationID, Name: resource.Name, Status: resource.Status, } dbResource, err := r.store.CreateResource(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create resource: %w", err) } return toDomainResource(dbResource), nil } ``` ## Step 4: Application Layer ### Define Service Interface In `internal/resources/app/services/resource_service_interface.go`: ```go type ResourceService interface { CreateResource(ctx context.Context, orgID int32, req *CreateResourceRequest) (*domain.Resource, error) GetResource(ctx context.Context, id int32) (*domain.Resource, error) ListResources(ctx context.Context, orgID int32) ([]*domain.Resource, error) } ``` ### Implement Service In `internal/resources/app/services/resource_service.go`: ```go type resourceService struct { repo domain.ResourceRepository } func NewResourceService(repo domain.ResourceRepository) ResourceService { return &resourceService{repo: repo} } func (s *resourceService) CreateResource( ctx context.Context, orgID int32, req *CreateResourceRequest, ) (*domain.Resource, error) { // Validate request if err := req.Validate(); err != nil { return nil, err } // Create entity resource := &domain.Resource{ OrganizationID: orgID, Name: req.Name, Status: "active", } // Persist return s.repo.Create(ctx, resource) } ``` ## Step 5: API Layer ### Create Handler In `internal/resources/handler.go`: ```go type Handler struct { service services.ResourceService } func NewHandler(service services.ResourceService) *Handler { return &Handler{service: service} } func (h *Handler) CreateResource(c *gin.Context) { // Get auth context reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(401, gin.H{"error": "unauthorized"}) return } // Parse request var req services.CreateResourceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "invalid request"}) return } // Call service resource, err := h.service.CreateResource(c.Request.Context(), reqCtx.OrganizationID, &req) if err != nil { c.JSON(500, gin.H{"error": "failed to create resource"}) return } c.JSON(201, resource) } ``` ### Register Routes In `internal/resources/routes.go`: ```go type Routes struct { handler *Handler authMiddleware *auth.Middleware } func NewRoutes(handler *Handler, authMiddleware *auth.Middleware) *Routes { return &Routes{handler: handler, authMiddleware: authMiddleware} } func (r *Routes) Register(router *gin.Engine) { apiGroup := router.Group("/api/resources") apiGroup.Use(r.authMiddleware.RequireAuth()) apiGroup.Use(r.authMiddleware.RequireOrganization()) { apiGroup.POST("", auth.RequirePermissionFunc("resource", "create"), r.handler.CreateResource) apiGroup.GET("/:id", r.handler.GetResource) apiGroup.GET("", r.handler.ListResources) } } ``` ## Step 6: Module Registration ### Create Module In `internal/resources/module.go`: ```go type Module struct { container *dig.Container } func NewModule(container *dig.Container) *Module { return &Module{container: container} } func (m *Module) RegisterDependencies() error { // Repository if err := m.container.Provide(func(store adapters.ResourceStore) domain.ResourceRepository { return repositories.NewResourceRepository(store) }); err != nil { return err } // Service if err := m.container.Provide(func(repo domain.ResourceRepository) services.ResourceService { return services.NewResourceService(repo) }); err != nil { return err } return nil } ``` ### Initialize Module In `internal/resources/cmd/init.go`: ```go func Init(container *dig.Container) error { module := NewModule(container) return module.RegisterDependencies() } ``` ### Register API In `internal/resources/provider.go`: ```go func RegisterDependencies(container *dig.Container) error { // Register handler if err := container.Provide(func(service services.ResourceService) *Handler { return NewHandler(service) }); err != nil { return err } // Register routes if err := container.Provide(func( handler *Handler, authMiddleware *auth.Middleware, ) *Routes { return NewRoutes(handler, authMiddleware) }); err != nil { return err } return nil } ``` ## Quick Reference ### File Structure ``` internal/resources/ ├── domain/ │ ├── entity.go │ ├── repository.go │ └── errors.go ├── app/services/ │ ├── resource_service_interface.go │ └── resource_service.go ├── infra/repositories/ │ └── resource_repository.go ├── cmd/init.go └── module.go internal/resources/ ├── handler.go ├── routes.go └── provider.go ``` ### Common Response Codes - `200` - Success - `201` - Created - `400` - Bad Request - `401` - Unauthorized - `403` - Forbidden - `404` - Not Found - `500` - Internal Server Error ## Next Steps - **Add tests**: Unit tests for service, integration tests for repository - **Add Swagger docs**: Document API with Swagger annotations - **Add validation**: Request/response validation - **Add events**: Publish domain events for cross-module communication ================================================ FILE: go-b2b-starter/docs/architecture.md ================================================ # Backend Architecture The backend is a **Modular Monolith** using **idiomatic Go project layout** with Clear separation between business features and infrastructure. ## High-Level Structure ``` go-b2b-starter/ ├── cmd/ # Application entry points │ └── api/ │ └── main.go # Main entry point │ ├── internal/ # Private application code (import boundary) │ ├── modules/ # Feature modules (business domains) │ ├── platform/ # Cross-cutting infrastructure │ ├── db/ # Database layer (SQLC, DI registration) │ ├── bootstrap/ # Application initialization │ └── api/ # API route registration │ ├── pkg/ # Public reusable packages │ ├── httperr/ # HTTP error types │ ├── pagination/ # Pagination helpers │ └── response/ # API response utilities │ └── go.mod # Single consolidated module ``` ## Architectural Layers ### Feature Modules (`internal/modules/`) Business domain modules following **Clean Architecture**. Each module represents a distinct business capability: ``` internal/modules/ ├── auth/ # Authentication & RBAC ├── billing/ # Polar.sh subscriptions & quota management ├── organizations/ # Multi-tenant organization management ├── documents/ # PDF document processing ├── cognitive/ # RAG (Retrieval-Augmented Generation) & embeddings ├── files/ # File storage (R2 + metadata) └── paywall/ # Subscription middleware ``` **Characteristics of Feature Modules:** - Business domain logic - Domain entities with business rules - API endpoints - Use cases and workflows - Follow Clean Architecture layers (domain → app → infra) ### Platform Services (`internal/platform/`) Cross-cutting infrastructure components used by multiple modules: ``` internal/platform/ ├── server/ # HTTP server & middleware ├── eventbus/ # Event pub/sub system ├── logger/ # Structured logging ├── redis/ # Redis cache client ├── stytch/ # Stytch auth provider client ├── polar/ # Polar.sh billing provider client ├── llm/ # LLM integration (OpenAI) └── ocr/ # OCR service (Mistral) ``` **Characteristics of Platform Components:** - Infrastructure concerns - Used by multiple modules - No business logic - Provide technical capabilities ### Database Layer (`internal/db/`) Centralized database layer using SQLC: ``` internal/db/ ├── postgres/ │ ├── sqlc/ │ │ ├── migrations/ # SQL migration files │ │ ├── query/ # SQL queries with SQLC annotations │ │ └── gen/ # Generated Go code │ └── postgres.go # DB connection and pooling ├── inject.go # DI registration for all repositories └── core/ └── errors.go # Database error types ``` **Key Responsibilities:** - SQLC code generation - Repository DI registration - Database connection management - Transaction support ## Module Structure (Clean Architecture) Each feature module in `internal/modules/` follows **Clean Architecture**: ```mermaid graph TD A[Handler] --> B[Service] B --> C[Repository Interface] C --> D[Repository Implementation] D --> E[SQLC Store] E --> F[PostgreSQL] ``` ### Layer Details ``` internal/modules/billing/ ├── cmd/ # Module initialization (DI wiring) │ └── init.go │ ├── app/ # Application Layer (Use Cases) │ └── services/ │ └── billing_service.go │ ├── domain/ # Domain Layer (Core Business Logic) │ ├── entity.go # Data structures │ ├── repository.go # Interface definitions │ ├── errors.go # Domain errors │ └── events/ # Domain events │ ├── infra/ # Infrastructure Layer (External) │ ├── repositories/ # Repository implementations │ │ └── subscription_repository.go │ └── polar/ # Polar.sh adapter │ └── polar_adapter.go │ ├── handler.go # HTTP handlers (Delivery Layer) ├── routes.go # Route registration └── module.go # Dependency injection setup ``` ## Key Principles ### 1. `internal/` Boundary Code in `internal/` cannot be imported by external packages. This enforces encapsulation and prevents unintended dependencies. ### 2. Dependency Rule (Clean Architecture) ```mermaid graph LR A[Domain] --> B[Application] B --> C[Infrastructure] C --> D[Delivery/Handler] ``` **Direction of dependencies:** - Domain → Nothing (pure business logic) - Application → Domain (uses domain interfaces) - Infrastructure → Domain (implements domain interfaces) - Handlers → Application (calls services) **Key Point**: Inner layers never depend on outer layers. Infrastructure implements interfaces defined in domain. ### 3. Feature-Based Organization Modules are organized by **business feature** (billing, auth, organizations), not by technical layer (controllers, services, models). This promotes: - High cohesion within features - Low coupling between features - Easy to understand and navigate - Clear ownership and boundaries ### 4. Single `go.mod` One module for the entire project eliminates workspace complexity and simplifies dependency management. ## Request Flow ```mermaid sequenceDiagram participant Client participant Middleware participant Handler participant Service participant Repository participant Database Client->>Middleware: HTTP Request Middleware->>Middleware: Auth & Validation Middleware->>Handler: Authenticated Request Handler->>Handler: Parse & Validate Handler->>Service: Call Use Case Service->>Service: Business Logic Service->>Repository: Get/Save Data (Interface) Repository->>Database: SQL Query (SQLC) Database-->>Repository: Result Repository-->>Service: Domain Entity Service-->>Handler: DTO Response Handler-->>Client: JSON Response ``` ### Flow Breakdown 1. **Client Request**: HTTP request arrives at server 2. **Middleware**: Auth, logging, rate limiting, CORS 3. **Handler**: Parse request, extract context (org ID, user ID) 4. **Service**: Execute business logic, orchestrate operations 5. **Repository**: Data access using domain interfaces 6. **Database**: SQLC-generated type-safe queries ## Initialization Flow ```mermaid graph TD A[cmd/api/main.go] --> B[bootstrap.Execute] B --> C[InitMods] C --> D[Infrastructure Layer] C --> E[Platform Services] C --> F[Feature Modules] C --> G[API Routes] D --> D1[Logger] D --> D2[Server] D --> D3[Database] E --> E1[Redis] E --> E2[EventBus] E --> E3[Stytch] E --> E4[Polar] F --> F1[Auth Module] F --> F2[Billing Module] F --> F3[Organizations Module] F --> F4[Documents Module] G --> G1[Register Routes] G --> G2[Setup Middleware] ``` ### Initialization Order (Critical) ``` cmd/api/main.go └── bootstrap.Execute() ├── 1. Infrastructure (no dependencies) │ ├── logger.Inject() │ ├── server.Inject() │ └── db.Inject() # Registers all domain repositories │ ├── 2. Platform Services │ ├── redis.Inject() │ ├── llm.Inject() │ ├── ocr.Inject() │ ├── polar.Inject() │ └── eventbus.Inject() │ ├── 3. Feature Modules (order matters!) │ ├── files.SetupDependencies() │ ├── auth.SetupDependencies() │ ├── organizations.RegisterDependencies() │ ├── billing.Configure() │ ├── cognitive.RegisterDependencies() │ └── documents.RegisterDependencies() │ ├── 4. Event Subscriptions │ └── cognitive.SetupEventSubscriptions() │ └── 5. HTTP Server Setup ├── server.SetupMiddleware() └── api.RegisterRoutes() ``` **Why Order Matters:** - `db.Inject()` must run early (registers all repositories) - `auth` must be before modules that need auth middleware - `files` must be before `documents` (documents depend on files) - Event subscriptions must be after all modules are loaded ## Dependency Injection The project uses **uber-go/dig** for dependency injection: ```go // 1. Define interface in domain package domain type ProductRepository interface { Create(ctx context.Context, p *Product) (*Product, error) } // 2. Implement in infrastructure package repositories func NewProductRepository(store sqlc.Store) domain.ProductRepository { return &productRepository{store: store} } // 3. Register in DI (internal/db/inject.go) container.Provide(func(sqlcStore sqlc.Store) productDomain.ProductRepository { return productRepos.NewProductRepository(sqlcStore) }) // 4. Inject into service package services func NewProductService(repo domain.ProductRepository) ProductService { return &productService{repo: repo} } ``` **Benefits:** - Automatic dependency resolution - Easy testing (inject mocks) - Clear dependency graph - Compile-time safety ## Modules vs Platform Decision ```mermaid graph TD A{Is it a business domain feature?} A -->|Yes| B[Create in internal/modules/] A -->|No| C{Used by multiple modules?} C -->|Yes| D[Create in internal/platform/] C -->|No| E{Part of existing module?} E -->|Yes| F[Add to that module] E -->|No| D ``` ### Examples | Component | Location | Reason | |-----------|----------|--------| | Product Catalog | `modules/products/` | Business domain feature | | Invoice Processing | `modules/invoices/` | Business domain feature | | Subscription Management | `modules/billing/` | Business domain feature | | Event Bus | `platform/eventbus/` | Used by all modules | | Logging | `platform/logger/` | Cross-cutting infrastructure | | Stytch Client | `platform/stytch/` | Auth provider client | | Auth Module | `modules/auth/` | Business auth logic using Stytch | ## Communication Between Modules Modules communicate through: ### 1. Event Bus (Loosely Coupled) ```go // Module A publishes event eventBus.Publish(ctx, events.NewDocumentUploadedEvent(docID, orgID)) // Module B subscribes eventBus.Subscribe("document.uploaded", func(ctx context.Context, event Event) error { docEvent := event.(*DocumentUploadedEvent) return embeddingService.GenerateForDocument(ctx, docEvent.DocumentID) }) ``` **Use When:** - Asynchronous operations - One-to-many communication - Loose coupling desired ### 2. Direct Service Injection (Tightly Coupled) ```go // Service A uses Service B func NewInvoiceService( repo domain.InvoiceRepository, billingService billing.BillingService, // Direct dependency ) InvoiceService { return &invoiceService{ repo: repo, billing: billingService, } } ``` **Use When:** - Synchronous operations - One-to-one communication - Strong dependency relationship ### 3. Shared Platform Components ```go // Multiple modules use platform logger func NewDocumentService( repo domain.DocumentRepository, logger logger.Logger, // Platform component ) DocumentService { return &documentService{repo: repo, logger: logger} } ``` ## Multi-Tenancy Every module supports multi-tenancy through **Organization ID**: ```go // Organization context in middleware type RequestContext struct { OrganizationID int32 // From JWT token AccountID int32 // User account ID Identity *Identity } // Used in handlers reqCtx := auth.GetRequestContext(c) products, err := service.ListByOrganization(ctx, reqCtx.OrganizationID) // Enforced in repository SELECT * FROM products WHERE organization_id = $1 AND id = $2 ``` **Benefits:** - Data isolation per organization - Single database for all tenants - Efficient resource usage - Simplified deployment ## File Structure Summary | Layer | Path | Purpose | |-------|------|---------| | Entry point | `cmd/api/main.go` | Application startup | | Feature modules | `internal/modules/*/` | Business domains | | Platform services | `internal/platform/*/` | Infrastructure | | Database layer | `internal/db/` | SQLC, migrations, DI | | Bootstrap | `internal/bootstrap/` | Initialization | | API routes | `internal/api/` | Route registration | | Shared utilities | `pkg/*/` | Public packages | ## Next Steps - **Adding a Module**: See [Adding a Module Guide](./adding-a-module.md) - **Database Operations**: See [Database Guide](./database.md) - **API Development**: See [API Development Guide](./api-development.md) - **Authentication**: See [Authentication Guide](./authentication.md) - **Event Bus**: See [Event Bus Guide](./event-bus.md) ================================================ FILE: go-b2b-starter/docs/authentication.md ================================================ # Authentication Guide The authentication system uses Stytch B2B for identity management with JWT verification, RBAC, and multi-tenant organization context. ## Architecture **Provider**: Stytch B2B handles user authentication and sessions **Middleware**: Verifies JWTs and resolves organization/account context **RBAC**: Role-based access control with permissions **Resolvers**: Bridge auth provider IDs to database IDs ## JWT Verification The system uses a two-tier verification strategy: **1. Fast Path** - Verify JWT locally using cached public keys **2. API Fallback** - Call Stytch API if local verification fails This approach balances security with performance. ### Configuration ```env STYTCH_PROJECT_ID=project-test-xxx STYTCH_SECRET=secret-test-xxx STYTCH_ENV=test # or "live" ``` ## Middleware Three middleware functions protect routes: ### RequireAuth Verifies JWT and extracts identity. ```go router.Use(authMiddleware.RequireAuth()) ``` **What it does:** - Verifies JWT from `Authorization: Bearer {token}` header - Extracts user identity (email, roles, permissions) - Stores `auth.Identity` in request context - Returns 401 if auth fails ### RequireOrganization Resolves organization and account IDs from auth provider. ```go router.Use(authMiddleware.RequireOrganization()) ``` **What it does:** - Gets organization ID from Stytch → resolves to database ID - Gets user email → resolves to account ID - Stores `auth.RequestContext` with IDs - Returns 401 if resolution fails **Note:** Always use after `RequireAuth()`. ### RequirePermission Checks user has specific permission. ```go router.POST("/resources", auth.RequirePermissionFunc("resource", "create"), handler.CreateResource) ``` **What it does:** - Checks if user has permission (e.g., `"resource:create"`) - Returns 403 if permission missing **Note:** Use after `RequireOrganization()`. ## Using Context in Handlers Access authentication info from request context: ```go func (h *Handler) MyHandler(c *gin.Context) { // Get full context reqCtx := auth.GetRequestContext(c) orgID := reqCtx.OrganizationID // int32 accountID := reqCtx.AccountID // int32 email := reqCtx.Identity.Email // string // Or use convenience functions orgID := auth.GetOrganizationID(c) accountID := auth.GetAccountID(c) } ``` ## RBAC System ### Roles Defined in `internal/auth/roles.go`: - `RoleAdmin` - Full system access - `RoleManager` - Organization management - `RoleMember` - Standard user access ### Permissions Format: `"{resource}:{action}"` **Common permissions:** - `resource:view` - Read access - `resource:create` - Create new items - `resource:update` - Modify existing items - `resource:delete` - Delete items - `org:manage` - Organization administration Defined in `internal/auth/permissions.go`. ### Permission Checks ```go // In middleware (route-level) router.POST("/resources", auth.RequirePermissionFunc("resource", "create"), handler.CreateResource) // In code (programmatic) if !auth.HasPermission(identity, "resource:delete") { return errors.New("permission denied") } ``` ## Resolver Pattern Resolvers convert auth provider IDs to database IDs. ### Why Needed? - Stytch uses string UUIDs for organizations - Database uses int32 for primary keys - Auth package can't depend on domain modules (circular dependency) ### How It Works **1. Auth package defines interfaces:** ```go type OrganizationResolver interface { ResolveByProviderID(ctx context.Context, providerID string) (int32, error) } ``` **2. Domain modules implement via adapters:** ```go type orgResolverAdapter struct { repo domain.OrganizationRepository } func (a *orgResolverAdapter) ResolveByProviderID(ctx context.Context, id string) (int32, error) { org, err := a.repo.GetByStytchID(ctx, id) if err != nil { return 0, err } return org.ID, nil } ``` **3. Wired in initialization:** Resolvers registered in `internal/bootstrap/init_mods.go` after organization module loads. ## Route Protection Patterns ### Public Route (No Auth) ```go router.GET("/health", handler.Health) ``` ### Authenticated Route ```go apiGroup := router.Group("/api") apiGroup.Use(authMiddleware.RequireAuth()) apiGroup.Use(authMiddleware.RequireOrganization()) { apiGroup.GET("/profile", handler.GetProfile) } ``` ### Permission-Protected Route ```go apiGroup.POST("/resources", auth.RequirePermissionFunc("resource", "create"), handler.CreateResource) apiGroup.DELETE("/resources/:id", auth.RequirePermissionFunc("resource", "delete"), handler.DeleteResource) ``` ### Role-Protected Route ```go adminGroup := router.Group("/admin") adminGroup.Use(authMiddleware.RequireRole(auth.RoleAdmin)) { adminGroup.GET("/users", handler.ListUsers) } ``` ## Adding New Permissions **1. Define permission constant** in `internal/auth/permissions.go`: ```go const PermResourceView = Permission("resource:view") const PermResourceCreate = Permission("resource:create") ``` **2. Assign to roles** in `internal/auth/rbac.go`: ```go { RoleMember: { PermResourceView, // ... other permissions }, RoleManager: { PermResourceView, PermResourceCreate, // ... other permissions }, } ``` **3. Protect routes**: ```go router.POST("/resources", auth.RequirePermissionFunc("resource", "create"), handler.CreateResource) ``` ## Common Patterns ### Check Organization Ownership ```go func (h *Handler) GetResource(c *gin.Context) { orgID := auth.GetOrganizationID(c) resourceID := parseID(c.Param("id")) resource, err := h.service.GetResource(c.Request.Context(), resourceID) if err != nil { c.JSON(500, gin.H{"error": "failed to get resource"}) return } // Verify resource belongs to user's organization if resource.OrganizationID != orgID { c.JSON(403, gin.H{"error": "access denied"}) return } c.JSON(200, resource) } ``` ### Optional Authentication ```go func (h *Handler) PublicResource(c *gin.Context) { // Try to get org ID (may be 0 if not authenticated) orgID := auth.GetOrganizationID(c) if orgID != 0 { // User is authenticated, show personalized data } else { // User is not authenticated, show public data } } ``` ## File Locations | Component | Path | |-----------|------| | Auth provider interface | `internal/auth/auth.go` | | Middleware | `internal/auth/middleware.go` | | Context helpers | `internal/auth/context.go` | | RBAC definitions | `internal/auth/rbac.go` | | Roles | `internal/auth/roles.go` | | Permissions | `internal/auth/permissions.go` | | Resolvers | `internal/auth/resolvers.go` | | Stytch adapter | `internal/auth/adapters/stytch/` | ## Next Steps - **Database operations**: See [Database Guide](./database.md) - **Building APIs**: See [API Development Guide](./api-development.md) - **Stytch documentation**: https://stytch.com/docs/b2b ================================================ FILE: go-b2b-starter/docs/billing.md ================================================ # Billing Guide The billing system integrates with Polar.sh for subscription management, usage-based billing, and payment processing with a hybrid sync strategy. ## Architecture **Polar.sh**: Payment provider for subscriptions and metering **Hybrid Sync**: Webhooks + on-demand fetching **Paywall Middleware**: Protects routes based on subscription status **Quota Tracking**: Usage-based billing with meters ## Core Concepts ### Subscriptions Managed in Polar.sh, synced to local database. **Subscription states:** - `active` - Valid subscription - `incomplete` - Payment pending - `cancelled` - Subscription cancelled - `unpaid` - Payment failed ### Quota Tracking Track usage for metered billing. **How it works:** 1. User performs action (API call, file upload, etc.) 2. System increments local quota counter 3. Periodically sync usage to Polar meters 4. Polar charges based on usage ### Billing Status Represents organization's billing state: - **Subscription**: Active subscription details - **Quota Usage**: Current usage vs limits - **Payment Status**: Last payment result - **Metering**: Usage meters for billing ## Hybrid Sync Strategy Combines webhooks with on-demand fetching for reliability. ### Webhook Path (Real-time) ``` Polar Event → Webhook → Update Database ``` **Handles:** - Subscription created/updated/cancelled - Payment succeeded/failed - Customer created/updated ### Lazy Guarding (On-demand) ``` API Request → Check Subscription → Fetch if stale → Update Database ``` **When used:** - Webhook delivery failed - Data drift detected - Initial subscription fetch **Benefits:** - Self-healing system - No critical webhook dependency - Always up-to-date data ## Paywall Middleware Protects routes based on subscription requirements. ### Basic Usage ```go router.POST("/premium-feature", paywallMiddleware.RequireActiveSubscription(), handler.PremiumFeature) ``` ### Quota-Based Protection ```go router.POST("/api-call", paywallMiddleware.RequireQuota("api_calls", 1), handler.APICall) ``` **What it does:** 1. Checks organization has active subscription 2. Verifies quota available 3. Increments usage counter 4. Returns 402 (Payment Required) if quota exceeded ### Feature-Based Protection ```go router.POST("/advanced-feature", paywallMiddleware.RequireFeature("advanced_analytics"), handler.AdvancedFeature) ``` Checks if subscription plan includes specific feature. ## Webhook Processing Polar sends webhooks for billing events. ### Webhook Handler Located in `internal/billing/polar_handler.go`. **Events handled:** - `subscription.created` - `subscription.updated` - `subscription.canceled` - `checkout.created` - `checkout.updated` ### Verification Webhooks are verified using Polar webhook secret: ```env POLAR_WEBHOOK_SECRET=whsec_xxx ``` Invalid signatures are rejected. ## Usage Tracking Track resource usage for billing. ### Recording Usage ```go func (s *service) ProcessAction(ctx context.Context, orgID int32) error { // Perform action result, err := s.doAction(ctx) if err != nil { return err } // Record usage err = s.billingService.IncrementQuota(ctx, orgID, "actions", 1) if err != nil { // Log error but don't fail the operation log.Error("failed to record usage", zap.Error(err)) } return nil } ``` ### Meter Ingestion Usage synced to Polar periodically: 1. Accumulate usage locally 2. Batch send to Polar meters API 3. Polar charges based on metered usage Configured in `internal/billing/app/services/metering_service.go`. ## Configuration ```env # Polar.sh POLAR_ACCESS_TOKEN=polar_xxx POLAR_WEBHOOK_SECRET=whsec_xxx POLAR_ORGANIZATION_ID=org_xxx ``` ## Common Patterns ### Check Subscription Status ```go func (h *Handler) GetFeature(c *gin.Context) { orgID := auth.GetOrganizationID(c) status, err := h.billingService.GetBillingStatus(ctx, orgID) if err != nil { c.JSON(500, gin.H{"error": "failed to get billing status"}) return } if status.Subscription == nil || !status.Subscription.IsActive() { c.JSON(402, gin.H{"error": "active subscription required"}) return } // Proceed with feature } ``` ### Track Usage ```go func (s *service) ProcessFile(ctx context.Context, orgID int32, file *File) error { // Process file err := s.processor.Process(file) if err != nil { return err } // Record usage s.billingService.IncrementQuota(ctx, orgID, "files_processed", 1) return nil } ``` ### Handle Payment Failures ```go func (h *WebhookHandler) HandlePaymentFailed(ctx context.Context, event *Event) error { // Update subscription status err := h.billingService.UpdateSubscriptionStatus(ctx, event.SubscriptionID, "unpaid") if err != nil { return err } // Notify organization h.notificationService.SendPaymentFailure(ctx, event.OrganizationID) return nil } ``` ## File Locations | Component | Path | |-----------|------| | Billing domain | `internal/billing/domain/` | | Billing service | `internal/billing/app/services/` | | Polar adapter | `internal/billing/infra/adapters/polar/` | | Paywall middleware | `internal/paywall/` | | Polar client | `internal/polar/` | | Webhook handlers | `internal/billing/` | ## Next Steps - **API protection**: Use paywall middleware in routes - **Usage tracking**: Implement quota consumption - **Polar documentation**: https://docs.polar.sh/ ================================================ FILE: go-b2b-starter/docs/database.md ================================================ # Database Guide The database layer uses PostgreSQL with SQLC for type-safe SQL operations. Domain modules define repository interfaces in their `domain/` layer, which are implemented by repositories in the `infra/` layer using SQLC. ## Architecture The database layer follows the **Repository Pattern**: **1. Domain Interfaces** (`internal/modules/{module}/domain/repository.go`) - Repository contracts defined by the domain **2. Repository Implementations** (`internal/modules/{module}/infra/repositories/`) - Implement interfaces using SQLC **3. SQLC Generated Code** (`internal/db/postgres/sqlc/gen/`) - Auto-generated type-safe queries **4. DI Registration** (`internal/db/inject.go`) - Wire repositories to domain interfaces ### Why This Pattern? - **Dependency Inversion** - Domain defines what it needs, infrastructure provides it - **SQLC Isolation** - SQLC types never leak out of the `infra/` layer - **Easy Testing** - Mock domain interfaces, not SQLC - **Clean Boundaries** - Domain stays pure, no database knowledge - **Type Safety** - SQLC generates type-safe Go code from SQL ### Legacy Note > **Note**: Earlier versions used an `adapters/` and `adapter_impl/` pattern. This has been phased out in favor of the simpler repository pattern where domain interfaces are implemented directly by repository classes. ## SQLC Workflow ### 1. Write SQL Query Create queries in `internal/db/postgres/sqlc/query/{domain}.sql`: ```sql -- name: GetDocumentByID :one SELECT * FROM documents WHERE organization_id = $1 AND id = $2; -- name: CreateDocument :one INSERT INTO documents (organization_id, title, file_path, status) VALUES ($1, $2, $3, $4) RETURNING *; -- name: ListDocuments :many SELECT * FROM documents WHERE organization_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3; ``` **SQLC Annotations:** - `:one` - Returns single row - `:many` - Returns slice of rows - `:exec` - Returns error only (no data) ### 2. Generate Code ```bash make sqlc ``` Generates Go code in `internal/db/postgres/sqlc/gen/`. **Never edit generated files** - they are regenerated on every run. ### 3. Define Repository Interface in Domain Define interface in `internal/modules/documents/domain/repository.go`: ```go package domain import "context" type DocumentRepository interface { GetByID(ctx context.Context, orgID, docID int32) (*Document, error) Create(ctx context.Context, doc *Document) (*Document, error) List(ctx context.Context, orgID int32, limit, offset int32) ([]*Document, error) Update(ctx context.Context, doc *Document) error Delete(ctx context.Context, orgID, docID int32) error } ``` **Key Points:** - Interface uses **domain types** (`*Document`), not SQLC types - Defined where it's used (in the domain layer) - Independent of implementation details ### 4. Implement Repository Create repository in `internal/modules/documents/infra/repositories/document_repository.go`: ```go package repositories import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) type documentRepository struct { store sqlc.Store } func NewDocumentRepository(store sqlc.Store) domain.DocumentRepository { return &documentRepository{store: store} } func (r *documentRepository) GetByID(ctx context.Context, orgID, docID int32) (*domain.Document, error) { // Call SQLC-generated method dbDoc, err := r.store.GetDocumentByID(ctx, sqlc.GetDocumentByIDParams{ OrganizationID: orgID, ID: docID, }) if err != nil { return nil, fmt.Errorf("failed to get document: %w", err) } // Map SQLC type to domain type return &domain.Document{ ID: dbDoc.ID, OrganizationID: dbDoc.OrganizationID, Title: dbDoc.Title, FilePath: dbDoc.FilePath, Status: dbDoc.Status, CreatedAt: dbDoc.CreatedAt, UpdatedAt: dbDoc.UpdatedAt, }, nil } func (r *documentRepository) Create(ctx context.Context, doc *domain.Document) (*domain.Document, error) { dbDoc, err := r.store.CreateDocument(ctx, sqlc.CreateDocumentParams{ OrganizationID: doc.OrganizationID, Title: doc.Title, FilePath: doc.FilePath, Status: doc.Status, }) if err != nil { return nil, fmt.Errorf("failed to create document: %w", err) } // Map back to domain return &domain.Document{ ID: dbDoc.ID, OrganizationID: dbDoc.OrganizationID, Title: dbDoc.Title, FilePath: dbDoc.FilePath, Status: dbDoc.Status, CreatedAt: dbDoc.CreatedAt, UpdatedAt: dbDoc.UpdatedAt, }, nil } ``` **Why Map Types?** - SQLC types (`sqlc.Document`) are generated and may change - Domain types (`domain.Document`) are stable and business-focused - Mapping keeps SQLC isolated in the infrastructure layer ### 5. Register in DI Add to `internal/db/inject.go`: ```go import ( documentDomain "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" documentRepos "github.com/moasq/go-b2b-starter/internal/modules/documents/infra/repositories" ) // In registerDomainStores function: if err := container.Provide(func(sqlcStore sqlc.Store) documentDomain.DocumentRepository { return documentRepos.NewDocumentRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide document repository: %w", err) } ``` **Why Centralize DI?** - All repository registrations in one place - Easy to see all database dependencies - Consistent pattern across modules ## Database Migrations ### File Structure Migrations live in `internal/db/postgres/sqlc/migrations/`: ``` 000001_create_schema.up.sql 000001_create_schema.down.sql 000002_add_indexes.up.sql 000002_add_indexes.down.sql ``` ### Naming Convention Format: `{6-digit-number}_{description}.{up|down}.sql` - `.up.sql` - Apply the migration - `.down.sql` - Rollback the migration ### Example Migration **Up migration** (`000005_create_documents.up.sql`): ```sql CREATE SCHEMA IF NOT EXISTS app; CREATE TABLE app.documents ( id SERIAL PRIMARY KEY, organization_id INTEGER NOT NULL, title VARCHAR(255) NOT NULL, file_path VARCHAR(512) NOT NULL, status VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_organization FOREIGN KEY (organization_id) REFERENCES app.organizations(id) ON DELETE CASCADE ); CREATE INDEX idx_documents_org_id ON app.documents(organization_id); CREATE INDEX idx_documents_status ON app.documents(status); ``` **Down migration** (`000005_create_documents.down.sql`): ```sql DROP TABLE IF EXISTS app.documents; ``` ### Running Migrations ```bash make migrateup # Apply all pending migrations make migratedown # Rollback last migration ``` ## Type Conversions PostgreSQL types need conversion to Go types. ### Nullable Fields SQLC uses `pgtype` for nullable fields: ```go // Convert pgtype.Text to string str := postgres.StringFromPgText(dbRecord.NullableField) // Convert string to pgtype.Text pgText := postgres.ToPgText(str) // Convert pgtype.Int4 to int32 num := postgres.Int32FromPgInt4(dbRecord.NullableInt) ``` Helper functions in `internal/db/postgres/types_transform.go`. ### JSONB Fields ```go // Convert map to JSONB jsonbData := postgres.ToJSONB(map[string]any{"key": "value"}) // Convert JSONB to map data := postgres.JSONBToMap(dbRecord.Metadata) ``` ## Error Handling The database layer provides specific error types in `internal/db/core/errors.go`: **Common Errors:** - `ErrNoRows` - Query returned no results - `ErrTxClosed` - Transaction already committed/rolled back - `ErrTimeout` - Operation exceeded timeout - `ErrPoolClosed` - Connection pool is closed **Helper Functions:** ```go if core.IsNoRowsError(err) { return domain.ErrDocumentNotFound } if core.IsConstraintError(err, "unique_title") { return domain.ErrDocumentAlreadyExists } if core.IsTimeoutError(err) { return domain.ErrDatabaseTimeout } ``` ## Transactions Use transactions for multi-step operations that must be atomic. ### Basic Transaction ```go func (r *repository) CreateWithRelation(ctx context.Context, doc *domain.Document) error { return r.db.WithTx(ctx, func(tx core.Transaction) error { // Step 1: Create document created, err := tx.CreateDocument(ctx, params) if err != nil { return err } // Step 2: Create embeddings _, err = tx.CreateEmbedding(ctx, embeddingParams) if err != nil { return err // Transaction auto-rolls back on error } return nil // Transaction commits on success }) } ``` ### Transaction Options ```go // Read-only transaction err := r.db.WithTxOptions(ctx, &sql.TxOptions{ReadOnly: true}, func(tx core.Transaction) error { // Read operations only }) // Custom isolation level err := r.db.WithTxOptions(ctx, &sql.TxOptions{ Isolation: sql.LevelSerializable, }, func(tx core.Transaction) error { // Operations }) ``` ## Best Practices ### Always Use Context ```go // ✅ Good func (r *repository) GetDocument(ctx context.Context, id int32) (*Document, error) // ❌ Bad func (r *repository) GetDocument(id int32) (*Document, error) ``` ### Handle Errors Appropriately ```go // ✅ Convert database errors to domain errors doc, err := r.store.GetDocumentByID(ctx, params) if err != nil { if core.IsNoRowsError(err) { return nil, domain.ErrDocumentNotFound } return nil, fmt.Errorf("failed to get document: %w", err) } ``` ### Use Prepared Statements SQLC automatically creates prepared statements. Never concatenate SQL strings. ```go // ✅ Good (SQLC handles this) SELECT * FROM documents WHERE title = $1 // ❌ Bad (SQL injection risk) query := fmt.Sprintf("SELECT * FROM documents WHERE title = '%s'", title) ``` ### Indexes for Performance Add indexes for commonly queried fields: ```sql -- Foreign keys CREATE INDEX idx_documents_org_id ON documents(organization_id); -- Status fields CREATE INDEX idx_documents_status ON documents(status); -- Timestamps for sorting CREATE INDEX idx_documents_created_at ON documents(created_at DESC); -- Composite indexes for multi-column queries CREATE INDEX idx_documents_org_status ON documents(organization_id, status); ``` ### Map SQLC Types to Domain Types Always convert SQLC types to domain types in the repository layer: ```go // ✅ Good - Repository returns domain types func (r *repository) GetDocument(ctx context.Context, id int32) (*domain.Document, error) { dbDoc, err := r.store.GetDocumentByID(ctx, id) if err != nil { return nil, err } // Map SQLC type to domain type return &domain.Document{ ID: dbDoc.ID, Title: dbDoc.Title, // ... other fields }, nil } // ❌ Bad - Service receives SQLC types func (s *service) GetDocument(ctx context.Context, id int32) (*sqlc.Document, error) ``` ## Complete Example: Adding a New Entity Let's add a `Comment` entity to the documents module: ### 1. Write Migration `internal/db/postgres/sqlc/migrations/000010_create_comments.up.sql`: ```sql CREATE TABLE app.comments ( id SERIAL PRIMARY KEY, document_id INTEGER NOT NULL, author_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_document FOREIGN KEY (document_id) REFERENCES app.documents(id) ON DELETE CASCADE ); CREATE INDEX idx_comments_document_id ON app.comments(document_id); ``` ### 2. Write SQLC Queries `internal/db/postgres/sqlc/query/comments.sql`: ```sql -- name: CreateComment :one INSERT INTO comments (document_id, author_id, content) VALUES ($1, $2, $3) RETURNING *; -- name: ListCommentsByDocument :many SELECT * FROM comments WHERE document_id = $1 ORDER BY created_at ASC; ``` ### 3. Generate SQLC Code ```bash make migrateup make sqlc ``` ### 4. Define Domain Interface `internal/modules/documents/domain/repository.go`: ```go type CommentRepository interface { Create(ctx context.Context, comment *Comment) (*Comment, error) ListByDocument(ctx context.Context, docID int32) ([]*Comment, error) } ``` ### 5. Implement Repository `internal/modules/documents/infra/repositories/comment_repository.go`: ```go package repositories import ( "context" "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) type commentRepository struct { store sqlc.Store } func NewCommentRepository(store sqlc.Store) domain.CommentRepository { return &commentRepository{store: store} } func (r *commentRepository) Create(ctx context.Context, comment *domain.Comment) (*domain.Comment, error) { dbComment, err := r.store.CreateComment(ctx, sqlc.CreateCommentParams{ DocumentID: comment.DocumentID, AuthorID: comment.AuthorID, Content: comment.Content, }) if err != nil { return nil, err } return &domain.Comment{ ID: dbComment.ID, DocumentID: dbComment.DocumentID, AuthorID: dbComment.AuthorID, Content: dbComment.Content, CreatedAt: dbComment.CreatedAt, }, nil } ``` ### 6. Register in DI `internal/db/inject.go`: ```go if err := container.Provide(func(sqlcStore sqlc.Store) documentDomain.CommentRepository { return documentRepos.NewCommentRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide comment repository: %w", err) } ``` ## File Locations | Component | Path | |-----------|------| | Domain interfaces | `internal/modules/{module}/domain/repository.go` | | Repository implementations | `internal/modules/{module}/infra/repositories/` | | SQL queries | `internal/db/postgres/sqlc/query/` | | Migrations | `internal/db/postgres/sqlc/migrations/` | | Generated code | `internal/db/postgres/sqlc/gen/` | | Type helpers | `internal/db/postgres/types_transform.go` | | Error types | `internal/db/core/errors.go` | | DI registration | `internal/db/inject.go` | ## Next Steps - **Using repositories in services**: See [Architecture Guide](./architecture.md) - **Building APIs**: See [API Development Guide](./api-development.md) - **Adding a new module**: See [Adding a Module Guide](./02-adding-a-module.md) - **SQLC documentation**: https://docs.sqlc.dev/ ================================================ FILE: go-b2b-starter/docs/event-bus.md ================================================ # Event Bus Guide The event bus enables event-driven architecture for loose coupling between modules using an in-memory publish-subscribe pattern. ## Architecture **In-memory event bus** - Simple, fast, synchronous **Publisher-subscriber pattern** - Decouple event producers from consumers **Type-safe events** - Events are Go structs implementing Event interface ## Core Concepts ### Events Events represent things that have happened in the system. **Naming**: Past tense (ResourceCreated, ResourceUpdated, ResourceDeleted) ```go type ResourceCreatedEvent struct { BaseEvent ResourceID int32 `json:"resource_id"` Name string `json:"name"` CreatedBy int32 `json:"created_by"` CreatedAt time.Time `json:"created_at"` } ``` ### Event Interface All events implement the Event interface: ```go type Event interface { EventName() string EventID() string OccurredAt() time.Time } ``` ### BaseEvent Provides common event fields: ```go type BaseEvent struct { ID string `json:"id"` Name string `json:"name"` Timestamp time.Time `json:"timestamp"` } ``` ## Publishing Events Emit events when something happens: ```go func (s *service) CreateResource(ctx context.Context, req *Request) (*Resource, error) { // Create resource resource, err := s.repo.Create(ctx, req) if err != nil { return nil, err } // Publish event event := &ResourceCreatedEvent{ ResourceID: resource.ID, Name: resource.Name, CreatedBy: req.UserID, CreatedAt: resource.CreatedAt, } s.eventBus.Publish(ctx, event) return resource, nil } ``` **Note**: Publish is fire-and-forget. Failures don't block the operation. ## Subscribing to Events Listen for events and react: ```go func (l *ResourceListener) Init(eventBus eventbus.EventBus) { // Subscribe to events eventBus.Subscribe("resource.created", l.HandleResourceCreated) eventBus.Subscribe("resource.updated", l.HandleResourceUpdated) } func (l *ResourceListener) HandleResourceCreated(ctx context.Context, event eventbus.Event) error { resourceEvent := event.(*ResourceCreatedEvent) // React to event log.Info("Resource created", zap.Int32("id", resourceEvent.ResourceID)) // Trigger other actions return l.notificationService.NotifyResourceCreated(ctx, resourceEvent.ResourceID) } ``` ## Event Flow ``` Service → Publish Event → Event Bus → Notify Subscribers → Execute Handlers ``` **Synchronous**: Subscribers execute in the same request context **Ordered**: Subscribers execute in registration order **Error handling**: Subscriber errors are logged but don't fail the operation ## Common Patterns ### Cross-Module Communication Module A publishes events, Module B subscribes: ```go // Module A (Resources) func (s *resourceService) Delete(ctx context.Context, id int32) error { err := s.repo.Delete(ctx, id) if err != nil { return err } s.eventBus.Publish(ctx, &ResourceDeletedEvent{ResourceID: id}) return nil } // Module B (Analytics) func (l *analyticsListener) HandleResourceDeleted(ctx context.Context, event eventbus.Event) error { evt := event.(*ResourceDeletedEvent) return l.analyticsService.RecordDeletion(ctx, evt.ResourceID) } ``` ### Audit Logging Subscribe to all events for audit trail: ```go func (l *auditListener) Init(eventBus eventbus.EventBus) { eventBus.Subscribe("*.created", l.HandleCreated) eventBus.Subscribe("*.updated", l.HandleUpdated) eventBus.Subscribe("*.deleted", l.HandleDeleted) } func (l *auditListener) HandleCreated(ctx context.Context, event eventbus.Event) error { return l.auditService.Log(ctx, "created", event) } ``` ### Async Processing Trigger background jobs from events: ```go func (l *processingListener) HandleFileUploaded(ctx context.Context, event eventbus.Event) error { evt := event.(*FileUploadedEvent) // Queue async job return l.jobQueue.Enqueue(ctx, &ProcessFileJob{ FileID: evt.FileID, }) } ``` ## Registration Register listeners during module initialization: ```go // internal/resources/cmd/init.go func Init(container *dig.Container) error { return container.Invoke(func( eventBus eventbus.EventBus, listener *listeners.ResourceListener, ) { listener.Init(eventBus) }) } ``` ## File Locations | Component | Path | |-----------|------| | Event bus interface | `internal/eventbus/eventbus.go` | | Event interface | `internal/eventbus/event.go` | | Base event | `internal/eventbus/base_event.go` | | Implementation | `internal/eventbus/memory_eventbus.go` | | Domain events | `internal/*/domain/events/` | | Event listeners | `internal/*/domain/listeners/` | ## Next Steps - **Define events**: Create event structs in `domain/events/` - **Implement listeners**: Handle events in `domain/listeners/` - **Publish events**: Emit events in service layer ================================================ FILE: go-b2b-starter/docs/file-manager.md ================================================ # File Manager Guide The file manager provides file storage using Cloudflare R2 (object storage) with PostgreSQL for searchable metadata. ## Architecture **Dual-layer design:** **R2 Storage** - Stores actual file content **PostgreSQL** - Stores searchable metadata This separation enables fast querying while leveraging object storage scalability. ## Components **FileRepository**: Combined operations (upload, download, delete, search) **R2Repository**: R2 object storage operations **FileMetadataRepository**: Database metadata operations **FileService**: Business logic with validation ## File Upload ### Basic Upload ```go req := &domain.FileUploadRequest{ Filename: "document.pdf", ContentType: "application/pdf", Context: file_manager.ContextDocument, } file, err := fileService.UploadFile(ctx, req, fileReader) ``` ### Upload with Entity Linking Link files to domain entities (like resources, users, etc.): ```go req := &domain.FileUploadRequest{ Filename: "profile.jpg", ContentType: "image/jpeg", Context: file_manager.ContextProfile, } file := &domain.FileAsset{ EntityType: "user", EntityID: userID, } uploadedFile, err := fileService.UploadFile(ctx, req, fileReader) ``` ### Upload Flow 1. Validate file (size, type, magic bytes) 2. Save metadata to database (get ID) 3. Upload content to R2 (using database ID in key) 4. Update metadata with storage path 5. Rollback on failure (atomic operation) ## File Download ### Get Presigned URL Generate temporary download link: ```go url, err := fileService.GetPresignedURL(ctx, fileID, 15*time.Minute) ``` Returns a time-limited URL for direct download from R2. ### Download File Content ```go content, err := fileService.DownloadFile(ctx, fileID) ``` Returns `io.ReadCloser` with file content. ## File Search ### By Entity Get all files for a specific entity: ```go files, err := fileRepo.GetByEntity(ctx, "resource", resourceID) ``` ### By Category Find files by category: ```go documents, err := fileRepo.GetByCategory(ctx, file_manager.CategoryDocument, 10, 0) ``` ### By Context Search by context type: ```go profiles, err := fileRepo.GetByContext(ctx, file_manager.ContextProfile, 20, 0) ``` ## File Validation Automatic validation on upload: **Magic byte verification** - Validates file type matches content **Size limits** - Configurable max file size **Content type** - Ensures valid MIME type Configure in `FileService` initialization. ## Contexts and Categories ### Predefined Contexts - `ContextDocument` - General documents - `ContextProfile` - Profile images - `ContextAttachment` - Email/message attachments - `ContextThumbnail` - Image thumbnails ### Categories - `CategoryDocument` - PDFs, docs - `CategoryImage` - Images - `CategoryVideo` - Videos - `CategoryArchive` - ZIP, TAR files Defined in `internal/files/domain/constants.go`. ## Configuration ```env # Cloudflare R2 R2_ACCOUNT_ID=your-account-id R2_ACCESS_KEY_ID=your-access-key R2_SECRET_ACCESS_KEY=your-secret-key R2_BUCKET_NAME=files R2_REGION=auto # Usually "auto" for R2 ``` ## Common Patterns ### Upload User Avatar ```go func (s *service) UpdateAvatar(ctx context.Context, userID int32, avatar io.Reader) error { req := &domain.FileUploadRequest{ Filename: fmt.Sprintf("avatar_%d.jpg", userID), ContentType: "image/jpeg", Context: file_manager.ContextProfile, } file, err := s.fileService.UploadFile(ctx, req, avatar) if err != nil { return err } // Link to user return s.userRepo.UpdateAvatar(ctx, userID, file.ID) } ``` ### Get Entity Files ```go func (h *Handler) GetResourceFiles(c *gin.Context) { resourceID := parseID(c.Param("id")) files, err := h.fileRepo.GetByEntity(c.Request.Context(), "resource", resourceID) if err != nil { c.JSON(500, gin.H{"error": "failed to get files"}) return } c.JSON(200, files) } ``` ### Delete File ```go func (s *service) DeleteResource(ctx context.Context, resourceID int32) error { // Get associated files files, err := s.fileRepo.GetByEntity(ctx, "resource", resourceID) if err != nil { return err } // Delete files for _, file := range files { err = s.fileService.DeleteFile(ctx, file.ID) if err != nil { return err } } // Delete resource return s.resourceRepo.Delete(ctx, resourceID) } ``` ## File Locations | Component | Path | |-----------|------| | Domain entities | `internal/files/domain/` | | File service | `internal/files/internal/app/` | | R2 repository | `internal/files/internal/infra/r2/` | | Metadata repository | `internal/files/internal/infra/metadata/` | | Constants | `internal/files/domain/constants.go` | ## Next Steps - **Upload files**: Integrate file upload in your features - **Link entities**: Associate files with domain objects - **R2 documentation**: https://developers.cloudflare.com/r2/ ================================================ FILE: go-b2b-starter/example.env ================================================ # Go B2B SaaS Starter Kit - Environment Configuration Template # Copy this file to app.env and fill in your actual values # Environment ENV=DEV ALLOW_SELF_APPROVAL=true # Server SERVER_ADDRESS=:8080 RATE_LIMIT_PER_SECOND=100 MAX_REQUEST_SIZE=10485760 # Security Settings TLS_CERT_PATH=/path/to/cert.pem TLS_KEY_PATH=/path/to/key.pem TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 # Redis Configuration REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 # Postgres Configuration POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=mydatabase POSTGRES_USER=user POSTGRES_PASSWORD=password DB_SSL_MODE=disable MIGRATION_URL=src/pkg/db/postgres/sqlc/migrations SEED_URL=src/pkg/db/postgres/seed # Auth Configuration ACCESS_TOKEN_DURATION=3h REFRESH_TOKEN_DURATION=72h TOKEN_SYMMETRIC_KEY=REPLACE_WITH_YOUR_32_CHAR_SECRET_KEY SESSION_ENCRYPTION_KEY=REPLACE_WITH_YOUR_32_CHAR_SESSION_KEY PASSWORD_HASH_COST=12 MAX_LOGIN_ATTEMPTS=5 LOCKOUT_DURATION=15m JWT_ISSUER=go-b2b-starter # === Stytch B2B configuration === STYTCH_PROJECT_ID=project-test-REPLACE_WITH_YOUR_STYTCH_PROJECT_ID STYTCH_SECRET=secret-test-REPLACE_WITH_YOUR_STYTCH_SECRET STYTCH_ENV=test STYTCH_SESSION_DURATION_MINUTES=1440 STYTCH_INVITE_REDIRECT_URL=http://localhost:3000/authenticate STYTCH_LOGIN_REDIRECT_URL=http://localhost:3000/authenticate STYTCH_OWNER_ROLE_SLUG=owner STYTCH_DISABLE_SESSION_VERIFICATION=false # Cloudflare R2 Configuration R2_ACCOUNT_ID=REPLACE_WITH_YOUR_R2_ACCOUNT_ID R2_ACCESS_KEY_ID=REPLACE_WITH_YOUR_R2_ACCESS_KEY R2_SECRET_ACCESS_KEY=REPLACE_WITH_YOUR_R2_SECRET_KEY R2_BUCKET=uploads R2_REGION=auto S3_API=https://REPLACE_WITH_YOUR_R2_ACCOUNT_ID.r2.cloudflarestorage.com # OpenAI Configuration OPENAI_API_KEY=sk-proj-REPLACE_WITH_YOUR_OPENAI_API_KEY OPENAI_MODEL=gpt-4o-mini OPENAI_MAX_TOKENS=500 OPENAI_TEMPERATURE=0.0 LLM_TIMEOUT_SEC=30 LLM_MAX_RETRIES=1 LLM_FALLBACK_ENABLED=true # Mistral Configuration MISTRAL_API_KEY=REPLACE_WITH_YOUR_MISTRAL_API_KEY OCR_DEBUG_MODE=true # Polar Configuration POLAR_ACCESS_TOKEN=polar_oat_REPLACE_WITH_YOUR_POLAR_ACCESS_TOKEN POLAR_BASE_URL=https://sandbox-api.polar.sh POLAR_DEBUG=true WEBHOOK_SECRET=polar_whs_REPLACE_WITH_YOUR_WEBHOOK_SECRET NEXT_PUBLIC_POLAR_PRODUCT_ID=REPLACE_WITH_YOUR_PRODUCT_ID NEXT_PUBLIC_POLAR_BUSINESS_PRODUCT_ID=REPLACE_WITH_YOUR_BUSINESS_PRODUCT_ID ================================================ FILE: go-b2b-starter/go.mod ================================================ module github.com/moasq/go-b2b-starter go 1.25 require ( github.com/KyleBanks/depth v1.2.1 github.com/MicahParks/keyfunc/v2 v2.0.1 github.com/aws/aws-sdk-go-v2 v1.24.0 github.com/aws/aws-sdk-go-v2/config v1.26.1 github.com/aws/aws-sdk-go-v2/credentials v1.16.12 github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 github.com/aws/smithy-go v1.19.0 github.com/gabriel-vasile/mimetype v1.4.10 github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.1 github.com/go-openapi/jsonpointer v0.19.5 github.com/go-openapi/jsonreference v0.20.0 github.com/go-openapi/spec v0.20.6 github.com/go-openapi/swag v0.19.15 github.com/go-playground/validator/v10 v10.23.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-migrate/migrate/v4 v4.17.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 github.com/joho/godotenv v1.5.1 github.com/pgvector/pgvector-go v0.3.0 github.com/prometheus/client_golang v1.20.5 github.com/redis/go-redis/v9 v9.7.0 github.com/rs/zerolog v1.33.0 github.com/shopspring/decimal v1.4.0 github.com/spf13/viper v1.19.0 github.com/stytchauth/stytch-go/v16 v16.40.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.3 github.com/twpayne/go-geom v1.6.1 go.uber.org/dig v1.19.0 go.uber.org/zap v1.27.0 golang.org/x/time v0.8.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.12.5 // indirect github.com/bytedance/sonic/loader v0.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/net v0.37.0 // indirect golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.31.0 // indirect google.golang.org/protobuf v1.35.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go-b2b-starter/go.sum ================================================ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/MicahParks/keyfunc/v2 v2.0.1 h1:6FrNNvG/20gEKkjxV+5anrkq0VOF666G2zUn8lk8dgk= github.com/MicahParks/keyfunc/v2 v2.0.1/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg= github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY= github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg= github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= github.com/bytedance/sonic v1.12.5/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stytchauth/stytch-go/v16 v16.40.0 h1:xT9QyPtWi4j6rJPhkROfGCDzDeVBqvS2KQge1dv8rfs= github.com/stytchauth/stytch-go/v16 v16.40.0/go.mod h1:b2Dj63HNogYxAwJz7l9S7aJ8k3xyFYrMOtkzdTme+tk= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= ================================================ FILE: go-b2b-starter/internal/api/provider.go ================================================ package api import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/modules/billing" "github.com/moasq/go-b2b-starter/internal/modules/cognitive" "github.com/moasq/go-b2b-starter/internal/modules/documents" "github.com/moasq/go-b2b-starter/internal/modules/organizations" server "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) // moduleRoutes holds handlers for all API modules // 1. OrganizationRoutes - Handles organization, account, and member management routes (includes /auth routes) // 2. RbacRoutes - Handles RBAC role and permission routes // 3. BillingHandler - Handles billing status and subscription routes (uses billing module) // 4. DocumentsRoutes - Handles PDF document upload and management routes // 5. CognitiveRoutes - Handles AI/RAG chat and document search routes type moduleRoutes struct { OrganizationRoutes *organizations.Routes RbacRoutes *auth.Routes SubscriptionHandler *billing.Handler DocumentsRoutes *documents.Routes CognitiveRoutes *cognitive.Routes } // Init sets up all module dependencies and registers API routes func Init(container *dig.Container) error { if err := setupDependencies(container); err != nil { return err } if err := registerAPI(container); err != nil { return err } return nil } // registerAPI registers all module handlers and routes func registerAPI(container *dig.Container) error { if err := container.Provide(func( organizationRoutes *organizations.Routes, rbacRoutes *auth.Routes, subscriptionHandler *billing.Handler, documentsRoutes *documents.Routes, cognitiveRoutes *cognitive.Routes, ) *moduleRoutes { return &moduleRoutes{ OrganizationRoutes: organizationRoutes, RbacRoutes: rbacRoutes, SubscriptionHandler: subscriptionHandler, DocumentsRoutes: documentsRoutes, CognitiveRoutes: cognitiveRoutes, } }); err != nil { return err } return container.Invoke(func( srv server.Server, modules *moduleRoutes, ) { // Register each module's routes srv.RegisterRoutes(modules.OrganizationRoutes.Routes, server.ApiPrefix) srv.RegisterRoutes(modules.RbacRoutes.Routes, server.ApiPrefix) srv.RegisterRoutes(modules.SubscriptionHandler.Routes, server.ApiPrefix) srv.RegisterRoutes(modules.DocumentsRoutes.Routes, server.ApiPrefix) srv.RegisterRoutes(modules.CognitiveRoutes.Routes, server.ApiPrefix) }) } // setupDependencies initializes all module dependencies func setupDependencies(container *dig.Container) error { if err := organizations.NewProvider(container).RegisterDependencies(); err != nil { return err } // Initialize RBAC API (role and permission discovery) if err := auth.NewProvider(container).RegisterDependencies(); err != nil { return err } // Initialize billing API (subscription and billing status) if err := billing.RegisterHandlers(container); err != nil { return err } // Initialize documents API (PDF upload and management) if err := documents.NewProvider(container).RegisterDependencies(); err != nil { return err } // Initialize cognitive API (AI/RAG chat and document search) if err := cognitive.NewProvider(container).RegisterDependencies(); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/bootstrap/init_mods.go ================================================ package bootstrap import ( "context" "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/api" "github.com/moasq/go-b2b-starter/internal/modules/auth" authCmd "github.com/moasq/go-b2b-starter/internal/modules/auth/cmd" billing "github.com/moasq/go-b2b-starter/internal/modules/billing/cmd" cognitive "github.com/moasq/go-b2b-starter/internal/modules/cognitive/cmd" db "github.com/moasq/go-b2b-starter/internal/db/cmd" docs "github.com/moasq/go-b2b-starter/internal/docs/cmd" documents "github.com/moasq/go-b2b-starter/internal/modules/documents/cmd" eventbus "github.com/moasq/go-b2b-starter/internal/platform/eventbus/cmd" files "github.com/moasq/go-b2b-starter/internal/modules/files/cmd" llm "github.com/moasq/go-b2b-starter/internal/platform/llm/cmd" logger "github.com/moasq/go-b2b-starter/internal/platform/logger/cmd" ocr "github.com/moasq/go-b2b-starter/internal/platform/ocr/cmd" orgDomain "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" organizations "github.com/moasq/go-b2b-starter/internal/modules/organizations/cmd" paywall "github.com/moasq/go-b2b-starter/internal/modules/paywall/cmd" polar "github.com/moasq/go-b2b-starter/internal/platform/polar/cmd" redisCmd "github.com/moasq/go-b2b-starter/internal/platform/redis/cmd" server "github.com/moasq/go-b2b-starter/internal/platform/server/cmd" stytchCmd "github.com/moasq/go-b2b-starter/internal/platform/stytch/cmd" ) // orgLookupAdapter adapts orgDomain.OrganizationRepository to auth.OrganizationLookup type orgLookupAdapter struct { repo orgDomain.OrganizationRepository } func (a *orgLookupAdapter) GetByStytchID(ctx context.Context, stytchOrgID string) (auth.OrganizationEntity, error) { return a.repo.GetByStytchID(ctx, stytchOrgID) } // accLookupAdapter adapts orgDomain.AccountRepository to auth.AccountLookup type accLookupAdapter struct { repo orgDomain.AccountRepository } func (a *accLookupAdapter) GetByEmail(ctx context.Context, orgID int32, email string) (auth.AccountEntity, error) { return a.repo.GetByEmail(ctx, orgID, email) } func InitMods(container *dig.Container) { // pkg server.Init(container) logger.Init(container) db.Init(container) files.Init(container) if err := eventbus.Init(container); err != nil { panic(err) } if err := llm.Init(container); err != nil { panic(err) } // Polar package must be initialized before payment module (payment depends on Polar client) if err := polar.Init(container); err != nil { panic(err) } // Redis must be initialized before auth (Stytch repositories rely on Redis-backed clients upstream) if err := redisCmd.Init(container); err != nil { panic(err) } // Stytch client package must be initialized before app/auth (for organization/member management) // This provides: stytch.Config, stytch.Client, stytch.RBACPolicyService if err := stytchCmd.ProvideStytchDependencies(container); err != nil { panic(err) } // Auth package (pkg/auth) must be initialized before app/auth // This provides: auth.AuthProvider (authentication/authorization) if err := authCmd.Init(container); err != nil { panic(err) } // docs docs.Init(container) // app if err := organizations.Init(container); err != nil { panic(err) } // Register auth resolvers (bridges organizations domain to auth package) if err := auth.ProvideResolvers(container, func(repo orgDomain.OrganizationRepository) auth.OrganizationResolver { return auth.NewOrganizationResolver(&orgLookupAdapter{repo: repo}) }, func(repo orgDomain.AccountRepository) auth.AccountResolver { return auth.NewAccountResolver(&accLookupAdapter{repo: repo}) }, ); err != nil { panic(err) } // Initialize auth middleware (requires resolvers to be registered) if err := authCmd.InitMiddleware(container); err != nil { panic(err) } // Register auth middleware as named middlewares for use in routes if err := auth.RegisterNamedMiddlewares(container); err != nil { panic(err) } // Billing module (subscription lifecycle, quotas, webhooks) if err := billing.Init(container); err != nil { panic(err) } // Paywall middleware (access gating based on subscription status) if err := paywall.SetupMiddleware(container); err != nil { panic(err) } if err := paywall.RegisterNamedMiddlewares(container); err != nil { panic(err) } // OCR service (Mistral API for document text extraction) // Must be initialized before documents module (documents depends on OCR) if err := ocr.Init(container); err != nil { panic(err) } // Documents module (PDF upload and text extraction) if err := documents.Init(container); err != nil { panic(err) } // Cognitive module (AI/RAG with embeddings and vector search) // Note: This also wires the event listener for DocumentUploaded events if err := cognitive.Init(container); err != nil { panic(err) } // api api.Init(container) } ================================================ FILE: go-b2b-starter/internal/bootstrap/root.go ================================================ package bootstrap import ( "log" "github.com/joho/godotenv" "go.uber.org/dig" server "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) func Execute() { if err := godotenv.Load("app.env"); err != nil { log.Printf("Warning: Error loading app.env file: %v", err) } container := dig.New() InitMods(container) var srv server.Server if err := container.Invoke(func(s server.Server) { srv = s }); err != nil { panic(err) } srv.Start() } ================================================ FILE: go-b2b-starter/internal/db/README.md ================================================ # Database Layer Guide This guide shows you how to work with the database layer using our adapter pattern. It's designed to be simple and practical. ## What's the Adapter Pattern? We keep database code separate from business logic: - **`adapters/`** - Interface definitions (what operations are available) - **`postgres/adapter_impl/`** - Actual database code (how PostgreSQL does it) Your business modules only need to know about `adapters/`. They never touch PostgreSQL directly. ## The Workflow Here's the typical flow when you need to add something to the database: ### 1. Create a Database Migration Use the Makefile to create migration files: ```bash make create-migration MIGRATION_NAME=add_users_table ``` This creates two files in `postgres/sqlc/migrations/`: - `000XXX_add_users_table.up.sql` (creates your changes) - `000XXX_add_users_table.down.sql` (removes your changes) Write your SQL in these files. Keep it simple. ### 2. Apply Your Migration Run the migration to update your database: ```bash make migrateup ``` If something goes wrong, you can rollback: ```bash make migratedown ``` ### 3. Write SQL Queries Create a file in `postgres/sqlc/query/` with your queries. Use SQLC's comments to tell it what to generate: ```sql -- name: GetUserByID :one SELECT * FROM users WHERE id = $1; -- name: CreateUser :one INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *; -- name: UpdateUserBalance :exec UPDATE users SET balance = balance + $1 WHERE id = $2; ``` The comment annotations tell SQLC what kind of method to create (`:one` returns single row, `:many` returns multiple, `:exec` runs without returning). ### 4. Generate Go Code Let SQLC generate type-safe Go code from your queries: ```bash make sqlc ``` This creates Go methods in `postgres/sqlc/gen/` that you can use safely without writing SQL in Go code. ### 5. Create an Adapter Interface Define what operations your business logic needs in `adapters/user_adapter.go`: ```go package adapters import ( "context" db "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" ) type UserAdapter interface { GetUserByID(ctx context.Context, id int32) (db.User, error) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) // For transactions - aggregate multiple operations TransferMoney(ctx context.Context, fromUserID, toUserID int32, amount int32) error } ``` ### 6. Implement the Adapter Create the actual implementation in `postgres/adapter_impl/user_adapter.go`: ```go package adapterimpl import ( "context" "fmt" "github.com/moasq/go-b2b-starter/pkg/db/adapters" sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" ) type userAdapter struct { store sqlc.Store } func NewUserAdapter(store sqlc.Store) adapters.UserAdapter { return &userAdapter{store: store} } func (a *userAdapter) GetUserByID(ctx context.Context, id int32) (sqlc.User, error) { return a.store.GetUserByID(ctx, id) } func (a *userAdapter) CreateUser(ctx context.Context, arg sqlc.CreateUserParams) (sqlc.User, error) { return a.store.CreateUser(ctx, arg) } // TransferMoney - transaction example aggregating multiple queries func (a *userAdapter) TransferMoney(ctx context.Context, fromUserID, toUserID int32, amount int32) error { // Start transaction using the store's ExecTx method return a.store.ExecTx(ctx, func(q sqlc.Querier) error { // All queries run within this transaction // 1. Deduct from sender err := q.UpdateUserBalance(ctx, sqlc.UpdateUserBalanceParams{ ID: fromUserID, Amount: -amount, }) if err != nil { return fmt.Errorf("failed to deduct balance: %w", err) } // 2. Add to receiver err = q.UpdateUserBalance(ctx, sqlc.UpdateUserBalanceParams{ ID: toUserID, Amount: amount, }) if err != nil { return fmt.Errorf("failed to add balance: %w", err) } // 3. Verify sender has enough balance sender, err := q.GetUserByID(ctx, fromUserID) if err != nil { return fmt.Errorf("failed to get sender: %w", err) } if sender.Balance < 0 { return fmt.Errorf("insufficient balance") } // If any query fails, transaction auto-rollbacks // If all succeed, transaction auto-commits return nil }) } ``` ### 7. Register in Dependency Injection Add your adapter to `inject.go` so it's available everywhere: ```go if err := container.Provide(func(sqlcStore sqlc.Store) adapters.UserAdapter { return adapterImpl.NewUserAdapter(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide user adapter: %w", err) } ``` ### 8. Use in Your Module Now your business modules can request the adapter through dependency injection: ```go type UserService struct { userAdapter adapters.UserAdapter } func NewUserService(userAdapter adapters.UserAdapter) *UserService { return &UserService{userAdapter: userAdapter} } func (s *UserService) GetUser(ctx context.Context, id int32) (*User, error) { user, err := s.userAdapter.GetUserByID(ctx, id) if err != nil { return nil, err } // ... your business logic here } func (s *UserService) TransferFunds(ctx context.Context, fromID, toID int32, amount int32) error { // The adapter handles the transaction internally return s.userAdapter.TransferMoney(ctx, fromID, toID, amount) } ``` ## Working with Transactions When you need multiple queries to succeed or fail together, add a transaction method to your adapter: **Key Points:** - Your module only depends on the adapter interface (never touches the database pool) - The adapter implementation handles transaction logic using `store.ExecTx()` - All queries inside `ExecTx()` run in a transaction - If any query fails, everything rolls back automatically - If all succeed, everything commits automatically ## Common Commands ```bash make create-migration MIGRATION_NAME=your_change # Create migration files make migrateup # Apply migrations make migratedown # Rollback last migration make sqlc # Generate Go code from SQL make build # Verify everything compiles ``` ## Why This Pattern? - **Clean separation** - modules only know about adapters, not databases - **Easy to test** - mock the adapter interface, no database needed - **Type safety** - SQLC catches SQL errors at compile time - **Transaction safety** - adapters encapsulate transaction logic - **Single dependency** - modules only inject adapters That's it! The pattern keeps your business logic clean and focused. ================================================ FILE: go-b2b-starter/internal/db/adapters/cognitive_store.go ================================================ package adapters import ( "context" db "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/pgvector/pgvector-go" ) // EmbeddingStore provides database operations for document embeddings type EmbeddingStore interface { CreateDocumentEmbedding(ctx context.Context, arg db.CreateDocumentEmbeddingParams) (db.CognitiveDocumentEmbedding, error) GetDocumentEmbeddingByID(ctx context.Context, arg db.GetDocumentEmbeddingByIDParams) (db.CognitiveDocumentEmbedding, error) GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg db.GetDocumentEmbeddingsByDocumentIDParams) ([]db.CognitiveDocumentEmbedding, error) SearchSimilarDocuments(ctx context.Context, arg db.SearchSimilarDocumentsParams) ([]db.SearchSimilarDocumentsRow, error) DeleteDocumentEmbeddings(ctx context.Context, arg db.DeleteDocumentEmbeddingsParams) error CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) } // ChatStore provides database operations for chat sessions and messages type ChatStore interface { // Sessions CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.CognitiveChatSession, error) GetChatSessionByID(ctx context.Context, arg db.GetChatSessionByIDParams) (db.CognitiveChatSession, error) ListChatSessionsByAccount(ctx context.Context, arg db.ListChatSessionsByAccountParams) ([]db.CognitiveChatSession, error) UpdateChatSessionTitle(ctx context.Context, arg db.UpdateChatSessionTitleParams) (db.CognitiveChatSession, error) DeleteChatSession(ctx context.Context, arg db.DeleteChatSessionParams) error // Messages CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.CognitiveChatMessage, error) GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]db.CognitiveChatMessage, error) GetRecentChatMessages(ctx context.Context, arg db.GetRecentChatMessagesParams) ([]db.CognitiveChatMessage, error) CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) DeleteChatMessage(ctx context.Context, id int32) error } // VectorHelper provides utilities for working with pgvector type VectorHelper interface { ToVector(embedding []float64) pgvector.Vector FromVector(v pgvector.Vector) []float64 } ================================================ FILE: go-b2b-starter/internal/db/adapters/document_store.go ================================================ package adapters import ( "context" db "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) // DocumentStore provides database operations for documents type DocumentStore interface { CreateDocument(ctx context.Context, arg db.CreateDocumentParams) (db.DocumentsDocument, error) GetDocumentByID(ctx context.Context, arg db.GetDocumentByIDParams) (db.DocumentsDocument, error) GetDocumentByFileAssetID(ctx context.Context, arg db.GetDocumentByFileAssetIDParams) (db.DocumentsDocument, error) ListDocumentsByOrganization(ctx context.Context, arg db.ListDocumentsByOrganizationParams) ([]db.DocumentsDocument, error) ListDocumentsByStatus(ctx context.Context, arg db.ListDocumentsByStatusParams) ([]db.DocumentsDocument, error) UpdateDocumentStatus(ctx context.Context, arg db.UpdateDocumentStatusParams) (db.DocumentsDocument, error) UpdateDocumentExtractedText(ctx context.Context, arg db.UpdateDocumentExtractedTextParams) (db.DocumentsDocument, error) UpdateDocument(ctx context.Context, arg db.UpdateDocumentParams) (db.DocumentsDocument, error) DeleteDocument(ctx context.Context, arg db.DeleteDocumentParams) error CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) CountDocumentsByStatus(ctx context.Context, arg db.CountDocumentsByStatusParams) (int64, error) } ================================================ FILE: go-b2b-starter/internal/db/adapters/file_asset_store.go ================================================ package adapters import ( "context" db "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) // FileAssetStore defines the interface for file asset database operations // It exposes only file asset-related methods and returns SQLC types directly type FileAssetStore interface { // Basic file asset operations - using SQLC method signatures CreateFileAsset(ctx context.Context, arg db.CreateFileAssetParams) (db.FileManagerFileAsset, error) GetFileAssetByID(ctx context.Context, id int32) (db.FileManagerFileAsset, error) DeleteFileAsset(ctx context.Context, id int32) error GetFileAssetsByEntity(ctx context.Context, arg db.GetFileAssetsByEntityParams) ([]db.FileManagerFileAsset, error) GetFileAssetsByEntityAndPurpose(ctx context.Context, arg db.GetFileAssetsByEntityAndPurposeParams) ([]db.FileManagerFileAsset, error) // Category and context-based operations GetFileAssetsByCategory(ctx context.Context, categoryName string) ([]db.GetFileAssetsByCategoryRow, error) GetFileAssetsByContext(ctx context.Context, contextName string) ([]db.GetFileAssetsByContextRow, error) // Update operations UpdateFileAsset(ctx context.Context, arg db.UpdateFileAssetParams) error // Search and lookup operations GetFileAssetByStoragePath(ctx context.Context, storagePath string) (db.FileManagerFileAsset, error) ListFileAssets(ctx context.Context, arg db.ListFileAssetsParams) ([]db.ListFileAssetsRow, error) // Lookup tables operations GetFileCategories(ctx context.Context) ([]db.FileManagerFileCategory, error) GetFileContexts(ctx context.Context) ([]db.FileManagerFileContext, error) } ================================================ FILE: go-b2b-starter/internal/db/adapters/organization_store.go ================================================ package adapters import ( "context" db "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/jackc/pgx/v5/pgtype" ) // OrganizationStore provides database operations for organizations type OrganizationStore interface { CreateOrganization(ctx context.Context, arg db.CreateOrganizationParams) (db.OrganizationsOrganization, error) GetOrganizationByID(ctx context.Context, id int32) (db.OrganizationsOrganization, error) GetOrganizationBySlug(ctx context.Context, slug string) (db.OrganizationsOrganization, error) GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (db.OrganizationsOrganization, error) GetOrganizationByUserEmail(ctx context.Context, email string) (db.OrganizationsOrganization, error) UpdateOrganization(ctx context.Context, arg db.UpdateOrganizationParams) (db.OrganizationsOrganization, error) UpdateOrganizationStytchInfo(ctx context.Context, arg db.UpdateOrganizationStytchInfoParams) (db.OrganizationsOrganization, error) ListOrganizations(ctx context.Context, arg db.ListOrganizationsParams) ([]db.OrganizationsOrganization, error) DeleteOrganization(ctx context.Context, id int32) error GetOrganizationStats(ctx context.Context, id int32) (db.GetOrganizationStatsRow, error) } // AccountStore provides database operations for accounts type AccountStore interface { CreateAccount(ctx context.Context, arg db.CreateAccountParams) (db.OrganizationsAccount, error) GetAccountByID(ctx context.Context, arg db.GetAccountByIDParams) (db.OrganizationsAccount, error) GetAccountByEmail(ctx context.Context, arg db.GetAccountByEmailParams) (db.OrganizationsAccount, error) ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]db.OrganizationsAccount, error) UpdateAccount(ctx context.Context, arg db.UpdateAccountParams) (db.OrganizationsAccount, error) UpdateAccountStytchInfo(ctx context.Context, arg db.UpdateAccountStytchInfoParams) (db.OrganizationsAccount, error) UpdateAccountLastLogin(ctx context.Context, arg db.UpdateAccountLastLoginParams) (db.OrganizationsAccount, error) DeleteAccount(ctx context.Context, arg db.DeleteAccountParams) error GetAccountOrganization(ctx context.Context, id int32) (db.OrganizationsOrganization, error) CheckAccountPermission(ctx context.Context, arg db.CheckAccountPermissionParams) (db.CheckAccountPermissionRow, error) GetAccountStats(ctx context.Context, id int32) (db.GetAccountStatsRow, error) } ================================================ FILE: go-b2b-starter/internal/db/adapters/subscription_store.go ================================================ package adapters import ( "context" db "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) // SubscriptionStore provides database operations for subscription billing type SubscriptionStore interface { // Subscription operations GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (db.SubscriptionBillingSubscription, error) GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (db.SubscriptionBillingSubscription, error) UpsertSubscription(ctx context.Context, arg db.UpsertSubscriptionParams) (db.SubscriptionBillingSubscription, error) DeleteSubscription(ctx context.Context, organizationID int32) error ListActiveSubscriptions(ctx context.Context) ([]db.SubscriptionBillingSubscription, error) // Quota operations GetQuotaByOrgID(ctx context.Context, organizationID int32) (db.SubscriptionBillingQuotaTracking, error) UpsertQuota(ctx context.Context, arg db.UpsertQuotaParams) (db.SubscriptionBillingQuotaTracking, error) DecrementInvoiceCount(ctx context.Context, organizationID int32) (db.SubscriptionBillingQuotaTracking, error) ResetQuotaForPeriod(ctx context.Context, arg db.ResetQuotaForPeriodParams) (db.SubscriptionBillingQuotaTracking, error) // Combined operations GetQuotaStatus(ctx context.Context, organizationID int32) (db.GetQuotaStatusRow, error) ListQuotasNearLimit(ctx context.Context, threshold int32) ([]db.ListQuotasNearLimitRow, error) } ================================================ FILE: go-b2b-starter/internal/db/cmd/init.go ================================================ package cmd import ( "go.uber.org/dig" ) func Init(container *dig.Container) { ProvideDependencies(container) } ================================================ FILE: go-b2b-starter/internal/db/cmd/providers.go ================================================ package cmd import ( "github.com/moasq/go-b2b-starter/internal/db" "go.uber.org/dig" ) // ProvideDependencies registers all database dependencies using the centralized inject func ProvideDependencies(container *dig.Container) error { // Use the centralized inject function with default options return db.Inject(container) } // ProvideDependenciesWithOptions registers database dependencies with custom options func ProvideDependenciesWithOptions(container *dig.Container, opts db.InjectOptions) error { return db.InjectWithOptions(container, opts) } ================================================ FILE: go-b2b-starter/internal/db/core/connection.go ================================================ package core import ( "context" "time" ) // Connection represents a database connection interface type Connection interface { // Execute runs a query that doesn't return rows Execute(ctx context.Context, query string, args ...any) error // Query executes a query that returns rows Query(ctx context.Context, query string, args ...any) (Rows, error) // QueryRow executes a query that returns a single row QueryRow(ctx context.Context, query string, args ...any) Row // BeginTx starts a new transaction BeginTx(ctx context.Context) (Transaction, error) // Ping verifies the connection to the database is still alive Ping(ctx context.Context) error // Close closes the database connection Close() error } // Pool represents a connection pool interface type Pool interface { Connection // Stats returns connection pool statistics Stats() PoolStats // SetMaxConnections sets the maximum number of connections in the pool SetMaxConnections(n int) // SetMaxConnectionLifetime sets the maximum lifetime of a connection SetMaxConnectionLifetime(d time.Duration) // SetMaxConnectionIdleTime sets the maximum idle time of a connection SetMaxConnectionIdleTime(d time.Duration) } // PoolStats contains connection pool statistics type PoolStats struct { TotalConnections int IdleConnections int AcquiredConnections int MaxConnections int } // Rows represents the result of a query type Rows interface { // Next prepares the next row for reading Next() bool // Scan reads the values from the current row Scan(dest ...any) error // Close closes the rows Close() error // Err returns any error that occurred during iteration Err() error } // Row represents a single row result type Row interface { // Scan reads the values from the row Scan(dest ...any) error } ================================================ FILE: go-b2b-starter/internal/db/core/errors.go ================================================ package core import ( "errors" "fmt" ) // Common database errors var ( // ErrNoRows is returned when a query returns no rows ErrNoRows = errors.New("no rows in result set") // ErrTxClosed is returned when an operation is attempted on a closed transaction ErrTxClosed = errors.New("transaction has already been committed or rolled back") // ErrPoolClosed is returned when an operation is attempted on a closed pool ErrPoolClosed = errors.New("connection pool is closed") // ErrInvalidConnection is returned when the connection is invalid ErrInvalidConnection = errors.New("invalid database connection") // ErrTimeout is returned when a database operation times out ErrTimeout = errors.New("database operation timed out") ) // ErrTxRollbackFailed is returned when a transaction rollback fails type ErrTxRollbackFailed struct { OriginalErr error RollbackErr error } func (e ErrTxRollbackFailed) Error() string { return fmt.Sprintf("transaction rollback failed: %v (original error: %v)", e.RollbackErr, e.OriginalErr) } func (e ErrTxRollbackFailed) Unwrap() error { return e.OriginalErr } // ErrTxCommitFailed is returned when a transaction commit fails type ErrTxCommitFailed struct { Err error } func (e ErrTxCommitFailed) Error() string { return fmt.Sprintf("transaction commit failed: %v", e.Err) } func (e ErrTxCommitFailed) Unwrap() error { return e.Err } // ErrConstraintViolation represents a database constraint violation type ErrConstraintViolation struct { Constraint string Message string } func (e ErrConstraintViolation) Error() string { return fmt.Sprintf("constraint violation '%s': %s", e.Constraint, e.Message) } // IsNoRowsError checks if an error is a no rows error func IsNoRowsError(err error) bool { return errors.Is(err, ErrNoRows) } // IsConstraintError checks if an error is a constraint violation func IsConstraintError(err error) bool { var constraintErr ErrConstraintViolation return errors.As(err, &constraintErr) } // IsTimeoutError checks if an error is a timeout error func IsTimeoutError(err error) bool { return errors.Is(err, ErrTimeout) } ================================================ FILE: go-b2b-starter/internal/db/core/transaction.go ================================================ package core import "context" // Transaction represents a database transaction type Transaction interface { Connection // Commit commits the transaction Commit(ctx context.Context) error // Rollback rolls back the transaction Rollback(ctx context.Context) error } // TxFunc represents a function that runs within a transaction type TxFunc func(ctx context.Context, tx Transaction) error // WithTransaction executes a function within a transaction // It automatically handles commit/rollback based on the function's return value func WithTransaction(ctx context.Context, pool Pool, fn TxFunc) error { tx, err := pool.BeginTx(ctx) if err != nil { return err } defer func() { if p := recover(); p != nil { _ = tx.Rollback(ctx) panic(p) } }() if err := fn(ctx, tx); err != nil { if rbErr := tx.Rollback(ctx); rbErr != nil { return ErrTxRollbackFailed{ OriginalErr: err, RollbackErr: rbErr, } } return err } if err := tx.Commit(ctx); err != nil { return ErrTxCommitFailed{Err: err} } return nil } ================================================ FILE: go-b2b-starter/internal/db/helpers/helpers.go ================================================ // Package helpers provides utility functions for converting between Go types // and PostgreSQL types (pgtype, pgvector). These helpers are used by repository // implementations across all modules. package helpers import ( "encoding/json" "github.com/jackc/pgx/v5/pgtype" "github.com/pgvector/pgvector-go" ) // ToPgText converts a string to pgtype.Text func ToPgText(s string) pgtype.Text { if s == "" { return pgtype.Text{Valid: false} } return pgtype.Text{String: s, Valid: true} } // FromPgText converts pgtype.Text to string func FromPgText(t pgtype.Text) string { if !t.Valid { return "" } return t.String } // ToPgInt4 converts an int32 to pgtype.Int4 func ToPgInt4(i int32) pgtype.Int4 { return pgtype.Int4{Int32: i, Valid: true} } // ToPgInt4Ptr converts a pointer to int32 to pgtype.Int4 func ToPgInt4Ptr(i *int32) pgtype.Int4 { if i == nil { return pgtype.Int4{Valid: false} } return pgtype.Int4{Int32: *i, Valid: true} } // FromPgInt4 converts pgtype.Int4 to int32 func FromPgInt4(i pgtype.Int4) int32 { if !i.Valid { return 0 } return i.Int32 } // ToPgBool converts a bool to pgtype.Bool func ToPgBool(b bool) pgtype.Bool { return pgtype.Bool{Bool: b, Valid: true} } // ToPgBoolPtr converts a pointer to bool to pgtype.Bool func ToPgBoolPtr(b *bool) pgtype.Bool { if b == nil { return pgtype.Bool{Valid: false} } return pgtype.Bool{Bool: *b, Valid: true} } // FromPgBool converts pgtype.Bool to bool func FromPgBool(b pgtype.Bool) bool { if !b.Valid { return false } return b.Bool } // ToJSONB converts a map to JSON bytes func ToJSONB(m map[string]any) []byte { if m == nil { return []byte("{}") } data, err := json.Marshal(m) if err != nil { return []byte("{}") } return data } // FromJSONB converts JSON bytes to a map func FromJSONB(b []byte) map[string]any { if len(b) == 0 { return nil } var result map[string]any if err := json.Unmarshal(b, &result); err != nil { return nil } return result } // ToVector converts a float64 slice to pgvector.Vector func ToVector(embedding []float64) pgvector.Vector { // Convert []float64 to []float32 for pgvector f32 := make([]float32, len(embedding)) for i, v := range embedding { f32[i] = float32(v) } return pgvector.NewVector(f32) } // FromVector converts pgvector.Vector to float64 slice func FromVector(v pgvector.Vector) []float64 { f32 := v.Slice() result := make([]float64, len(f32)) for i, val := range f32 { result[i] = float64(val) } return result } ================================================ FILE: go-b2b-starter/internal/db/inject.go ================================================ package db import ( "context" "database/sql" "fmt" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "go.uber.org/dig" // Domain interfaces - these are the interfaces we provide billingDomain "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" cognitiveDomain "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" documentDomain "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" fileDomain "github.com/moasq/go-b2b-starter/internal/modules/files/domain" orgDomain "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" // Repository implementations from module infra layers billingRepos "github.com/moasq/go-b2b-starter/internal/modules/billing/infra/repositories" cognitiveRepos "github.com/moasq/go-b2b-starter/internal/modules/cognitive/infra/repositories" documentRepos "github.com/moasq/go-b2b-starter/internal/modules/documents/infra/repositories" fileInfra "github.com/moasq/go-b2b-starter/internal/modules/files/infra" orgRepos "github.com/moasq/go-b2b-starter/internal/modules/organizations/infra/repositories" // Legacy adapters - kept temporarily for backward compatibility "github.com/moasq/go-b2b-starter/internal/db/adapters" "github.com/moasq/go-b2b-starter/internal/db/postgres" adapterImpl "github.com/moasq/go-b2b-starter/internal/db/postgres/adapter_impl" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) // Inject registers all database dependencies in the DI container func Inject(container *dig.Container) error { // Register configuration if err := container.Provide(postgres.LoadConfig); err != nil { return fmt.Errorf("failed to provide database config: %w", err) } // Register connection pool if err := container.Provide(provideDBPool); err != nil { return fmt.Errorf("failed to provide database pool: %w", err) } // Register SQLC store if err := container.Provide(provideSQLCStore); err != nil { return fmt.Errorf("failed to provide SQLC store: %w", err) } // Register *sql.DB for modules that need standard database/sql interface if err := container.Provide(provideSQLDB); err != nil { return fmt.Errorf("failed to provide SQL DB: %w", err) } // Register domain stores if err := registerDomainStores(container); err != nil { return fmt.Errorf("failed to register domain stores: %w", err) } // Register database manager if err := container.Provide(provideDBManager); err != nil { return fmt.Errorf("failed to provide database manager: %w", err) } return nil } // provideDBPool creates the database connection pool func provideDBPool(config postgres.Config) (*pgxpool.Pool, error) { return postgres.InitDB(config) } // provideSQLCStore creates the SQLC store func provideSQLCStore(pool *pgxpool.Pool) sqlc.Store { return sqlc.NewStore(pool) } // provideSQLDB creates a *sql.DB from the pgxpool for compatibility func provideSQLDB(pool *pgxpool.Pool) *sql.DB { // Use pgx stdlib to create a sql.DB from the pool connection string connConfig := pool.Config().ConnConfig return stdlib.OpenDB(*connConfig) } // provideDBManager creates the database manager for migrations and health checks func provideDBManager(config postgres.Config, pool *pgxpool.Pool) *postgres.PostgresManager { return postgres.NewPostgresManager(config, pool) } // registerDomainStores registers all domain-specific repositories. // These repositories implement domain ports using SQLC internally - no SQLC types leak out. func registerDomainStores(container *dig.Container) error { // ============================================ // NEW: Sealed repository implementations // These use domain interfaces and hide SQLC internals // ============================================ // Register DocumentRepository - implements documents/domain.DocumentRepository if err := container.Provide(func(sqlcStore sqlc.Store) documentDomain.DocumentRepository { return documentRepos.NewDocumentRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide document repository: %w", err) } // Register OrganizationRepository - implements organizations/domain.OrganizationRepository if err := container.Provide(func(sqlcStore sqlc.Store) orgDomain.OrganizationRepository { return orgRepos.NewOrganizationRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide organization repository: %w", err) } // Register AccountRepository - implements organizations/domain.AccountRepository if err := container.Provide(func(sqlcStore sqlc.Store) orgDomain.AccountRepository { return orgRepos.NewAccountRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide account repository: %w", err) } // Register SubscriptionRepository - implements billing/domain.SubscriptionRepository if err := container.Provide(func(sqlcStore sqlc.Store) billingDomain.SubscriptionRepository { return billingRepos.NewSubscriptionRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide subscription repository: %w", err) } // Register EmbeddingRepository - implements cognitive/domain.EmbeddingRepository if err := container.Provide(func(sqlcStore sqlc.Store) cognitiveDomain.EmbeddingRepository { return cognitiveRepos.NewEmbeddingRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide embedding repository: %w", err) } // Register ChatRepository - implements cognitive/domain.ChatRepository if err := container.Provide(func(sqlcStore sqlc.Store) cognitiveDomain.ChatRepository { return cognitiveRepos.NewChatRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide chat repository: %w", err) } // Register FileMetadataRepository - implements files/domain.FileMetadataRepository if err := container.Provide(func(sqlcStore sqlc.Store) fileDomain.FileMetadataRepository { return fileInfra.NewFileMetadataRepository(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide file metadata repository: %w", err) } // ============================================ // LEGACY: Adapter stores (kept for backward compatibility) // TODO: Migrate callers to use domain interfaces, then remove these // ============================================ // Register FileAssetStore - thin wrapper for file management operations if err := container.Provide(func(sqlcStore sqlc.Store) adapters.FileAssetStore { return adapterImpl.NewFileAssetStore(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide file asset store: %w", err) } // Register OrganizationStore - thin wrapper for organization operations if err := container.Provide(func(sqlcStore sqlc.Store) adapters.OrganizationStore { return adapterImpl.NewOrganizationStore(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide organization store: %w", err) } // Register AccountStore - thin wrapper for account operations if err := container.Provide(func(sqlcStore sqlc.Store) adapters.AccountStore { return adapterImpl.NewAccountStore(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide account store: %w", err) } // Register SubscriptionStore - thin wrapper for subscription billing operations if err := container.Provide(func(sqlcStore sqlc.Store) adapters.SubscriptionStore { return adapterImpl.NewSubscriptionStore(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide subscription store: %w", err) } // Register DocumentStore - thin wrapper for document operations if err := container.Provide(func(sqlcStore sqlc.Store) adapters.DocumentStore { return adapterImpl.NewDocumentStore(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide document store: %w", err) } // Register EmbeddingStore - thin wrapper for cognitive embedding operations if err := container.Provide(func(sqlcStore sqlc.Store) adapters.EmbeddingStore { return adapterImpl.NewEmbeddingStore(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide embedding store: %w", err) } // Register ChatStore - thin wrapper for cognitive chat operations if err := container.Provide(func(sqlcStore sqlc.Store) adapters.ChatStore { return adapterImpl.NewChatStore(sqlcStore) }); err != nil { return fmt.Errorf("failed to provide chat store: %w", err) } return nil } // InjectWithOptions allows injecting with custom options type InjectOptions struct { // SkipMigrations skips running database migrations SkipMigrations bool // SkipHealthCheck skips the initial health check SkipHealthCheck bool } // InjectWithOptions registers database dependencies with options func InjectWithOptions(container *dig.Container, opts InjectOptions) error { if err := Inject(container); err != nil { return err } // Optionally run migrations and health checks if !opts.SkipMigrations || !opts.SkipHealthCheck { if err := container.Invoke(func(manager *postgres.PostgresManager) error { if !opts.SkipHealthCheck { if err := manager.CheckHealth(context.Background()); err != nil { return fmt.Errorf("database health check failed: %w", err) } } if !opts.SkipMigrations { if err := manager.RunMigrations(); err != nil { return fmt.Errorf("failed to run migrations: %w", err) } } return nil }); err != nil { return err } } return nil } ================================================ FILE: go-b2b-starter/internal/db/postgres/adapter_impl/cognitive_store.go ================================================ package adapterimpl import ( "context" "github.com/moasq/go-b2b-starter/internal/db/adapters" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) // embeddingStore implements adapters.EmbeddingStore type embeddingStore struct { store sqlc.Store } func NewEmbeddingStore(store sqlc.Store) adapters.EmbeddingStore { return &embeddingStore{store: store} } func (s *embeddingStore) CreateDocumentEmbedding(ctx context.Context, arg sqlc.CreateDocumentEmbeddingParams) (sqlc.CognitiveDocumentEmbedding, error) { return s.store.CreateDocumentEmbedding(ctx, arg) } func (s *embeddingStore) GetDocumentEmbeddingByID(ctx context.Context, arg sqlc.GetDocumentEmbeddingByIDParams) (sqlc.CognitiveDocumentEmbedding, error) { return s.store.GetDocumentEmbeddingByID(ctx, arg) } func (s *embeddingStore) GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg sqlc.GetDocumentEmbeddingsByDocumentIDParams) ([]sqlc.CognitiveDocumentEmbedding, error) { return s.store.GetDocumentEmbeddingsByDocumentID(ctx, arg) } func (s *embeddingStore) SearchSimilarDocuments(ctx context.Context, arg sqlc.SearchSimilarDocumentsParams) ([]sqlc.SearchSimilarDocumentsRow, error) { return s.store.SearchSimilarDocuments(ctx, arg) } func (s *embeddingStore) DeleteDocumentEmbeddings(ctx context.Context, arg sqlc.DeleteDocumentEmbeddingsParams) error { return s.store.DeleteDocumentEmbeddings(ctx, arg) } func (s *embeddingStore) CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) { return s.store.CountDocumentEmbeddingsByOrganization(ctx, organizationID) } // chatStore implements adapters.ChatStore type chatStore struct { store sqlc.Store } func NewChatStore(store sqlc.Store) adapters.ChatStore { return &chatStore{store: store} } // Sessions func (s *chatStore) CreateChatSession(ctx context.Context, arg sqlc.CreateChatSessionParams) (sqlc.CognitiveChatSession, error) { return s.store.CreateChatSession(ctx, arg) } func (s *chatStore) GetChatSessionByID(ctx context.Context, arg sqlc.GetChatSessionByIDParams) (sqlc.CognitiveChatSession, error) { return s.store.GetChatSessionByID(ctx, arg) } func (s *chatStore) ListChatSessionsByAccount(ctx context.Context, arg sqlc.ListChatSessionsByAccountParams) ([]sqlc.CognitiveChatSession, error) { return s.store.ListChatSessionsByAccount(ctx, arg) } func (s *chatStore) UpdateChatSessionTitle(ctx context.Context, arg sqlc.UpdateChatSessionTitleParams) (sqlc.CognitiveChatSession, error) { return s.store.UpdateChatSessionTitle(ctx, arg) } func (s *chatStore) DeleteChatSession(ctx context.Context, arg sqlc.DeleteChatSessionParams) error { return s.store.DeleteChatSession(ctx, arg) } // Messages func (s *chatStore) CreateChatMessage(ctx context.Context, arg sqlc.CreateChatMessageParams) (sqlc.CognitiveChatMessage, error) { return s.store.CreateChatMessage(ctx, arg) } func (s *chatStore) GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]sqlc.CognitiveChatMessage, error) { return s.store.GetChatMessagesBySession(ctx, sessionID) } func (s *chatStore) GetRecentChatMessages(ctx context.Context, arg sqlc.GetRecentChatMessagesParams) ([]sqlc.CognitiveChatMessage, error) { return s.store.GetRecentChatMessages(ctx, arg) } func (s *chatStore) CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) { return s.store.CountChatMessagesBySession(ctx, sessionID) } func (s *chatStore) DeleteChatMessage(ctx context.Context, id int32) error { return s.store.DeleteChatMessage(ctx, id) } ================================================ FILE: go-b2b-starter/internal/db/postgres/adapter_impl/document_store.go ================================================ package adapterimpl import ( "context" "github.com/moasq/go-b2b-starter/internal/db/adapters" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) // documentStore implements adapters.DocumentStore type documentStore struct { store sqlc.Store } func NewDocumentStore(store sqlc.Store) adapters.DocumentStore { return &documentStore{store: store} } func (s *documentStore) CreateDocument(ctx context.Context, arg sqlc.CreateDocumentParams) (sqlc.DocumentsDocument, error) { return s.store.CreateDocument(ctx, arg) } func (s *documentStore) GetDocumentByID(ctx context.Context, arg sqlc.GetDocumentByIDParams) (sqlc.DocumentsDocument, error) { return s.store.GetDocumentByID(ctx, arg) } func (s *documentStore) GetDocumentByFileAssetID(ctx context.Context, arg sqlc.GetDocumentByFileAssetIDParams) (sqlc.DocumentsDocument, error) { return s.store.GetDocumentByFileAssetID(ctx, arg) } func (s *documentStore) ListDocumentsByOrganization(ctx context.Context, arg sqlc.ListDocumentsByOrganizationParams) ([]sqlc.DocumentsDocument, error) { return s.store.ListDocumentsByOrganization(ctx, arg) } func (s *documentStore) ListDocumentsByStatus(ctx context.Context, arg sqlc.ListDocumentsByStatusParams) ([]sqlc.DocumentsDocument, error) { return s.store.ListDocumentsByStatus(ctx, arg) } func (s *documentStore) UpdateDocumentStatus(ctx context.Context, arg sqlc.UpdateDocumentStatusParams) (sqlc.DocumentsDocument, error) { return s.store.UpdateDocumentStatus(ctx, arg) } func (s *documentStore) UpdateDocumentExtractedText(ctx context.Context, arg sqlc.UpdateDocumentExtractedTextParams) (sqlc.DocumentsDocument, error) { return s.store.UpdateDocumentExtractedText(ctx, arg) } func (s *documentStore) UpdateDocument(ctx context.Context, arg sqlc.UpdateDocumentParams) (sqlc.DocumentsDocument, error) { return s.store.UpdateDocument(ctx, arg) } func (s *documentStore) DeleteDocument(ctx context.Context, arg sqlc.DeleteDocumentParams) error { return s.store.DeleteDocument(ctx, arg) } func (s *documentStore) CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) { return s.store.CountDocumentsByOrganization(ctx, organizationID) } func (s *documentStore) CountDocumentsByStatus(ctx context.Context, arg sqlc.CountDocumentsByStatusParams) (int64, error) { return s.store.CountDocumentsByStatus(ctx, arg) } ================================================ FILE: go-b2b-starter/internal/db/postgres/adapter_impl/file_asset_store.go ================================================ package adapterimpl import ( "context" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/db/adapters" ) // fileAssetStore is a thin wrapper around SQLC store that implements adapters.FileAssetStore // It only exposes file asset-related methods and delegates directly to the underlying store type fileAssetStore struct { store sqlc.Store } func NewFileAssetStore(store sqlc.Store) adapters.FileAssetStore { return &fileAssetStore{ store: store, } } // Basic file asset operations - direct delegation to SQLC store func (f *fileAssetStore) CreateFileAsset(ctx context.Context, arg sqlc.CreateFileAssetParams) (sqlc.FileManagerFileAsset, error) { return f.store.CreateFileAsset(ctx, arg) } func (f *fileAssetStore) GetFileAssetByID(ctx context.Context, id int32) (sqlc.FileManagerFileAsset, error) { return f.store.GetFileAssetByID(ctx, id) } func (f *fileAssetStore) DeleteFileAsset(ctx context.Context, id int32) error { return f.store.DeleteFileAsset(ctx, id) } func (f *fileAssetStore) GetFileAssetsByEntity(ctx context.Context, arg sqlc.GetFileAssetsByEntityParams) ([]sqlc.FileManagerFileAsset, error) { return f.store.GetFileAssetsByEntity(ctx, arg) } func (f *fileAssetStore) GetFileAssetsByEntityAndPurpose(ctx context.Context, arg sqlc.GetFileAssetsByEntityAndPurposeParams) ([]sqlc.FileManagerFileAsset, error) { return f.store.GetFileAssetsByEntityAndPurpose(ctx, arg) } // Category and context-based operations - direct delegation func (f *fileAssetStore) GetFileAssetsByCategory(ctx context.Context, categoryName string) ([]sqlc.GetFileAssetsByCategoryRow, error) { return f.store.GetFileAssetsByCategory(ctx, categoryName) } func (f *fileAssetStore) GetFileAssetsByContext(ctx context.Context, contextName string) ([]sqlc.GetFileAssetsByContextRow, error) { return f.store.GetFileAssetsByContext(ctx, contextName) } // Update operations - direct delegation func (f *fileAssetStore) UpdateFileAsset(ctx context.Context, arg sqlc.UpdateFileAssetParams) error { return f.store.UpdateFileAsset(ctx, arg) } // Search and lookup operations - direct delegation func (f *fileAssetStore) GetFileAssetByStoragePath(ctx context.Context, storagePath string) (sqlc.FileManagerFileAsset, error) { return f.store.GetFileAssetByStoragePath(ctx, storagePath) } func (f *fileAssetStore) ListFileAssets(ctx context.Context, arg sqlc.ListFileAssetsParams) ([]sqlc.ListFileAssetsRow, error) { return f.store.ListFileAssets(ctx, arg) } // Lookup tables operations - direct delegation func (f *fileAssetStore) GetFileCategories(ctx context.Context) ([]sqlc.FileManagerFileCategory, error) { return f.store.GetFileCategories(ctx) } func (f *fileAssetStore) GetFileContexts(ctx context.Context) ([]sqlc.FileManagerFileContext, error) { return f.store.GetFileContexts(ctx) } ================================================ FILE: go-b2b-starter/internal/db/postgres/adapter_impl/organization_store.go ================================================ package adapterimpl import ( "context" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/db/adapters" "github.com/jackc/pgx/v5/pgtype" ) // organizationStore implements adapters.OrganizationStore type organizationStore struct { store sqlc.Store } func NewOrganizationStore(store sqlc.Store) adapters.OrganizationStore { return &organizationStore{store: store} } func (s *organizationStore) GetOrganizationByID(ctx context.Context, id int32) (sqlc.OrganizationsOrganization, error) { return s.store.GetOrganizationByID(ctx, id) } func (s *organizationStore) GetOrganizationBySlug(ctx context.Context, slug string) (sqlc.OrganizationsOrganization, error) { return s.store.GetOrganizationBySlug(ctx, slug) } func (s *organizationStore) GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (sqlc.OrganizationsOrganization, error) { return s.store.GetOrganizationByStytchID(ctx, stytchOrgID) } func (s *organizationStore) GetOrganizationByUserEmail(ctx context.Context, email string) (sqlc.OrganizationsOrganization, error) { return s.store.GetOrganizationByUserEmail(ctx, email) } func (s *organizationStore) CreateOrganization(ctx context.Context, arg sqlc.CreateOrganizationParams) (sqlc.OrganizationsOrganization, error) { return s.store.CreateOrganization(ctx, arg) } func (s *organizationStore) UpdateOrganization(ctx context.Context, arg sqlc.UpdateOrganizationParams) (sqlc.OrganizationsOrganization, error) { return s.store.UpdateOrganization(ctx, arg) } func (s *organizationStore) UpdateOrganizationStytchInfo(ctx context.Context, arg sqlc.UpdateOrganizationStytchInfoParams) (sqlc.OrganizationsOrganization, error) { return s.store.UpdateOrganizationStytchInfo(ctx, arg) } func (s *organizationStore) ListOrganizations(ctx context.Context, arg sqlc.ListOrganizationsParams) ([]sqlc.OrganizationsOrganization, error) { return s.store.ListOrganizations(ctx, arg) } func (s *organizationStore) DeleteOrganization(ctx context.Context, id int32) error { return s.store.DeleteOrganization(ctx, id) } func (s *organizationStore) GetOrganizationStats(ctx context.Context, id int32) (sqlc.GetOrganizationStatsRow, error) { return s.store.GetOrganizationStats(ctx, id) } // accountStore implements adapters.AccountStore type accountStore struct { store sqlc.Store } func NewAccountStore(store sqlc.Store) adapters.AccountStore { return &accountStore{store: store} } func (s *accountStore) CreateAccount(ctx context.Context, arg sqlc.CreateAccountParams) (sqlc.OrganizationsAccount, error) { return s.store.CreateAccount(ctx, arg) } func (s *accountStore) GetAccountByID(ctx context.Context, arg sqlc.GetAccountByIDParams) (sqlc.OrganizationsAccount, error) { return s.store.GetAccountByID(ctx, arg) } func (s *accountStore) GetAccountByEmail(ctx context.Context, arg sqlc.GetAccountByEmailParams) (sqlc.OrganizationsAccount, error) { return s.store.GetAccountByEmail(ctx, arg) } func (s *accountStore) ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]sqlc.OrganizationsAccount, error) { return s.store.ListAccountsByOrganization(ctx, organizationID) } func (s *accountStore) UpdateAccount(ctx context.Context, arg sqlc.UpdateAccountParams) (sqlc.OrganizationsAccount, error) { return s.store.UpdateAccount(ctx, arg) } func (s *accountStore) UpdateAccountStytchInfo(ctx context.Context, arg sqlc.UpdateAccountStytchInfoParams) (sqlc.OrganizationsAccount, error) { return s.store.UpdateAccountStytchInfo(ctx, arg) } func (s *accountStore) UpdateAccountLastLogin(ctx context.Context, arg sqlc.UpdateAccountLastLoginParams) (sqlc.OrganizationsAccount, error) { return s.store.UpdateAccountLastLogin(ctx, arg) } func (s *accountStore) DeleteAccount(ctx context.Context, arg sqlc.DeleteAccountParams) error { return s.store.DeleteAccount(ctx, arg) } func (s *accountStore) GetAccountOrganization(ctx context.Context, id int32) (sqlc.OrganizationsOrganization, error) { return s.store.GetAccountOrganization(ctx, id) } func (s *accountStore) CheckAccountPermission(ctx context.Context, arg sqlc.CheckAccountPermissionParams) (sqlc.CheckAccountPermissionRow, error) { return s.store.CheckAccountPermission(ctx, arg) } func (s *accountStore) GetAccountStats(ctx context.Context, id int32) (sqlc.GetAccountStatsRow, error) { return s.store.GetAccountStats(ctx, id) } ================================================ FILE: go-b2b-starter/internal/db/postgres/adapter_impl/subscription_store.go ================================================ package adapterimpl import ( "context" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/db/adapters" ) // subscriptionStore implements adapters.SubscriptionStore type subscriptionStore struct { store sqlc.Store } func NewSubscriptionStore(store sqlc.Store) adapters.SubscriptionStore { return &subscriptionStore{store: store} } // Subscription operations func (s *subscriptionStore) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingSubscription, error) { return s.store.GetSubscriptionByOrgID(ctx, organizationID) } func (s *subscriptionStore) GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (sqlc.SubscriptionBillingSubscription, error) { return s.store.GetSubscriptionBySubscriptionID(ctx, subscriptionID) } func (s *subscriptionStore) UpsertSubscription(ctx context.Context, arg sqlc.UpsertSubscriptionParams) (sqlc.SubscriptionBillingSubscription, error) { return s.store.UpsertSubscription(ctx, arg) } func (s *subscriptionStore) DeleteSubscription(ctx context.Context, organizationID int32) error { return s.store.DeleteSubscription(ctx, organizationID) } func (s *subscriptionStore) ListActiveSubscriptions(ctx context.Context) ([]sqlc.SubscriptionBillingSubscription, error) { return s.store.ListActiveSubscriptions(ctx) } // Quota operations func (s *subscriptionStore) GetQuotaByOrgID(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingQuotaTracking, error) { return s.store.GetQuotaByOrgID(ctx, organizationID) } func (s *subscriptionStore) UpsertQuota(ctx context.Context, arg sqlc.UpsertQuotaParams) (sqlc.SubscriptionBillingQuotaTracking, error) { return s.store.UpsertQuota(ctx, arg) } func (s *subscriptionStore) DecrementInvoiceCount(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingQuotaTracking, error) { return s.store.DecrementInvoiceCount(ctx, organizationID) } func (s *subscriptionStore) ResetQuotaForPeriod(ctx context.Context, arg sqlc.ResetQuotaForPeriodParams) (sqlc.SubscriptionBillingQuotaTracking, error) { return s.store.ResetQuotaForPeriod(ctx, arg) } // Combined operations func (s *subscriptionStore) GetQuotaStatus(ctx context.Context, organizationID int32) (sqlc.GetQuotaStatusRow, error) { return s.store.GetQuotaStatus(ctx, organizationID) } func (s *subscriptionStore) ListQuotasNearLimit(ctx context.Context, threshold int32) ([]sqlc.ListQuotasNearLimitRow, error) { return s.store.ListQuotasNearLimit(ctx, threshold) } ================================================ FILE: go-b2b-starter/internal/db/postgres/connection.go ================================================ package postgres import ( "context" "fmt" "log" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) func connPool(cfg Config) (*pgxpool.Pool, error) { // Create a pool configuration poolConfig, err := pgxpool.ParseConfig(cfg.ConnectionString()) if err != nil { return nil, fmt.Errorf("unable to parse pool config: %w", err) } // Set pool configuration parameters poolConfig.MaxConns = int32(cfg.MaxConns) poolConfig.MinConns = int32(cfg.MinConns) poolConfig.MaxConnLifetime = cfg.ConnLifetime poolConfig.MaxConnIdleTime = cfg.ConnIdleTime poolConfig.HealthCheckPeriod = cfg.HealthCheckPeriod // Add connection lifecycle callbacks poolConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { // Optional validation before using a connection return true } poolConfig.AfterRelease = func(conn *pgx.Conn) bool { // Clean up after connection use if needed return true } // Create the connection pool with the configured settings pool, err := pgxpool.NewWithConfig(context.Background(), poolConfig) if err != nil { return nil, fmt.Errorf("unable to create connection pool: %w", err) } // Test the connection if err := pool.Ping(context.Background()); err != nil { return nil, fmt.Errorf("unable to ping database: %w", err) } log.Printf("Successfully connected to PostgreSQL database at %s:%s", cfg.Host, cfg.Port) return pool, nil } ================================================ FILE: go-b2b-starter/internal/db/postgres/db_config.go ================================================ package postgres import ( "fmt" "time" "github.com/spf13/viper" ) type Config struct { Host string `mapstructure:"POSTGRES_HOST"` Port string `mapstructure:"POSTGRES_PORT"` User string `mapstructure:"POSTGRES_USER"` Password string `mapstructure:"POSTGRES_PASSWORD"` DBName string `mapstructure:"POSTGRES_DB"` SSLMode string `mapstructure:"DB_SSL_MODE"` MigrationURL string `mapstructure:"MIGRATION_URL"` SeedURL string `mapstructure:"SEED_URL"` // Connection pool settings MaxConns int `mapstructure:"DB_MAX_CONNS"` MinConns int `mapstructure:"DB_MIN_CONNS"` ConnLifetime time.Duration `mapstructure:"DB_CONN_LIFETIME"` ConnIdleTime time.Duration `mapstructure:"DB_CONN_IDLE_TIME"` HealthCheckPeriod time.Duration `mapstructure:"DB_HEALTH_CHECK_PERIOD"` } // ConnectionString returns a formatted PostgreSQL connection string func (c Config) ConnectionString() string { return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s application_name=nomadezy_api", c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode) } // LoadConfig reads configuration from file or environment variables. func LoadConfig() (Config, error) { var cfg Config viper.SetConfigName("app") // Name of the config file (without extension) viper.SetConfigType("env") // Set the type of the configuration files - .env viper.AddConfigPath(".") // Optionally look for config in the working directory viper.AutomaticEnv() // Set default values, these are overridden if values are present in config or environment variables viper.SetDefault("POSTGRES_PORT", "5432") viper.SetDefault("DB_SSL_MODE", "disable") // Use "require" in production, "disable" for local dev viper.SetDefault("POSTGRES_HOST", "localhost") viper.SetDefault("POSTGRES_USER", "user") viper.SetDefault("POSTGRES_PASSWORD", "password") viper.SetDefault("POSTGRES_DB", "mydatabase") // Connection pool defaults viper.SetDefault("DB_MAX_CONNS", 20) viper.SetDefault("DB_MIN_CONNS", 5) viper.SetDefault("DB_CONN_LIFETIME", "1h") viper.SetDefault("DB_CONN_IDLE_TIME", "30m") viper.SetDefault("DB_HEALTH_CHECK_PERIOD", "1m") viper.SetDefault("MIGRATION_URL", "/migrations") viper.SetDefault("SEED_URL", "/seed") if err := viper.ReadInConfig(); err == nil { _ = err // Placeholder statement to avoid empty branch error } if err := viper.Unmarshal(&cfg); err != nil { return cfg, err } return cfg, nil } ================================================ FILE: go-b2b-starter/internal/db/postgres/init.go ================================================ package postgres import ( "context" "log" "time" "github.com/jackc/pgx/v5/pgxpool" ) // InitDB initializes and returns a connection pool to the database func InitDB(cfg Config) (*pgxpool.Pool, error) { // Create connection pool with retry logic var pool *pgxpool.Pool // Setup context with timeout for initial connection ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() err := RetryOperation(ctx, func(ctx context.Context) error { var connErr error pool, connErr = connPool(cfg) return connErr }) if err != nil { log.Printf("Failed to initialize database connection after retries: %v", err) return nil, err } log.Println("Database connection successfully initialized") // Perform initial health check healthCtx, healthCancel := context.WithTimeout(context.Background(), 5*time.Second) defer healthCancel() manager := NewPostgresManager(cfg, pool) if err := manager.CheckHealth(healthCtx); err != nil { log.Printf("Initial database health check failed: %v", err) pool.Close() return nil, err } return pool, nil } ================================================ FILE: go-b2b-starter/internal/db/postgres/postgres_manager.go ================================================ package postgres import ( "fmt" "log" "os" "sort" "github.com/jackc/pgx/v5/pgxpool" "context" "path/filepath" "strings" "time" _ "github.com/golang-migrate/migrate/v4/source/file" ) // PostgresManager implements DatabaseManager for PostgreSQL type PostgresManager struct { config Config connPool *pgxpool.Pool } func NewPostgresManager(config Config, connPool *pgxpool.Pool) *PostgresManager { return &PostgresManager{ config: config, connPool: connPool, } } // CheckHealth performs a health check on the database connection func (pm *PostgresManager) CheckHealth(ctx context.Context) error { // Create a context with timeout for health check ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() // Try to ping the database err := pm.connPool.Ping(ctx) if err != nil { log.Printf("Database health check failed: %v", err) return fmt.Errorf("database health check failed: %w", err) } log.Println("Database health check successful") return nil } func (pm *PostgresManager) RunMigrations() error { ctx := context.Background() // Log the migration URL log.Printf("Migration URL: %s", pm.config.MigrationURL) // Check if the directory exists if _, err := os.Stat(pm.config.MigrationURL); os.IsNotExist(err) { log.Printf("Migration directory does not exist: %s", pm.config.MigrationURL) return fmt.Errorf("migration directory does not exist: %w", err) } files, err := os.ReadDir(pm.config.MigrationURL) if err != nil { log.Printf("Failed to read migrations directory: %v", err) return fmt.Errorf("failed to read migrations directory: %w", err) } log.Printf("Total files in migration directory: %d", len(files)) // Filter and sort migration files var migrationFiles []string for _, file := range files { log.Printf("Found file: %s", file.Name()) if strings.HasSuffix(file.Name(), ".up.sql") { migrationFiles = append(migrationFiles, file.Name()) } } sort.Strings(migrationFiles) log.Printf("Migration files to execute: %v", migrationFiles) for _, fileName := range migrationFiles { fullPath := filepath.Join(pm.config.MigrationURL, fileName) log.Printf("Attempting to read file: %s", fullPath) content, err := os.ReadFile(fullPath) if err != nil { log.Printf("Failed to read migration file %s: %v", fileName, err) return fmt.Errorf("failed to read migration file %s: %w", fileName, err) } _, err = pm.connPool.Exec(ctx, string(content)) if err != nil { log.Printf("Failed to execute migration file %s: %v", fileName, err) return fmt.Errorf("failed to execute migration file %s: %w", fileName, err) } log.Printf("Executed migration file: %s", fileName) } return nil } func (pm *PostgresManager) RunSeeds() error { ctx := context.Background() // Log the seed URL log.Printf("Seed URL: %s", pm.config.SeedURL) // Check if the directory exists if _, err := os.Stat(pm.config.SeedURL); os.IsNotExist(err) { log.Printf("Seed directory does not exist: %s", pm.config.SeedURL) return fmt.Errorf("seed directory does not exist: %w", err) } files, err := os.ReadDir(pm.config.SeedURL) if err != nil { log.Printf("Failed to read seeds directory: %v", err) return fmt.Errorf("failed to read seeds directory: %w", err) } log.Printf("Total files in seed directory: %d", len(files)) for _, file := range files { log.Printf("Found file: %s", file.Name()) if !strings.HasSuffix(file.Name(), ".sql") { continue } fullPath := filepath.Join(pm.config.SeedURL, file.Name()) log.Printf("Attempting to read file: %s", fullPath) content, err := os.ReadFile(fullPath) if err != nil { log.Printf("Failed to read seed file %s: %v", file.Name(), err) return fmt.Errorf("failed to read seed file %s: %w", file.Name(), err) } _, err = pm.connPool.Exec(ctx, string(content)) if err != nil { log.Printf("Failed to execute seed file %s: %v", file.Name(), err) return fmt.Errorf("failed to execute seed file %s: %w", file.Name(), err) } log.Printf("Executed seed file: %s", file.Name()) } return nil } ================================================ FILE: go-b2b-starter/internal/db/postgres/retry.go ================================================ package postgres import ( "context" "errors" "log" "time" ) const ( maxRetries = 3 retryDelay = 100 * time.Millisecond maxRetryDelay = 1 * time.Second ) // RetryOperation executes a database operation with exponential backoff retry func RetryOperation(ctx context.Context, operation func(context.Context) error) error { var err error backoff := retryDelay for i := 0; i < maxRetries; i++ { // Execute the operation err = operation(ctx) // If no error or context cancelled, return immediately if err == nil || errors.Is(err, context.Canceled) { return err } // Log the error for debugging log.Printf("Database operation failed (attempt %d/%d): %v", i+1, maxRetries, err) // Don't retry if the final attempt if i == maxRetries-1 { break } // Wait with backoff before retrying select { case <-time.After(backoff): // Exponential backoff backoff *= 2 if backoff > maxRetryDelay { backoff = maxRetryDelay } case <-ctx.Done(): return ctx.Err() } } return err } // CreateDBContext creates a context with an appropriate timeout for database operations func CreateDBContext(parent context.Context) (context.Context, context.CancelFunc) { // Default timeout of 10 seconds for database operations return context.WithTimeout(parent, 10*time.Second) } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/cognitive.sql.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 // source: cognitive.sql package postgres import ( "context" "github.com/jackc/pgx/v5/pgtype" pgvector_go "github.com/pgvector/pgvector-go" ) const countChatMessagesBySession = `-- name: CountChatMessagesBySession :one SELECT COUNT(*) FROM cognitive.chat_messages WHERE session_id = $1 ` func (q *Queries) CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) { row := q.db.QueryRow(ctx, countChatMessagesBySession, sessionID) var count int64 err := row.Scan(&count) return count, err } const countDocumentEmbeddingsByOrganization = `-- name: CountDocumentEmbeddingsByOrganization :one SELECT COUNT(*) FROM cognitive.document_embeddings WHERE organization_id = $1 ` func (q *Queries) CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) { row := q.db.QueryRow(ctx, countDocumentEmbeddingsByOrganization, organizationID) var count int64 err := row.Scan(&count) return count, err } const createChatMessage = `-- name: CreateChatMessage :one INSERT INTO cognitive.chat_messages ( session_id, role, content, referenced_docs, tokens_used ) VALUES ( $1, $2, $3, $4, $5 ) RETURNING id, session_id, role, content, referenced_docs, tokens_used, created_at ` type CreateChatMessageParams struct { SessionID int32 `json:"session_id"` Role string `json:"role"` Content string `json:"content"` ReferencedDocs []int32 `json:"referenced_docs"` TokensUsed pgtype.Int4 `json:"tokens_used"` } // Chat Messages func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) (CognitiveChatMessage, error) { row := q.db.QueryRow(ctx, createChatMessage, arg.SessionID, arg.Role, arg.Content, arg.ReferencedDocs, arg.TokensUsed, ) var i CognitiveChatMessage err := row.Scan( &i.ID, &i.SessionID, &i.Role, &i.Content, &i.ReferencedDocs, &i.TokensUsed, &i.CreatedAt, ) return i, err } const createChatSession = `-- name: CreateChatSession :one INSERT INTO cognitive.chat_sessions ( organization_id, account_id, title ) VALUES ( $1, $2, $3 ) RETURNING id, organization_id, account_id, title, created_at, updated_at ` type CreateChatSessionParams struct { OrganizationID int32 `json:"organization_id"` AccountID int32 `json:"account_id"` Title pgtype.Text `json:"title"` } // Chat Sessions func (q *Queries) CreateChatSession(ctx context.Context, arg CreateChatSessionParams) (CognitiveChatSession, error) { row := q.db.QueryRow(ctx, createChatSession, arg.OrganizationID, arg.AccountID, arg.Title) var i CognitiveChatSession err := row.Scan( &i.ID, &i.OrganizationID, &i.AccountID, &i.Title, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const createDocumentEmbedding = `-- name: CreateDocumentEmbedding :one INSERT INTO cognitive.document_embeddings ( document_id, organization_id, embedding, content_hash, content_preview, chunk_index ) VALUES ( $1, $2, $3, $4, $5, $6 ) RETURNING id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at ` type CreateDocumentEmbeddingParams struct { DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` Embedding pgvector_go.Vector `json:"embedding"` ContentHash pgtype.Text `json:"content_hash"` ContentPreview pgtype.Text `json:"content_preview"` ChunkIndex pgtype.Int4 `json:"chunk_index"` } // Cognitive Agent queries // Document Embeddings func (q *Queries) CreateDocumentEmbedding(ctx context.Context, arg CreateDocumentEmbeddingParams) (CognitiveDocumentEmbedding, error) { row := q.db.QueryRow(ctx, createDocumentEmbedding, arg.DocumentID, arg.OrganizationID, arg.Embedding, arg.ContentHash, arg.ContentPreview, arg.ChunkIndex, ) var i CognitiveDocumentEmbedding err := row.Scan( &i.ID, &i.DocumentID, &i.OrganizationID, &i.Embedding, &i.ContentHash, &i.ContentPreview, &i.ChunkIndex, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const deleteChatMessage = `-- name: DeleteChatMessage :exec DELETE FROM cognitive.chat_messages WHERE id = $1 ` func (q *Queries) DeleteChatMessage(ctx context.Context, id int32) error { _, err := q.db.Exec(ctx, deleteChatMessage, id) return err } const deleteChatSession = `-- name: DeleteChatSession :exec DELETE FROM cognitive.chat_sessions WHERE id = $1 AND organization_id = $2 ` type DeleteChatSessionParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) DeleteChatSession(ctx context.Context, arg DeleteChatSessionParams) error { _, err := q.db.Exec(ctx, deleteChatSession, arg.ID, arg.OrganizationID) return err } const deleteDocumentEmbeddings = `-- name: DeleteDocumentEmbeddings :exec DELETE FROM cognitive.document_embeddings WHERE document_id = $1 AND organization_id = $2 ` type DeleteDocumentEmbeddingsParams struct { DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) DeleteDocumentEmbeddings(ctx context.Context, arg DeleteDocumentEmbeddingsParams) error { _, err := q.db.Exec(ctx, deleteDocumentEmbeddings, arg.DocumentID, arg.OrganizationID) return err } const getChatMessagesBySession = `-- name: GetChatMessagesBySession :many SELECT id, session_id, role, content, referenced_docs, tokens_used, created_at FROM cognitive.chat_messages WHERE session_id = $1 ORDER BY created_at ASC ` func (q *Queries) GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]CognitiveChatMessage, error) { rows, err := q.db.Query(ctx, getChatMessagesBySession, sessionID) if err != nil { return nil, err } defer rows.Close() items := []CognitiveChatMessage{} for rows.Next() { var i CognitiveChatMessage if err := rows.Scan( &i.ID, &i.SessionID, &i.Role, &i.Content, &i.ReferencedDocs, &i.TokensUsed, &i.CreatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getChatSessionByID = `-- name: GetChatSessionByID :one SELECT id, organization_id, account_id, title, created_at, updated_at FROM cognitive.chat_sessions WHERE id = $1 AND organization_id = $2 ` type GetChatSessionByIDParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) GetChatSessionByID(ctx context.Context, arg GetChatSessionByIDParams) (CognitiveChatSession, error) { row := q.db.QueryRow(ctx, getChatSessionByID, arg.ID, arg.OrganizationID) var i CognitiveChatSession err := row.Scan( &i.ID, &i.OrganizationID, &i.AccountID, &i.Title, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getDocumentEmbeddingByID = `-- name: GetDocumentEmbeddingByID :one SELECT id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at FROM cognitive.document_embeddings WHERE id = $1 AND organization_id = $2 ` type GetDocumentEmbeddingByIDParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) GetDocumentEmbeddingByID(ctx context.Context, arg GetDocumentEmbeddingByIDParams) (CognitiveDocumentEmbedding, error) { row := q.db.QueryRow(ctx, getDocumentEmbeddingByID, arg.ID, arg.OrganizationID) var i CognitiveDocumentEmbedding err := row.Scan( &i.ID, &i.DocumentID, &i.OrganizationID, &i.Embedding, &i.ContentHash, &i.ContentPreview, &i.ChunkIndex, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getDocumentEmbeddingsByDocumentID = `-- name: GetDocumentEmbeddingsByDocumentID :many SELECT id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at FROM cognitive.document_embeddings WHERE document_id = $1 AND organization_id = $2 ORDER BY chunk_index ` type GetDocumentEmbeddingsByDocumentIDParams struct { DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg GetDocumentEmbeddingsByDocumentIDParams) ([]CognitiveDocumentEmbedding, error) { rows, err := q.db.Query(ctx, getDocumentEmbeddingsByDocumentID, arg.DocumentID, arg.OrganizationID) if err != nil { return nil, err } defer rows.Close() items := []CognitiveDocumentEmbedding{} for rows.Next() { var i CognitiveDocumentEmbedding if err := rows.Scan( &i.ID, &i.DocumentID, &i.OrganizationID, &i.Embedding, &i.ContentHash, &i.ContentPreview, &i.ChunkIndex, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getRecentChatMessages = `-- name: GetRecentChatMessages :many SELECT id, session_id, role, content, referenced_docs, tokens_used, created_at FROM cognitive.chat_messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT $2 ` type GetRecentChatMessagesParams struct { SessionID int32 `json:"session_id"` Limit int32 `json:"limit"` } func (q *Queries) GetRecentChatMessages(ctx context.Context, arg GetRecentChatMessagesParams) ([]CognitiveChatMessage, error) { rows, err := q.db.Query(ctx, getRecentChatMessages, arg.SessionID, arg.Limit) if err != nil { return nil, err } defer rows.Close() items := []CognitiveChatMessage{} for rows.Next() { var i CognitiveChatMessage if err := rows.Scan( &i.ID, &i.SessionID, &i.Role, &i.Content, &i.ReferencedDocs, &i.TokensUsed, &i.CreatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listChatSessionsByAccount = `-- name: ListChatSessionsByAccount :many SELECT id, organization_id, account_id, title, created_at, updated_at FROM cognitive.chat_sessions WHERE organization_id = $1 AND account_id = $2 ORDER BY updated_at DESC LIMIT $3 OFFSET $4 ` type ListChatSessionsByAccountParams struct { OrganizationID int32 `json:"organization_id"` AccountID int32 `json:"account_id"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } func (q *Queries) ListChatSessionsByAccount(ctx context.Context, arg ListChatSessionsByAccountParams) ([]CognitiveChatSession, error) { rows, err := q.db.Query(ctx, listChatSessionsByAccount, arg.OrganizationID, arg.AccountID, arg.Limit, arg.Offset, ) if err != nil { return nil, err } defer rows.Close() items := []CognitiveChatSession{} for rows.Next() { var i CognitiveChatSession if err := rows.Scan( &i.ID, &i.OrganizationID, &i.AccountID, &i.Title, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const searchSimilarDocuments = `-- name: SearchSimilarDocuments :many SELECT de.id, de.document_id, de.organization_id, de.content_hash, de.content_preview, de.chunk_index, de.created_at, de.updated_at, (1 - (de.embedding <=> $1::vector))::double precision as similarity_score FROM cognitive.document_embeddings de WHERE de.organization_id = $2 ORDER BY de.embedding <=> $1::vector LIMIT $3 ` type SearchSimilarDocumentsParams struct { Column1 pgvector_go.Vector `json:"column_1"` OrganizationID int32 `json:"organization_id"` Limit int32 `json:"limit"` } type SearchSimilarDocumentsRow struct { ID int32 `json:"id"` DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` ContentHash pgtype.Text `json:"content_hash"` ContentPreview pgtype.Text `json:"content_preview"` ChunkIndex pgtype.Int4 `json:"chunk_index"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` SimilarityScore float64 `json:"similarity_score"` } func (q *Queries) SearchSimilarDocuments(ctx context.Context, arg SearchSimilarDocumentsParams) ([]SearchSimilarDocumentsRow, error) { rows, err := q.db.Query(ctx, searchSimilarDocuments, arg.Column1, arg.OrganizationID, arg.Limit) if err != nil { return nil, err } defer rows.Close() items := []SearchSimilarDocumentsRow{} for rows.Next() { var i SearchSimilarDocumentsRow if err := rows.Scan( &i.ID, &i.DocumentID, &i.OrganizationID, &i.ContentHash, &i.ContentPreview, &i.ChunkIndex, &i.CreatedAt, &i.UpdatedAt, &i.SimilarityScore, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const updateChatSessionTitle = `-- name: UpdateChatSessionTitle :one UPDATE cognitive.chat_sessions SET title = $3, updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, account_id, title, created_at, updated_at ` type UpdateChatSessionTitleParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` Title pgtype.Text `json:"title"` } func (q *Queries) UpdateChatSessionTitle(ctx context.Context, arg UpdateChatSessionTitleParams) (CognitiveChatSession, error) { row := q.db.QueryRow(ctx, updateChatSessionTitle, arg.ID, arg.OrganizationID, arg.Title) var i CognitiveChatSession err := row.Scan( &i.ID, &i.OrganizationID, &i.AccountID, &i.Title, &i.CreatedAt, &i.UpdatedAt, ) return i, err } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/db.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 package postgres import ( "context" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) type DBTX interface { Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) Query(context.Context, string, ...interface{}) (pgx.Rows, error) QueryRow(context.Context, string, ...interface{}) pgx.Row } func New(db DBTX) *Queries { return &Queries{db: db} } type Queries struct { db DBTX } func (q *Queries) WithTx(tx pgx.Tx) *Queries { return &Queries{ db: tx, } } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/documents.sql.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 // source: documents.sql package postgres import ( "context" "github.com/jackc/pgx/v5/pgtype" ) const countDocumentsByOrganization = `-- name: CountDocumentsByOrganization :one SELECT COUNT(*) FROM documents.documents WHERE organization_id = $1 ` func (q *Queries) CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) { row := q.db.QueryRow(ctx, countDocumentsByOrganization, organizationID) var count int64 err := row.Scan(&count) return count, err } const countDocumentsByStatus = `-- name: CountDocumentsByStatus :one SELECT COUNT(*) FROM documents.documents WHERE organization_id = $1 AND status = $2 ` type CountDocumentsByStatusParams struct { OrganizationID int32 `json:"organization_id"` Status string `json:"status"` } func (q *Queries) CountDocumentsByStatus(ctx context.Context, arg CountDocumentsByStatusParams) (int64, error) { row := q.db.QueryRow(ctx, countDocumentsByStatus, arg.OrganizationID, arg.Status) var count int64 err := row.Scan(&count) return count, err } const createDocument = `-- name: CreateDocument :one INSERT INTO documents.documents ( organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at ` type CreateDocumentParams struct { OrganizationID int32 `json:"organization_id"` FileAssetID int32 `json:"file_asset_id"` Title string `json:"title"` FileName string `json:"file_name"` ContentType string `json:"content_type"` FileSize int64 `json:"file_size"` ExtractedText pgtype.Text `json:"extracted_text"` Status string `json:"status"` Metadata []byte `json:"metadata"` } // Documents queries func (q *Queries) CreateDocument(ctx context.Context, arg CreateDocumentParams) (DocumentsDocument, error) { row := q.db.QueryRow(ctx, createDocument, arg.OrganizationID, arg.FileAssetID, arg.Title, arg.FileName, arg.ContentType, arg.FileSize, arg.ExtractedText, arg.Status, arg.Metadata, ) var i DocumentsDocument err := row.Scan( &i.ID, &i.OrganizationID, &i.FileAssetID, &i.Title, &i.FileName, &i.ContentType, &i.FileSize, &i.ExtractedText, &i.Status, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const deleteDocument = `-- name: DeleteDocument :exec DELETE FROM documents.documents WHERE id = $1 AND organization_id = $2 ` type DeleteDocumentParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error { _, err := q.db.Exec(ctx, deleteDocument, arg.ID, arg.OrganizationID) return err } const getDocumentByFileAssetID = `-- name: GetDocumentByFileAssetID :one SELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents WHERE file_asset_id = $1 AND organization_id = $2 ` type GetDocumentByFileAssetIDParams struct { FileAssetID int32 `json:"file_asset_id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) GetDocumentByFileAssetID(ctx context.Context, arg GetDocumentByFileAssetIDParams) (DocumentsDocument, error) { row := q.db.QueryRow(ctx, getDocumentByFileAssetID, arg.FileAssetID, arg.OrganizationID) var i DocumentsDocument err := row.Scan( &i.ID, &i.OrganizationID, &i.FileAssetID, &i.Title, &i.FileName, &i.ContentType, &i.FileSize, &i.ExtractedText, &i.Status, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getDocumentByID = `-- name: GetDocumentByID :one SELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents WHERE id = $1 AND organization_id = $2 ` type GetDocumentByIDParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (DocumentsDocument, error) { row := q.db.QueryRow(ctx, getDocumentByID, arg.ID, arg.OrganizationID) var i DocumentsDocument err := row.Scan( &i.ID, &i.OrganizationID, &i.FileAssetID, &i.Title, &i.FileName, &i.ContentType, &i.FileSize, &i.ExtractedText, &i.Status, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const listDocumentsByOrganization = `-- name: ListDocumentsByOrganization :many SELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents WHERE organization_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 ` type ListDocumentsByOrganizationParams struct { OrganizationID int32 `json:"organization_id"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } func (q *Queries) ListDocumentsByOrganization(ctx context.Context, arg ListDocumentsByOrganizationParams) ([]DocumentsDocument, error) { rows, err := q.db.Query(ctx, listDocumentsByOrganization, arg.OrganizationID, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() items := []DocumentsDocument{} for rows.Next() { var i DocumentsDocument if err := rows.Scan( &i.ID, &i.OrganizationID, &i.FileAssetID, &i.Title, &i.FileName, &i.ContentType, &i.FileSize, &i.ExtractedText, &i.Status, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listDocumentsByStatus = `-- name: ListDocumentsByStatus :many SELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents WHERE organization_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4 ` type ListDocumentsByStatusParams struct { OrganizationID int32 `json:"organization_id"` Status string `json:"status"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } func (q *Queries) ListDocumentsByStatus(ctx context.Context, arg ListDocumentsByStatusParams) ([]DocumentsDocument, error) { rows, err := q.db.Query(ctx, listDocumentsByStatus, arg.OrganizationID, arg.Status, arg.Limit, arg.Offset, ) if err != nil { return nil, err } defer rows.Close() items := []DocumentsDocument{} for rows.Next() { var i DocumentsDocument if err := rows.Scan( &i.ID, &i.OrganizationID, &i.FileAssetID, &i.Title, &i.FileName, &i.ContentType, &i.FileSize, &i.ExtractedText, &i.Status, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const updateDocument = `-- name: UpdateDocument :one UPDATE documents.documents SET title = COALESCE($3, title), metadata = COALESCE($4, metadata), updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at ` type UpdateDocumentParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` Title string `json:"title"` Metadata []byte `json:"metadata"` } func (q *Queries) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (DocumentsDocument, error) { row := q.db.QueryRow(ctx, updateDocument, arg.ID, arg.OrganizationID, arg.Title, arg.Metadata, ) var i DocumentsDocument err := row.Scan( &i.ID, &i.OrganizationID, &i.FileAssetID, &i.Title, &i.FileName, &i.ContentType, &i.FileSize, &i.ExtractedText, &i.Status, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const updateDocumentExtractedText = `-- name: UpdateDocumentExtractedText :one UPDATE documents.documents SET extracted_text = $3, status = 'processed', updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at ` type UpdateDocumentExtractedTextParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` ExtractedText pgtype.Text `json:"extracted_text"` } func (q *Queries) UpdateDocumentExtractedText(ctx context.Context, arg UpdateDocumentExtractedTextParams) (DocumentsDocument, error) { row := q.db.QueryRow(ctx, updateDocumentExtractedText, arg.ID, arg.OrganizationID, arg.ExtractedText) var i DocumentsDocument err := row.Scan( &i.ID, &i.OrganizationID, &i.FileAssetID, &i.Title, &i.FileName, &i.ContentType, &i.FileSize, &i.ExtractedText, &i.Status, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const updateDocumentStatus = `-- name: UpdateDocumentStatus :one UPDATE documents.documents SET status = $3, updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at ` type UpdateDocumentStatusParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` Status string `json:"status"` } func (q *Queries) UpdateDocumentStatus(ctx context.Context, arg UpdateDocumentStatusParams) (DocumentsDocument, error) { row := q.db.QueryRow(ctx, updateDocumentStatus, arg.ID, arg.OrganizationID, arg.Status) var i DocumentsDocument err := row.Scan( &i.ID, &i.OrganizationID, &i.FileAssetID, &i.Title, &i.FileName, &i.ContentType, &i.FileSize, &i.ExtractedText, &i.Status, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/error.go ================================================ package postgres import ( "errors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) const ( ForeignKeyViolation = "23503" UniqueViolation = "23505" ) var ErrRecordNotFound = pgx.ErrNoRows var ErrUniqueViolation = &pgconn.PgError{ Code: UniqueViolation, } func ErrorCode(err error) string { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { return pgErr.Code } return "" } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/example_resource.sql.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 // source: example_resource.sql package postgres import ( "context" "github.com/jackc/pgx/v5/pgtype" ) const assignResourceApproval = `-- name: AssignResourceApproval :exec UPDATE example_resources SET approval_assigned_to_id = $3, approval_status = 'pending', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 AND is_active = true ` type AssignResourceApprovalParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` ApprovalAssignedToID pgtype.Int4 `json:"approval_assigned_to_id"` } // Assign resource to someone for approval func (q *Queries) AssignResourceApproval(ctx context.Context, arg AssignResourceApprovalParams) error { _, err := q.db.Exec(ctx, assignResourceApproval, arg.ID, arg.OrganizationID, arg.ApprovalAssignedToID) return err } const attachFileToResource = `-- name: AttachFileToResource :exec UPDATE example_resources SET file_id = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 AND is_active = true ` type AttachFileToResourceParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` FileID pgtype.Int4 `json:"file_id"` } // Attach a file to a resource func (q *Queries) AttachFileToResource(ctx context.Context, arg AttachFileToResourceParams) error { _, err := q.db.Exec(ctx, attachFileToResource, arg.ID, arg.OrganizationID, arg.FileID) return err } const countResources = `-- name: CountResources :one SELECT COUNT(*) FROM example_resources WHERE organization_id = $1 AND is_active = true AND ($2::smallint IS NULL OR status_id = $2) AND ($3::varchar IS NULL OR approval_status = $3) AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%') ` type CountResourcesParams struct { OrganizationID int32 `json:"organization_id"` Column2 int16 `json:"column_2"` Column3 string `json:"column_3"` Column4 string `json:"column_4"` } // Count resources for pagination func (q *Queries) CountResources(ctx context.Context, arg CountResourcesParams) (int64, error) { row := q.db.QueryRow(ctx, countResources, arg.OrganizationID, arg.Column2, arg.Column3, arg.Column4, ) var count int64 err := row.Scan(&count) return count, err } const createMinimalResource = `-- name: CreateMinimalResource :one INSERT INTO example_resources ( resource_number, title, organization_id, created_by_account_id, status_id ) VALUES ( $1, $2, $3, $4, 1 ) RETURNING id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at ` type CreateMinimalResourceParams struct { ResourceNumber string `json:"resource_number"` Title string `json:"title"` OrganizationID int32 `json:"organization_id"` CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` } // Creates a minimal placeholder resource func (q *Queries) CreateMinimalResource(ctx context.Context, arg CreateMinimalResourceParams) (ExampleResource, error) { row := q.db.QueryRow(ctx, createMinimalResource, arg.ResourceNumber, arg.Title, arg.OrganizationID, arg.CreatedByAccountID, ) var i ExampleResource err := row.Scan( &i.ID, &i.ResourceNumber, &i.Title, &i.Description, &i.StatusID, &i.FileID, &i.ExtractedData, &i.ProcessedData, &i.Confidence, &i.OrganizationID, &i.CreatedByAccountID, &i.ApprovalStatus, &i.ApprovalAssignedToID, &i.ApprovalActionTakerID, &i.ApprovalNotes, &i.Metadata, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const createResource = `-- name: CreateResource :one INSERT INTO example_resources ( resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) RETURNING id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at ` type CreateResourceParams struct { ResourceNumber string `json:"resource_number"` Title string `json:"title"` Description pgtype.Text `json:"description"` StatusID int16 `json:"status_id"` FileID pgtype.Int4 `json:"file_id"` ExtractedData []byte `json:"extracted_data"` ProcessedData []byte `json:"processed_data"` Confidence pgtype.Numeric `json:"confidence"` OrganizationID int32 `json:"organization_id"` CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` Metadata []byte `json:"metadata"` } // Example Resource Queries // Demonstrates Clean Architecture patterns with CRUD operations, // file attachments, OCR/LLM processing, and approval workflows // CREATE operations func (q *Queries) CreateResource(ctx context.Context, arg CreateResourceParams) (ExampleResource, error) { row := q.db.QueryRow(ctx, createResource, arg.ResourceNumber, arg.Title, arg.Description, arg.StatusID, arg.FileID, arg.ExtractedData, arg.ProcessedData, arg.Confidence, arg.OrganizationID, arg.CreatedByAccountID, arg.Metadata, ) var i ExampleResource err := row.Scan( &i.ID, &i.ResourceNumber, &i.Title, &i.Description, &i.StatusID, &i.FileID, &i.ExtractedData, &i.ProcessedData, &i.Confidence, &i.OrganizationID, &i.CreatedByAccountID, &i.ApprovalStatus, &i.ApprovalAssignedToID, &i.ApprovalActionTakerID, &i.ApprovalNotes, &i.Metadata, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const deleteResource = `-- name: DeleteResource :exec UPDATE example_resources SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 ` type DeleteResourceParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } // DELETE operations // Soft delete a resource func (q *Queries) DeleteResource(ctx context.Context, arg DeleteResourceParams) error { _, err := q.db.Exec(ctx, deleteResource, arg.ID, arg.OrganizationID) return err } const getRecentResources = `-- name: GetRecentResources :many SELECT id, resource_number, title, status_id, confidence, created_by_account_id, created_at FROM example_resources WHERE organization_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT $2 ` type GetRecentResourcesParams struct { OrganizationID int32 `json:"organization_id"` Limit int32 `json:"limit"` } type GetRecentResourcesRow struct { ID int32 `json:"id"` ResourceNumber string `json:"resource_number"` Title string `json:"title"` StatusID int16 `json:"status_id"` Confidence pgtype.Numeric `json:"confidence"` CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` CreatedAt pgtype.Timestamp `json:"created_at"` } // Get most recently created resources func (q *Queries) GetRecentResources(ctx context.Context, arg GetRecentResourcesParams) ([]GetRecentResourcesRow, error) { rows, err := q.db.Query(ctx, getRecentResources, arg.OrganizationID, arg.Limit) if err != nil { return nil, err } defer rows.Close() items := []GetRecentResourcesRow{} for rows.Next() { var i GetRecentResourcesRow if err := rows.Scan( &i.ID, &i.ResourceNumber, &i.Title, &i.StatusID, &i.Confidence, &i.CreatedByAccountID, &i.CreatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getResourceByID = `-- name: GetResourceByID :one SELECT id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at FROM example_resources WHERE id = $1 AND organization_id = $2 AND is_active = true ` type GetResourceByIDParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } // READ operations func (q *Queries) GetResourceByID(ctx context.Context, arg GetResourceByIDParams) (ExampleResource, error) { row := q.db.QueryRow(ctx, getResourceByID, arg.ID, arg.OrganizationID) var i ExampleResource err := row.Scan( &i.ID, &i.ResourceNumber, &i.Title, &i.Description, &i.StatusID, &i.FileID, &i.ExtractedData, &i.ProcessedData, &i.Confidence, &i.OrganizationID, &i.CreatedByAccountID, &i.ApprovalStatus, &i.ApprovalAssignedToID, &i.ApprovalActionTakerID, &i.ApprovalNotes, &i.Metadata, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getResourceByNumber = `-- name: GetResourceByNumber :one SELECT id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at FROM example_resources WHERE resource_number = $1 AND organization_id = $2 AND is_active = true ` type GetResourceByNumberParams struct { ResourceNumber string `json:"resource_number"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) GetResourceByNumber(ctx context.Context, arg GetResourceByNumberParams) (ExampleResource, error) { row := q.db.QueryRow(ctx, getResourceByNumber, arg.ResourceNumber, arg.OrganizationID) var i ExampleResource err := row.Scan( &i.ID, &i.ResourceNumber, &i.Title, &i.Description, &i.StatusID, &i.FileID, &i.ExtractedData, &i.ProcessedData, &i.Confidence, &i.OrganizationID, &i.CreatedByAccountID, &i.ApprovalStatus, &i.ApprovalAssignedToID, &i.ApprovalActionTakerID, &i.ApprovalNotes, &i.Metadata, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getResourceStats = `-- name: GetResourceStats :one SELECT COUNT(*) as total_resources, COUNT(*) FILTER (WHERE status_id = 1) as draft_count, COUNT(*) FILTER (WHERE status_id = 2) as processing_count, COUNT(*) FILTER (WHERE status_id = 3) as completed_count, COUNT(*) FILTER (WHERE approval_status = 'pending') as pending_approval, COUNT(*) FILTER (WHERE approval_status = 'approved') as approved_count, AVG(confidence) as avg_confidence FROM example_resources WHERE organization_id = $1 AND is_active = true ` type GetResourceStatsRow struct { TotalResources int64 `json:"total_resources"` DraftCount int64 `json:"draft_count"` ProcessingCount int64 `json:"processing_count"` CompletedCount int64 `json:"completed_count"` PendingApproval int64 `json:"pending_approval"` ApprovedCount int64 `json:"approved_count"` AvgConfidence float64 `json:"avg_confidence"` } // ANALYTICS queries // Get statistics for dashboard func (q *Queries) GetResourceStats(ctx context.Context, organizationID int32) (GetResourceStatsRow, error) { row := q.db.QueryRow(ctx, getResourceStats, organizationID) var i GetResourceStatsRow err := row.Scan( &i.TotalResources, &i.DraftCount, &i.ProcessingCount, &i.CompletedCount, &i.PendingApproval, &i.ApprovedCount, &i.AvgConfidence, ) return i, err } const getResourcesByCreator = `-- name: GetResourcesByCreator :many SELECT id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at FROM example_resources WHERE organization_id = $1 AND created_by_account_id = $2 AND is_active = true ORDER BY created_at DESC LIMIT $3 OFFSET $4 ` type GetResourcesByCreatorParams struct { OrganizationID int32 `json:"organization_id"` CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } // Get resources created by a specific user func (q *Queries) GetResourcesByCreator(ctx context.Context, arg GetResourcesByCreatorParams) ([]ExampleResource, error) { rows, err := q.db.Query(ctx, getResourcesByCreator, arg.OrganizationID, arg.CreatedByAccountID, arg.Limit, arg.Offset, ) if err != nil { return nil, err } defer rows.Close() items := []ExampleResource{} for rows.Next() { var i ExampleResource if err := rows.Scan( &i.ID, &i.ResourceNumber, &i.Title, &i.Description, &i.StatusID, &i.FileID, &i.ExtractedData, &i.ProcessedData, &i.Confidence, &i.OrganizationID, &i.CreatedByAccountID, &i.ApprovalStatus, &i.ApprovalAssignedToID, &i.ApprovalActionTakerID, &i.ApprovalNotes, &i.Metadata, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const hardDeleteResource = `-- name: HardDeleteResource :exec DELETE FROM example_resources WHERE id = $1 AND organization_id = $2 ` type HardDeleteResourceParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } // Hard delete a resource (use with caution) func (q *Queries) HardDeleteResource(ctx context.Context, arg HardDeleteResourceParams) error { _, err := q.db.Exec(ctx, hardDeleteResource, arg.ID, arg.OrganizationID) return err } const listResources = `-- name: ListResources :many SELECT id, resource_number, title, description, status_id, file_id, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, is_active, created_at, updated_at FROM example_resources WHERE organization_id = $1 AND is_active = true AND ($2::smallint IS NULL OR status_id = $2) AND ($3::varchar IS NULL OR approval_status = $3) AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%') ORDER BY created_at DESC LIMIT $5 OFFSET $6 ` type ListResourcesParams struct { OrganizationID int32 `json:"organization_id"` Column2 int16 `json:"column_2"` Column3 string `json:"column_3"` Column4 string `json:"column_4"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } type ListResourcesRow struct { ID int32 `json:"id"` ResourceNumber string `json:"resource_number"` Title string `json:"title"` Description pgtype.Text `json:"description"` StatusID int16 `json:"status_id"` FileID pgtype.Int4 `json:"file_id"` Confidence pgtype.Numeric `json:"confidence"` OrganizationID int32 `json:"organization_id"` CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` ApprovalStatus pgtype.Text `json:"approval_status"` ApprovalAssignedToID pgtype.Int4 `json:"approval_assigned_to_id"` IsActive bool `json:"is_active"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } // List resources with filtering and pagination func (q *Queries) ListResources(ctx context.Context, arg ListResourcesParams) ([]ListResourcesRow, error) { rows, err := q.db.Query(ctx, listResources, arg.OrganizationID, arg.Column2, arg.Column3, arg.Column4, arg.Limit, arg.Offset, ) if err != nil { return nil, err } defer rows.Close() items := []ListResourcesRow{} for rows.Next() { var i ListResourcesRow if err := rows.Scan( &i.ID, &i.ResourceNumber, &i.Title, &i.Description, &i.StatusID, &i.FileID, &i.Confidence, &i.OrganizationID, &i.CreatedByAccountID, &i.ApprovalStatus, &i.ApprovalAssignedToID, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const searchResourcesByText = `-- name: SearchResourcesByText :many SELECT id, resource_number, title, description, status_id, confidence, created_at, updated_at, ts_rank(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')), to_tsquery('english', $2)) AS rank FROM example_resources WHERE organization_id = $1 AND is_active = true AND to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')) @@ to_tsquery('english', $2) ORDER BY rank DESC, created_at DESC LIMIT $3 OFFSET $4 ` type SearchResourcesByTextParams struct { OrganizationID int32 `json:"organization_id"` ToTsquery string `json:"to_tsquery"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } type SearchResourcesByTextRow struct { ID int32 `json:"id"` ResourceNumber string `json:"resource_number"` Title string `json:"title"` Description pgtype.Text `json:"description"` StatusID int16 `json:"status_id"` Confidence pgtype.Numeric `json:"confidence"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` Rank float32 `json:"rank"` } // SEARCH operations // Full-text search on title and description func (q *Queries) SearchResourcesByText(ctx context.Context, arg SearchResourcesByTextParams) ([]SearchResourcesByTextRow, error) { rows, err := q.db.Query(ctx, searchResourcesByText, arg.OrganizationID, arg.ToTsquery, arg.Limit, arg.Offset, ) if err != nil { return nil, err } defer rows.Close() items := []SearchResourcesByTextRow{} for rows.Next() { var i SearchResourcesByTextRow if err := rows.Scan( &i.ID, &i.ResourceNumber, &i.Title, &i.Description, &i.StatusID, &i.Confidence, &i.CreatedAt, &i.UpdatedAt, &i.Rank, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const updateResource = `-- name: UpdateResource :exec UPDATE example_resources SET title = COALESCE($1, title), description = COALESCE($2, description), status_id = COALESCE($3, status_id), metadata = COALESCE($4, metadata), updated_at = CURRENT_TIMESTAMP WHERE id = $5 AND organization_id = $6 AND is_active = true ` type UpdateResourceParams struct { Title pgtype.Text `json:"title"` Description pgtype.Text `json:"description"` StatusID pgtype.Int2 `json:"status_id"` Metadata []byte `json:"metadata"` ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } // UPDATE operations func (q *Queries) UpdateResource(ctx context.Context, arg UpdateResourceParams) error { _, err := q.db.Exec(ctx, updateResource, arg.Title, arg.Description, arg.StatusID, arg.Metadata, arg.ID, arg.OrganizationID, ) return err } const updateResourceApprovalStatus = `-- name: UpdateResourceApprovalStatus :exec UPDATE example_resources SET approval_status = $3, approval_action_taker_id = $4, approval_notes = $5, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 AND is_active = true ` type UpdateResourceApprovalStatusParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` ApprovalStatus pgtype.Text `json:"approval_status"` ApprovalActionTakerID pgtype.Int4 `json:"approval_action_taker_id"` ApprovalNotes pgtype.Text `json:"approval_notes"` } // Update approval workflow status func (q *Queries) UpdateResourceApprovalStatus(ctx context.Context, arg UpdateResourceApprovalStatusParams) error { _, err := q.db.Exec(ctx, updateResourceApprovalStatus, arg.ID, arg.OrganizationID, arg.ApprovalStatus, arg.ApprovalActionTakerID, arg.ApprovalNotes, ) return err } const updateResourceProcessingData = `-- name: UpdateResourceProcessingData :exec UPDATE example_resources SET extracted_data = COALESCE($1, extracted_data), processed_data = COALESCE($2, processed_data), confidence = COALESCE($3, confidence), status_id = COALESCE($4, status_id), updated_at = CURRENT_TIMESTAMP WHERE id = $5 AND organization_id = $6 ` type UpdateResourceProcessingDataParams struct { ExtractedData []byte `json:"extracted_data"` ProcessedData []byte `json:"processed_data"` Confidence pgtype.Numeric `json:"confidence"` StatusID pgtype.Int2 `json:"status_id"` ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } // Update OCR/LLM processing results func (q *Queries) UpdateResourceProcessingData(ctx context.Context, arg UpdateResourceProcessingDataParams) error { _, err := q.db.Exec(ctx, updateResourceProcessingData, arg.ExtractedData, arg.ProcessedData, arg.Confidence, arg.StatusID, arg.ID, arg.OrganizationID, ) return err } const updateResourceStatus = `-- name: UpdateResourceStatus :exec UPDATE example_resources SET status_id = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 AND is_active = true ` type UpdateResourceStatusParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` StatusID int16 `json:"status_id"` } func (q *Queries) UpdateResourceStatus(ctx context.Context, arg UpdateResourceStatusParams) error { _, err := q.db.Exec(ctx, updateResourceStatus, arg.ID, arg.OrganizationID, arg.StatusID) return err } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/exec.go ================================================ package postgres import ( "context" "fmt" ) // ExecTx executes a function within a database transaction func (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) error { tx, err := store.connPool.Begin(ctx) if err != nil { return err } q := New(tx) err = fn(q) if err != nil { if rbErr := tx.Rollback(ctx); rbErr != nil { return fmt.Errorf("tx err: %v, rb err: %v", err, rbErr) } return err } return tx.Commit(ctx) } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/file_manager.sql.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 // source: files.sql package postgres import ( "context" "github.com/jackc/pgx/v5/pgtype" ) const createFileAsset = `-- name: CreateFileAsset :one INSERT INTO files.file_assets ( file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 ) RETURNING id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at ` type CreateFileAssetParams struct { FileName string `json:"file_name"` OriginalFileName string `json:"original_file_name"` StoragePath string `json:"storage_path"` BucketName string `json:"bucket_name"` FileSize int64 `json:"file_size"` MimeType string `json:"mime_type"` FileCategoryID int16 `json:"file_category_id"` FileContextID int16 `json:"file_context_id"` IsPublic pgtype.Bool `json:"is_public"` EntityType pgtype.Text `json:"entity_type"` EntityID pgtype.Int4 `json:"entity_id"` Purpose pgtype.Text `json:"purpose"` Metadata []byte `json:"metadata"` } func (q *Queries) CreateFileAsset(ctx context.Context, arg CreateFileAssetParams) (FileManagerFileAsset, error) { row := q.db.QueryRow(ctx, createFileAsset, arg.FileName, arg.OriginalFileName, arg.StoragePath, arg.BucketName, arg.FileSize, arg.MimeType, arg.FileCategoryID, arg.FileContextID, arg.IsPublic, arg.EntityType, arg.EntityID, arg.Purpose, arg.Metadata, ) var i FileManagerFileAsset err := row.Scan( &i.ID, &i.FileName, &i.OriginalFileName, &i.StoragePath, &i.BucketName, &i.FileSize, &i.MimeType, &i.FileCategoryID, &i.FileContextID, &i.IsPublic, &i.EntityType, &i.EntityID, &i.Purpose, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const deleteFileAsset = `-- name: DeleteFileAsset :exec DELETE FROM files.file_assets WHERE id = $1 ` func (q *Queries) DeleteFileAsset(ctx context.Context, id int32) error { _, err := q.db.Exec(ctx, deleteFileAsset, id) return err } const getFileAssetByID = `-- name: GetFileAssetByID :one SELECT id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at FROM files.file_assets WHERE id = $1 ` func (q *Queries) GetFileAssetByID(ctx context.Context, id int32) (FileManagerFileAsset, error) { row := q.db.QueryRow(ctx, getFileAssetByID, id) var i FileManagerFileAsset err := row.Scan( &i.ID, &i.FileName, &i.OriginalFileName, &i.StoragePath, &i.BucketName, &i.FileSize, &i.MimeType, &i.FileCategoryID, &i.FileContextID, &i.IsPublic, &i.EntityType, &i.EntityID, &i.Purpose, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getFileAssetByStoragePath = `-- name: GetFileAssetByStoragePath :one SELECT id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at FROM files.file_assets WHERE storage_path = $1 ` func (q *Queries) GetFileAssetByStoragePath(ctx context.Context, storagePath string) (FileManagerFileAsset, error) { row := q.db.QueryRow(ctx, getFileAssetByStoragePath, storagePath) var i FileManagerFileAsset err := row.Scan( &i.ID, &i.FileName, &i.OriginalFileName, &i.StoragePath, &i.BucketName, &i.FileSize, &i.MimeType, &i.FileCategoryID, &i.FileContextID, &i.IsPublic, &i.EntityType, &i.EntityID, &i.Purpose, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getFileAssetsByCategory = `-- name: GetFileAssetsByCategory :many SELECT fa.id, fa.file_name, fa.original_file_name, fa.storage_path, fa.bucket_name, fa.file_size, fa.mime_type, fa.file_category_id, fa.file_context_id, fa.is_public, fa.entity_type, fa.entity_id, fa.purpose, fa.metadata, fa.created_at, fa.updated_at, fc.name as category_name FROM files.file_assets fa JOIN files.file_categories fc ON fa.file_category_id = fc.id WHERE fc.name = $1 ORDER BY fa.created_at DESC ` type GetFileAssetsByCategoryRow struct { ID int32 `json:"id"` FileName string `json:"file_name"` OriginalFileName string `json:"original_file_name"` StoragePath string `json:"storage_path"` BucketName string `json:"bucket_name"` FileSize int64 `json:"file_size"` MimeType string `json:"mime_type"` FileCategoryID int16 `json:"file_category_id"` FileContextID int16 `json:"file_context_id"` IsPublic pgtype.Bool `json:"is_public"` EntityType pgtype.Text `json:"entity_type"` EntityID pgtype.Int4 `json:"entity_id"` Purpose pgtype.Text `json:"purpose"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` CategoryName string `json:"category_name"` } func (q *Queries) GetFileAssetsByCategory(ctx context.Context, name string) ([]GetFileAssetsByCategoryRow, error) { rows, err := q.db.Query(ctx, getFileAssetsByCategory, name) if err != nil { return nil, err } defer rows.Close() items := []GetFileAssetsByCategoryRow{} for rows.Next() { var i GetFileAssetsByCategoryRow if err := rows.Scan( &i.ID, &i.FileName, &i.OriginalFileName, &i.StoragePath, &i.BucketName, &i.FileSize, &i.MimeType, &i.FileCategoryID, &i.FileContextID, &i.IsPublic, &i.EntityType, &i.EntityID, &i.Purpose, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, &i.CategoryName, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getFileAssetsByContext = `-- name: GetFileAssetsByContext :many SELECT fa.id, fa.file_name, fa.original_file_name, fa.storage_path, fa.bucket_name, fa.file_size, fa.mime_type, fa.file_category_id, fa.file_context_id, fa.is_public, fa.entity_type, fa.entity_id, fa.purpose, fa.metadata, fa.created_at, fa.updated_at, fctx.name as context_name FROM files.file_assets fa JOIN files.file_contexts fctx ON fa.file_context_id = fctx.id WHERE fctx.name = $1 ORDER BY fa.created_at DESC ` type GetFileAssetsByContextRow struct { ID int32 `json:"id"` FileName string `json:"file_name"` OriginalFileName string `json:"original_file_name"` StoragePath string `json:"storage_path"` BucketName string `json:"bucket_name"` FileSize int64 `json:"file_size"` MimeType string `json:"mime_type"` FileCategoryID int16 `json:"file_category_id"` FileContextID int16 `json:"file_context_id"` IsPublic pgtype.Bool `json:"is_public"` EntityType pgtype.Text `json:"entity_type"` EntityID pgtype.Int4 `json:"entity_id"` Purpose pgtype.Text `json:"purpose"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` ContextName string `json:"context_name"` } func (q *Queries) GetFileAssetsByContext(ctx context.Context, name string) ([]GetFileAssetsByContextRow, error) { rows, err := q.db.Query(ctx, getFileAssetsByContext, name) if err != nil { return nil, err } defer rows.Close() items := []GetFileAssetsByContextRow{} for rows.Next() { var i GetFileAssetsByContextRow if err := rows.Scan( &i.ID, &i.FileName, &i.OriginalFileName, &i.StoragePath, &i.BucketName, &i.FileSize, &i.MimeType, &i.FileCategoryID, &i.FileContextID, &i.IsPublic, &i.EntityType, &i.EntityID, &i.Purpose, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, &i.ContextName, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getFileAssetsByEntity = `-- name: GetFileAssetsByEntity :many SELECT id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at FROM files.file_assets WHERE entity_type = $1 AND entity_id = $2 ` type GetFileAssetsByEntityParams struct { EntityType pgtype.Text `json:"entity_type"` EntityID pgtype.Int4 `json:"entity_id"` } func (q *Queries) GetFileAssetsByEntity(ctx context.Context, arg GetFileAssetsByEntityParams) ([]FileManagerFileAsset, error) { rows, err := q.db.Query(ctx, getFileAssetsByEntity, arg.EntityType, arg.EntityID) if err != nil { return nil, err } defer rows.Close() items := []FileManagerFileAsset{} for rows.Next() { var i FileManagerFileAsset if err := rows.Scan( &i.ID, &i.FileName, &i.OriginalFileName, &i.StoragePath, &i.BucketName, &i.FileSize, &i.MimeType, &i.FileCategoryID, &i.FileContextID, &i.IsPublic, &i.EntityType, &i.EntityID, &i.Purpose, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getFileAssetsByEntityAndPurpose = `-- name: GetFileAssetsByEntityAndPurpose :many SELECT id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at FROM files.file_assets WHERE entity_type = $1 AND entity_id = $2 AND purpose = $3 ORDER BY created_at DESC ` type GetFileAssetsByEntityAndPurposeParams struct { EntityType pgtype.Text `json:"entity_type"` EntityID pgtype.Int4 `json:"entity_id"` Purpose pgtype.Text `json:"purpose"` } func (q *Queries) GetFileAssetsByEntityAndPurpose(ctx context.Context, arg GetFileAssetsByEntityAndPurposeParams) ([]FileManagerFileAsset, error) { rows, err := q.db.Query(ctx, getFileAssetsByEntityAndPurpose, arg.EntityType, arg.EntityID, arg.Purpose) if err != nil { return nil, err } defer rows.Close() items := []FileManagerFileAsset{} for rows.Next() { var i FileManagerFileAsset if err := rows.Scan( &i.ID, &i.FileName, &i.OriginalFileName, &i.StoragePath, &i.BucketName, &i.FileSize, &i.MimeType, &i.FileCategoryID, &i.FileContextID, &i.IsPublic, &i.EntityType, &i.EntityID, &i.Purpose, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getFileCategories = `-- name: GetFileCategories :many SELECT id, name, max_size_bytes FROM files.file_categories ORDER BY name ` func (q *Queries) GetFileCategories(ctx context.Context) ([]FileManagerFileCategory, error) { rows, err := q.db.Query(ctx, getFileCategories) if err != nil { return nil, err } defer rows.Close() items := []FileManagerFileCategory{} for rows.Next() { var i FileManagerFileCategory if err := rows.Scan(&i.ID, &i.Name, &i.MaxSizeBytes); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getFileContexts = `-- name: GetFileContexts :many SELECT id, name FROM files.file_contexts ORDER BY name ` func (q *Queries) GetFileContexts(ctx context.Context) ([]FileManagerFileContext, error) { rows, err := q.db.Query(ctx, getFileContexts) if err != nil { return nil, err } defer rows.Close() items := []FileManagerFileContext{} for rows.Next() { var i FileManagerFileContext if err := rows.Scan(&i.ID, &i.Name); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listFileAssets = `-- name: ListFileAssets :many SELECT fa.id, fa.file_name, fa.original_file_name, fa.storage_path, fa.bucket_name, fa.file_size, fa.mime_type, fa.file_category_id, fa.file_context_id, fa.is_public, fa.entity_type, fa.entity_id, fa.purpose, fa.metadata, fa.created_at, fa.updated_at, fc.name as category_name, fctx.name as context_name FROM files.file_assets fa JOIN files.file_categories fc ON fa.file_category_id = fc.id JOIN files.file_contexts fctx ON fa.file_context_id = fctx.id ORDER BY fa.created_at DESC LIMIT $1 OFFSET $2 ` type ListFileAssetsParams struct { Limit int32 `json:"limit"` Offset int32 `json:"offset"` } type ListFileAssetsRow struct { ID int32 `json:"id"` FileName string `json:"file_name"` OriginalFileName string `json:"original_file_name"` StoragePath string `json:"storage_path"` BucketName string `json:"bucket_name"` FileSize int64 `json:"file_size"` MimeType string `json:"mime_type"` FileCategoryID int16 `json:"file_category_id"` FileContextID int16 `json:"file_context_id"` IsPublic pgtype.Bool `json:"is_public"` EntityType pgtype.Text `json:"entity_type"` EntityID pgtype.Int4 `json:"entity_id"` Purpose pgtype.Text `json:"purpose"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` CategoryName string `json:"category_name"` ContextName string `json:"context_name"` } func (q *Queries) ListFileAssets(ctx context.Context, arg ListFileAssetsParams) ([]ListFileAssetsRow, error) { rows, err := q.db.Query(ctx, listFileAssets, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() items := []ListFileAssetsRow{} for rows.Next() { var i ListFileAssetsRow if err := rows.Scan( &i.ID, &i.FileName, &i.OriginalFileName, &i.StoragePath, &i.BucketName, &i.FileSize, &i.MimeType, &i.FileCategoryID, &i.FileContextID, &i.IsPublic, &i.EntityType, &i.EntityID, &i.Purpose, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, &i.CategoryName, &i.ContextName, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const updateFileAsset = `-- name: UpdateFileAsset :exec UPDATE files.file_assets SET file_name = $2, storage_path = $3, purpose = $4, metadata = $5, updated_at = CURRENT_TIMESTAMP WHERE id = $1 ` type UpdateFileAssetParams struct { ID int32 `json:"id"` FileName string `json:"file_name"` StoragePath string `json:"storage_path"` Purpose pgtype.Text `json:"purpose"` Metadata []byte `json:"metadata"` } func (q *Queries) UpdateFileAsset(ctx context.Context, arg UpdateFileAssetParams) error { _, err := q.db.Exec(ctx, updateFileAsset, arg.ID, arg.FileName, arg.StoragePath, arg.Purpose, arg.Metadata, ) return err } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/models.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 package postgres import ( "github.com/jackc/pgx/v5/pgtype" pgvector_go "github.com/pgvector/pgvector-go" ) // Messages within chat sessions with role (user/assistant/system) type CognitiveChatMessage struct { ID int32 `json:"id"` SessionID int32 `json:"session_id"` Role string `json:"role"` Content string `json:"content"` ReferencedDocs []int32 `json:"referenced_docs"` TokensUsed pgtype.Int4 `json:"tokens_used"` CreatedAt pgtype.Timestamp `json:"created_at"` } // Conversational AI sessions for RAG-based chat type CognitiveChatSession struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` AccountID int32 `json:"account_id"` Title pgtype.Text `json:"title"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } // Vector embeddings for documents using OpenAI text-embedding-3-small (1536 dimensions) type CognitiveDocumentEmbedding struct { ID int32 `json:"id"` DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` // Vector embedding for semantic similarity search Embedding pgvector_go.Vector `json:"embedding"` ContentHash pgtype.Text `json:"content_hash"` ContentPreview pgtype.Text `json:"content_preview"` // Index for chunked documents (0 for single-chunk docs) ChunkIndex pgtype.Int4 `json:"chunk_index"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } // Stores uploaded documents (PDFs) with extracted text for RAG type DocumentsDocument struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` FileAssetID int32 `json:"file_asset_id"` Title string `json:"title"` FileName string `json:"file_name"` ContentType string `json:"content_type"` FileSize int64 `json:"file_size"` // Text extracted from PDF using OCR or direct parsing ExtractedText pgtype.Text `json:"extracted_text"` // Processing status: pending, processing, processed, failed Status string `json:"status"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } // Stores potential duplicate resources found via vector similarity and LLM adjudication type DuplicateCandidate struct { ID int32 `json:"id"` ResourceID int32 `json:"resource_id"` CandidateResourceID int32 `json:"candidate_resource_id"` // Cosine similarity score from pgvector (0.0000 = completely different, 1.0000 = identical) SimilarityScore pgtype.Numeric `json:"similarity_score"` // How the duplicate was detected: exact_match (similarity >= 0.95) or llm_adjudicated (similarity >= 0.85, confirmed by LLM) DetectionMethod string `json:"detection_method"` ConfidenceLevel pgtype.Text `json:"confidence_level"` LlmReason pgtype.Text `json:"llm_reason"` LlmSimilarFields []byte `json:"llm_similar_fields"` LlmResponse []byte `json:"llm_response"` OrganizationID int32 `json:"organization_id"` Status pgtype.Text `json:"status"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } // Example module demonstrating Clean Architecture patterns with file uploads, OCR/LLM processing, RBAC, approval workflows, and multi-tenancy type ExampleResource struct { ID int32 `json:"id"` ResourceNumber string `json:"resource_number"` Title string `json:"title"` Description pgtype.Text `json:"description"` StatusID int16 `json:"status_id"` FileID pgtype.Int4 `json:"file_id"` // Raw OCR-extracted text and metadata ExtractedData []byte `json:"extracted_data"` // LLM-processed structured data ProcessedData []byte `json:"processed_data"` // AI confidence score between 0 and 1 Confidence pgtype.Numeric `json:"confidence"` OrganizationID int32 `json:"organization_id"` CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` // Workflow status: pending, approved, rejected ApprovalStatus pgtype.Text `json:"approval_status"` ApprovalAssignedToID pgtype.Int4 `json:"approval_assigned_to_id"` ApprovalActionTakerID pgtype.Int4 `json:"approval_action_taker_id"` ApprovalNotes pgtype.Text `json:"approval_notes"` Metadata []byte `json:"metadata"` IsActive bool `json:"is_active"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } type FileManagerFileAsset struct { ID int32 `json:"id"` FileName string `json:"file_name"` OriginalFileName string `json:"original_file_name"` StoragePath string `json:"storage_path"` BucketName string `json:"bucket_name"` FileSize int64 `json:"file_size"` MimeType string `json:"mime_type"` FileCategoryID int16 `json:"file_category_id"` FileContextID int16 `json:"file_context_id"` IsPublic pgtype.Bool `json:"is_public"` EntityType pgtype.Text `json:"entity_type"` EntityID pgtype.Int4 `json:"entity_id"` Purpose pgtype.Text `json:"purpose"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type FileManagerFileCategory struct { ID int16 `json:"id"` Name string `json:"name"` MaxSizeBytes int64 `json:"max_size_bytes"` } type FileManagerFileContext struct { ID int16 `json:"id"` Name string `json:"name"` } // User accounts within organizations type OrganizationsAccount struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` Email string `json:"email"` FullName string `json:"full_name"` // Stytch member identifier (member_xxx) StytchMemberID pgtype.Text `json:"stytch_member_id"` // Stytch role identifier assigned to the member StytchRoleID pgtype.Text `json:"stytch_role_id"` // Human-readable Stytch role slug assigned to the member StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` // Whether Stytch reports the member email as verified StytchEmailVerified bool `json:"stytch_email_verified"` // Last known role for business logic (e.g., owner, reviewer, employee) Role string `json:"role"` Status string `json:"status"` LastLoginAt pgtype.Timestamp `json:"last_login_at"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } // Organizations (tenants) in the system type OrganizationsOrganization struct { ID int32 `json:"id"` // URL-friendly unique identifier for organization Slug string `json:"slug"` Name string `json:"name"` Status string `json:"status"` // Stytch organization identifier (org_xxx) StytchOrgID pgtype.Text `json:"stytch_org_id"` // Optional Stytch connection or project identifier associated with the organization StytchConnectionID pgtype.Text `json:"stytch_connection_id"` // Optional Stytch connection name associated with the organization StytchConnectionName pgtype.Text `json:"stytch_connection_name"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } // Stores vector embeddings for resources using OpenAI text-embedding-3-small (1536 dimensions) type ResourceEmbedding struct { ID int32 `json:"id"` ResourceID int32 `json:"resource_id"` // Vector embedding for semantic similarity search (1536 dimensions from OpenAI) Embedding pgvector_go.Vector `json:"embedding"` OrganizationID int32 `json:"organization_id"` // SHA-256 hash of normalized content for exact duplicate detection ContentHash pgtype.Text `json:"content_hash"` ContentPreview pgtype.Text `json:"content_preview"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } // Tracks usage quotas per organization for fast quota checks type SubscriptionBillingQuotaTracking struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` MaxSeats pgtype.Int4 `json:"max_seats"` PeriodStart pgtype.Timestamp `json:"period_start"` PeriodEnd pgtype.Timestamp `json:"period_end"` LastSyncedAt pgtype.Timestamp `json:"last_synced_at"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` // Remaining invoices in current billing period (decremented on use) InvoiceCount int32 `json:"invoice_count"` } // Stores subscription details from Polar, synced via webhooks type SubscriptionBillingSubscription struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` // Polar customer ID (maps to organization via stytch_org_id) ExternalCustomerID string `json:"external_customer_id"` SubscriptionID string `json:"subscription_id"` SubscriptionStatus string `json:"subscription_status"` ProductID string `json:"product_id"` ProductName pgtype.Text `json:"product_name"` PlanName pgtype.Text `json:"plan_name"` CurrentPeriodStart pgtype.Timestamp `json:"current_period_start"` CurrentPeriodEnd pgtype.Timestamp `json:"current_period_end"` CancelAtPeriodEnd pgtype.Bool `json:"cancel_at_period_end"` CanceledAt pgtype.Timestamp `json:"canceled_at"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` Metadata []byte `json:"metadata"` } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/organizations.sql.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 // source: organizations.sql package postgres import ( "context" "github.com/jackc/pgx/v5/pgtype" ) const checkAccountPermission = `-- name: CheckAccountPermission :one SELECT a.id, a.role, a.status, o.status as org_status FROM organizations.accounts a INNER JOIN organizations.organizations o ON a.organization_id = o.id WHERE a.id = $1 AND a.organization_id = $2 ` type CheckAccountPermissionParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } type CheckAccountPermissionRow struct { ID int32 `json:"id"` Role string `json:"role"` Status string `json:"status"` OrgStatus string `json:"org_status"` } func (q *Queries) CheckAccountPermission(ctx context.Context, arg CheckAccountPermissionParams) (CheckAccountPermissionRow, error) { row := q.db.QueryRow(ctx, checkAccountPermission, arg.ID, arg.OrganizationID) var i CheckAccountPermissionRow err := row.Scan( &i.ID, &i.Role, &i.Status, &i.OrgStatus, ) return i, err } const createAccount = `-- name: CreateAccount :one INSERT INTO organizations.accounts ( organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at ` type CreateAccountParams struct { OrganizationID int32 `json:"organization_id"` Email string `json:"email"` FullName string `json:"full_name"` StytchMemberID pgtype.Text `json:"stytch_member_id"` StytchRoleID pgtype.Text `json:"stytch_role_id"` StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` StytchEmailVerified bool `json:"stytch_email_verified"` Role string `json:"role"` Status string `json:"status"` } // Accounts queries func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (OrganizationsAccount, error) { row := q.db.QueryRow(ctx, createAccount, arg.OrganizationID, arg.Email, arg.FullName, arg.StytchMemberID, arg.StytchRoleID, arg.StytchRoleSlug, arg.StytchEmailVerified, arg.Role, arg.Status, ) var i OrganizationsAccount err := row.Scan( &i.ID, &i.OrganizationID, &i.Email, &i.FullName, &i.StytchMemberID, &i.StytchRoleID, &i.StytchRoleSlug, &i.StytchEmailVerified, &i.Role, &i.Status, &i.LastLoginAt, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const createOrganization = `-- name: CreateOrganization :one INSERT INTO organizations.organizations ( slug, name, status ) VALUES ( $1, $2, $3 ) RETURNING id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at ` type CreateOrganizationParams struct { Slug string `json:"slug"` Name string `json:"name"` Status string `json:"status"` } func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (OrganizationsOrganization, error) { row := q.db.QueryRow(ctx, createOrganization, arg.Slug, arg.Name, arg.Status) var i OrganizationsOrganization err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const deleteAccount = `-- name: DeleteAccount :exec UPDATE organizations.accounts SET status = 'inactive', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 ` type DeleteAccountParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) DeleteAccount(ctx context.Context, arg DeleteAccountParams) error { _, err := q.db.Exec(ctx, deleteAccount, arg.ID, arg.OrganizationID) return err } const deleteOrganization = `-- name: DeleteOrganization :exec DELETE FROM organizations.organizations WHERE id = $1 ` func (q *Queries) DeleteOrganization(ctx context.Context, id int32) error { _, err := q.db.Exec(ctx, deleteOrganization, id) return err } const getAccountByEmail = `-- name: GetAccountByEmail :one SELECT id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at FROM organizations.accounts WHERE email = $1 AND organization_id = $2 ` type GetAccountByEmailParams struct { Email string `json:"email"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) GetAccountByEmail(ctx context.Context, arg GetAccountByEmailParams) (OrganizationsAccount, error) { row := q.db.QueryRow(ctx, getAccountByEmail, arg.Email, arg.OrganizationID) var i OrganizationsAccount err := row.Scan( &i.ID, &i.OrganizationID, &i.Email, &i.FullName, &i.StytchMemberID, &i.StytchRoleID, &i.StytchRoleSlug, &i.StytchEmailVerified, &i.Role, &i.Status, &i.LastLoginAt, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getAccountByID = `-- name: GetAccountByID :one SELECT id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at FROM organizations.accounts WHERE id = $1 AND organization_id = $2 ` type GetAccountByIDParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) GetAccountByID(ctx context.Context, arg GetAccountByIDParams) (OrganizationsAccount, error) { row := q.db.QueryRow(ctx, getAccountByID, arg.ID, arg.OrganizationID) var i OrganizationsAccount err := row.Scan( &i.ID, &i.OrganizationID, &i.Email, &i.FullName, &i.StytchMemberID, &i.StytchRoleID, &i.StytchRoleSlug, &i.StytchEmailVerified, &i.Role, &i.Status, &i.LastLoginAt, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getAccountOrganization = `-- name: GetAccountOrganization :one SELECT o.id, o.slug, o.name, o.status, o.stytch_org_id, o.stytch_connection_id, o.stytch_connection_name, o.created_at, o.updated_at FROM organizations.organizations o INNER JOIN organizations.accounts a ON o.id = a.organization_id WHERE a.id = $1 ` func (q *Queries) GetAccountOrganization(ctx context.Context, id int32) (OrganizationsOrganization, error) { row := q.db.QueryRow(ctx, getAccountOrganization, id) var i OrganizationsOrganization err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getAccountStats = `-- name: GetAccountStats :one SELECT a.id, a.organization_id, a.email, a.full_name, a.stytch_member_id, a.stytch_role_id, a.stytch_role_slug, a.stytch_email_verified, a.role, a.status, a.last_login_at, a.created_at, a.updated_at, o.name as organization_name, o.slug as organization_slug FROM organizations.accounts a INNER JOIN organizations.organizations o ON a.organization_id = o.id WHERE a.id = $1 ` type GetAccountStatsRow struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` Email string `json:"email"` FullName string `json:"full_name"` StytchMemberID pgtype.Text `json:"stytch_member_id"` StytchRoleID pgtype.Text `json:"stytch_role_id"` StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` StytchEmailVerified bool `json:"stytch_email_verified"` Role string `json:"role"` Status string `json:"status"` LastLoginAt pgtype.Timestamp `json:"last_login_at"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` OrganizationName string `json:"organization_name"` OrganizationSlug string `json:"organization_slug"` } func (q *Queries) GetAccountStats(ctx context.Context, id int32) (GetAccountStatsRow, error) { row := q.db.QueryRow(ctx, getAccountStats, id) var i GetAccountStatsRow err := row.Scan( &i.ID, &i.OrganizationID, &i.Email, &i.FullName, &i.StytchMemberID, &i.StytchRoleID, &i.StytchRoleSlug, &i.StytchEmailVerified, &i.Role, &i.Status, &i.LastLoginAt, &i.CreatedAt, &i.UpdatedAt, &i.OrganizationName, &i.OrganizationSlug, ) return i, err } const getOrganizationByID = `-- name: GetOrganizationByID :one SELECT id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at FROM organizations.organizations WHERE id = $1 ` func (q *Queries) GetOrganizationByID(ctx context.Context, id int32) (OrganizationsOrganization, error) { row := q.db.QueryRow(ctx, getOrganizationByID, id) var i OrganizationsOrganization err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getOrganizationBySlug = `-- name: GetOrganizationBySlug :one SELECT id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at FROM organizations.organizations WHERE slug = $1 ` func (q *Queries) GetOrganizationBySlug(ctx context.Context, slug string) (OrganizationsOrganization, error) { row := q.db.QueryRow(ctx, getOrganizationBySlug, slug) var i OrganizationsOrganization err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getOrganizationByStytchID = `-- name: GetOrganizationByStytchID :one SELECT id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at FROM organizations.organizations WHERE stytch_org_id = $1 ` func (q *Queries) GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (OrganizationsOrganization, error) { row := q.db.QueryRow(ctx, getOrganizationByStytchID, stytchOrgID) var i OrganizationsOrganization err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getOrganizationByUserEmail = `-- name: GetOrganizationByUserEmail :one SELECT o.id, o.slug, o.name, o.status, o.stytch_org_id, o.stytch_connection_id, o.stytch_connection_name, o.created_at, o.updated_at FROM organizations.organizations o INNER JOIN organizations.accounts a ON o.id = a.organization_id WHERE a.email = $1 AND a.status = 'active' AND o.status = 'active' LIMIT 1 ` // Organization membership queries func (q *Queries) GetOrganizationByUserEmail(ctx context.Context, email string) (OrganizationsOrganization, error) { row := q.db.QueryRow(ctx, getOrganizationByUserEmail, email) var i OrganizationsOrganization err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getOrganizationStats = `-- name: GetOrganizationStats :one SELECT o.id, o.slug, o.name, o.status, o.stytch_org_id, o.stytch_connection_id, o.stytch_connection_name, o.created_at, o.updated_at, COUNT(a.id) as account_count, COUNT(CASE WHEN a.status = 'active' THEN 1 END) as active_account_count FROM organizations.organizations o LEFT JOIN organizations.accounts a ON o.id = a.organization_id WHERE o.id = $1 GROUP BY o.id ` type GetOrganizationStatsRow struct { ID int32 `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Status string `json:"status"` StytchOrgID pgtype.Text `json:"stytch_org_id"` StytchConnectionID pgtype.Text `json:"stytch_connection_id"` StytchConnectionName pgtype.Text `json:"stytch_connection_name"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` AccountCount int64 `json:"account_count"` ActiveAccountCount int64 `json:"active_account_count"` } // Statistics queries (useful for admin panels) func (q *Queries) GetOrganizationStats(ctx context.Context, id int32) (GetOrganizationStatsRow, error) { row := q.db.QueryRow(ctx, getOrganizationStats, id) var i GetOrganizationStatsRow err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, &i.AccountCount, &i.ActiveAccountCount, ) return i, err } const listAccountsByOrganization = `-- name: ListAccountsByOrganization :many SELECT id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at FROM organizations.accounts WHERE organization_id = $1 ORDER BY created_at DESC ` func (q *Queries) ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]OrganizationsAccount, error) { rows, err := q.db.Query(ctx, listAccountsByOrganization, organizationID) if err != nil { return nil, err } defer rows.Close() items := []OrganizationsAccount{} for rows.Next() { var i OrganizationsAccount if err := rows.Scan( &i.ID, &i.OrganizationID, &i.Email, &i.FullName, &i.StytchMemberID, &i.StytchRoleID, &i.StytchRoleSlug, &i.StytchEmailVerified, &i.Role, &i.Status, &i.LastLoginAt, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listOrganizations = `-- name: ListOrganizations :many SELECT id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at FROM organizations.organizations ORDER BY created_at DESC LIMIT $1 OFFSET $2 ` type ListOrganizationsParams struct { Limit int32 `json:"limit"` Offset int32 `json:"offset"` } func (q *Queries) ListOrganizations(ctx context.Context, arg ListOrganizationsParams) ([]OrganizationsOrganization, error) { rows, err := q.db.Query(ctx, listOrganizations, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() items := []OrganizationsOrganization{} for rows.Next() { var i OrganizationsOrganization if err := rows.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const updateAccount = `-- name: UpdateAccount :one UPDATE organizations.accounts SET full_name = $3, stytch_role_id = $4, stytch_role_slug = $5, stytch_email_verified = $6, role = $7, status = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at ` type UpdateAccountParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` FullName string `json:"full_name"` StytchRoleID pgtype.Text `json:"stytch_role_id"` StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` StytchEmailVerified bool `json:"stytch_email_verified"` Role string `json:"role"` Status string `json:"status"` } func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (OrganizationsAccount, error) { row := q.db.QueryRow(ctx, updateAccount, arg.ID, arg.OrganizationID, arg.FullName, arg.StytchRoleID, arg.StytchRoleSlug, arg.StytchEmailVerified, arg.Role, arg.Status, ) var i OrganizationsAccount err := row.Scan( &i.ID, &i.OrganizationID, &i.Email, &i.FullName, &i.StytchMemberID, &i.StytchRoleID, &i.StytchRoleSlug, &i.StytchEmailVerified, &i.Role, &i.Status, &i.LastLoginAt, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const updateAccountLastLogin = `-- name: UpdateAccountLastLogin :one UPDATE organizations.accounts SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at ` type UpdateAccountLastLoginParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` } func (q *Queries) UpdateAccountLastLogin(ctx context.Context, arg UpdateAccountLastLoginParams) (OrganizationsAccount, error) { row := q.db.QueryRow(ctx, updateAccountLastLogin, arg.ID, arg.OrganizationID) var i OrganizationsAccount err := row.Scan( &i.ID, &i.OrganizationID, &i.Email, &i.FullName, &i.StytchMemberID, &i.StytchRoleID, &i.StytchRoleSlug, &i.StytchEmailVerified, &i.Role, &i.Status, &i.LastLoginAt, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const updateAccountStytchInfo = `-- name: UpdateAccountStytchInfo :one UPDATE organizations.accounts SET stytch_member_id = $3, stytch_role_id = $4, stytch_role_slug = $5, stytch_email_verified = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at ` type UpdateAccountStytchInfoParams struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` StytchMemberID pgtype.Text `json:"stytch_member_id"` StytchRoleID pgtype.Text `json:"stytch_role_id"` StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` StytchEmailVerified bool `json:"stytch_email_verified"` } func (q *Queries) UpdateAccountStytchInfo(ctx context.Context, arg UpdateAccountStytchInfoParams) (OrganizationsAccount, error) { row := q.db.QueryRow(ctx, updateAccountStytchInfo, arg.ID, arg.OrganizationID, arg.StytchMemberID, arg.StytchRoleID, arg.StytchRoleSlug, arg.StytchEmailVerified, ) var i OrganizationsAccount err := row.Scan( &i.ID, &i.OrganizationID, &i.Email, &i.FullName, &i.StytchMemberID, &i.StytchRoleID, &i.StytchRoleSlug, &i.StytchEmailVerified, &i.Role, &i.Status, &i.LastLoginAt, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const updateOrganization = `-- name: UpdateOrganization :one UPDATE organizations.organizations SET name = $2, status = $3, stytch_org_id = $4, stytch_connection_id = $5, stytch_connection_name = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at ` type UpdateOrganizationParams struct { ID int32 `json:"id"` Name string `json:"name"` Status string `json:"status"` StytchOrgID pgtype.Text `json:"stytch_org_id"` StytchConnectionID pgtype.Text `json:"stytch_connection_id"` StytchConnectionName pgtype.Text `json:"stytch_connection_name"` } func (q *Queries) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (OrganizationsOrganization, error) { row := q.db.QueryRow(ctx, updateOrganization, arg.ID, arg.Name, arg.Status, arg.StytchOrgID, arg.StytchConnectionID, arg.StytchConnectionName, ) var i OrganizationsOrganization err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const updateOrganizationStytchInfo = `-- name: UpdateOrganizationStytchInfo :one UPDATE organizations.organizations SET stytch_org_id = $2, stytch_connection_id = $3, stytch_connection_name = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at ` type UpdateOrganizationStytchInfoParams struct { ID int32 `json:"id"` StytchOrgID pgtype.Text `json:"stytch_org_id"` StytchConnectionID pgtype.Text `json:"stytch_connection_id"` StytchConnectionName pgtype.Text `json:"stytch_connection_name"` } func (q *Queries) UpdateOrganizationStytchInfo(ctx context.Context, arg UpdateOrganizationStytchInfoParams) (OrganizationsOrganization, error) { row := q.db.QueryRow(ctx, updateOrganizationStytchInfo, arg.ID, arg.StytchOrgID, arg.StytchConnectionID, arg.StytchConnectionName, ) var i OrganizationsOrganization err := row.Scan( &i.ID, &i.Slug, &i.Name, &i.Status, &i.StytchOrgID, &i.StytchConnectionID, &i.StytchConnectionName, &i.CreatedAt, &i.UpdatedAt, ) return i, err } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/querier.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 package postgres import ( "context" "github.com/jackc/pgx/v5/pgtype" ) type Querier interface { // Assign resource to someone for approval AssignResourceApproval(ctx context.Context, arg AssignResourceApprovalParams) error // Attach a file to a resource AttachFileToResource(ctx context.Context, arg AttachFileToResourceParams) error CheckAccountPermission(ctx context.Context, arg CheckAccountPermissionParams) (CheckAccountPermissionRow, error) CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) CountDocumentsByStatus(ctx context.Context, arg CountDocumentsByStatusParams) (int64, error) // Count resources for pagination CountResources(ctx context.Context, arg CountResourcesParams) (int64, error) // Accounts queries CreateAccount(ctx context.Context, arg CreateAccountParams) (OrganizationsAccount, error) // Chat Messages CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) (CognitiveChatMessage, error) // Chat Sessions CreateChatSession(ctx context.Context, arg CreateChatSessionParams) (CognitiveChatSession, error) // Documents queries CreateDocument(ctx context.Context, arg CreateDocumentParams) (DocumentsDocument, error) // Cognitive Agent queries // Document Embeddings CreateDocumentEmbedding(ctx context.Context, arg CreateDocumentEmbeddingParams) (CognitiveDocumentEmbedding, error) CreateFileAsset(ctx context.Context, arg CreateFileAssetParams) (FileManagerFileAsset, error) // Creates a minimal placeholder resource CreateMinimalResource(ctx context.Context, arg CreateMinimalResourceParams) (ExampleResource, error) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (OrganizationsOrganization, error) // Example Resource Queries // Demonstrates Clean Architecture patterns with CRUD operations, // file attachments, OCR/LLM processing, and approval workflows // CREATE operations CreateResource(ctx context.Context, arg CreateResourceParams) (ExampleResource, error) // Decrement invoice count by 1 (called after successful invoice processing) DecrementInvoiceCount(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) DeleteAccount(ctx context.Context, arg DeleteAccountParams) error DeleteChatMessage(ctx context.Context, id int32) error DeleteChatSession(ctx context.Context, arg DeleteChatSessionParams) error DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error DeleteDocumentEmbeddings(ctx context.Context, arg DeleteDocumentEmbeddingsParams) error DeleteFileAsset(ctx context.Context, id int32) error DeleteOrganization(ctx context.Context, id int32) error // DELETE operations // Soft delete a resource DeleteResource(ctx context.Context, arg DeleteResourceParams) error // Delete subscription (when subscription is permanently deleted) DeleteSubscription(ctx context.Context, organizationID int32) error GetAccountByEmail(ctx context.Context, arg GetAccountByEmailParams) (OrganizationsAccount, error) GetAccountByID(ctx context.Context, arg GetAccountByIDParams) (OrganizationsAccount, error) GetAccountOrganization(ctx context.Context, id int32) (OrganizationsOrganization, error) GetAccountStats(ctx context.Context, id int32) (GetAccountStatsRow, error) GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]CognitiveChatMessage, error) GetChatSessionByID(ctx context.Context, arg GetChatSessionByIDParams) (CognitiveChatSession, error) GetDocumentByFileAssetID(ctx context.Context, arg GetDocumentByFileAssetIDParams) (DocumentsDocument, error) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (DocumentsDocument, error) GetDocumentEmbeddingByID(ctx context.Context, arg GetDocumentEmbeddingByIDParams) (CognitiveDocumentEmbedding, error) GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg GetDocumentEmbeddingsByDocumentIDParams) ([]CognitiveDocumentEmbedding, error) GetFileAssetByID(ctx context.Context, id int32) (FileManagerFileAsset, error) GetFileAssetByStoragePath(ctx context.Context, storagePath string) (FileManagerFileAsset, error) GetFileAssetsByCategory(ctx context.Context, name string) ([]GetFileAssetsByCategoryRow, error) GetFileAssetsByContext(ctx context.Context, name string) ([]GetFileAssetsByContextRow, error) GetFileAssetsByEntity(ctx context.Context, arg GetFileAssetsByEntityParams) ([]FileManagerFileAsset, error) GetFileAssetsByEntityAndPurpose(ctx context.Context, arg GetFileAssetsByEntityAndPurposeParams) ([]FileManagerFileAsset, error) GetFileCategories(ctx context.Context) ([]FileManagerFileCategory, error) GetFileContexts(ctx context.Context) ([]FileManagerFileContext, error) GetOrganizationByID(ctx context.Context, id int32) (OrganizationsOrganization, error) GetOrganizationBySlug(ctx context.Context, slug string) (OrganizationsOrganization, error) GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (OrganizationsOrganization, error) // Organization membership queries GetOrganizationByUserEmail(ctx context.Context, email string) (OrganizationsOrganization, error) // Statistics queries (useful for admin panels) GetOrganizationStats(ctx context.Context, id int32) (GetOrganizationStatsRow, error) // Get quota tracking for an organization GetQuotaByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) // Get combined subscription and quota status for fast quota checks GetQuotaStatus(ctx context.Context, organizationID int32) (GetQuotaStatusRow, error) GetRecentChatMessages(ctx context.Context, arg GetRecentChatMessagesParams) ([]CognitiveChatMessage, error) // Get most recently created resources GetRecentResources(ctx context.Context, arg GetRecentResourcesParams) ([]GetRecentResourcesRow, error) // READ operations GetResourceByID(ctx context.Context, arg GetResourceByIDParams) (ExampleResource, error) GetResourceByNumber(ctx context.Context, arg GetResourceByNumberParams) (ExampleResource, error) // ANALYTICS queries // Get statistics for dashboard GetResourceStats(ctx context.Context, organizationID int32) (GetResourceStatsRow, error) // Get resources created by a specific user GetResourcesByCreator(ctx context.Context, arg GetResourcesByCreatorParams) ([]ExampleResource, error) // Get subscription details for an organization GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingSubscription, error) // Get subscription by Polar subscription ID GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (SubscriptionBillingSubscription, error) // Hard delete a resource (use with caution) HardDeleteResource(ctx context.Context, arg HardDeleteResourceParams) error ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]OrganizationsAccount, error) // List all active subscriptions for monitoring/admin purposes ListActiveSubscriptions(ctx context.Context) ([]SubscriptionBillingSubscription, error) ListChatSessionsByAccount(ctx context.Context, arg ListChatSessionsByAccountParams) ([]CognitiveChatSession, error) ListDocumentsByOrganization(ctx context.Context, arg ListDocumentsByOrganizationParams) ([]DocumentsDocument, error) ListDocumentsByStatus(ctx context.Context, arg ListDocumentsByStatusParams) ([]DocumentsDocument, error) ListFileAssets(ctx context.Context, arg ListFileAssetsParams) ([]ListFileAssetsRow, error) ListOrganizations(ctx context.Context, arg ListOrganizationsParams) ([]OrganizationsOrganization, error) // List organizations approaching their quota limit (for alerting) ListQuotasNearLimit(ctx context.Context, invoiceCount int32) ([]ListQuotasNearLimitRow, error) // List resources with filtering and pagination ListResources(ctx context.Context, arg ListResourcesParams) ([]ListResourcesRow, error) // Reset quota counters for a new billing period ResetQuotaForPeriod(ctx context.Context, arg ResetQuotaForPeriodParams) (SubscriptionBillingQuotaTracking, error) // SEARCH operations // Full-text search on title and description SearchResourcesByText(ctx context.Context, arg SearchResourcesByTextParams) ([]SearchResourcesByTextRow, error) SearchSimilarDocuments(ctx context.Context, arg SearchSimilarDocumentsParams) ([]SearchSimilarDocumentsRow, error) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (OrganizationsAccount, error) UpdateAccountLastLogin(ctx context.Context, arg UpdateAccountLastLoginParams) (OrganizationsAccount, error) UpdateAccountStytchInfo(ctx context.Context, arg UpdateAccountStytchInfoParams) (OrganizationsAccount, error) UpdateChatSessionTitle(ctx context.Context, arg UpdateChatSessionTitleParams) (CognitiveChatSession, error) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (DocumentsDocument, error) UpdateDocumentExtractedText(ctx context.Context, arg UpdateDocumentExtractedTextParams) (DocumentsDocument, error) UpdateDocumentStatus(ctx context.Context, arg UpdateDocumentStatusParams) (DocumentsDocument, error) UpdateFileAsset(ctx context.Context, arg UpdateFileAssetParams) error UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (OrganizationsOrganization, error) UpdateOrganizationStytchInfo(ctx context.Context, arg UpdateOrganizationStytchInfoParams) (OrganizationsOrganization, error) // UPDATE operations UpdateResource(ctx context.Context, arg UpdateResourceParams) error // Update approval workflow status UpdateResourceApprovalStatus(ctx context.Context, arg UpdateResourceApprovalStatusParams) error // Update OCR/LLM processing results UpdateResourceProcessingData(ctx context.Context, arg UpdateResourceProcessingDataParams) error UpdateResourceStatus(ctx context.Context, arg UpdateResourceStatusParams) error // Create or update quota tracking UpsertQuota(ctx context.Context, arg UpsertQuotaParams) (SubscriptionBillingQuotaTracking, error) // Create or update subscription from Polar webhook UpsertSubscription(ctx context.Context, arg UpsertSubscriptionParams) (SubscriptionBillingSubscription, error) } var _ Querier = (*Queries)(nil) ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/resource_embeddings.sql.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 // source: resource_embeddings.sql package postgres import ( "context" "github.com/jackc/pgx/v5/pgtype" pgvector_go "github.com/pgvector/pgvector-go" ) const confirmDuplicate = `-- name: ConfirmDuplicate :exec UPDATE duplicate_candidates SET status = 'confirmed', updated_at = NOW() WHERE id = $1 ` // Marks a duplicate candidate as confirmed func (q *Queries) ConfirmDuplicate(ctx context.Context, id int32) error { _, err := q.db.Exec(ctx, confirmDuplicate, id) return err } const countDuplicatesByStatus = `-- name: CountDuplicatesByStatus :one SELECT COUNT(*) FROM duplicate_candidates WHERE organization_id = $1 AND status = $2 ` type CountDuplicatesByStatusParams struct { OrganizationID int32 `json:"organization_id"` Status pgtype.Text `json:"status"` } // Counts duplicate candidates by status for an organization func (q *Queries) CountDuplicatesByStatus(ctx context.Context, arg CountDuplicatesByStatusParams) (int64, error) { row := q.db.QueryRow(ctx, countDuplicatesByStatus, arg.OrganizationID, arg.Status) var count int64 err := row.Scan(&count) return count, err } const countEmbeddingsByOrganization = `-- name: CountEmbeddingsByOrganization :one SELECT COUNT(*) FROM resource_embeddings WHERE organization_id = $1 ` // Counts total embeddings for an organization func (q *Queries) CountEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) { row := q.db.QueryRow(ctx, countEmbeddingsByOrganization, organizationID) var count int64 err := row.Scan(&count) return count, err } const createDuplicateCandidateExactMatch = `-- name: CreateDuplicateCandidateExactMatch :one INSERT INTO duplicate_candidates ( resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, organization_id, status ) VALUES ( $1, $2, $3, 'exact_match', 'very_high', $4, 'pending' ) RETURNING id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at ` type CreateDuplicateCandidateExactMatchParams struct { ResourceID int32 `json:"resource_id"` CandidateResourceID int32 `json:"candidate_resource_id"` SimilarityScore pgtype.Numeric `json:"similarity_score"` OrganizationID int32 `json:"organization_id"` } // Creates a duplicate candidate for exact/perfect matches (no LLM data) func (q *Queries) CreateDuplicateCandidateExactMatch(ctx context.Context, arg CreateDuplicateCandidateExactMatchParams) (DuplicateCandidate, error) { row := q.db.QueryRow(ctx, createDuplicateCandidateExactMatch, arg.ResourceID, arg.CandidateResourceID, arg.SimilarityScore, arg.OrganizationID, ) var i DuplicateCandidate err := row.Scan( &i.ID, &i.ResourceID, &i.CandidateResourceID, &i.SimilarityScore, &i.DetectionMethod, &i.ConfidenceLevel, &i.LlmReason, &i.LlmSimilarFields, &i.LlmResponse, &i.OrganizationID, &i.Status, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const createDuplicateCandidateLLM = `-- name: CreateDuplicateCandidateLLM :one INSERT INTO duplicate_candidates ( resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status ) VALUES ( $1, $2, $3, 'llm_adjudicated', $4, $5, $6, $7, $8, 'pending' ) RETURNING id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at ` type CreateDuplicateCandidateLLMParams struct { ResourceID int32 `json:"resource_id"` CandidateResourceID int32 `json:"candidate_resource_id"` SimilarityScore pgtype.Numeric `json:"similarity_score"` ConfidenceLevel pgtype.Text `json:"confidence_level"` LlmReason pgtype.Text `json:"llm_reason"` LlmSimilarFields []byte `json:"llm_similar_fields"` LlmResponse []byte `json:"llm_response"` OrganizationID int32 `json:"organization_id"` } // Creates a duplicate candidate with LLM adjudication data func (q *Queries) CreateDuplicateCandidateLLM(ctx context.Context, arg CreateDuplicateCandidateLLMParams) (DuplicateCandidate, error) { row := q.db.QueryRow(ctx, createDuplicateCandidateLLM, arg.ResourceID, arg.CandidateResourceID, arg.SimilarityScore, arg.ConfidenceLevel, arg.LlmReason, arg.LlmSimilarFields, arg.LlmResponse, arg.OrganizationID, ) var i DuplicateCandidate err := row.Scan( &i.ID, &i.ResourceID, &i.CandidateResourceID, &i.SimilarityScore, &i.DetectionMethod, &i.ConfidenceLevel, &i.LlmReason, &i.LlmSimilarFields, &i.LlmResponse, &i.OrganizationID, &i.Status, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const createResourceDuplicateCandidate = `-- name: CreateResourceDuplicateCandidate :one INSERT INTO duplicate_candidates ( resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) RETURNING id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at ` type CreateResourceDuplicateCandidateParams struct { ResourceID int32 `json:"resource_id"` CandidateResourceID int32 `json:"candidate_resource_id"` SimilarityScore pgtype.Numeric `json:"similarity_score"` DetectionMethod string `json:"detection_method"` ConfidenceLevel pgtype.Text `json:"confidence_level"` LlmReason pgtype.Text `json:"llm_reason"` LlmSimilarFields []byte `json:"llm_similar_fields"` LlmResponse []byte `json:"llm_response"` OrganizationID int32 `json:"organization_id"` Status pgtype.Text `json:"status"` } // Duplicate Candidates Queries // Creates a new duplicate candidate record func (q *Queries) CreateResourceDuplicateCandidate(ctx context.Context, arg CreateResourceDuplicateCandidateParams) (DuplicateCandidate, error) { row := q.db.QueryRow(ctx, createResourceDuplicateCandidate, arg.ResourceID, arg.CandidateResourceID, arg.SimilarityScore, arg.DetectionMethod, arg.ConfidenceLevel, arg.LlmReason, arg.LlmSimilarFields, arg.LlmResponse, arg.OrganizationID, arg.Status, ) var i DuplicateCandidate err := row.Scan( &i.ID, &i.ResourceID, &i.CandidateResourceID, &i.SimilarityScore, &i.DetectionMethod, &i.ConfidenceLevel, &i.LlmReason, &i.LlmSimilarFields, &i.LlmResponse, &i.OrganizationID, &i.Status, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const deleteDuplicateCandidate = `-- name: DeleteDuplicateCandidate :exec DELETE FROM duplicate_candidates WHERE id = $1 ` // Deletes a duplicate candidate record func (q *Queries) DeleteDuplicateCandidate(ctx context.Context, id int32) error { _, err := q.db.Exec(ctx, deleteDuplicateCandidate, id) return err } const deleteResourceEmbedding = `-- name: DeleteResourceEmbedding :exec DELETE FROM resource_embeddings WHERE resource_id = $1 AND organization_id = $2 ` type DeleteResourceEmbeddingParams struct { ResourceID int32 `json:"resource_id"` OrganizationID int32 `json:"organization_id"` } // Exclude the current resource // Deletes an embedding for a resource func (q *Queries) DeleteResourceEmbedding(ctx context.Context, arg DeleteResourceEmbeddingParams) error { _, err := q.db.Exec(ctx, deleteResourceEmbedding, arg.ResourceID, arg.OrganizationID) return err } const dismissDuplicate = `-- name: DismissDuplicate :exec UPDATE duplicate_candidates SET status = 'dismissed', updated_at = NOW() WHERE id = $1 ` // Marks a duplicate candidate as dismissed func (q *Queries) DismissDuplicate(ctx context.Context, id int32) error { _, err := q.db.Exec(ctx, dismissDuplicate, id) return err } const findExactDuplicateByHash = `-- name: FindExactDuplicateByHash :many SELECT resource_id, content_hash, content_preview FROM resource_embeddings WHERE organization_id = $1 AND content_hash = $2 AND resource_id != $3 ` type FindExactDuplicateByHashParams struct { OrganizationID int32 `json:"organization_id"` ContentHash pgtype.Text `json:"content_hash"` ResourceID int32 `json:"resource_id"` } type FindExactDuplicateByHashRow struct { ResourceID int32 `json:"resource_id"` ContentHash pgtype.Text `json:"content_hash"` ContentPreview pgtype.Text `json:"content_preview"` } // Finds exact duplicates using content hash (faster than vector search for exact matches) func (q *Queries) FindExactDuplicateByHash(ctx context.Context, arg FindExactDuplicateByHashParams) ([]FindExactDuplicateByHashRow, error) { rows, err := q.db.Query(ctx, findExactDuplicateByHash, arg.OrganizationID, arg.ContentHash, arg.ResourceID) if err != nil { return nil, err } defer rows.Close() items := []FindExactDuplicateByHashRow{} for rows.Next() { var i FindExactDuplicateByHashRow if err := rows.Scan(&i.ResourceID, &i.ContentHash, &i.ContentPreview); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const findSimilarResources = `-- name: FindSimilarResources :many SELECT resource_id, 1 - (embedding <=> $1::vector) AS similarity_score, content_hash, content_preview FROM resource_embeddings WHERE organization_id = $2 AND resource_id != $3 AND 1 - (embedding <=> $1::vector) >= $4 ORDER BY embedding <=> $1::vector -- Order by distance (closest first) LIMIT $5 ` type FindSimilarResourcesParams struct { Column1 pgvector_go.Vector `json:"column_1"` OrganizationID int32 `json:"organization_id"` ResourceID int32 `json:"resource_id"` Embedding pgvector_go.Vector `json:"embedding"` Limit int32 `json:"limit"` } type FindSimilarResourcesRow struct { ResourceID int32 `json:"resource_id"` SimilarityScore int32 `json:"similarity_score"` ContentHash pgtype.Text `json:"content_hash"` ContentPreview pgtype.Text `json:"content_preview"` } // Finds similar resources using vector cosine similarity search // The <=> operator calculates cosine distance (0 = identical, 2 = opposite) // We convert to similarity score: 1 - distance/2 = similarity (0 to 1) // // Parameters: // $1: embedding vector to search for // $2: organization_id to scope the search // $3: resource_id to exclude (don't match against itself) // $4: minimum similarity threshold (e.g., 0.85) // $5: limit on number of results func (q *Queries) FindSimilarResources(ctx context.Context, arg FindSimilarResourcesParams) ([]FindSimilarResourcesRow, error) { rows, err := q.db.Query(ctx, findSimilarResources, arg.Column1, arg.OrganizationID, arg.ResourceID, arg.Embedding, arg.Limit, ) if err != nil { return nil, err } defer rows.Close() items := []FindSimilarResourcesRow{} for rows.Next() { var i FindSimilarResourcesRow if err := rows.Scan( &i.ResourceID, &i.SimilarityScore, &i.ContentHash, &i.ContentPreview, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getDuplicateCandidate = `-- name: GetDuplicateCandidate :one SELECT id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at FROM duplicate_candidates WHERE id = $1 ` // Gets a specific duplicate candidate by ID func (q *Queries) GetDuplicateCandidate(ctx context.Context, id int32) (DuplicateCandidate, error) { row := q.db.QueryRow(ctx, getDuplicateCandidate, id) var i DuplicateCandidate err := row.Scan( &i.ID, &i.ResourceID, &i.CandidateResourceID, &i.SimilarityScore, &i.DetectionMethod, &i.ConfidenceLevel, &i.LlmReason, &i.LlmSimilarFields, &i.LlmResponse, &i.OrganizationID, &i.Status, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const getResourceDuplicateStats = `-- name: GetResourceDuplicateStats :one SELECT COUNT(*) as total_candidates, COUNT(*) FILTER (WHERE status = 'pending') as pending_count, COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count, COUNT(*) FILTER (WHERE status = 'dismissed') as dismissed_count, COUNT(*) FILTER (WHERE detection_method = 'exact_match') as exact_match_count, COUNT(*) FILTER (WHERE detection_method = 'llm_adjudicated') as llm_adjudicated_count, AVG(similarity_score) as avg_similarity_score FROM duplicate_candidates WHERE organization_id = $1 ` type GetResourceDuplicateStatsRow struct { TotalCandidates int64 `json:"total_candidates"` PendingCount int64 `json:"pending_count"` ConfirmedCount int64 `json:"confirmed_count"` DismissedCount int64 `json:"dismissed_count"` ExactMatchCount int64 `json:"exact_match_count"` LlmAdjudicatedCount int64 `json:"llm_adjudicated_count"` AvgSimilarityScore float64 `json:"avg_similarity_score"` } // Gets statistics about duplicate detection for an organization func (q *Queries) GetResourceDuplicateStats(ctx context.Context, organizationID int32) (GetResourceDuplicateStatsRow, error) { row := q.db.QueryRow(ctx, getResourceDuplicateStats, organizationID) var i GetResourceDuplicateStatsRow err := row.Scan( &i.TotalCandidates, &i.PendingCount, &i.ConfirmedCount, &i.DismissedCount, &i.ExactMatchCount, &i.LlmAdjudicatedCount, &i.AvgSimilarityScore, ) return i, err } const getResourceEmbedding = `-- name: GetResourceEmbedding :one SELECT id, resource_id, embedding, organization_id, content_hash, content_preview, created_at, updated_at FROM resource_embeddings WHERE resource_id = $1 AND organization_id = $2 LIMIT 1 ` type GetResourceEmbeddingParams struct { ResourceID int32 `json:"resource_id"` OrganizationID int32 `json:"organization_id"` } // Retrieves the embedding for a specific resource func (q *Queries) GetResourceEmbedding(ctx context.Context, arg GetResourceEmbeddingParams) (ResourceEmbedding, error) { row := q.db.QueryRow(ctx, getResourceEmbedding, arg.ResourceID, arg.OrganizationID) var i ResourceEmbedding err := row.Scan( &i.ID, &i.ResourceID, &i.Embedding, &i.OrganizationID, &i.ContentHash, &i.ContentPreview, &i.CreatedAt, &i.UpdatedAt, ) return i, err } const listDuplicateCandidatesForResource = `-- name: ListDuplicateCandidatesForResource :many SELECT id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at FROM duplicate_candidates WHERE resource_id = $1 AND organization_id = $2 ORDER BY similarity_score DESC, created_at DESC ` type ListDuplicateCandidatesForResourceParams struct { ResourceID int32 `json:"resource_id"` OrganizationID int32 `json:"organization_id"` } // Lists all duplicate candidates for a specific resource func (q *Queries) ListDuplicateCandidatesForResource(ctx context.Context, arg ListDuplicateCandidatesForResourceParams) ([]DuplicateCandidate, error) { rows, err := q.db.Query(ctx, listDuplicateCandidatesForResource, arg.ResourceID, arg.OrganizationID) if err != nil { return nil, err } defer rows.Close() items := []DuplicateCandidate{} for rows.Next() { var i DuplicateCandidate if err := rows.Scan( &i.ID, &i.ResourceID, &i.CandidateResourceID, &i.SimilarityScore, &i.DetectionMethod, &i.ConfidenceLevel, &i.LlmReason, &i.LlmSimilarFields, &i.LlmResponse, &i.OrganizationID, &i.Status, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listPendingDuplicates = `-- name: ListPendingDuplicates :many SELECT id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at FROM duplicate_candidates WHERE organization_id = $1 AND status = 'pending' ORDER BY similarity_score DESC, created_at DESC LIMIT $2 OFFSET $3 ` type ListPendingDuplicatesParams struct { OrganizationID int32 `json:"organization_id"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } // Lists all pending duplicate candidates for an organization func (q *Queries) ListPendingDuplicates(ctx context.Context, arg ListPendingDuplicatesParams) ([]DuplicateCandidate, error) { rows, err := q.db.Query(ctx, listPendingDuplicates, arg.OrganizationID, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() items := []DuplicateCandidate{} for rows.Next() { var i DuplicateCandidate if err := rows.Scan( &i.ID, &i.ResourceID, &i.CandidateResourceID, &i.SimilarityScore, &i.DetectionMethod, &i.ConfidenceLevel, &i.LlmReason, &i.LlmSimilarFields, &i.LlmResponse, &i.OrganizationID, &i.Status, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const saveResourceEmbedding = `-- name: SaveResourceEmbedding :exec INSERT INTO resource_embeddings ( resource_id, embedding, organization_id, content_hash, content_preview ) VALUES ( $1, $2, $3, $4, $5 ) ON CONFLICT (resource_id, organization_id) DO UPDATE SET embedding = EXCLUDED.embedding, content_hash = EXCLUDED.content_hash, content_preview = EXCLUDED.content_preview, updated_at = NOW() ` type SaveResourceEmbeddingParams struct { ResourceID int32 `json:"resource_id"` Embedding pgvector_go.Vector `json:"embedding"` OrganizationID int32 `json:"organization_id"` ContentHash pgtype.Text `json:"content_hash"` ContentPreview pgtype.Text `json:"content_preview"` } // Resource Embeddings Queries // These queries demonstrate pgvector usage for semantic similarity search // Saves or updates an embedding for a resource // Uses ON CONFLICT to handle duplicate resource_id + organization_id pairs func (q *Queries) SaveResourceEmbedding(ctx context.Context, arg SaveResourceEmbeddingParams) error { _, err := q.db.Exec(ctx, saveResourceEmbedding, arg.ResourceID, arg.Embedding, arg.OrganizationID, arg.ContentHash, arg.ContentPreview, ) return err } const updateDuplicateCandidateStatus = `-- name: UpdateDuplicateCandidateStatus :exec UPDATE duplicate_candidates SET status = $2, updated_at = NOW() WHERE id = $1 ` type UpdateDuplicateCandidateStatusParams struct { ID int32 `json:"id"` Status pgtype.Text `json:"status"` } // Updates the status of a duplicate candidate func (q *Queries) UpdateDuplicateCandidateStatus(ctx context.Context, arg UpdateDuplicateCandidateStatusParams) error { _, err := q.db.Exec(ctx, updateDuplicateCandidateStatus, arg.ID, arg.Status) return err } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/store.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 package postgres import ( "context" "github.com/jackc/pgx/v5/pgxpool" ) type Store interface { Querier } type SQLStore struct { connPool *pgxpool.Pool *Queries } func NewStore(connPool *pgxpool.Pool) Store { return &SQLStore{ connPool: connPool, Queries: New(connPool), } } func (store *SQLStore) WithTx(db DBTX) Store { return &SQLStore{ Queries: New(db), } } // ExecTx executes a function within a database transaction func (store *SQLStore) ExecTx(ctx context.Context, fn func(*Queries) error) error { return fn(store.Queries) } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/gen/subscription_billing.sql.go ================================================ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.26.0 // source: subscription_billing.sql package postgres import ( "context" "github.com/jackc/pgx/v5/pgtype" ) const decrementInvoiceCount = `-- name: DecrementInvoiceCount :one UPDATE subscription_billing.quota_tracking SET invoice_count = invoice_count - 1, updated_at = CURRENT_TIMESTAMP WHERE organization_id = $1 RETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count ` // Decrement invoice count by 1 (called after successful invoice processing) func (q *Queries) DecrementInvoiceCount(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) { row := q.db.QueryRow(ctx, decrementInvoiceCount, organizationID) var i SubscriptionBillingQuotaTracking err := row.Scan( &i.ID, &i.OrganizationID, &i.MaxSeats, &i.PeriodStart, &i.PeriodEnd, &i.LastSyncedAt, &i.CreatedAt, &i.UpdatedAt, &i.InvoiceCount, ) return i, err } const deleteSubscription = `-- name: DeleteSubscription :exec DELETE FROM subscription_billing.subscriptions WHERE organization_id = $1 ` // Delete subscription (when subscription is permanently deleted) func (q *Queries) DeleteSubscription(ctx context.Context, organizationID int32) error { _, err := q.db.Exec(ctx, deleteSubscription, organizationID) return err } const getQuotaByOrgID = `-- name: GetQuotaByOrgID :one SELECT id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count FROM subscription_billing.quota_tracking WHERE organization_id = $1 LIMIT 1 ` // Get quota tracking for an organization func (q *Queries) GetQuotaByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) { row := q.db.QueryRow(ctx, getQuotaByOrgID, organizationID) var i SubscriptionBillingQuotaTracking err := row.Scan( &i.ID, &i.OrganizationID, &i.MaxSeats, &i.PeriodStart, &i.PeriodEnd, &i.LastSyncedAt, &i.CreatedAt, &i.UpdatedAt, &i.InvoiceCount, ) return i, err } const getQuotaStatus = `-- name: GetQuotaStatus :one SELECT s.subscription_status, s.current_period_start, s.current_period_end, s.cancel_at_period_end, q.invoice_count, q.max_seats, CASE WHEN s.subscription_status = 'active' AND q.invoice_count > 0 THEN TRUE ELSE FALSE END AS can_process_invoice FROM subscription_billing.subscriptions s INNER JOIN subscription_billing.quota_tracking q ON s.organization_id = q.organization_id WHERE s.organization_id = $1 LIMIT 1 ` type GetQuotaStatusRow struct { SubscriptionStatus string `json:"subscription_status"` CurrentPeriodStart pgtype.Timestamp `json:"current_period_start"` CurrentPeriodEnd pgtype.Timestamp `json:"current_period_end"` CancelAtPeriodEnd pgtype.Bool `json:"cancel_at_period_end"` InvoiceCount int32 `json:"invoice_count"` MaxSeats pgtype.Int4 `json:"max_seats"` CanProcessInvoice bool `json:"can_process_invoice"` } // Get combined subscription and quota status for fast quota checks func (q *Queries) GetQuotaStatus(ctx context.Context, organizationID int32) (GetQuotaStatusRow, error) { row := q.db.QueryRow(ctx, getQuotaStatus, organizationID) var i GetQuotaStatusRow err := row.Scan( &i.SubscriptionStatus, &i.CurrentPeriodStart, &i.CurrentPeriodEnd, &i.CancelAtPeriodEnd, &i.InvoiceCount, &i.MaxSeats, &i.CanProcessInvoice, ) return i, err } const getSubscriptionByOrgID = `-- name: GetSubscriptionByOrgID :one SELECT id, organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at, metadata FROM subscription_billing.subscriptions WHERE organization_id = $1 LIMIT 1 ` // Get subscription details for an organization func (q *Queries) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingSubscription, error) { row := q.db.QueryRow(ctx, getSubscriptionByOrgID, organizationID) var i SubscriptionBillingSubscription err := row.Scan( &i.ID, &i.OrganizationID, &i.ExternalCustomerID, &i.SubscriptionID, &i.SubscriptionStatus, &i.ProductID, &i.ProductName, &i.PlanName, &i.CurrentPeriodStart, &i.CurrentPeriodEnd, &i.CancelAtPeriodEnd, &i.CanceledAt, &i.CreatedAt, &i.UpdatedAt, &i.Metadata, ) return i, err } const getSubscriptionBySubscriptionID = `-- name: GetSubscriptionBySubscriptionID :one SELECT id, organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at, metadata FROM subscription_billing.subscriptions WHERE subscription_id = $1 LIMIT 1 ` // Get subscription by Polar subscription ID func (q *Queries) GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (SubscriptionBillingSubscription, error) { row := q.db.QueryRow(ctx, getSubscriptionBySubscriptionID, subscriptionID) var i SubscriptionBillingSubscription err := row.Scan( &i.ID, &i.OrganizationID, &i.ExternalCustomerID, &i.SubscriptionID, &i.SubscriptionStatus, &i.ProductID, &i.ProductName, &i.PlanName, &i.CurrentPeriodStart, &i.CurrentPeriodEnd, &i.CancelAtPeriodEnd, &i.CanceledAt, &i.CreatedAt, &i.UpdatedAt, &i.Metadata, ) return i, err } const listActiveSubscriptions = `-- name: ListActiveSubscriptions :many SELECT id, organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at, metadata FROM subscription_billing.subscriptions WHERE subscription_status = 'active' ORDER BY created_at DESC ` // List all active subscriptions for monitoring/admin purposes func (q *Queries) ListActiveSubscriptions(ctx context.Context) ([]SubscriptionBillingSubscription, error) { rows, err := q.db.Query(ctx, listActiveSubscriptions) if err != nil { return nil, err } defer rows.Close() items := []SubscriptionBillingSubscription{} for rows.Next() { var i SubscriptionBillingSubscription if err := rows.Scan( &i.ID, &i.OrganizationID, &i.ExternalCustomerID, &i.SubscriptionID, &i.SubscriptionStatus, &i.ProductID, &i.ProductName, &i.PlanName, &i.CurrentPeriodStart, &i.CurrentPeriodEnd, &i.CancelAtPeriodEnd, &i.CanceledAt, &i.CreatedAt, &i.UpdatedAt, &i.Metadata, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listQuotasNearLimit = `-- name: ListQuotasNearLimit :many SELECT q.id, q.organization_id, q.max_seats, q.period_start, q.period_end, q.last_synced_at, q.created_at, q.updated_at, q.invoice_count, s.subscription_status, s.product_name FROM subscription_billing.quota_tracking q INNER JOIN subscription_billing.subscriptions s ON q.organization_id = s.organization_id WHERE s.subscription_status = 'active' AND q.invoice_count <= $1 ORDER BY q.invoice_count ASC ` type ListQuotasNearLimitRow struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` MaxSeats pgtype.Int4 `json:"max_seats"` PeriodStart pgtype.Timestamp `json:"period_start"` PeriodEnd pgtype.Timestamp `json:"period_end"` LastSyncedAt pgtype.Timestamp `json:"last_synced_at"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` InvoiceCount int32 `json:"invoice_count"` SubscriptionStatus string `json:"subscription_status"` ProductName pgtype.Text `json:"product_name"` } // List organizations approaching their quota limit (for alerting) func (q *Queries) ListQuotasNearLimit(ctx context.Context, invoiceCount int32) ([]ListQuotasNearLimitRow, error) { rows, err := q.db.Query(ctx, listQuotasNearLimit, invoiceCount) if err != nil { return nil, err } defer rows.Close() items := []ListQuotasNearLimitRow{} for rows.Next() { var i ListQuotasNearLimitRow if err := rows.Scan( &i.ID, &i.OrganizationID, &i.MaxSeats, &i.PeriodStart, &i.PeriodEnd, &i.LastSyncedAt, &i.CreatedAt, &i.UpdatedAt, &i.InvoiceCount, &i.SubscriptionStatus, &i.ProductName, ); err != nil { return nil, err } items = append(items, i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const resetQuotaForPeriod = `-- name: ResetQuotaForPeriod :one UPDATE subscription_billing.quota_tracking SET invoice_count = $2, period_start = $3, period_end = $4, updated_at = CURRENT_TIMESTAMP WHERE organization_id = $1 RETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count ` type ResetQuotaForPeriodParams struct { OrganizationID int32 `json:"organization_id"` InvoiceCount int32 `json:"invoice_count"` PeriodStart pgtype.Timestamp `json:"period_start"` PeriodEnd pgtype.Timestamp `json:"period_end"` } // Reset quota counters for a new billing period func (q *Queries) ResetQuotaForPeriod(ctx context.Context, arg ResetQuotaForPeriodParams) (SubscriptionBillingQuotaTracking, error) { row := q.db.QueryRow(ctx, resetQuotaForPeriod, arg.OrganizationID, arg.InvoiceCount, arg.PeriodStart, arg.PeriodEnd, ) var i SubscriptionBillingQuotaTracking err := row.Scan( &i.ID, &i.OrganizationID, &i.MaxSeats, &i.PeriodStart, &i.PeriodEnd, &i.LastSyncedAt, &i.CreatedAt, &i.UpdatedAt, &i.InvoiceCount, ) return i, err } const upsertQuota = `-- name: UpsertQuota :one INSERT INTO subscription_billing.quota_tracking ( organization_id, invoice_count, max_seats, period_start, period_end, last_synced_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT (organization_id) DO UPDATE SET invoice_count = EXCLUDED.invoice_count, max_seats = EXCLUDED.max_seats, period_start = EXCLUDED.period_start, period_end = EXCLUDED.period_end, last_synced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP RETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count ` type UpsertQuotaParams struct { OrganizationID int32 `json:"organization_id"` InvoiceCount int32 `json:"invoice_count"` MaxSeats pgtype.Int4 `json:"max_seats"` PeriodStart pgtype.Timestamp `json:"period_start"` PeriodEnd pgtype.Timestamp `json:"period_end"` } // Create or update quota tracking func (q *Queries) UpsertQuota(ctx context.Context, arg UpsertQuotaParams) (SubscriptionBillingQuotaTracking, error) { row := q.db.QueryRow(ctx, upsertQuota, arg.OrganizationID, arg.InvoiceCount, arg.MaxSeats, arg.PeriodStart, arg.PeriodEnd, ) var i SubscriptionBillingQuotaTracking err := row.Scan( &i.ID, &i.OrganizationID, &i.MaxSeats, &i.PeriodStart, &i.PeriodEnd, &i.LastSyncedAt, &i.CreatedAt, &i.UpdatedAt, &i.InvoiceCount, ) return i, err } const upsertSubscription = `-- name: UpsertSubscription :one INSERT INTO subscription_billing.subscriptions ( organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, metadata, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP ) ON CONFLICT (organization_id) DO UPDATE SET external_customer_id = EXCLUDED.external_customer_id, subscription_id = EXCLUDED.subscription_id, subscription_status = EXCLUDED.subscription_status, product_id = EXCLUDED.product_id, product_name = EXCLUDED.product_name, plan_name = EXCLUDED.plan_name, current_period_start = EXCLUDED.current_period_start, current_period_end = EXCLUDED.current_period_end, cancel_at_period_end = EXCLUDED.cancel_at_period_end, canceled_at = EXCLUDED.canceled_at, metadata = EXCLUDED.metadata, updated_at = CURRENT_TIMESTAMP RETURNING id, organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at, metadata ` type UpsertSubscriptionParams struct { OrganizationID int32 `json:"organization_id"` ExternalCustomerID string `json:"external_customer_id"` SubscriptionID string `json:"subscription_id"` SubscriptionStatus string `json:"subscription_status"` ProductID string `json:"product_id"` ProductName pgtype.Text `json:"product_name"` PlanName pgtype.Text `json:"plan_name"` CurrentPeriodStart pgtype.Timestamp `json:"current_period_start"` CurrentPeriodEnd pgtype.Timestamp `json:"current_period_end"` CancelAtPeriodEnd pgtype.Bool `json:"cancel_at_period_end"` CanceledAt pgtype.Timestamp `json:"canceled_at"` Metadata []byte `json:"metadata"` } // Create or update subscription from Polar webhook func (q *Queries) UpsertSubscription(ctx context.Context, arg UpsertSubscriptionParams) (SubscriptionBillingSubscription, error) { row := q.db.QueryRow(ctx, upsertSubscription, arg.OrganizationID, arg.ExternalCustomerID, arg.SubscriptionID, arg.SubscriptionStatus, arg.ProductID, arg.ProductName, arg.PlanName, arg.CurrentPeriodStart, arg.CurrentPeriodEnd, arg.CancelAtPeriodEnd, arg.CanceledAt, arg.Metadata, ) var i SubscriptionBillingSubscription err := row.Scan( &i.ID, &i.OrganizationID, &i.ExternalCustomerID, &i.SubscriptionID, &i.SubscriptionStatus, &i.ProductID, &i.ProductName, &i.PlanName, &i.CurrentPeriodStart, &i.CurrentPeriodEnd, &i.CancelAtPeriodEnd, &i.CanceledAt, &i.CreatedAt, &i.UpdatedAt, &i.Metadata, ) return i, err } ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000001_create_file_manager_schema.down.sql ================================================ BEGIN; -- Drop indexes DROP INDEX IF EXISTS file_manager.idx_file_assets_entity; DROP INDEX IF EXISTS file_manager.idx_file_assets_category; DROP INDEX IF EXISTS file_manager.idx_file_assets_context; DROP INDEX IF EXISTS file_manager.idx_file_assets_created_at; -- Drop tables in reverse order DROP TABLE IF EXISTS file_manager.file_assets; DROP TABLE IF EXISTS file_manager.file_contexts; DROP TABLE IF EXISTS file_manager.file_categories; -- Drop schema DROP SCHEMA IF EXISTS file_manager CASCADE; COMMIT; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000001_create_file_manager_schema.up.sql ================================================ CREATE SCHEMA IF NOT EXISTS file_manager; -- Create file categories table (similar to asset_types) CREATE TABLE file_manager.file_categories ( id SMALLINT PRIMARY KEY, name VARCHAR(50) UNIQUE NOT NULL, -- 'document', 'image', 'archive' max_size_bytes BIGINT NOT NULL ); -- Create file contexts table CREATE TABLE file_manager.file_contexts ( id SMALLINT PRIMARY KEY, name VARCHAR(50) UNIQUE NOT NULL -- 'invoice', 'receipt', 'contract', etc. ); -- Create file_assets table (following assets pattern) CREATE TABLE file_manager.file_assets ( id SERIAL PRIMARY KEY, file_name VARCHAR(255) NOT NULL, original_file_name VARCHAR(255) NOT NULL, storage_path VARCHAR(1000) NOT NULL, bucket_name VARCHAR(50) NOT NULL, file_size BIGINT NOT NULL CHECK (file_size > 0), mime_type VARCHAR(100) NOT NULL, file_category_id SMALLINT NOT NULL REFERENCES file_manager.file_categories(id), file_context_id SMALLINT NOT NULL REFERENCES file_manager.file_contexts(id), is_public BOOLEAN DEFAULT false, entity_type VARCHAR(50), -- 'user', 'invoice', 'contract', etc. entity_id INTEGER, -- The ID of the related entity purpose VARCHAR(100), -- Additional purpose description metadata JSONB DEFAULT '{}', created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Create indexes following the same pattern CREATE INDEX idx_file_assets_entity ON file_manager.file_assets(entity_type, entity_id); CREATE INDEX idx_file_assets_category ON file_manager.file_assets(file_category_id); CREATE INDEX idx_file_assets_context ON file_manager.file_assets(file_context_id); CREATE INDEX idx_file_assets_created_at ON file_manager.file_assets(created_at DESC); -- Insert default categories INSERT INTO file_manager.file_categories (id, name, max_size_bytes) VALUES (1, 'document', 52428800), -- 50MB (2, 'image', 10485760), -- 10MB (3, 'archive', 104857600); -- 100MB -- Insert default contexts INSERT INTO file_manager.file_contexts (id, name) VALUES (1, 'invoice'), (2, 'receipt'), (3, 'contract'), (4, 'report'), (5, 'profile'), (6, 'general'); ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000002_create_organizations_schema.down.sql ================================================ -- Drop triggers DROP TRIGGER IF EXISTS trigger_accounts_updated_at ON organizations.accounts; DROP TRIGGER IF EXISTS trigger_organizations_updated_at ON organizations.organizations; -- Drop indexes (will be dropped automatically with tables, but being explicit) DROP INDEX IF EXISTS organizations.idx_accounts_role; DROP INDEX IF EXISTS organizations.idx_accounts_status; DROP INDEX IF EXISTS organizations.idx_accounts_email; DROP INDEX IF EXISTS organizations.idx_accounts_org_id; DROP INDEX IF EXISTS organizations.idx_accounts_stytch_member_id; DROP INDEX IF EXISTS organizations.idx_organizations_created_at; DROP INDEX IF EXISTS organizations.idx_organizations_status; DROP INDEX IF EXISTS organizations.idx_organizations_slug; DROP INDEX IF EXISTS organizations.idx_organizations_stytch_org_id; -- Drop tables in dependency order DROP TABLE IF EXISTS organizations.accounts; DROP TABLE IF EXISTS organizations.organizations; -- Drop the schema DROP SCHEMA IF EXISTS organizations CASCADE; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000002_create_organizations_schema.up.sql ================================================ -- Create organizations schema CREATE SCHEMA IF NOT EXISTS organizations; -- Organizations table (top-level tenant) CREATE TABLE organizations.organizations ( id SERIAL PRIMARY KEY, slug VARCHAR(100) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, status VARCHAR(50) DEFAULT 'active' NOT NULL, -- Stytch linkage stytch_org_id VARCHAR(100) UNIQUE, stytch_connection_id VARCHAR(100), stytch_connection_name VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT chk_organizations_status CHECK (status IN ('active', 'suspended', 'cancelled')), CONSTRAINT chk_organizations_slug CHECK (slug ~ '^[a-z0-9-]+$' AND LENGTH(slug) >= 3) ); -- Accounts table (users within organizations) CREATE TABLE organizations.accounts ( id SERIAL PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, -- Account info email VARCHAR(255) NOT NULL, full_name VARCHAR(255) NOT NULL, stytch_member_id VARCHAR(100), stytch_role_id VARCHAR(100), stytch_role_slug VARCHAR(100), stytch_email_verified BOOLEAN DEFAULT FALSE NOT NULL, -- Role and status (legacy field retained for business logic) role VARCHAR(50) DEFAULT 'member' NOT NULL, status VARCHAR(50) DEFAULT 'active' NOT NULL, -- Activity tracking last_login_at TIMESTAMP, -- Timestamps created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, -- Unique constraints UNIQUE(organization_id, email), UNIQUE(organization_id, stytch_member_id), -- Check constraints CONSTRAINT chk_accounts_role CHECK (role IN ('owner', 'admin', 'member', 'reviewer', 'employee')), CONSTRAINT chk_accounts_status CHECK (status IN ('active', 'inactive', 'suspended')), CONSTRAINT chk_accounts_email CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') ); -- Indexes for performance CREATE INDEX idx_organizations_slug ON organizations.organizations(slug) WHERE status = 'active'; CREATE INDEX idx_organizations_status ON organizations.organizations(status); CREATE INDEX idx_organizations_created_at ON organizations.organizations(created_at DESC); CREATE UNIQUE INDEX idx_organizations_stytch_org_id ON organizations.organizations(stytch_org_id); CREATE INDEX idx_accounts_org_id ON organizations.accounts(organization_id); CREATE INDEX idx_accounts_email ON organizations.accounts(email); CREATE INDEX idx_accounts_status ON organizations.accounts(status); CREATE INDEX idx_accounts_role ON organizations.accounts(role); CREATE INDEX idx_accounts_stytch_member_id ON organizations.accounts(stytch_member_id); -- Updated at trigger function (reuse existing function if available) CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; -- Triggers to automatically update updated_at CREATE TRIGGER trigger_organizations_updated_at BEFORE UPDATE ON organizations.organizations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER trigger_accounts_updated_at BEFORE UPDATE ON organizations.accounts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Comments for documentation COMMENT ON SCHEMA organizations IS 'Schema for organization and account management'; COMMENT ON TABLE organizations.organizations IS 'Organizations (tenants) in the system'; COMMENT ON TABLE organizations.accounts IS 'User accounts within organizations'; COMMENT ON COLUMN organizations.organizations.slug IS 'URL-friendly unique identifier for organization'; COMMENT ON COLUMN organizations.organizations.stytch_org_id IS 'Stytch organization identifier (org_xxx)'; COMMENT ON COLUMN organizations.organizations.stytch_connection_id IS 'Optional Stytch connection or project identifier associated with the organization'; COMMENT ON COLUMN organizations.organizations.stytch_connection_name IS 'Optional Stytch connection name associated with the organization'; COMMENT ON COLUMN organizations.accounts.stytch_member_id IS 'Stytch member identifier (member_xxx)'; COMMENT ON COLUMN organizations.accounts.stytch_role_id IS 'Stytch role identifier assigned to the member'; COMMENT ON COLUMN organizations.accounts.stytch_role_slug IS 'Human-readable Stytch role slug assigned to the member'; COMMENT ON COLUMN organizations.accounts.stytch_email_verified IS 'Whether Stytch reports the member email as verified'; COMMENT ON COLUMN organizations.accounts.role IS 'Last known role for business logic (e.g., owner, reviewer, employee)'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000003_enforce_role_enum.down.sql ================================================ -- Rollback: Remove RBAC roles enum constraint -- Drop the check constraint that enforces valid role values ALTER TABLE organizations.accounts DROP CONSTRAINT valid_role_enum; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000003_enforce_role_enum.up.sql ================================================ -- Enforce RBAC roles enum according to PERMISSIONS.md specification -- Valid roles: member, approver, admin (plus legacy roles for backward compatibility) -- Add check constraint to organizations.accounts.role column -- Ensures only valid role values are stored in the database ALTER TABLE organizations.accounts ADD CONSTRAINT valid_role_enum CHECK ( role IN ('member', 'approver', 'admin', 'owner', 'reviewer', 'employee') ); -- Add comment documenting the role enum constraint COMMENT ON CONSTRAINT valid_role_enum ON organizations.accounts IS 'RBAC Role Enum Constraint per PERMISSIONS.md: - member: Process invoices day-to-day (Member role) - approver: Review and approve invoices assigned to them (Approver role) - admin: Full system control and management (Admin role) Legacy roles (for backward compatibility during migration): - owner: Legacy mapping to admin role - reviewer: Legacy mapping to approver role - employee: Legacy mapping to member role All new role assignments MUST use the three core roles: member, approver, admin'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.down.sql ================================================ -- Drop subscription billing schema and all its tables DROP TABLE IF EXISTS subscription_billing.quota_tracking CASCADE; DROP TABLE IF EXISTS subscription_billing.subscriptions CASCADE; DROP SCHEMA IF EXISTS subscription_billing CASCADE; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.up.sql ================================================ -- Create subscription_billing schema for local subscription and quota tracking CREATE SCHEMA IF NOT EXISTS subscription_billing; -- Subscriptions table: Stores Polar subscription data locally for fast access CREATE TABLE subscription_billing.subscriptions ( id SERIAL PRIMARY KEY, organization_id INT NOT NULL UNIQUE REFERENCES organizations.organizations(id) ON DELETE CASCADE, -- Polar identifiers external_customer_id VARCHAR(100) NOT NULL, -- Polar customer ID subscription_id VARCHAR(100) NOT NULL UNIQUE, -- Polar subscription ID -- Subscription details subscription_status VARCHAR(50) NOT NULL, -- active, canceled, past_due, etc. product_id VARCHAR(100) NOT NULL, -- Polar product ID product_name VARCHAR(255), -- Product display name plan_name VARCHAR(100), -- From subscription metadata -- Billing period current_period_start TIMESTAMP NOT NULL, current_period_end TIMESTAMP NOT NULL, -- Cancellation details cancel_at_period_end BOOLEAN DEFAULT FALSE, canceled_at TIMESTAMP, -- Audit timestamps created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Metadata from Polar (stored as JSONB for flexibility) metadata JSONB DEFAULT '{}'::jsonb ); -- Quota tracking table: Tracks usage quotas per organization CREATE TABLE subscription_billing.quota_tracking ( id SERIAL PRIMARY KEY, organization_id INT NOT NULL UNIQUE REFERENCES organizations.organizations(id) ON DELETE CASCADE, -- Invoice quota (main quota we're tracking) invoice_count_current INT DEFAULT 0, -- Current usage in period invoice_count_max INT NOT NULL, -- Maximum allowed in period -- Additional quotas from product metadata max_seats INT, -- Maximum seats allowed -- Quota period (should match subscription period) period_start TIMESTAMP NOT NULL, period_end TIMESTAMP NOT NULL, -- Sync tracking last_synced_at TIMESTAMP, -- Last time synced with Polar -- Audit timestamps created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Indexes for fast lookups CREATE INDEX idx_subscriptions_organization_id ON subscription_billing.subscriptions(organization_id); CREATE INDEX idx_subscriptions_subscription_status ON subscription_billing.subscriptions(subscription_status); CREATE INDEX idx_subscriptions_external_customer_id ON subscription_billing.subscriptions(external_customer_id); CREATE INDEX idx_quota_tracking_organization_id ON subscription_billing.quota_tracking(organization_id); CREATE INDEX idx_quota_tracking_period_end ON subscription_billing.quota_tracking(period_end); -- Comments for documentation COMMENT ON SCHEMA subscription_billing IS 'Local cache of Polar subscription and quota data for fast access'; COMMENT ON TABLE subscription_billing.subscriptions IS 'Stores subscription details from Polar, synced via webhooks'; COMMENT ON TABLE subscription_billing.quota_tracking IS 'Tracks usage quotas per organization for fast quota checks'; COMMENT ON COLUMN subscription_billing.subscriptions.external_customer_id IS 'Polar customer ID (maps to organization via stytch_org_id)'; COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_current IS 'Current invoice count in billing period'; COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_max IS 'Maximum invoices allowed from product metadata'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.down.sql ================================================ -- Rollback quota_tracking schema changes -- Restore invoice_count_max and invoice_count_current -- Step 1: Add back old columns ALTER TABLE subscription_billing.quota_tracking ADD COLUMN invoice_count_current INT DEFAULT 0, ADD COLUMN invoice_count_max INT; -- Step 2: Migrate data back (this is a best-effort rollback, data may be lost) -- We can't perfectly restore the original split, so we set current=0 and max=invoice_count UPDATE subscription_billing.quota_tracking SET invoice_count_current = 0, invoice_count_max = invoice_count; -- Step 3: Make columns NOT NULL ALTER TABLE subscription_billing.quota_tracking ALTER COLUMN invoice_count_current SET NOT NULL, ALTER COLUMN invoice_count_max SET NOT NULL; -- Step 4: Drop new column ALTER TABLE subscription_billing.quota_tracking DROP COLUMN invoice_count; -- Restore comments COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_current IS 'Current invoice count in billing period'; COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_max IS 'Maximum invoices allowed from product metadata'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.up.sql ================================================ -- Update quota_tracking schema to use count-down system -- Remove invoice_count_max, rename invoice_count_current to invoice_count (remaining invoices) -- Step 1: Add new invoice_count column (temporary) ALTER TABLE subscription_billing.quota_tracking ADD COLUMN invoice_count INT; -- Step 2: Migrate data: invoice_count = invoice_count_max - invoice_count_current -- For existing rows, calculate remaining invoices UPDATE subscription_billing.quota_tracking SET invoice_count = GREATEST(invoice_count_max - invoice_count_current, 0); -- Step 3: Make invoice_count NOT NULL with default 0 ALTER TABLE subscription_billing.quota_tracking ALTER COLUMN invoice_count SET NOT NULL, ALTER COLUMN invoice_count SET DEFAULT 0; -- Step 4: Drop old columns ALTER TABLE subscription_billing.quota_tracking DROP COLUMN invoice_count_current, DROP COLUMN invoice_count_max; -- Update comment COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count IS 'Remaining invoices in current billing period (decremented on use)'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000006_create_example_resources.down.sql ================================================ -- Rollback example_resources table -- Drop trigger first DROP TRIGGER IF EXISTS trigger_update_example_resources_updated_at ON example_resources; DROP FUNCTION IF EXISTS update_example_resources_updated_at(); -- Drop indexes DROP INDEX IF EXISTS idx_example_resources_search; DROP INDEX IF EXISTS idx_example_resources_active; DROP INDEX IF EXISTS idx_example_resources_created_at; DROP INDEX IF EXISTS idx_example_resources_approval_assigned; DROP INDEX IF EXISTS idx_example_resources_file; DROP INDEX IF EXISTS idx_example_resources_created_by; DROP INDEX IF EXISTS idx_example_resources_status; DROP INDEX IF EXISTS idx_example_resources_organization; -- Drop table DROP TABLE IF EXISTS example_resources; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000006_create_example_resources.up.sql ================================================ -- Create example_resources table -- This table demonstrates the full architecture pattern with: -- - File attachments -- - OCR/LLM processing -- - Multi-status workflow -- - RBAC integration -- - Multi-tenancy -- - Approval workflow -- - Audit tracking CREATE TABLE IF NOT EXISTS example_resources ( id SERIAL PRIMARY KEY, resource_number VARCHAR(100) UNIQUE NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, -- Status workflow status_id SMALLINT NOT NULL DEFAULT 1, -- File attachment file_id INTEGER REFERENCES file_manager.file_assets(id) ON DELETE SET NULL, -- AI Processing results extracted_data JSONB DEFAULT '{}', -- OCR output processed_data JSONB DEFAULT '{}', -- LLM structured output confidence DECIMAL(5,4), -- AI confidence score (0.0000 to 1.0000) -- Multi-tenancy (required) organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, created_by_account_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL, -- Approval workflow approval_status VARCHAR(50) DEFAULT 'pending', approval_assigned_to_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL, approval_action_taker_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL, approval_notes TEXT, -- Additional metadata metadata JSONB DEFAULT '{}', -- Audit fields is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); -- Indexes for performance CREATE INDEX idx_example_resources_organization ON example_resources(organization_id); CREATE INDEX idx_example_resources_status ON example_resources(status_id); CREATE INDEX idx_example_resources_created_by ON example_resources(created_by_account_id); CREATE INDEX idx_example_resources_file ON example_resources(file_id); CREATE INDEX idx_example_resources_approval_assigned ON example_resources(approval_assigned_to_id); CREATE INDEX idx_example_resources_created_at ON example_resources(created_at DESC); CREATE INDEX idx_example_resources_active ON example_resources(is_active); -- Full text search on title and description CREATE INDEX idx_example_resources_search ON example_resources USING gin(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, ''))); -- Trigger to automatically update updated_at timestamp CREATE OR REPLACE FUNCTION update_example_resources_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_update_example_resources_updated_at BEFORE UPDATE ON example_resources FOR EACH ROW EXECUTE FUNCTION update_example_resources_updated_at(); -- Comments for documentation COMMENT ON TABLE example_resources IS 'Example module demonstrating Clean Architecture patterns with file uploads, OCR/LLM processing, RBAC, approval workflows, and multi-tenancy'; COMMENT ON COLUMN example_resources.extracted_data IS 'Raw OCR-extracted text and metadata'; COMMENT ON COLUMN example_resources.processed_data IS 'LLM-processed structured data'; COMMENT ON COLUMN example_resources.confidence IS 'AI confidence score between 0 and 1'; COMMENT ON COLUMN example_resources.approval_status IS 'Workflow status: pending, approved, rejected'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000007_create_resource_embeddings.down.sql ================================================ -- Drop triggers DROP TRIGGER IF EXISTS duplicate_candidates_updated_at ON duplicate_candidates; DROP TRIGGER IF EXISTS resource_embeddings_updated_at ON resource_embeddings; -- Drop trigger functions DROP FUNCTION IF EXISTS update_duplicate_candidates_updated_at(); DROP FUNCTION IF EXISTS update_resource_embeddings_updated_at(); -- Drop tables (cascade to remove dependent objects) DROP TABLE IF EXISTS duplicate_candidates CASCADE; DROP TABLE IF EXISTS resource_embeddings CASCADE; -- Note: We don't drop the vector extension as it might be used by other tables -- If you want to remove it completely, uncomment the line below: -- DROP EXTENSION IF EXISTS vector CASCADE; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000007_create_resource_embeddings.up.sql ================================================ -- Enable pgvector extension for vector similarity search CREATE EXTENSION IF NOT EXISTS vector; -- Resource embeddings table -- Stores vector embeddings generated from resource text content for semantic similarity search CREATE TABLE resource_embeddings ( id SERIAL PRIMARY KEY, resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE, embedding vector(1536) NOT NULL, -- OpenAI text-embedding-3-small dimension is 1536 organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, content_hash VARCHAR(64), -- SHA-256 hash for exact duplicate detection content_preview TEXT, -- First 500 chars of content for debugging created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE(resource_id, organization_id) -- One embedding per resource per organization ); -- Create ivfflat index for fast vector similarity search using cosine distance -- ivfflat divides vectors into lists for approximate nearest neighbor search -- lists=100 is a good starting point (adjust based on dataset size) CREATE INDEX idx_resource_embeddings_vector ON resource_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); -- Regular indexes for lookups CREATE INDEX idx_resource_embeddings_organization ON resource_embeddings(organization_id); CREATE INDEX idx_resource_embeddings_content_hash ON resource_embeddings(content_hash); CREATE INDEX idx_resource_embeddings_resource ON resource_embeddings(resource_id); -- Duplicate candidates table -- Stores potential duplicates found through vector similarity search and LLM adjudication CREATE TABLE duplicate_candidates ( id SERIAL PRIMARY KEY, resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE, candidate_resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE, similarity_score DECIMAL(5,4) NOT NULL, -- Vector cosine similarity score (0.0000 to 1.0000) detection_method VARCHAR(50) NOT NULL, -- 'exact_match' or 'llm_adjudicated' confidence_level VARCHAR(50), -- 'very_high', 'high', 'medium', 'low' (from LLM) llm_reason TEXT, -- LLM's explanation for duplicate decision llm_similar_fields JSONB, -- Fields identified as similar by LLM llm_response JSONB, -- Full LLM response for audit trail organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, status VARCHAR(50) DEFAULT 'pending', -- 'pending', 'confirmed', 'dismissed' created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), CHECK (resource_id != candidate_resource_id) -- Prevent self-duplicates ); -- Indexes for duplicate candidates CREATE INDEX idx_duplicate_candidates_resource ON duplicate_candidates(resource_id); CREATE INDEX idx_duplicate_candidates_candidate ON duplicate_candidates(candidate_resource_id); CREATE INDEX idx_duplicate_candidates_organization ON duplicate_candidates(organization_id); CREATE INDEX idx_duplicate_candidates_status ON duplicate_candidates(status); CREATE INDEX idx_duplicate_candidates_method ON duplicate_candidates(detection_method); -- Composite index for common query pattern: find all duplicates for a resource CREATE INDEX idx_duplicate_candidates_resource_org ON duplicate_candidates(resource_id, organization_id); -- Auto-update trigger for updated_at CREATE OR REPLACE FUNCTION update_resource_embeddings_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER resource_embeddings_updated_at BEFORE UPDATE ON resource_embeddings FOR EACH ROW EXECUTE FUNCTION update_resource_embeddings_updated_at(); CREATE OR REPLACE FUNCTION update_duplicate_candidates_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER duplicate_candidates_updated_at BEFORE UPDATE ON duplicate_candidates FOR EACH ROW EXECUTE FUNCTION update_duplicate_candidates_updated_at(); -- Comments for documentation COMMENT ON TABLE resource_embeddings IS 'Stores vector embeddings for resources using OpenAI text-embedding-3-small (1536 dimensions)'; COMMENT ON COLUMN resource_embeddings.embedding IS 'Vector embedding for semantic similarity search (1536 dimensions from OpenAI)'; COMMENT ON COLUMN resource_embeddings.content_hash IS 'SHA-256 hash of normalized content for exact duplicate detection'; COMMENT ON INDEX idx_resource_embeddings_vector IS 'IVFFlat index for fast approximate nearest neighbor search using cosine distance'; COMMENT ON TABLE duplicate_candidates IS 'Stores potential duplicate resources found via vector similarity and LLM adjudication'; COMMENT ON COLUMN duplicate_candidates.similarity_score IS 'Cosine similarity score from pgvector (0.0000 = completely different, 1.0000 = identical)'; COMMENT ON COLUMN duplicate_candidates.detection_method IS 'How the duplicate was detected: exact_match (similarity >= 0.95) or llm_adjudicated (similarity >= 0.85, confirmed by LLM)'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000008_create_documents_schema.down.sql ================================================ -- Drop documents schema DROP TRIGGER IF EXISTS documents_updated_at ON documents.documents; DROP FUNCTION IF EXISTS documents.update_documents_updated_at(); DROP TABLE IF EXISTS documents.documents; DROP SCHEMA IF EXISTS documents; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000008_create_documents_schema.up.sql ================================================ -- Documents schema for PDF upload and text extraction CREATE SCHEMA IF NOT EXISTS documents; -- Documents table CREATE TABLE documents.documents ( id SERIAL PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, file_asset_id INTEGER NOT NULL REFERENCES file_manager.file_assets(id) ON DELETE CASCADE, title VARCHAR(500) NOT NULL, file_name VARCHAR(500) NOT NULL, content_type VARCHAR(100) NOT NULL, file_size BIGINT NOT NULL, extracted_text TEXT, status VARCHAR(50) NOT NULL DEFAULT 'pending', metadata JSONB DEFAULT '{}', created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT valid_status CHECK (status IN ('pending', 'processing', 'processed', 'failed')) ); -- Indexes CREATE INDEX idx_documents_organization ON documents.documents(organization_id); CREATE INDEX idx_documents_file_asset ON documents.documents(file_asset_id); CREATE INDEX idx_documents_status ON documents.documents(status); CREATE INDEX idx_documents_created_at ON documents.documents(created_at DESC); -- Auto-update trigger for updated_at CREATE OR REPLACE FUNCTION documents.update_documents_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER documents_updated_at BEFORE UPDATE ON documents.documents FOR EACH ROW EXECUTE FUNCTION documents.update_documents_updated_at(); -- Comments for documentation COMMENT ON TABLE documents.documents IS 'Stores uploaded documents (PDFs) with extracted text for RAG'; COMMENT ON COLUMN documents.documents.extracted_text IS 'Text extracted from PDF using OCR or direct parsing'; COMMENT ON COLUMN documents.documents.status IS 'Processing status: pending, processing, processed, failed'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000009_create_cognitive_schema.down.sql ================================================ -- Drop cognitive schema DROP TRIGGER IF EXISTS chat_sessions_updated_at ON cognitive.chat_sessions; DROP TRIGGER IF EXISTS doc_embeddings_updated_at ON cognitive.document_embeddings; DROP FUNCTION IF EXISTS cognitive.update_sessions_updated_at(); DROP FUNCTION IF EXISTS cognitive.update_embeddings_updated_at(); DROP TABLE IF EXISTS cognitive.chat_messages; DROP TABLE IF EXISTS cognitive.chat_sessions; DROP TABLE IF EXISTS cognitive.document_embeddings; DROP SCHEMA IF EXISTS cognitive; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/migrations/000009_create_cognitive_schema.up.sql ================================================ -- Cognitive Agent schema for RAG and AI-powered features CREATE SCHEMA IF NOT EXISTS cognitive; -- Ensure pgvector extension is available CREATE EXTENSION IF NOT EXISTS vector; -- Document embeddings for RAG (vector search) CREATE TABLE cognitive.document_embeddings ( id SERIAL PRIMARY KEY, document_id INTEGER NOT NULL REFERENCES documents.documents(id) ON DELETE CASCADE, organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, embedding vector(1536) NOT NULL, -- OpenAI text-embedding-3-small dimension content_hash VARCHAR(64), content_preview TEXT, chunk_index INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE(document_id, chunk_index) ); -- IVFFlat index for fast vector similarity search using cosine distance CREATE INDEX idx_doc_embeddings_vector ON cognitive.document_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); CREATE INDEX idx_doc_embeddings_organization ON cognitive.document_embeddings(organization_id); CREATE INDEX idx_doc_embeddings_document ON cognitive.document_embeddings(document_id); CREATE INDEX idx_doc_embeddings_content_hash ON cognitive.document_embeddings(content_hash); -- Chat sessions for conversational AI CREATE TABLE cognitive.chat_sessions ( id SERIAL PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, account_id INTEGER NOT NULL, title VARCHAR(500), created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_chat_sessions_organization ON cognitive.chat_sessions(organization_id); CREATE INDEX idx_chat_sessions_account ON cognitive.chat_sessions(account_id); CREATE INDEX idx_chat_sessions_created_at ON cognitive.chat_sessions(created_at DESC); -- Chat messages within sessions CREATE TABLE cognitive.chat_messages ( id SERIAL PRIMARY KEY, session_id INTEGER NOT NULL REFERENCES cognitive.chat_sessions(id) ON DELETE CASCADE, role VARCHAR(20) NOT NULL, content TEXT NOT NULL, referenced_docs INTEGER[], tokens_used INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT valid_role CHECK (role IN ('user', 'assistant', 'system')) ); CREATE INDEX idx_chat_messages_session ON cognitive.chat_messages(session_id); CREATE INDEX idx_chat_messages_created_at ON cognitive.chat_messages(created_at); -- Auto-update triggers for updated_at CREATE OR REPLACE FUNCTION cognitive.update_embeddings_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER doc_embeddings_updated_at BEFORE UPDATE ON cognitive.document_embeddings FOR EACH ROW EXECUTE FUNCTION cognitive.update_embeddings_updated_at(); CREATE OR REPLACE FUNCTION cognitive.update_sessions_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER chat_sessions_updated_at BEFORE UPDATE ON cognitive.chat_sessions FOR EACH ROW EXECUTE FUNCTION cognitive.update_sessions_updated_at(); -- Comments for documentation COMMENT ON TABLE cognitive.document_embeddings IS 'Vector embeddings for documents using OpenAI text-embedding-3-small (1536 dimensions)'; COMMENT ON COLUMN cognitive.document_embeddings.embedding IS 'Vector embedding for semantic similarity search'; COMMENT ON COLUMN cognitive.document_embeddings.chunk_index IS 'Index for chunked documents (0 for single-chunk docs)'; COMMENT ON TABLE cognitive.chat_sessions IS 'Conversational AI sessions for RAG-based chat'; COMMENT ON TABLE cognitive.chat_messages IS 'Messages within chat sessions with role (user/assistant/system)'; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/query/cognitive.sql ================================================ -- Cognitive Agent queries -- Document Embeddings -- name: CreateDocumentEmbedding :one INSERT INTO cognitive.document_embeddings ( document_id, organization_id, embedding, content_hash, content_preview, chunk_index ) VALUES ( $1, $2, $3, $4, $5, $6 ) RETURNING *; -- name: GetDocumentEmbeddingByID :one SELECT * FROM cognitive.document_embeddings WHERE id = $1 AND organization_id = $2; -- name: GetDocumentEmbeddingsByDocumentID :many SELECT * FROM cognitive.document_embeddings WHERE document_id = $1 AND organization_id = $2 ORDER BY chunk_index; -- name: SearchSimilarDocuments :many SELECT de.id, de.document_id, de.organization_id, de.content_hash, de.content_preview, de.chunk_index, de.created_at, de.updated_at, (1 - (de.embedding <=> $1::vector))::double precision as similarity_score FROM cognitive.document_embeddings de WHERE de.organization_id = $2 ORDER BY de.embedding <=> $1::vector LIMIT $3; -- name: DeleteDocumentEmbeddings :exec DELETE FROM cognitive.document_embeddings WHERE document_id = $1 AND organization_id = $2; -- name: CountDocumentEmbeddingsByOrganization :one SELECT COUNT(*) FROM cognitive.document_embeddings WHERE organization_id = $1; -- Chat Sessions -- name: CreateChatSession :one INSERT INTO cognitive.chat_sessions ( organization_id, account_id, title ) VALUES ( $1, $2, $3 ) RETURNING *; -- name: GetChatSessionByID :one SELECT * FROM cognitive.chat_sessions WHERE id = $1 AND organization_id = $2; -- name: ListChatSessionsByAccount :many SELECT * FROM cognitive.chat_sessions WHERE organization_id = $1 AND account_id = $2 ORDER BY updated_at DESC LIMIT $3 OFFSET $4; -- name: UpdateChatSessionTitle :one UPDATE cognitive.chat_sessions SET title = $3, updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING *; -- name: DeleteChatSession :exec DELETE FROM cognitive.chat_sessions WHERE id = $1 AND organization_id = $2; -- Chat Messages -- name: CreateChatMessage :one INSERT INTO cognitive.chat_messages ( session_id, role, content, referenced_docs, tokens_used ) VALUES ( $1, $2, $3, $4, $5 ) RETURNING *; -- name: GetChatMessagesBySession :many SELECT * FROM cognitive.chat_messages WHERE session_id = $1 ORDER BY created_at ASC; -- name: GetRecentChatMessages :many SELECT * FROM cognitive.chat_messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT $2; -- name: CountChatMessagesBySession :one SELECT COUNT(*) FROM cognitive.chat_messages WHERE session_id = $1; -- name: DeleteChatMessage :exec DELETE FROM cognitive.chat_messages WHERE id = $1; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/query/documents.sql ================================================ -- Documents queries -- name: CreateDocument :one INSERT INTO documents.documents ( organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING *; -- name: GetDocumentByID :one SELECT * FROM documents.documents WHERE id = $1 AND organization_id = $2; -- name: GetDocumentByFileAssetID :one SELECT * FROM documents.documents WHERE file_asset_id = $1 AND organization_id = $2; -- name: ListDocumentsByOrganization :many SELECT * FROM documents.documents WHERE organization_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3; -- name: ListDocumentsByStatus :many SELECT * FROM documents.documents WHERE organization_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4; -- name: UpdateDocumentStatus :one UPDATE documents.documents SET status = $3, updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING *; -- name: UpdateDocumentExtractedText :one UPDATE documents.documents SET extracted_text = $3, status = 'processed', updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING *; -- name: UpdateDocument :one UPDATE documents.documents SET title = COALESCE($3, title), metadata = COALESCE($4, metadata), updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING *; -- name: DeleteDocument :exec DELETE FROM documents.documents WHERE id = $1 AND organization_id = $2; -- name: CountDocumentsByOrganization :one SELECT COUNT(*) FROM documents.documents WHERE organization_id = $1; -- name: CountDocumentsByStatus :one SELECT COUNT(*) FROM documents.documents WHERE organization_id = $1 AND status = $2; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/query/example_resource.sql ================================================ -- Example Resource Queries -- Demonstrates Clean Architecture patterns with CRUD operations, -- file attachments, OCR/LLM processing, and approval workflows -- CREATE operations -- name: CreateResource :one INSERT INTO example_resources ( resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) RETURNING *; -- name: CreateMinimalResource :one -- Creates a minimal placeholder resource INSERT INTO example_resources ( resource_number, title, organization_id, created_by_account_id, status_id ) VALUES ( $1, $2, $3, $4, 1 ) RETURNING *; -- READ operations -- name: GetResourceByID :one SELECT * FROM example_resources WHERE id = $1 AND organization_id = $2 AND is_active = true; -- name: GetResourceByNumber :one SELECT * FROM example_resources WHERE resource_number = $1 AND organization_id = $2 AND is_active = true; -- name: ListResources :many -- List resources with filtering and pagination SELECT id, resource_number, title, description, status_id, file_id, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, is_active, created_at, updated_at FROM example_resources WHERE organization_id = $1 AND is_active = true AND ($2::smallint IS NULL OR status_id = $2) AND ($3::varchar IS NULL OR approval_status = $3) AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%') ORDER BY created_at DESC LIMIT $5 OFFSET $6; -- name: CountResources :one -- Count resources for pagination SELECT COUNT(*) FROM example_resources WHERE organization_id = $1 AND is_active = true AND ($2::smallint IS NULL OR status_id = $2) AND ($3::varchar IS NULL OR approval_status = $3) AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%'); -- UPDATE operations -- name: UpdateResource :exec UPDATE example_resources SET title = COALESCE(sqlc.narg('title'), title), description = COALESCE(sqlc.narg('description'), description), status_id = COALESCE(sqlc.narg('status_id'), status_id), metadata = COALESCE(sqlc.narg('metadata'), metadata), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') AND organization_id = sqlc.arg('organization_id') AND is_active = true; -- name: UpdateResourceProcessingData :exec -- Update OCR/LLM processing results UPDATE example_resources SET extracted_data = COALESCE(sqlc.narg('extracted_data'), extracted_data), processed_data = COALESCE(sqlc.narg('processed_data'), processed_data), confidence = COALESCE(sqlc.narg('confidence'), confidence), status_id = COALESCE(sqlc.narg('status_id'), status_id), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') AND organization_id = sqlc.arg('organization_id'); -- name: UpdateResourceStatus :exec UPDATE example_resources SET status_id = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 AND is_active = true; -- name: UpdateResourceApprovalStatus :exec -- Update approval workflow status UPDATE example_resources SET approval_status = $3, approval_action_taker_id = $4, approval_notes = $5, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 AND is_active = true; -- name: AssignResourceApproval :exec -- Assign resource to someone for approval UPDATE example_resources SET approval_assigned_to_id = $3, approval_status = 'pending', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 AND is_active = true; -- name: AttachFileToResource :exec -- Attach a file to a resource UPDATE example_resources SET file_id = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 AND is_active = true; -- DELETE operations -- name: DeleteResource :exec -- Soft delete a resource UPDATE example_resources SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2; -- name: HardDeleteResource :exec -- Hard delete a resource (use with caution) DELETE FROM example_resources WHERE id = $1 AND organization_id = $2; -- SEARCH operations -- name: SearchResourcesByText :many -- Full-text search on title and description SELECT id, resource_number, title, description, status_id, confidence, created_at, updated_at, ts_rank(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')), to_tsquery('english', $2)) AS rank FROM example_resources WHERE organization_id = $1 AND is_active = true AND to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')) @@ to_tsquery('english', $2) ORDER BY rank DESC, created_at DESC LIMIT $3 OFFSET $4; -- ANALYTICS queries -- name: GetResourceStats :one -- Get statistics for dashboard SELECT COUNT(*) as total_resources, COUNT(*) FILTER (WHERE status_id = 1) as draft_count, COUNT(*) FILTER (WHERE status_id = 2) as processing_count, COUNT(*) FILTER (WHERE status_id = 3) as completed_count, COUNT(*) FILTER (WHERE approval_status = 'pending') as pending_approval, COUNT(*) FILTER (WHERE approval_status = 'approved') as approved_count, AVG(confidence) as avg_confidence FROM example_resources WHERE organization_id = $1 AND is_active = true; -- name: GetRecentResources :many -- Get most recently created resources SELECT id, resource_number, title, status_id, confidence, created_by_account_id, created_at FROM example_resources WHERE organization_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT $2; -- name: GetResourcesByCreator :many -- Get resources created by a specific user SELECT * FROM example_resources WHERE organization_id = $1 AND created_by_account_id = $2 AND is_active = true ORDER BY created_at DESC LIMIT $3 OFFSET $4; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/query/file_manager.sql ================================================ -- name: CreateFileAsset :one INSERT INTO file_manager.file_assets ( file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 ) RETURNING *; -- name: GetFileAssetByID :one SELECT * FROM file_manager.file_assets WHERE id = $1; -- name: DeleteFileAsset :exec DELETE FROM file_manager.file_assets WHERE id = $1; -- name: GetFileAssetsByEntity :many SELECT * FROM file_manager.file_assets WHERE entity_type = $1 AND entity_id = $2; -- name: GetFileAssetsByEntityAndPurpose :many SELECT * FROM file_manager.file_assets WHERE entity_type = $1 AND entity_id = $2 AND purpose = $3 ORDER BY created_at DESC; -- name: GetFileAssetsByCategory :many SELECT fa.*, fc.name as category_name FROM file_manager.file_assets fa JOIN file_manager.file_categories fc ON fa.file_category_id = fc.id WHERE fc.name = $1 ORDER BY fa.created_at DESC; -- name: GetFileAssetsByContext :many SELECT fa.*, fctx.name as context_name FROM file_manager.file_assets fa JOIN file_manager.file_contexts fctx ON fa.file_context_id = fctx.id WHERE fctx.name = $1 ORDER BY fa.created_at DESC; -- name: UpdateFileAsset :exec UPDATE file_manager.file_assets SET file_name = $2, storage_path = $3, purpose = $4, metadata = $5, updated_at = CURRENT_TIMESTAMP WHERE id = $1; -- name: GetFileAssetByStoragePath :one SELECT * FROM file_manager.file_assets WHERE storage_path = $1; -- name: ListFileAssets :many SELECT fa.*, fc.name as category_name, fctx.name as context_name FROM file_manager.file_assets fa JOIN file_manager.file_categories fc ON fa.file_category_id = fc.id JOIN file_manager.file_contexts fctx ON fa.file_context_id = fctx.id ORDER BY fa.created_at DESC LIMIT $1 OFFSET $2; -- name: GetFileCategories :many SELECT * FROM file_manager.file_categories ORDER BY name; -- name: GetFileContexts :many SELECT * FROM file_manager.file_contexts ORDER BY name; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/query/organizations.sql ================================================ -- name: CreateOrganization :one INSERT INTO organizations.organizations ( slug, name, status ) VALUES ( $1, $2, $3 ) RETURNING id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at; -- name: GetOrganizationByID :one SELECT id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at FROM organizations.organizations WHERE id = $1; -- name: GetOrganizationBySlug :one SELECT id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at FROM organizations.organizations WHERE slug = $1; -- name: GetOrganizationByStytchID :one SELECT id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at FROM organizations.organizations WHERE stytch_org_id = $1; -- name: UpdateOrganization :one UPDATE organizations.organizations SET name = $2, status = $3, stytch_org_id = $4, stytch_connection_id = $5, stytch_connection_name = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at; -- name: UpdateOrganizationStytchInfo :one UPDATE organizations.organizations SET stytch_org_id = $2, stytch_connection_id = $3, stytch_connection_name = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at; -- name: ListOrganizations :many SELECT id, slug, name, status, stytch_org_id, stytch_connection_id, stytch_connection_name, created_at, updated_at FROM organizations.organizations ORDER BY created_at DESC LIMIT $1 OFFSET $2; -- name: DeleteOrganization :exec DELETE FROM organizations.organizations WHERE id = $1; -- Accounts queries -- name: CreateAccount :one INSERT INTO organizations.accounts ( organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at; -- name: GetAccountByID :one SELECT id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at FROM organizations.accounts WHERE id = $1 AND organization_id = $2; -- name: GetAccountByEmail :one SELECT id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at FROM organizations.accounts WHERE email = $1 AND organization_id = $2; -- name: ListAccountsByOrganization :many SELECT id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at FROM organizations.accounts WHERE organization_id = $1 ORDER BY created_at DESC; -- name: UpdateAccount :one UPDATE organizations.accounts SET full_name = $3, stytch_role_id = $4, stytch_role_slug = $5, stytch_email_verified = $6, role = $7, status = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at; -- name: UpdateAccountStytchInfo :one UPDATE organizations.accounts SET stytch_member_id = $3, stytch_role_id = $4, stytch_role_slug = $5, stytch_email_verified = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at; -- name: UpdateAccountLastLogin :one UPDATE organizations.accounts SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, email, full_name, stytch_member_id, stytch_role_id, stytch_role_slug, stytch_email_verified, role, status, last_login_at, created_at, updated_at; -- name: DeleteAccount :exec UPDATE organizations.accounts SET status = 'inactive', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND organization_id = $2; -- Organization membership queries -- name: GetOrganizationByUserEmail :one SELECT o.id, o.slug, o.name, o.status, o.stytch_org_id, o.stytch_connection_id, o.stytch_connection_name, o.created_at, o.updated_at FROM organizations.organizations o INNER JOIN organizations.accounts a ON o.id = a.organization_id WHERE a.email = $1 AND a.status = 'active' AND o.status = 'active' LIMIT 1; -- name: GetAccountOrganization :one SELECT o.id, o.slug, o.name, o.status, o.stytch_org_id, o.stytch_connection_id, o.stytch_connection_name, o.created_at, o.updated_at FROM organizations.organizations o INNER JOIN organizations.accounts a ON o.id = a.organization_id WHERE a.id = $1; -- name: CheckAccountPermission :one SELECT a.id, a.role, a.status, o.status as org_status FROM organizations.accounts a INNER JOIN organizations.organizations o ON a.organization_id = o.id WHERE a.id = $1 AND a.organization_id = $2; -- Statistics queries (useful for admin panels) -- name: GetOrganizationStats :one SELECT o.id, o.slug, o.name, o.status, o.stytch_org_id, o.stytch_connection_id, o.stytch_connection_name, o.created_at, o.updated_at, COUNT(a.id) as account_count, COUNT(CASE WHEN a.status = 'active' THEN 1 END) as active_account_count FROM organizations.organizations o LEFT JOIN organizations.accounts a ON o.id = a.organization_id WHERE o.id = $1 GROUP BY o.id; -- name: GetAccountStats :one SELECT a.id, a.organization_id, a.email, a.full_name, a.stytch_member_id, a.stytch_role_id, a.stytch_role_slug, a.stytch_email_verified, a.role, a.status, a.last_login_at, a.created_at, a.updated_at, o.name as organization_name, o.slug as organization_slug FROM organizations.accounts a INNER JOIN organizations.organizations o ON a.organization_id = o.id WHERE a.id = $1; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/query/subscription_billing.sql ================================================ -- name: GetSubscriptionByOrgID :one -- Get subscription details for an organization SELECT * FROM subscription_billing.subscriptions WHERE organization_id = $1 LIMIT 1; -- name: GetSubscriptionBySubscriptionID :one -- Get subscription by Polar subscription ID SELECT * FROM subscription_billing.subscriptions WHERE subscription_id = $1 LIMIT 1; -- name: UpsertSubscription :one -- Create or update subscription from Polar webhook INSERT INTO subscription_billing.subscriptions ( organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, metadata, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP ) ON CONFLICT (organization_id) DO UPDATE SET external_customer_id = EXCLUDED.external_customer_id, subscription_id = EXCLUDED.subscription_id, subscription_status = EXCLUDED.subscription_status, product_id = EXCLUDED.product_id, product_name = EXCLUDED.product_name, plan_name = EXCLUDED.plan_name, current_period_start = EXCLUDED.current_period_start, current_period_end = EXCLUDED.current_period_end, cancel_at_period_end = EXCLUDED.cancel_at_period_end, canceled_at = EXCLUDED.canceled_at, metadata = EXCLUDED.metadata, updated_at = CURRENT_TIMESTAMP RETURNING *; -- name: DeleteSubscription :exec -- Delete subscription (when subscription is permanently deleted) DELETE FROM subscription_billing.subscriptions WHERE organization_id = $1; -- name: GetQuotaByOrgID :one -- Get quota tracking for an organization SELECT * FROM subscription_billing.quota_tracking WHERE organization_id = $1 LIMIT 1; -- name: UpsertQuota :one -- Create or update quota tracking INSERT INTO subscription_billing.quota_tracking ( organization_id, invoice_count, max_seats, period_start, period_end, last_synced_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT (organization_id) DO UPDATE SET invoice_count = EXCLUDED.invoice_count, max_seats = EXCLUDED.max_seats, period_start = EXCLUDED.period_start, period_end = EXCLUDED.period_end, last_synced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP RETURNING *; -- name: DecrementInvoiceCount :one -- Decrement invoice count by 1 (called after successful invoice processing) UPDATE subscription_billing.quota_tracking SET invoice_count = invoice_count - 1, updated_at = CURRENT_TIMESTAMP WHERE organization_id = $1 RETURNING *; -- name: ResetQuotaForPeriod :one -- Reset quota counters for a new billing period UPDATE subscription_billing.quota_tracking SET invoice_count = $2, period_start = $3, period_end = $4, updated_at = CURRENT_TIMESTAMP WHERE organization_id = $1 RETURNING *; -- name: GetQuotaStatus :one -- Get combined subscription and quota status for fast quota checks SELECT s.subscription_status, s.current_period_start, s.current_period_end, s.cancel_at_period_end, q.invoice_count, q.max_seats, CASE WHEN s.subscription_status = 'active' AND q.invoice_count > 0 THEN TRUE ELSE FALSE END AS can_process_invoice FROM subscription_billing.subscriptions s INNER JOIN subscription_billing.quota_tracking q ON s.organization_id = q.organization_id WHERE s.organization_id = $1 LIMIT 1; -- name: ListActiveSubscriptions :many -- List all active subscriptions for monitoring/admin purposes SELECT * FROM subscription_billing.subscriptions WHERE subscription_status = 'active' ORDER BY created_at DESC; -- name: ListQuotasNearLimit :many -- List organizations approaching their quota limit (for alerting) SELECT q.*, s.subscription_status, s.product_name FROM subscription_billing.quota_tracking q INNER JOIN subscription_billing.subscriptions s ON q.organization_id = s.organization_id WHERE s.subscription_status = 'active' AND q.invoice_count <= $1 ORDER BY q.invoice_count ASC; ================================================ FILE: go-b2b-starter/internal/db/postgres/sqlc/sqlc.yml ================================================ version: "2" sql: - schema: "./migrations" queries: "./query" engine: "postgresql" gen: go: package: "postgres" out: "./gen" sql_package: "pgx/v5" emit_json_tags: true emit_interface: true emit_empty_slices: true overrides: - column: "travel.places.geom" go_type: "string" - db_type: "vector" go_type: type: "Vector" import: "github.com/pgvector/pgvector-go" ================================================ FILE: go-b2b-starter/internal/db/postgres/types_transform.go ================================================ package postgres import ( "encoding/hex" "fmt" "strconv" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/shopspring/decimal" geomPkg "github.com/twpayne/go-geom" "github.com/twpayne/go-geom/encoding/ewkb" "github.com/twpayne/go-geom/encoding/wkb" ) // Int16Ptr converts pgtype.Int2 to *int16 func Int16Ptr(i pgtype.Int2) *int16 { if !i.Valid { return nil } return &i.Int16 } // Int32Ptr converts pgtype.Int4 to *int32 func Int32Ptr(i pgtype.Int4) *int32 { if !i.Valid { return nil } return &i.Int32 } // Int64Ptr converts pgtype.Int8 to *int64 func Int64Ptr(i pgtype.Int8) *int64 { if !i.Valid { return nil } return &i.Int64 } // Float32Ptr converts pgtype.Float4 to *float32 func Float32Ptr(f pgtype.Float4) *float32 { if !f.Valid { return nil } return &f.Float32 } // Float64Ptr converts pgtype.Float8 to *float64 func Float64Ptr(f pgtype.Float8) *float64 { if !f.Valid { return nil } return &f.Float64 } // StringPtr converts pgtype.Text to *string func StringPtr(t pgtype.Text) *string { if !t.Valid { return nil } return &t.String } // TimeStampPtr converts pgtype.Timestamp to *time.Time func TimeStampPtr(t pgtype.Timestamp) *time.Time { if !t.Valid { return nil } return &t.Time } // time tampz // TimeStampTzPtr converts pgtype.Timestamptz to *time.Time func TimeStampTzPtr(t pgtype.Timestamptz) *time.Time { if !t.Valid { return nil } return &t.Time } // time ptr // TimePtr converts pgtype.Time to *time.Time func TimePtr(t pgtype.Time) *time.Time { if !t.Valid { return nil } // Convert microseconds to time.Time seconds := t.Microseconds / 1_000_000 // Convert to seconds nanos := (t.Microseconds % 1_000_000) * 1000 // Convert remaining microseconds to nanoseconds timeVal := time.Unix(seconds, nanos) return &timeVal } // BoolPtr converts pgtype.Bool to *bool func BoolPtr(b pgtype.Bool) *bool { if !b.Valid { return nil } return &b.Bool } // from go types to pg types // PgInt2 converts *int16 to pgtype.PgInt2 func PgInt2(i *int16) pgtype.Int2 { if i == nil { return pgtype.Int2{Valid: false} } return pgtype.Int2{Int16: *i, Valid: true} } // PgInt4 converts *int32 to pgtype.PgInt4 func PgInt4(i *int32) pgtype.Int4 { if i == nil { return pgtype.Int4{Valid: false} } return pgtype.Int4{Int32: *i, Valid: true} } // PgInt8 converts *int64 to pgtype.PgInt8 func PgInt8(i *int64) pgtype.Int8 { if i == nil { return pgtype.Int8{Valid: false} } return pgtype.Int8{Int64: *i, Valid: true} } // PgFloat4 converts *float32 to pgtype.PgFloat4 func PgFloat4(f *float32) pgtype.Float4 { if f == nil { return pgtype.Float4{Valid: false} } return pgtype.Float4{Float32: *f, Valid: true} } // PgFloat8 converts *float64 to pgtype.PgFloat8 func PgFloat8(f *float64) pgtype.Float8 { if f == nil { return pgtype.Float8{Valid: false} } return pgtype.Float8{Float64: *f, Valid: true} } // PgText converts *string to pgtype.PgText func PgText(s *string) pgtype.Text { if s == nil { return pgtype.Text{Valid: false} } return pgtype.Text{String: *s, Valid: true} } // PgTimestamp converts *time.Time to pgtype.PgTimestamp func PgTimestamp(t *time.Time) pgtype.Timestamp { if t == nil { return pgtype.Timestamp{Valid: false} } return pgtype.Timestamp{Time: *t, Valid: true} } // PgTimestamptz converts *time.Time to pgtype.PgTimestamptz func PgTimestamptz(t *time.Time) pgtype.Timestamptz { if t == nil { return pgtype.Timestamptz{Valid: false} } return pgtype.Timestamptz{Time: *t, Valid: true} } // PgTime converts *time.Time to pgtype.PgTime func PgTime(t *time.Time) pgtype.Time { if t == nil { return pgtype.Time{Valid: false} } // Convert time.Time to microseconds microseconds := t.Unix()*1_000_000 + int64(t.Nanosecond())/1000 return pgtype.Time{ Microseconds: microseconds, Valid: true, } } // PgBool converts *bool to pgtype.PgBool func PgBool(b *bool) pgtype.Bool { if b == nil { return pgtype.Bool{Valid: false} } return pgtype.Bool{Bool: *b, Valid: true} } // UUIDPtr converts pgtype.UUID to *uuid.UUID func UUIDPtr(u pgtype.UUID) *uuid.UUID { if !u.Valid { return nil } id := uuid.UUID(u.Bytes) return &id } // PgUUID converts *uuid.UUID to pgtype.UUID func PgUUID(u *uuid.UUID) pgtype.UUID { if u == nil { return pgtype.UUID{Valid: false} } return pgtype.UUID{ Bytes: [16]byte(*u), Valid: true, } } // NumericPtr converts pgtype.Numeric to *float64 with improved error handling func NumericPtr(n pgtype.Numeric) *float64 { if !n.Valid { return nil } // Use Value() method for safe conversion val, err := n.Value() if err != nil { return nil } // Handle different return types from PostgreSQL numeric switch v := val.(type) { case float64: return &v case string: // Parse string representation like "1315.0000" if f64, err := strconv.ParseFloat(v, 64); err == nil { return &f64 } case int64: f64 := float64(v) return &f64 case int32: f64 := float64(v) return &f64 case int: f64 := float64(v) return &f64 } return nil } // Numeric converts *float64 to pgtype.Numeric with improved accuracy func Numeric(f *float64) pgtype.Numeric { if f == nil { return pgtype.Numeric{Valid: false} } // Use Scan for proper conversion numeric := pgtype.Numeric{} err := numeric.Scan(fmt.Sprintf("%.6f", *f)) if err != nil { return pgtype.Numeric{Valid: false} } return numeric } // numeric from decimal // NumericFromDecimal converts decimal.Decimal to pgtype.Numeric func NumericFromDecimal(d *decimal.Decimal) pgtype.Numeric { if d == nil { return pgtype.Numeric{Valid: false} } numeric := pgtype.Numeric{} err := numeric.Scan(d.String()) if err != nil { return pgtype.Numeric{Valid: false} } return numeric } // NumericFromFloat32 converts float32 to pgtype.Numeric func NumericFromFloat32(f float32) pgtype.Numeric { numeric := pgtype.Numeric{} err := numeric.Scan(fmt.Sprintf("%.6f", f)) if err != nil { return pgtype.Numeric{Valid: false} } return numeric } // Float32FromNumeric converts pgtype.Numeric to float32 func Float32FromNumeric(n pgtype.Numeric) float32 { if !n.Valid { return 0 } val, err := n.Value() if err != nil { return 0 } // Handle different return types from PostgreSQL numeric switch v := val.(type) { case float64: result := float32(v) return result case string: // Parse string representation like "0.9000" if f64, err := strconv.ParseFloat(v, 64); err == nil { result := float32(f64) return result } case int64: result := float32(v) return result case int32: result := float32(v) return result case int: result := float32(v) return result } return 0 } // DatePtr converts pgtype.Date to *time.Time func DatePtr(d pgtype.Date) *time.Time { if !d.Valid { return nil } return &d.Time } // PgDate converts *time.Time to pgtype.Date func PgDate(t *time.Time) pgtype.Date { if t == nil { return pgtype.Date{Valid: false} } return pgtype.Date{Time: *t, Valid: true} } // PgTextFromString converts string to pgtype.Text (for non-empty strings) func PgTextFromString(s string) pgtype.Text { if s == "" { return pgtype.Text{Valid: false} } return pgtype.Text{String: s, Valid: true} } // StringFromPgText converts pgtype.Text to string (empty string if invalid) func StringFromPgText(t pgtype.Text) string { if !t.Valid { return "" } return t.String } // PgInt4FromInt32 converts int32 to pgtype.Int4 func PgInt4FromInt32(i int32) pgtype.Int4 { return pgtype.Int4{Int32: i, Valid: true} } // Int32FromPgInt4 converts pgtype.Int4 to int32 (0 if invalid) func Int32FromPgInt4(i pgtype.Int4) int32 { if !i.Valid { return 0 } return i.Int32 } func ConvertWKBToPoint(wkbHex string) (*geomPkg.Point, error) { // Decode the hex string to bytes wkbBytes, err := hex.DecodeString(wkbHex) if err != nil { return nil, fmt.Errorf("failed to decode WKB hex: %w", err) } // Parse the WKB bytes geom, err := wkb.Unmarshal(wkbBytes) if err != nil { return nil, fmt.Errorf("failed to unmarshal WKB: %w", err) } // Assert that it's a Point geom.Bounds() point, ok := geom.(*geomPkg.Point) if !ok { return nil, fmt.Errorf("geometry is not a Point") } return point, nil } func ConvertWKBToPointString(wkbHex string) (string, error) { // Decode the hex string to bytes wkbBytes, err := hex.DecodeString(wkbHex) if err != nil { return "", fmt.Errorf("failed to decode WKB hex: %w", err) } // Parse the EWKB bytes g, err := ewkb.Unmarshal(wkbBytes) if err != nil { return "", fmt.Errorf("failed to unmarshal EWKB: %w", err) } // Get the coordinates coords := g.FlatCoords() if len(coords) < 2 { return "", fmt.Errorf("invalid point data") } // Convert to "POINT(longitude latitude)" format return fmt.Sprintf("POINT(%.6f %.6f)", coords[0], coords[1]), nil } ================================================ FILE: go-b2b-starter/internal/docs/api/handler.go ================================================ package api type Handler struct { } func NewHandler() *Handler { return &Handler{} } ================================================ FILE: go-b2b-starter/internal/docs/api/routes.go ================================================ package api import ( docs "github.com/moasq/go-b2b-starter/internal/docs/gen" "github.com/moasq/go-b2b-starter/internal/platform/server/domain" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) func (h *Handler) Routes(router *gin.RouterGroup, resolver domain.MiddlewareResolver) { if gin.Mode() != gin.ReleaseMode { docs.SwaggerInfo.Title = "API" docs.SwaggerInfo.Description = "API" docs.SwaggerInfo.BasePath = "/" router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } } ================================================ FILE: go-b2b-starter/internal/docs/cmd/init.go ================================================ package cmd import ( "log" "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/docs/api" server "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) func Init(container *dig.Container) { err := container.Invoke(func(srv server.Server) { handler := api.NewHandler() srv.RegisterRoutes(handler.Routes, "") }) if err != nil { log.Fatalf("Failed to start server: %v", err) } } ================================================ FILE: go-b2b-starter/internal/docs/gen/docs.go ================================================ // Package gen Code generated by swaggo/swag. DO NOT EDIT package gen import "github.com/swaggo/swag" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, "swagger": "2.0", "info": { "description": "{{escape .Description}}", "title": "{{.Title}}", "termsOfService": "http://swagger.io/terms/", "contact": { "name": "API Support", "url": "http://www.swagger.io/support", "email": "support@swagger.io" }, "license": { "name": "Apache 2.0", "url": "http://www.apache.org/licenses/LICENSE-2.0.html" }, "version": "{{.Version}}" }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { "/api/subscriptions/status": { "get": { "description": "Retrieve the current subscription billing status and invoice quota information for the organization", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "subscriptions" ], "summary": "Get current billing and quota status", "responses": { "200": { "description": "Current billing and quota status", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus" } }, "400": { "description": "Invalid request parameters or missing organization context", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal server error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/api/subscriptions/verify-payment": { "post": { "description": "Verifies a payment by checking the Polar checkout session and updates subscription status. This is the primary mechanism for \"Verification on Redirect\" pattern when user returns from payment page.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "subscriptions" ], "summary": "Verify payment from checkout session", "parameters": [ { "description": "Checkout session ID", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/internal_modules_billing.VerifyPaymentRequest" } } ], "responses": { "200": { "description": "Verification result with updated billing status", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus" } }, "400": { "description": "Invalid request parameters or checkout session failed", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "404": { "description": "Checkout session not found", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal server error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/auth/check-email": { "get": { "description": "Checks if an email exists in any organization. Returns 200 OK (empty response) if exists, 404 Not Found if doesn't exist. This is a public endpoint used during login flow.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Check if email exists", "parameters": [ { "type": "string", "description": "Email address to check", "name": "email", "in": "query", "required": true } ], "responses": { "200": { "description": "Email exists" }, "400": { "description": "Invalid email format", "schema": { "type": "object", "additionalProperties": true } }, "404": { "description": "Email not found", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Internal server error", "schema": { "type": "object", "additionalProperties": true } } } } }, "/auth/members": { "get": { "description": "Retrieves all members of the current organization. Restricted to admin role only.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "List organization members", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse" } }, "400": { "description": "Missing organization context", "schema": { "type": "object", "additionalProperties": true } }, "403": { "description": "Insufficient permissions - admin role required", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to list members", "schema": { "type": "object", "additionalProperties": true } } } }, "post": { "description": "Adds a new member to an existing organization with a specified role. Organization ID is automatically extracted from JWT token. Member receives a magic link invite email for passwordless authentication. Request body: {\"email\": \"user@example.com\", \"name\": \"Full Name\", \"role_slug\": \"member\"}", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Add member to organization", "parameters": [ { "type": "string", "description": "Bearer JWT token", "name": "Authorization", "in": "header", "required": true }, { "description": "Member email address", "name": "email", "in": "body", "required": true, "schema": { "type": "string" } }, { "description": "Member full name", "name": "name", "in": "body", "required": true, "schema": { "type": "string" } }, { "description": "Role slug (defaults to 'member')", "name": "role_slug", "in": "body", "schema": { "type": "string" } } ], "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse" } }, "400": { "description": "Invalid request payload or missing organization context", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to add member", "schema": { "type": "object", "additionalProperties": true } } } } }, "/auth/members/{member_id}": { "delete": { "description": "Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Delete organization member", "parameters": [ { "type": "string", "description": "Bearer JWT token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "Member ID to delete", "name": "member_id", "in": "path", "required": true } ], "responses": { "204": { "description": "Member deleted successfully", "schema": { "type": "object", "additionalProperties": true } }, "400": { "description": "Invalid member ID or missing organization context", "schema": { "type": "object", "additionalProperties": true } }, "403": { "description": "Insufficient permissions - admin role required", "schema": { "type": "object", "additionalProperties": true } }, "404": { "description": "Member not found", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to delete member", "schema": { "type": "object", "additionalProperties": true } } } } }, "/auth/profile/me": { "get": { "description": "Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Get current user profile", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse" } }, "400": { "description": "Missing required context (organization or claims)", "schema": { "type": "object", "additionalProperties": true } }, "401": { "description": "Authentication required", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to retrieve profile", "schema": { "type": "object", "additionalProperties": true } } } } }, "/auth/signup": { "post": { "description": "Creates a new organization in Stytch with an initial admin member. The admin receives a magic link invite email to complete passwordless onboarding. Organization slug is auto-generated from the organization name.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Bootstrap organization", "parameters": [ { "description": "Organization bootstrap request (passwordless - no password required)", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest" } } ], "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse" } }, "400": { "description": "Invalid request payload", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to bootstrap organization", "schema": { "type": "object", "additionalProperties": true } } } } }, "/example_cognitive/chat": { "post": { "description": "Sends a message to the AI and gets a response, optionally using RAG", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Cognitive" ], "summary": "Chat with AI", "parameters": [ { "description": "Chat request", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/internal_modules_cognitive.ChatRequest" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_cognitive/sessions": { "get": { "description": "Lists chat sessions for the current user with pagination", "produces": [ "application/json" ], "tags": [ "Cognitive" ], "summary": "List chat sessions", "parameters": [ { "type": "integer", "default": 10, "description": "Limit", "name": "limit", "in": "query" }, { "type": "integer", "default": 0, "description": "Offset", "name": "offset", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_cognitive/sessions/{id}/messages": { "get": { "description": "Retrieves all messages for a chat session", "produces": [ "application/json" ], "tags": [ "Cognitive" ], "summary": "Get session history", "parameters": [ { "type": "integer", "description": "Session ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage" } } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_documents": { "get": { "description": "Lists documents with optional filtering and pagination", "produces": [ "application/json" ], "tags": [ "Documents" ], "summary": "List documents", "parameters": [ { "type": "integer", "default": 10, "description": "Limit", "name": "limit", "in": "query" }, { "type": "integer", "default": 0, "description": "Offset", "name": "offset", "in": "query" }, { "type": "string", "description": "Filter by status (pending, processing, processed, failed)", "name": "status", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_documents/upload": { "post": { "description": "Uploads a PDF document, extracts text, and creates embeddings", "consumes": [ "multipart/form-data" ], "produces": [ "application/json" ], "tags": [ "Documents" ], "summary": "Upload PDF document", "parameters": [ { "type": "file", "description": "PDF file to upload", "name": "file", "in": "formData", "required": true }, { "type": "string", "description": "Document title", "name": "title", "in": "formData", "required": true } ], "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_documents/{id}": { "delete": { "description": "Deletes a document and its associated file", "tags": [ "Documents" ], "summary": "Delete document", "parameters": [ { "type": "integer", "description": "Document ID", "name": "id", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/rbac/check-permission": { "post": { "description": "Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Check if a role has a specific permission", "parameters": [ { "description": "Role and permission to check", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/internal_modules_auth.PermissionCheckRequest" } } ], "responses": { "200": { "description": "Permission check result", "schema": { "$ref": "#/definitions/internal_modules_auth.PermissionCheckResponse" } }, "400": { "description": "Invalid request", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "/rbac/metadata": { "get": { "description": "Returns summary information about the RBAC system including total roles, permissions, and categories.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get RBAC system metadata", "responses": { "200": { "description": "RBAC system metadata", "schema": { "$ref": "#/definitions/internal_modules_auth.RBACMetadata" } } } } }, "/rbac/permissions": { "get": { "description": "Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get all permissions", "responses": { "200": { "description": "All permissions", "schema": { "$ref": "#/definitions/internal_modules_auth.PermissionsResponse" } }, "500": { "description": "Internal error", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "/rbac/permissions/by-category": { "get": { "description": "Returns all permissions organized by their category for better UI organization.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get permissions grouped by category", "responses": { "200": { "description": "Permissions by category", "schema": { "$ref": "#/definitions/internal_modules_auth.PermissionsByCategoryResponse" } }, "500": { "description": "Internal error", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "/rbac/roles": { "get": { "description": "Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get all roles with permissions", "responses": { "200": { "description": "Roles with permissions", "schema": { "$ref": "#/definitions/internal_modules_auth.RolesResponse" } }, "500": { "description": "Internal error", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "/rbac/roles/{role_id}": { "get": { "description": "Returns comprehensive information about a role including permissions, statistics, and restrictions.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get detailed information about a specific role", "parameters": [ { "type": "string", "description": "Role ID (member, approver, admin)", "name": "role_id", "in": "path", "required": true } ], "responses": { "200": { "description": "Role details with statistics", "schema": { "$ref": "#/definitions/internal_modules_auth.RolePermissionsResponse" } }, "400": { "description": "Invalid role ID", "schema": { "type": "object", "additionalProperties": { "type": "string" } } }, "404": { "description": "Role not found", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } } }, "definitions": { "github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus": { "type": "object", "properties": { "canProcessInvoices": { "type": "boolean" }, "checkedAt": { "type": "string" }, "externalID": { "type": "string" }, "hasActiveSubscription": { "type": "boolean" }, "invoiceCount": { "description": "Remaining invoices", "type": "integer", "format": "int32" }, "organizationID": { "type": "integer", "format": "int32" }, "reason": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage": { "type": "object", "properties": { "content": { "type": "string" }, "created_at": { "type": "string" }, "id": { "type": "integer" }, "referenced_docs": { "type": "array", "items": { "type": "integer" } }, "role": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole" }, "session_id": { "type": "integer" }, "tokens_used": { "type": "integer" } } }, "github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse": { "type": "object", "properties": { "message": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage" }, "referenced_docs": { "type": "array", "items": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument" } }, "session_id": { "type": "integer" }, "tokens_used": { "type": "integer" } } }, "github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole": { "type": "string", "enum": [ "user", "assistant", "system" ], "x-enum-varnames": [ "ChatRoleUser", "ChatRoleAssistant", "ChatRoleSystem" ] }, "github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument": { "type": "object", "properties": { "chunk_index": { "type": "integer" }, "content_hash": { "type": "string" }, "content_preview": { "type": "string" }, "created_at": { "type": "string" }, "document_id": { "type": "integer" }, "embedding": { "description": "1536 dimensions for OpenAI", "type": "array", "items": { "type": "number" } }, "id": { "type": "integer" }, "organization_id": { "type": "integer" }, "similarity_score": { "type": "number" }, "updated_at": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse": { "type": "object", "properties": { "documents": { "type": "array", "items": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document" } }, "limit": { "type": "integer" }, "offset": { "type": "integer" }, "total": { "type": "integer" } } }, "github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document": { "type": "object", "properties": { "content_type": { "type": "string" }, "created_at": { "type": "string" }, "extracted_text": { "type": "string" }, "file_asset_id": { "type": "integer" }, "file_name": { "type": "string" }, "file_size": { "type": "integer" }, "id": { "type": "integer" }, "metadata": { "type": "object", "additionalProperties": true }, "organization_id": { "type": "integer" }, "status": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus" }, "title": { "type": "string" }, "updated_at": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus": { "type": "string", "enum": [ "pending", "processing", "processed", "failed" ], "x-enum-varnames": [ "DocumentStatusPending", "DocumentStatusProcessing", "DocumentStatusProcessed", "DocumentStatusFailed" ] }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse": { "type": "object", "properties": { "email": { "type": "string" }, "invite_sent": { "type": "boolean" }, "member_id": { "type": "string" }, "name": { "type": "string" }, "org_id": { "type": "string" }, "role_slug": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest": { "type": "object", "required": [ "org_display_name", "owner_email", "owner_name" ], "properties": { "org_display_name": { "description": "Organization details", "type": "string" }, "owner_email": { "description": "Owner member details", "type": "string" }, "owner_name": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse": { "type": "object", "properties": { "display_name": { "type": "string" }, "invite_sent": { "type": "boolean" }, "magic_link_sent": { "type": "boolean" }, "org_slug": { "type": "string" }, "organization_id": { "type": "string" }, "owner_email": { "type": "string" }, "owner_member_id": { "type": "string" }, "owner_name": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse": { "type": "object", "properties": { "members": { "type": "array", "items": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo" } }, "total": { "type": "integer" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo": { "type": "object", "properties": { "created_at": { "type": "string" }, "email": { "type": "string" }, "email_verified": { "type": "boolean" }, "member_id": { "type": "string" }, "name": { "type": "string" }, "roles": { "type": "array", "items": { "type": "string" } }, "status": { "type": "string" }, "updated_at": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization": { "type": "object", "properties": { "name": { "type": "string" }, "organization_id": { "type": "string" }, "slug": { "type": "string" }, "status": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse": { "type": "object", "properties": { "account_id": { "description": "Internal account details", "type": "integer" }, "created_at": { "type": "string" }, "email": { "type": "string" }, "email_verified": { "type": "boolean" }, "member_id": { "description": "Auth provider member details", "type": "string" }, "name": { "type": "string" }, "organization": { "description": "Organization details", "allOf": [ { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization" } ] }, "permissions": { "type": "array", "items": { "type": "string" } }, "roles": { "type": "array", "items": { "type": "string" } }, "status": { "type": "string" }, "updated_at": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError": { "type": "object", "properties": { "code": { "type": "string" }, "message": { "type": "string" } } }, "internal_modules_auth.PermissionCheckRequest": { "type": "object", "required": [ "permission_id", "role_id" ], "properties": { "permission_id": { "type": "string" }, "role_id": { "type": "string" } } }, "internal_modules_auth.PermissionCheckResponse": { "type": "object", "properties": { "has_permission": { "type": "boolean" }, "permission_id": { "type": "string" }, "role_id": { "type": "string" } } }, "internal_modules_auth.PermissionDTO": { "type": "object", "properties": { "action": { "type": "string" }, "category": { "type": "string" }, "description": { "type": "string" }, "display_name": { "type": "string" }, "id": { "type": "string" }, "resource": { "type": "string" } } }, "internal_modules_auth.PermissionsByCategoryResponse": { "type": "object", "properties": { "categories": { "type": "object", "additionalProperties": { "type": "array", "items": { "$ref": "#/definitions/internal_modules_auth.PermissionDTO" } } } } }, "internal_modules_auth.PermissionsResponse": { "type": "object", "properties": { "permissions": { "type": "array", "items": { "$ref": "#/definitions/internal_modules_auth.PermissionDTO" } } } }, "internal_modules_auth.RBACMetadata": { "type": "object", "properties": { "description": { "type": "string" }, "permissions_by_role": { "type": "object", "additionalProperties": { "type": "integer" } }, "total_permissions": { "type": "integer" }, "total_roles": { "type": "integer" } } }, "internal_modules_auth.RoleDTO": { "type": "object", "properties": { "description": { "type": "string" }, "id": { "type": "string" }, "name": { "type": "string" }, "permissions": { "type": "array", "items": { "$ref": "#/definitions/internal_modules_auth.PermissionDTO" } } } }, "internal_modules_auth.RolePermissionsResponse": { "type": "object", "properties": { "restrictions": { "$ref": "#/definitions/internal_modules_auth.RoleRestrictions" }, "role": { "$ref": "#/definitions/internal_modules_auth.RoleDTO" }, "statistics": { "$ref": "#/definitions/internal_modules_auth.RoleStatistics" } } }, "internal_modules_auth.RoleRestrictions": { "type": "object", "properties": { "cannot_do": { "type": "array", "items": { "type": "string" } }, "data_access_level": { "type": "string" }, "scope": { "type": "string" } } }, "internal_modules_auth.RoleStatistics": { "type": "object", "properties": { "can_approve": { "type": "boolean" }, "can_manage_org": { "type": "boolean" }, "description": { "type": "string" }, "total_permissions": { "type": "integer" } } }, "internal_modules_auth.RolesResponse": { "type": "object", "properties": { "roles": { "type": "array", "items": { "$ref": "#/definitions/internal_modules_auth.RoleDTO" } } } }, "internal_modules_billing.VerifyPaymentRequest": { "type": "object", "required": [ "session_id" ], "properties": { "session_id": { "type": "string" } } }, "internal_modules_cognitive.ChatRequest": { "type": "object", "required": [ "message" ], "properties": { "context_history": { "type": "integer" }, "max_documents": { "type": "integer" }, "message": { "type": "string" }, "session_id": { "type": "integer" }, "use_rag": { "type": "boolean" } } } }, "securityDefinitions": { "BasicAuth": { "type": "basic" } }, "externalDocs": { "description": "OpenAPI", "url": "https://swagger.io/resources/open-api/" } }` // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ Version: "1.0", Host: "localhost:8080", BasePath: "/api", Schemes: []string{}, Title: "B2B SaaS Starter API", Description: "This is the API server for B2B SaaS Starter.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", RightDelim: "}}", } func init() { swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) } ================================================ FILE: go-b2b-starter/internal/docs/gen/swagger.json ================================================ { "swagger": "2.0", "info": { "description": "This is the API server for B2B SaaS Starter.", "title": "B2B SaaS Starter API", "termsOfService": "http://swagger.io/terms/", "contact": { "name": "API Support", "url": "http://www.swagger.io/support", "email": "support@swagger.io" }, "license": { "name": "Apache 2.0", "url": "http://www.apache.org/licenses/LICENSE-2.0.html" }, "version": "1.0" }, "host": "localhost:8080", "basePath": "/api", "paths": { "/api/subscriptions/status": { "get": { "description": "Retrieve the current subscription billing status and invoice quota information for the organization", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "subscriptions" ], "summary": "Get current billing and quota status", "responses": { "200": { "description": "Current billing and quota status", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus" } }, "400": { "description": "Invalid request parameters or missing organization context", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal server error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/api/subscriptions/verify-payment": { "post": { "description": "Verifies a payment by checking the Polar checkout session and updates subscription status. This is the primary mechanism for \"Verification on Redirect\" pattern when user returns from payment page.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "subscriptions" ], "summary": "Verify payment from checkout session", "parameters": [ { "description": "Checkout session ID", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/internal_modules_billing.VerifyPaymentRequest" } } ], "responses": { "200": { "description": "Verification result with updated billing status", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus" } }, "400": { "description": "Invalid request parameters or checkout session failed", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "404": { "description": "Checkout session not found", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal server error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/auth/check-email": { "get": { "description": "Checks if an email exists in any organization. Returns 200 OK (empty response) if exists, 404 Not Found if doesn't exist. This is a public endpoint used during login flow.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Check if email exists", "parameters": [ { "type": "string", "description": "Email address to check", "name": "email", "in": "query", "required": true } ], "responses": { "200": { "description": "Email exists" }, "400": { "description": "Invalid email format", "schema": { "type": "object", "additionalProperties": true } }, "404": { "description": "Email not found", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Internal server error", "schema": { "type": "object", "additionalProperties": true } } } } }, "/auth/members": { "get": { "description": "Retrieves all members of the current organization. Restricted to admin role only.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "List organization members", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse" } }, "400": { "description": "Missing organization context", "schema": { "type": "object", "additionalProperties": true } }, "403": { "description": "Insufficient permissions - admin role required", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to list members", "schema": { "type": "object", "additionalProperties": true } } } }, "post": { "description": "Adds a new member to an existing organization with a specified role. Organization ID is automatically extracted from JWT token. Member receives a magic link invite email for passwordless authentication. Request body: {\"email\": \"user@example.com\", \"name\": \"Full Name\", \"role_slug\": \"member\"}", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Add member to organization", "parameters": [ { "type": "string", "description": "Bearer JWT token", "name": "Authorization", "in": "header", "required": true }, { "description": "Member email address", "name": "email", "in": "body", "required": true, "schema": { "type": "string" } }, { "description": "Member full name", "name": "name", "in": "body", "required": true, "schema": { "type": "string" } }, { "description": "Role slug (defaults to 'member')", "name": "role_slug", "in": "body", "schema": { "type": "string" } } ], "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse" } }, "400": { "description": "Invalid request payload or missing organization context", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to add member", "schema": { "type": "object", "additionalProperties": true } } } } }, "/auth/members/{member_id}": { "delete": { "description": "Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Delete organization member", "parameters": [ { "type": "string", "description": "Bearer JWT token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "Member ID to delete", "name": "member_id", "in": "path", "required": true } ], "responses": { "204": { "description": "Member deleted successfully", "schema": { "type": "object", "additionalProperties": true } }, "400": { "description": "Invalid member ID or missing organization context", "schema": { "type": "object", "additionalProperties": true } }, "403": { "description": "Insufficient permissions - admin role required", "schema": { "type": "object", "additionalProperties": true } }, "404": { "description": "Member not found", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to delete member", "schema": { "type": "object", "additionalProperties": true } } } } }, "/auth/profile/me": { "get": { "description": "Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Get current user profile", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse" } }, "400": { "description": "Missing required context (organization or claims)", "schema": { "type": "object", "additionalProperties": true } }, "401": { "description": "Authentication required", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to retrieve profile", "schema": { "type": "object", "additionalProperties": true } } } } }, "/auth/signup": { "post": { "description": "Creates a new organization in Stytch with an initial admin member. The admin receives a magic link invite email to complete passwordless onboarding. Organization slug is auto-generated from the organization name.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "auth" ], "summary": "Bootstrap organization", "parameters": [ { "description": "Organization bootstrap request (passwordless - no password required)", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest" } } ], "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse" } }, "400": { "description": "Invalid request payload", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Failed to bootstrap organization", "schema": { "type": "object", "additionalProperties": true } } } } }, "/example_cognitive/chat": { "post": { "description": "Sends a message to the AI and gets a response, optionally using RAG", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Cognitive" ], "summary": "Chat with AI", "parameters": [ { "description": "Chat request", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/internal_modules_cognitive.ChatRequest" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_cognitive/sessions": { "get": { "description": "Lists chat sessions for the current user with pagination", "produces": [ "application/json" ], "tags": [ "Cognitive" ], "summary": "List chat sessions", "parameters": [ { "type": "integer", "default": 10, "description": "Limit", "name": "limit", "in": "query" }, { "type": "integer", "default": 0, "description": "Offset", "name": "offset", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "object", "additionalProperties": true } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_cognitive/sessions/{id}/messages": { "get": { "description": "Retrieves all messages for a chat session", "produces": [ "application/json" ], "tags": [ "Cognitive" ], "summary": "Get session history", "parameters": [ { "type": "integer", "description": "Session ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage" } } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_documents": { "get": { "description": "Lists documents with optional filtering and pagination", "produces": [ "application/json" ], "tags": [ "Documents" ], "summary": "List documents", "parameters": [ { "type": "integer", "default": 10, "description": "Limit", "name": "limit", "in": "query" }, { "type": "integer", "default": 0, "description": "Offset", "name": "offset", "in": "query" }, { "type": "string", "description": "Filter by status (pending, processing, processed, failed)", "name": "status", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_documents/upload": { "post": { "description": "Uploads a PDF document, extracts text, and creates embeddings", "consumes": [ "multipart/form-data" ], "produces": [ "application/json" ], "tags": [ "Documents" ], "summary": "Upload PDF document", "parameters": [ { "type": "file", "description": "PDF file to upload", "name": "file", "in": "formData", "required": true }, { "type": "string", "description": "Document title", "name": "title", "in": "formData", "required": true } ], "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/example_documents/{id}": { "delete": { "description": "Deletes a document and its associated file", "tags": [ "Documents" ], "summary": "Delete document", "parameters": [ { "type": "integer", "description": "Document ID", "name": "id", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError" } } } } }, "/rbac/check-permission": { "post": { "description": "Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Check if a role has a specific permission", "parameters": [ { "description": "Role and permission to check", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/internal_modules_auth.PermissionCheckRequest" } } ], "responses": { "200": { "description": "Permission check result", "schema": { "$ref": "#/definitions/internal_modules_auth.PermissionCheckResponse" } }, "400": { "description": "Invalid request", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "/rbac/metadata": { "get": { "description": "Returns summary information about the RBAC system including total roles, permissions, and categories.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get RBAC system metadata", "responses": { "200": { "description": "RBAC system metadata", "schema": { "$ref": "#/definitions/internal_modules_auth.RBACMetadata" } } } } }, "/rbac/permissions": { "get": { "description": "Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get all permissions", "responses": { "200": { "description": "All permissions", "schema": { "$ref": "#/definitions/internal_modules_auth.PermissionsResponse" } }, "500": { "description": "Internal error", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "/rbac/permissions/by-category": { "get": { "description": "Returns all permissions organized by their category for better UI organization.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get permissions grouped by category", "responses": { "200": { "description": "Permissions by category", "schema": { "$ref": "#/definitions/internal_modules_auth.PermissionsByCategoryResponse" } }, "500": { "description": "Internal error", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "/rbac/roles": { "get": { "description": "Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get all roles with permissions", "responses": { "200": { "description": "Roles with permissions", "schema": { "$ref": "#/definitions/internal_modules_auth.RolesResponse" } }, "500": { "description": "Internal error", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "/rbac/roles/{role_id}": { "get": { "description": "Returns comprehensive information about a role including permissions, statistics, and restrictions.", "produces": [ "application/json" ], "tags": [ "RBAC" ], "summary": "Get detailed information about a specific role", "parameters": [ { "type": "string", "description": "Role ID (member, approver, admin)", "name": "role_id", "in": "path", "required": true } ], "responses": { "200": { "description": "Role details with statistics", "schema": { "$ref": "#/definitions/internal_modules_auth.RolePermissionsResponse" } }, "400": { "description": "Invalid role ID", "schema": { "type": "object", "additionalProperties": { "type": "string" } } }, "404": { "description": "Role not found", "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } } }, "definitions": { "github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus": { "type": "object", "properties": { "canProcessInvoices": { "type": "boolean" }, "checkedAt": { "type": "string" }, "externalID": { "type": "string" }, "hasActiveSubscription": { "type": "boolean" }, "invoiceCount": { "description": "Remaining invoices", "type": "integer", "format": "int32" }, "organizationID": { "type": "integer", "format": "int32" }, "reason": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage": { "type": "object", "properties": { "content": { "type": "string" }, "created_at": { "type": "string" }, "id": { "type": "integer" }, "referenced_docs": { "type": "array", "items": { "type": "integer" } }, "role": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole" }, "session_id": { "type": "integer" }, "tokens_used": { "type": "integer" } } }, "github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse": { "type": "object", "properties": { "message": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage" }, "referenced_docs": { "type": "array", "items": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument" } }, "session_id": { "type": "integer" }, "tokens_used": { "type": "integer" } } }, "github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole": { "type": "string", "enum": [ "user", "assistant", "system" ], "x-enum-varnames": [ "ChatRoleUser", "ChatRoleAssistant", "ChatRoleSystem" ] }, "github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument": { "type": "object", "properties": { "chunk_index": { "type": "integer" }, "content_hash": { "type": "string" }, "content_preview": { "type": "string" }, "created_at": { "type": "string" }, "document_id": { "type": "integer" }, "embedding": { "description": "1536 dimensions for OpenAI", "type": "array", "items": { "type": "number" } }, "id": { "type": "integer" }, "organization_id": { "type": "integer" }, "similarity_score": { "type": "number" }, "updated_at": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse": { "type": "object", "properties": { "documents": { "type": "array", "items": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document" } }, "limit": { "type": "integer" }, "offset": { "type": "integer" }, "total": { "type": "integer" } } }, "github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document": { "type": "object", "properties": { "content_type": { "type": "string" }, "created_at": { "type": "string" }, "extracted_text": { "type": "string" }, "file_asset_id": { "type": "integer" }, "file_name": { "type": "string" }, "file_size": { "type": "integer" }, "id": { "type": "integer" }, "metadata": { "type": "object", "additionalProperties": true }, "organization_id": { "type": "integer" }, "status": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus" }, "title": { "type": "string" }, "updated_at": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus": { "type": "string", "enum": [ "pending", "processing", "processed", "failed" ], "x-enum-varnames": [ "DocumentStatusPending", "DocumentStatusProcessing", "DocumentStatusProcessed", "DocumentStatusFailed" ] }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse": { "type": "object", "properties": { "email": { "type": "string" }, "invite_sent": { "type": "boolean" }, "member_id": { "type": "string" }, "name": { "type": "string" }, "org_id": { "type": "string" }, "role_slug": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest": { "type": "object", "required": [ "org_display_name", "owner_email", "owner_name" ], "properties": { "org_display_name": { "description": "Organization details", "type": "string" }, "owner_email": { "description": "Owner member details", "type": "string" }, "owner_name": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse": { "type": "object", "properties": { "display_name": { "type": "string" }, "invite_sent": { "type": "boolean" }, "magic_link_sent": { "type": "boolean" }, "org_slug": { "type": "string" }, "organization_id": { "type": "string" }, "owner_email": { "type": "string" }, "owner_member_id": { "type": "string" }, "owner_name": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse": { "type": "object", "properties": { "members": { "type": "array", "items": { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo" } }, "total": { "type": "integer" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo": { "type": "object", "properties": { "created_at": { "type": "string" }, "email": { "type": "string" }, "email_verified": { "type": "boolean" }, "member_id": { "type": "string" }, "name": { "type": "string" }, "roles": { "type": "array", "items": { "type": "string" } }, "status": { "type": "string" }, "updated_at": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization": { "type": "object", "properties": { "name": { "type": "string" }, "organization_id": { "type": "string" }, "slug": { "type": "string" }, "status": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse": { "type": "object", "properties": { "account_id": { "description": "Internal account details", "type": "integer" }, "created_at": { "type": "string" }, "email": { "type": "string" }, "email_verified": { "type": "boolean" }, "member_id": { "description": "Auth provider member details", "type": "string" }, "name": { "type": "string" }, "organization": { "description": "Organization details", "allOf": [ { "$ref": "#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization" } ] }, "permissions": { "type": "array", "items": { "type": "string" } }, "roles": { "type": "array", "items": { "type": "string" } }, "status": { "type": "string" }, "updated_at": { "type": "string" } } }, "github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError": { "type": "object", "properties": { "code": { "type": "string" }, "message": { "type": "string" } } }, "internal_modules_auth.PermissionCheckRequest": { "type": "object", "required": [ "permission_id", "role_id" ], "properties": { "permission_id": { "type": "string" }, "role_id": { "type": "string" } } }, "internal_modules_auth.PermissionCheckResponse": { "type": "object", "properties": { "has_permission": { "type": "boolean" }, "permission_id": { "type": "string" }, "role_id": { "type": "string" } } }, "internal_modules_auth.PermissionDTO": { "type": "object", "properties": { "action": { "type": "string" }, "category": { "type": "string" }, "description": { "type": "string" }, "display_name": { "type": "string" }, "id": { "type": "string" }, "resource": { "type": "string" } } }, "internal_modules_auth.PermissionsByCategoryResponse": { "type": "object", "properties": { "categories": { "type": "object", "additionalProperties": { "type": "array", "items": { "$ref": "#/definitions/internal_modules_auth.PermissionDTO" } } } } }, "internal_modules_auth.PermissionsResponse": { "type": "object", "properties": { "permissions": { "type": "array", "items": { "$ref": "#/definitions/internal_modules_auth.PermissionDTO" } } } }, "internal_modules_auth.RBACMetadata": { "type": "object", "properties": { "description": { "type": "string" }, "permissions_by_role": { "type": "object", "additionalProperties": { "type": "integer" } }, "total_permissions": { "type": "integer" }, "total_roles": { "type": "integer" } } }, "internal_modules_auth.RoleDTO": { "type": "object", "properties": { "description": { "type": "string" }, "id": { "type": "string" }, "name": { "type": "string" }, "permissions": { "type": "array", "items": { "$ref": "#/definitions/internal_modules_auth.PermissionDTO" } } } }, "internal_modules_auth.RolePermissionsResponse": { "type": "object", "properties": { "restrictions": { "$ref": "#/definitions/internal_modules_auth.RoleRestrictions" }, "role": { "$ref": "#/definitions/internal_modules_auth.RoleDTO" }, "statistics": { "$ref": "#/definitions/internal_modules_auth.RoleStatistics" } } }, "internal_modules_auth.RoleRestrictions": { "type": "object", "properties": { "cannot_do": { "type": "array", "items": { "type": "string" } }, "data_access_level": { "type": "string" }, "scope": { "type": "string" } } }, "internal_modules_auth.RoleStatistics": { "type": "object", "properties": { "can_approve": { "type": "boolean" }, "can_manage_org": { "type": "boolean" }, "description": { "type": "string" }, "total_permissions": { "type": "integer" } } }, "internal_modules_auth.RolesResponse": { "type": "object", "properties": { "roles": { "type": "array", "items": { "$ref": "#/definitions/internal_modules_auth.RoleDTO" } } } }, "internal_modules_billing.VerifyPaymentRequest": { "type": "object", "required": [ "session_id" ], "properties": { "session_id": { "type": "string" } } }, "internal_modules_cognitive.ChatRequest": { "type": "object", "required": [ "message" ], "properties": { "context_history": { "type": "integer" }, "max_documents": { "type": "integer" }, "message": { "type": "string" }, "session_id": { "type": "integer" }, "use_rag": { "type": "boolean" } } } }, "securityDefinitions": { "BasicAuth": { "type": "basic" } }, "externalDocs": { "description": "OpenAPI", "url": "https://swagger.io/resources/open-api/" } } ================================================ FILE: go-b2b-starter/internal/docs/gen/swagger.yaml ================================================ basePath: /api definitions: github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus: properties: canProcessInvoices: type: boolean checkedAt: type: string externalID: type: string hasActiveSubscription: type: boolean invoiceCount: description: Remaining invoices format: int32 type: integer organizationID: format: int32 type: integer reason: type: string type: object github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage: properties: content: type: string created_at: type: string id: type: integer referenced_docs: items: type: integer type: array role: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole' session_id: type: integer tokens_used: type: integer type: object github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse: properties: message: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage' referenced_docs: items: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument' type: array session_id: type: integer tokens_used: type: integer type: object github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole: enum: - user - assistant - system type: string x-enum-varnames: - ChatRoleUser - ChatRoleAssistant - ChatRoleSystem github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument: properties: chunk_index: type: integer content_hash: type: string content_preview: type: string created_at: type: string document_id: type: integer embedding: description: 1536 dimensions for OpenAI items: type: number type: array id: type: integer organization_id: type: integer similarity_score: type: number updated_at: type: string type: object github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse: properties: documents: items: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document' type: array limit: type: integer offset: type: integer total: type: integer type: object github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document: properties: content_type: type: string created_at: type: string extracted_text: type: string file_asset_id: type: integer file_name: type: string file_size: type: integer id: type: integer metadata: additionalProperties: true type: object organization_id: type: integer status: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus' title: type: string updated_at: type: string type: object github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus: enum: - pending - processing - processed - failed type: string x-enum-varnames: - DocumentStatusPending - DocumentStatusProcessing - DocumentStatusProcessed - DocumentStatusFailed github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse: properties: email: type: string invite_sent: type: boolean member_id: type: string name: type: string org_id: type: string role_slug: type: string type: object github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest: properties: org_display_name: description: Organization details type: string owner_email: description: Owner member details type: string owner_name: type: string required: - org_display_name - owner_email - owner_name type: object github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse: properties: display_name: type: string invite_sent: type: boolean magic_link_sent: type: boolean org_slug: type: string organization_id: type: string owner_email: type: string owner_member_id: type: string owner_name: type: string type: object github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse: properties: members: items: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo' type: array total: type: integer type: object github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo: properties: created_at: type: string email: type: string email_verified: type: boolean member_id: type: string name: type: string roles: items: type: string type: array status: type: string updated_at: type: string type: object github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization: properties: name: type: string organization_id: type: string slug: type: string status: type: string type: object github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse: properties: account_id: description: Internal account details type: integer created_at: type: string email: type: string email_verified: type: boolean member_id: description: Auth provider member details type: string name: type: string organization: allOf: - $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization' description: Organization details permissions: items: type: string type: array roles: items: type: string type: array status: type: string updated_at: type: string type: object github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError: properties: code: type: string message: type: string type: object internal_modules_auth.PermissionCheckRequest: properties: permission_id: type: string role_id: type: string required: - permission_id - role_id type: object internal_modules_auth.PermissionCheckResponse: properties: has_permission: type: boolean permission_id: type: string role_id: type: string type: object internal_modules_auth.PermissionDTO: properties: action: type: string category: type: string description: type: string display_name: type: string id: type: string resource: type: string type: object internal_modules_auth.PermissionsByCategoryResponse: properties: categories: additionalProperties: items: $ref: '#/definitions/internal_modules_auth.PermissionDTO' type: array type: object type: object internal_modules_auth.PermissionsResponse: properties: permissions: items: $ref: '#/definitions/internal_modules_auth.PermissionDTO' type: array type: object internal_modules_auth.RBACMetadata: properties: description: type: string permissions_by_role: additionalProperties: type: integer type: object total_permissions: type: integer total_roles: type: integer type: object internal_modules_auth.RoleDTO: properties: description: type: string id: type: string name: type: string permissions: items: $ref: '#/definitions/internal_modules_auth.PermissionDTO' type: array type: object internal_modules_auth.RolePermissionsResponse: properties: restrictions: $ref: '#/definitions/internal_modules_auth.RoleRestrictions' role: $ref: '#/definitions/internal_modules_auth.RoleDTO' statistics: $ref: '#/definitions/internal_modules_auth.RoleStatistics' type: object internal_modules_auth.RoleRestrictions: properties: cannot_do: items: type: string type: array data_access_level: type: string scope: type: string type: object internal_modules_auth.RoleStatistics: properties: can_approve: type: boolean can_manage_org: type: boolean description: type: string total_permissions: type: integer type: object internal_modules_auth.RolesResponse: properties: roles: items: $ref: '#/definitions/internal_modules_auth.RoleDTO' type: array type: object internal_modules_billing.VerifyPaymentRequest: properties: session_id: type: string required: - session_id type: object internal_modules_cognitive.ChatRequest: properties: context_history: type: integer max_documents: type: integer message: type: string session_id: type: integer use_rag: type: boolean required: - message type: object externalDocs: description: OpenAPI url: https://swagger.io/resources/open-api/ host: localhost:8080 info: contact: email: support@swagger.io name: API Support url: http://www.swagger.io/support description: This is the API server for B2B SaaS Starter. license: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html termsOfService: http://swagger.io/terms/ title: B2B SaaS Starter API version: "1.0" paths: /api/subscriptions/status: get: consumes: - application/json description: Retrieve the current subscription billing status and invoice quota information for the organization produces: - application/json responses: "200": description: Current billing and quota status schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus' "400": description: Invalid request parameters or missing organization context schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' "500": description: Internal server error schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' summary: Get current billing and quota status tags: - subscriptions /api/subscriptions/verify-payment: post: consumes: - application/json description: Verifies a payment by checking the Polar checkout session and updates subscription status. This is the primary mechanism for "Verification on Redirect" pattern when user returns from payment page. parameters: - description: Checkout session ID in: body name: request required: true schema: $ref: '#/definitions/internal_modules_billing.VerifyPaymentRequest' produces: - application/json responses: "200": description: Verification result with updated billing status schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus' "400": description: Invalid request parameters or checkout session failed schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' "404": description: Checkout session not found schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' "500": description: Internal server error schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' summary: Verify payment from checkout session tags: - subscriptions /auth/check-email: get: consumes: - application/json description: Checks if an email exists in any organization. Returns 200 OK (empty response) if exists, 404 Not Found if doesn't exist. This is a public endpoint used during login flow. parameters: - description: Email address to check in: query name: email required: true type: string produces: - application/json responses: "200": description: Email exists "400": description: Invalid email format schema: additionalProperties: true type: object "404": description: Email not found schema: additionalProperties: true type: object "500": description: Internal server error schema: additionalProperties: true type: object summary: Check if email exists tags: - auth /auth/members: get: consumes: - application/json description: Retrieves all members of the current organization. Restricted to admin role only. produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse' "400": description: Missing organization context schema: additionalProperties: true type: object "403": description: Insufficient permissions - admin role required schema: additionalProperties: true type: object "500": description: Failed to list members schema: additionalProperties: true type: object summary: List organization members tags: - auth post: consumes: - application/json description: 'Adds a new member to an existing organization with a specified role. Organization ID is automatically extracted from JWT token. Member receives a magic link invite email for passwordless authentication. Request body: {"email": "user@example.com", "name": "Full Name", "role_slug": "member"}' parameters: - description: Bearer JWT token in: header name: Authorization required: true type: string - description: Member email address in: body name: email required: true schema: type: string - description: Member full name in: body name: name required: true schema: type: string - description: Role slug (defaults to 'member') in: body name: role_slug schema: type: string produces: - application/json responses: "201": description: Created schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse' "400": description: Invalid request payload or missing organization context schema: additionalProperties: true type: object "500": description: Failed to add member schema: additionalProperties: true type: object summary: Add member to organization tags: - auth /auth/members/{member_id}: delete: consumes: - application/json description: Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members. parameters: - description: Bearer JWT token in: header name: Authorization required: true type: string - description: Member ID to delete in: path name: member_id required: true type: string produces: - application/json responses: "204": description: Member deleted successfully schema: additionalProperties: true type: object "400": description: Invalid member ID or missing organization context schema: additionalProperties: true type: object "403": description: Insufficient permissions - admin role required schema: additionalProperties: true type: object "404": description: Member not found schema: additionalProperties: true type: object "500": description: Failed to delete member schema: additionalProperties: true type: object summary: Delete organization member tags: - auth /auth/profile/me: get: consumes: - application/json description: Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status. produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse' "400": description: Missing required context (organization or claims) schema: additionalProperties: true type: object "401": description: Authentication required schema: additionalProperties: true type: object "500": description: Failed to retrieve profile schema: additionalProperties: true type: object summary: Get current user profile tags: - auth /auth/signup: post: consumes: - application/json description: Creates a new organization in Stytch with an initial admin member. The admin receives a magic link invite email to complete passwordless onboarding. Organization slug is auto-generated from the organization name. parameters: - description: Organization bootstrap request (passwordless - no password required) in: body name: request required: true schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest' produces: - application/json responses: "201": description: Created schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse' "400": description: Invalid request payload schema: additionalProperties: true type: object "500": description: Failed to bootstrap organization schema: additionalProperties: true type: object summary: Bootstrap organization tags: - auth /example_cognitive/chat: post: consumes: - application/json description: Sends a message to the AI and gets a response, optionally using RAG parameters: - description: Chat request in: body name: request required: true schema: $ref: '#/definitions/internal_modules_cognitive.ChatRequest' produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' summary: Chat with AI tags: - Cognitive /example_cognitive/sessions: get: description: Lists chat sessions for the current user with pagination parameters: - default: 10 description: Limit in: query name: limit type: integer - default: 0 description: Offset in: query name: offset type: integer produces: - application/json responses: "200": description: OK schema: additionalProperties: true type: object "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' summary: List chat sessions tags: - Cognitive /example_cognitive/sessions/{id}/messages: get: description: Retrieves all messages for a chat session parameters: - description: Session ID in: path name: id required: true type: integer produces: - application/json responses: "200": description: OK schema: items: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage' type: array "400": description: Bad Request schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' summary: Get session history tags: - Cognitive /example_documents: get: description: Lists documents with optional filtering and pagination parameters: - default: 10 description: Limit in: query name: limit type: integer - default: 0 description: Offset in: query name: offset type: integer - description: Filter by status (pending, processing, processed, failed) in: query name: status type: string produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' summary: List documents tags: - Documents /example_documents/{id}: delete: description: Deletes a document and its associated file parameters: - description: Document ID in: path name: id required: true type: integer responses: "204": description: No Content "400": description: Bad Request schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' summary: Delete document tags: - Documents /example_documents/upload: post: consumes: - multipart/form-data description: Uploads a PDF document, extracts text, and creates embeddings parameters: - description: PDF file to upload in: formData name: file required: true type: file - description: Document title in: formData name: title required: true type: string produces: - application/json responses: "201": description: Created schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document' "400": description: Bad Request schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError' summary: Upload PDF document tags: - Documents /rbac/check-permission: post: consumes: - application/json description: Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering. parameters: - description: Role and permission to check in: body name: body required: true schema: $ref: '#/definitions/internal_modules_auth.PermissionCheckRequest' produces: - application/json responses: "200": description: Permission check result schema: $ref: '#/definitions/internal_modules_auth.PermissionCheckResponse' "400": description: Invalid request schema: additionalProperties: type: string type: object summary: Check if a role has a specific permission tags: - RBAC /rbac/metadata: get: description: Returns summary information about the RBAC system including total roles, permissions, and categories. produces: - application/json responses: "200": description: RBAC system metadata schema: $ref: '#/definitions/internal_modules_auth.RBACMetadata' summary: Get RBAC system metadata tags: - RBAC /rbac/permissions: get: description: Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering. produces: - application/json responses: "200": description: All permissions schema: $ref: '#/definitions/internal_modules_auth.PermissionsResponse' "500": description: Internal error schema: additionalProperties: type: string type: object summary: Get all permissions tags: - RBAC /rbac/permissions/by-category: get: description: Returns all permissions organized by their category for better UI organization. produces: - application/json responses: "200": description: Permissions by category schema: $ref: '#/definitions/internal_modules_auth.PermissionsByCategoryResponse' "500": description: Internal error schema: additionalProperties: type: string type: object summary: Get permissions grouped by category tags: - RBAC /rbac/roles: get: description: Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery. produces: - application/json responses: "200": description: Roles with permissions schema: $ref: '#/definitions/internal_modules_auth.RolesResponse' "500": description: Internal error schema: additionalProperties: type: string type: object summary: Get all roles with permissions tags: - RBAC /rbac/roles/{role_id}: get: description: Returns comprehensive information about a role including permissions, statistics, and restrictions. parameters: - description: Role ID (member, approver, admin) in: path name: role_id required: true type: string produces: - application/json responses: "200": description: Role details with statistics schema: $ref: '#/definitions/internal_modules_auth.RolePermissionsResponse' "400": description: Invalid role ID schema: additionalProperties: type: string type: object "404": description: Role not found schema: additionalProperties: type: string type: object summary: Get detailed information about a specific role tags: - RBAC securityDefinitions: BasicAuth: type: basic swagger: "2.0" ================================================ FILE: go-b2b-starter/internal/modules/auth/README.md ================================================ # Auth Package Provider-agnostic authentication and authorization with type-safe middleware. Supports JWT verification, RBAC permissions, and multi-tenant organization context. ## Quick Start ### Setup Middleware ```go // In server initialization authMiddleware := auth.NewMiddleware(authProvider, orgResolver, accResolver, nil) // Apply to routes router.Use(authMiddleware.RequireAuth()) // Verify JWT token router.Use(authMiddleware.RequireOrganization()) // Resolve org/account IDs ``` ### Protect Routes ```go router.GET("/invoices", auth.RequirePermissionFunc("invoice", "view"), handler.ListInvoices) router.POST("/invoices", auth.RequirePermissionFunc("invoice", "create"), handler.CreateInvoice) ``` ### Get Context in Handlers ```go func (h *Handler) MyHandler(c *gin.Context) { reqCtx := auth.GetRequestContext(c) orgID := reqCtx.OrganizationID // int32 database ID accountID := reqCtx.AccountID // int32 database ID email := reqCtx.Identity.Email // User's email } ``` ## Core Concepts - **Identity**: User info from auth provider (email, roles, permissions) - **RequestContext**: Resolved database IDs (OrganizationID, AccountID) - **Permissions**: Format `"resource:action"` (e.g., `"invoice:create"`, `"org:manage"`) ## Common Patterns ### Pattern 1: Public Route No authentication required: ```go router.POST("/auth/signup", handler.Signup) router.GET("/auth/check-email", handler.CheckEmail) ``` ### Pattern 2: Authenticated Route Any logged-in user can access: ```go router.GET("/profile/me", authMiddleware.RequireAuth(), handler.GetProfile) ``` ### Pattern 3: Organization Route Requires organization context (most common pattern): ```go orgGroup := router.Group("/organizations") orgGroup.Use( authMiddleware.RequireAuth(), authMiddleware.RequireOrganization(), ) { orgGroup.GET("", handler.GetOrganization) orgGroup.PUT("", handler.UpdateOrganization) orgGroup.GET("/stats", handler.GetOrganizationStats) } ``` ### Pattern 4: Permission-Protected Route Requires specific permission: ```go router.POST("/invoices", authMiddleware.RequireAuth(), authMiddleware.RequireOrganization(), auth.RequirePermissionFunc("invoice", "create"), handler.CreateInvoice) router.DELETE("/invoices/:id", authMiddleware.RequireAuth(), authMiddleware.RequireOrganization(), auth.RequirePermissionFunc("invoice", "delete"), handler.DeleteInvoice) ``` ### Pattern 5: Role-Protected Route Requires specific role: ```go router.DELETE("/organizations/:id", authMiddleware.RequireAuth(), authMiddleware.RequireRole(auth.RoleAdmin), handler.DeleteOrganization) ``` ## Stytch Project Setup ### Create Stytch Account & Project 1. Go to [https://stytch.com](https://stytch.com) and sign up 2. Create a new **B2B project** 3. Choose **"Test"** environment for development ### Get Your Credentials From your Stytch project dashboard: ```env STYTCH_PROJECT_ID=project-test-xxx-xxx # Project Settings → Project ID STYTCH_SECRET=secret-test-xxx # API Keys → Secret STYTCH_ENV=test # "test" or "live" ``` ### Configure RBAC Policies Go to **Dashboard → RBAC → Policies** and create these resources: | Resource | Actions | Description | |----------|---------|-------------| | `resource` | `view`, `create`, `edit`, `delete`, `approve` | Your domain entity (rename to your business) | | `org` | `view`, `manage` | Organization settings | > **Tip**: Rename "resource" to your domain entity (e.g., `invoice`, `patient`, `project`). ### Set Up Roles Go to **Dashboard → RBAC → Roles**: - **stytch_admin** (built-in): Auto-assigned to organization creator, has all permissions - **stytch_member** (built-in): Default role for all members - **manager** (custom): Create for elevated access | Role | Permissions | |------|-------------| | member | `resource:view`, `resource:create` | | manager | All resource permissions + `org:view` | | admin | All permissions | Assign permissions to roles based on your business needs. ### Test Your Setup ```bash # Add credentials to app.env STYTCH_PROJECT_ID=project-test-xxx-xxx STYTCH_SECRET=secret-test-xxx STYTCH_ENV=test STYTCH_SESSION_DURATION_MINUTES=1440 # Optional: 24 hours # Run the application make server # First user to sign up becomes stytch_admin # Subsequent users get stytch_member role ``` ## Configuration After completing setup above, your `app.env` should have: ```env STYTCH_PROJECT_ID=project-test-xxx-xxx # Required STYTCH_SECRET=secret-test-xxx # Required STYTCH_ENV=test # Optional: "test" or "live" STYTCH_SESSION_DURATION_MINUTES=1440 # Optional: 24 hours (default) STYTCH_API_TIMEOUT=15s # Optional: 15 seconds (default) ``` ## Adding New Permissions **Step 1:** Define in `rbac.go` ```go // In the permissions section of rbac.go var ( // Add your new permissions PermReportView = NewPermission("report", "view") PermReportExport = NewPermission("report", "export") ) // Don't forget to add to AllPermissions var AllPermissions = []Permission{ // ... existing permissions PermReportView, PermReportExport, } ``` **Step 2:** Use in routes ```go router.GET("/reports", auth.RequirePermissionFunc("report", "view"), handler.ListReports) router.POST("/reports/export", auth.RequirePermissionFunc("report", "export"), handler.ExportReport) ``` **Step 3:** Configure in Stytch Dashboard - Go to Stytch Dashboard → RBAC → Policies - Add resource: `report` - Add actions: `view`, `export` - Assign to roles ## Common Handler Patterns ### Check Permission ```go identity := auth.GetIdentity(c) if identity.HasResourcePermission("invoice", "delete") { // Show delete button } ``` ### Check Role ```go identity := auth.GetIdentity(c) if identity.HasRole(auth.RoleAdmin) { // Show admin panel } ``` ### Get Organization ID ```go // Safe: returns 0 if not set orgID := auth.GetOrganizationID(c) // Panics if not set (use only after RequireOrganization) reqCtx := auth.MustGetRequestContext(c) ``` ### Get Account ID ```go // Safe: returns 0 if not set accountID := auth.GetAccountID(c) ``` ### Get Full Context ```go reqCtx := auth.GetRequestContext(c) if reqCtx == nil { // Handle missing context return } // Access all fields orgID := reqCtx.OrganizationID accountID := reqCtx.AccountID email := reqCtx.Identity.Email roles := reqCtx.Identity.Roles permissions := reqCtx.Identity.Permissions ``` ## Multiple Permission Checks ### Require Any Permission At least one permission required: ```go router.GET("/reports", auth.RequireAnyPermissionFunc( auth.PermReportView, auth.PermReportExport, ), handler.GetReports) ``` ### Require All Permissions All permissions required: ```go router.POST("/admin/dangerous", authMiddleware.RequireAllPermissions( auth.NewPermission("admin", "access"), auth.NewPermission("admin", "write"), ), handler.DangerousOperation) ``` ## Troubleshooting **"authentication required"** - Check `Authorization: Bearer ` header format - Verify token not expired - Check STYTCH_PROJECT_ID matches token issuer **"organization not found"** - Organization must exist in database before authentication - Check provider org ID is mapped to database ID **"insufficient permissions"** - Verify user has required permission in Stytch RBAC dashboard - Check permission format: `"resource:action"` (not `resource.action`) - Check role-based permissions in `roles.go` ## Predefined Permissions Generic permissions available as constants (rename "resource" to your domain): ```go // Resource (rename to your domain: invoice, patient, project, etc.) auth.PermResourceView // "resource:view" auth.PermResourceCreate // "resource:create" auth.PermResourceEdit // "resource:edit" auth.PermResourceDelete // "resource:delete" auth.PermResourceApprove // "resource:approve" // Organization auth.PermOrgView // "org:view" auth.PermOrgManage // "org:manage" // See rbac.go for complete list and customization instructions ``` ## Custom Middleware Example Create custom auth middleware for specific needs: ```go func RequireAdminOrOwner() gin.HandlerFunc { return func(c *gin.Context) { identity := auth.GetIdentity(c) if identity == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) c.Abort() return } if !identity.HasRole(auth.RoleAdmin) && !identity.HasRole(auth.RoleOwner) { c.JSON(http.StatusForbidden, gin.H{"error": "admin or owner required"}) c.Abort() return } c.Next() } } // Usage router.DELETE("/organizations/:id", RequireAdminOrOwner(), handler.Delete) ``` ## Learn More - **RBAC Definitions**: See `rbac.go` for all roles, permissions, and customization instructions - **Stytch Setup**: See `STYTCH_SETUP.md` for dashboard configuration - **API reference**: Run `go doc github.com/moasq/go-b2b-starter/pkg/auth` - **Examples**: See `src/api/organizations/routes.go` for real-world usage - **Stytch B2B RBAC**: https://stytch.com/docs/b2b/guides/rbac/overview ================================================ FILE: go-b2b-starter/internal/modules/auth/adapters/stytch/adapter.go ================================================ // Package stytch provides Stytch B2B authentication integration. // // This package implements the auth.AuthProvider interface using Stytch // as the identity provider. It handles JWT verification, JWKS caching, // and RBAC policy management. // // # Architecture // // The adapter uses a two-tier verification strategy: // 1. Fast Path: Local JWT verification using cached JWKS (Redis) // 2. Slow Path: Stytch API verification (fallback) // // This optimization saves 300-500ms per request for the common case. // // # Components // // - StytchAuthAdapter: Main entry point implementing auth.AuthProvider // - TokenVerifier: JWT verification with local/API fallback // - JWKSCache: Public key caching in Redis // - RBACPolicyService: Role permission resolution // // # Usage // // cfg, err := stytch.LoadConfig() // if err != nil { // log.Fatal(err) // } // // adapter, err := stytch.NewStytchAuthAdapter(cfg, redisClient, logger) // if err != nil { // log.Fatal(err) // } // // // Use as auth.AuthProvider // identity, err := adapter.VerifyToken(ctx, token) package stytch import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/moasq/go-b2b-starter/internal/platform/redis" "github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi" ) // StytchAuthAdapter implements auth.AuthProvider using Stytch B2B. // // It provides authentication and authorization using Stytch's // session management and RBAC capabilities. type StytchAuthAdapter struct { client *b2bstytchapi.API tokenVerifier *TokenVerifier policyService *RBACPolicyService cfg *Config logger logger.Logger } // Ensure StytchAuthAdapter implements auth.AuthProvider. var _ auth.AuthProvider = (*StytchAuthAdapter)(nil) // It initializes the Stytch client, JWKS cache, and RBAC policy service. // Returns an error if configuration or client initialization fails. func NewStytchAuthAdapter( cfg *Config, redisClient redis.Client, log logger.Logger, ) (*StytchAuthAdapter, error) { // Validate configuration if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid stytch config: %w", err) } // Create Stytch API client client, err := b2bstytchapi.NewClient(cfg.ProjectID, cfg.Secret) if err != nil { return nil, fmt.Errorf("failed to create stytch client: %w", err) } // Create JWKS cache for local JWT verification jwksCache := NewJWKSCache(cfg.JWKSURL, redisClient, log) // Create RBAC policy service for permission resolution policyService := NewRBACPolicyService(client, redisClient, log) // Create token verifier with two-tier strategy tokenVerifier := NewTokenVerifier(client, jwksCache, policyService, cfg, log) return &StytchAuthAdapter{ client: client, tokenVerifier: tokenVerifier, policyService: policyService, cfg: cfg, logger: log, }, nil } // NewStytchAuthAdapterWithClient creates an adapter with an existing Stytch client. // // This is useful for testing or when you want to reuse an existing client. func NewStytchAuthAdapterWithClient( client *b2bstytchapi.API, cfg *Config, redisClient redis.Client, log logger.Logger, ) *StytchAuthAdapter { jwksCache := NewJWKSCache(cfg.JWKSURL, redisClient, log) policyService := NewRBACPolicyService(client, redisClient, log) tokenVerifier := NewTokenVerifier(client, jwksCache, policyService, cfg, log) return &StytchAuthAdapter{ client: client, tokenVerifier: tokenVerifier, policyService: policyService, cfg: cfg, logger: log, } } // VerifyToken validates the supplied session JWT and returns an Identity. // // This implements auth.AuthProvider.VerifyToken. // // The verification uses a two-tier strategy: // 1. Fast Path: Local JWT verification using cached JWKS // 2. Slow Path: Stytch API verification (fallback) // // Returns auth.ErrInvalidToken if the token is invalid. // Returns auth.ErrTokenExpired if the token has expired. // Returns auth.ErrEmailNotVerified if email is not verified. func (a *StytchAuthAdapter) VerifyToken(ctx context.Context, token string) (*auth.Identity, error) { if token == "" { return nil, auth.ErrInvalidToken } identity, err := a.tokenVerifier.Verify(ctx, token) if err != nil { a.logger.Debug("token verification failed", logger.Fields{ "error": err.Error(), }) return nil, err } a.logger.Debug("token verified successfully", logger.Fields{ "user_id": identity.UserID, "email": identity.Email, "organization_id": identity.OrganizationID, "roles_count": len(identity.Roles), "permissions_count": len(identity.Permissions), }) return identity, nil } // Client returns the underlying Stytch API client. // // This is useful for advanced operations not covered by auth.AuthProvider, // such as member management, organization settings, etc. func (a *StytchAuthAdapter) Client() *b2bstytchapi.API { return a.client } // Config returns the Stytch configuration. func (a *StytchAuthAdapter) Config() *Config { return a.cfg } // PolicyService returns the RBAC policy service. // // This is useful for permission queries outside the normal auth flow. func (a *StytchAuthAdapter) PolicyService() *RBACPolicyService { return a.policyService } ================================================ FILE: go-b2b-starter/internal/modules/auth/adapters/stytch/config.go ================================================ package stytch import ( "fmt" "strings" "time" "github.com/spf13/viper" ) // Environment constants supported by the Stytch B2B API. const ( EnvTest = "test" EnvLive = "live" ) // Config captures the runtime configuration for Stytch authentication. // // All configuration values can be set via environment variables with the // STYTCH_ prefix (e.g., STYTCH_PROJECT_ID, STYTCH_SECRET). type Config struct { // ProjectID is the Stytch project identifier (required) ProjectID string `mapstructure:"STYTCH_PROJECT_ID"` // Secret is the Stytch API secret (required) Secret string `mapstructure:"STYTCH_SECRET"` // Env is the Stytch environment: "test" or "live" Env string `mapstructure:"STYTCH_ENV"` // BaseURL is the Stytch API base URL (derived from Env if not set) BaseURL string `mapstructure:"STYTCH_BASE_URL"` // CustomDomain is an optional custom domain for Stytch CustomDomain string `mapstructure:"STYTCH_CUSTOM_DOMAIN"` // JWKSURL is the JWKS endpoint URL (derived from BaseURL if not set) JWKSURL string `mapstructure:"STYTCH_JWKS_URL"` // SessionDurationMinutes is how long sessions should last SessionDurationMinutes int32 `mapstructure:"STYTCH_SESSION_DURATION_MINUTES"` // DisableSessionVerification disables JWT signature verification (testing only!) DisableSessionVerification bool `mapstructure:"STYTCH_DISABLE_SESSION_VERIFICATION"` // OwnerRoleSlug is the role slug for organization owners OwnerRoleSlug string `mapstructure:"STYTCH_OWNER_ROLE_SLUG"` // InviteRedirectURL is where to redirect after invitation acceptance InviteRedirectURL string `mapstructure:"STYTCH_INVITE_REDIRECT_URL"` // LoginRedirectURL is where to redirect after login LoginRedirectURL string `mapstructure:"STYTCH_LOGIN_REDIRECT_URL"` // APITimeout is the timeout for Stytch API calls APITimeout time.Duration `mapstructure:"STYTCH_API_TIMEOUT"` } // LoadConfig loads the Stytch configuration from environment variables and app.env file. // // Configuration priority: // 1. Environment variables (highest) // 2. app.env file // 3. Default values (lowest) func LoadConfig() (*Config, error) { v := viper.New() v.SetConfigName("app") v.SetConfigType("env") v.AddConfigPath(".") v.AutomaticEnv() // Set defaults v.SetDefault("STYTCH_ENV", EnvTest) v.SetDefault("STYTCH_SESSION_DURATION_MINUTES", 1440) // 24 hours v.SetDefault("STYTCH_API_TIMEOUT", "15s") v.SetDefault("STYTCH_DISABLE_SESSION_VERIFICATION", false) // Try to read config file (ignore if not found) if err := v.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return nil, fmt.Errorf("failed to read config: %w", err) } } var cfg Config if err := v.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("unable to decode stytch config: %w", err) } // Normalize environment cfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env)) if cfg.Env == "" { cfg.Env = EnvTest } // Validate required fields if cfg.ProjectID == "" { return nil, fmt.Errorf("stytch configuration invalid: STYTCH_PROJECT_ID is required") } if cfg.Secret == "" { return nil, fmt.Errorf("stytch configuration invalid: STYTCH_SECRET is required") } // Normalize timeout if cfg.APITimeout <= 0 { cfg.APITimeout = 15 * time.Second } // Derive base URL if not set if cfg.BaseURL == "" { switch cfg.Env { case EnvLive: cfg.BaseURL = "https://api.stytch.com" default: cfg.BaseURL = "https://test.stytch.com" } } // Custom domain overrides base URL if cfg.CustomDomain != "" { cfg.BaseURL = fmt.Sprintf("https://%s", strings.TrimSuffix(cfg.CustomDomain, "/")) } // Derive JWKS URL if not set if cfg.JWKSURL == "" { if cfg.CustomDomain != "" { cfg.JWKSURL = fmt.Sprintf("https://%s/.well-known/jwks.json", strings.TrimSuffix(cfg.CustomDomain, "/")) } else { cfg.JWKSURL = fmt.Sprintf("%s/v1/b2b/sessions/jwks/%s", strings.TrimSuffix(cfg.BaseURL, "/"), cfg.ProjectID) } } return &cfg, nil } // Validate checks that the configuration has all required fields. func (c *Config) Validate() error { if c.ProjectID == "" { return fmt.Errorf("stytch configuration invalid: ProjectID is required") } if c.Secret == "" { return fmt.Errorf("stytch configuration invalid: Secret is required") } return nil } // This allows gradual migration from the old config type. func NewConfigFromExisting(projectID, secret, env, baseURL, jwksURL string, sessionDurationMinutes int32, disableVerification bool, apiTimeout time.Duration) *Config { return &Config{ ProjectID: projectID, Secret: secret, Env: env, BaseURL: baseURL, JWKSURL: jwksURL, SessionDurationMinutes: sessionDurationMinutes, DisableSessionVerification: disableVerification, APITimeout: apiTimeout, } } ================================================ FILE: go-b2b-starter/internal/modules/auth/adapters/stytch/jwks_cache.go ================================================ package stytch import ( "context" "crypto/rsa" "encoding/base64" "encoding/json" "fmt" "math/big" "net/http" "time" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/moasq/go-b2b-starter/internal/platform/redis" ) const ( // Redis cache keys for JWKS jwksCacheKeyPattern = "auth:stytch:jwks:key:%s" // Individual public key by kid jwksCacheTTL = 24 * time.Hour // 24-hour cache ) // JWKSCache manages caching of JSON Web Key Sets from Stytch. // // It fetches JWKS from Stytch's endpoint and caches public keys in Redis. // This enables local JWT verification without making Stytch API calls // on every request (saving 300-500ms per request). type JWKSCache struct { jwksURL string redis redis.Client logger logger.Logger httpClient *http.Client } // JWKS represents the JSON Web Key Set structure from Stytch. type JWKS struct { Keys []JWK `json:"keys"` } // JWK represents a single JSON Web Key. type JWK struct { Kid string `json:"kid"` // Key ID Kty string `json:"kty"` // Key type (RSA) N string `json:"n"` // Modulus (base64url encoded) E string `json:"e"` // Exponent (base64url encoded) Alg string `json:"alg"` // Algorithm (RS256) Use string `json:"use"` // Public key use (sig) } // serializedPublicKey represents RSA public key components for Redis storage. type serializedPublicKey struct { N string `json:"n"` // Modulus (base64url encoded) E string `json:"e"` // Exponent (base64url encoded) } func NewJWKSCache(jwksURL string, redisClient redis.Client, logger logger.Logger) *JWKSCache { return &JWKSCache{ jwksURL: jwksURL, redis: redisClient, logger: logger, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } // GetPublicKey retrieves a public key by kid from cache or fetches from Stytch. func (c *JWKSCache) GetPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { // Try to get from Redis cache first cacheKey := fmt.Sprintf(jwksCacheKeyPattern, kid) cached, err := c.redis.Get(ctx, cacheKey) if err == nil && cached != "" { var serialized serializedPublicKey if err := json.Unmarshal([]byte(cached), &serialized); err == nil { key, err := c.deserializePublicKey(&serialized) if err == nil { c.logger.Debug("public key fetched from Redis cache", logger.Fields{ "kid": kid, }) return key, nil } c.logger.Warn("failed to deserialize cached public key", logger.Fields{ "kid": kid, "error": err.Error(), }) } } // Cache miss - fetch JWKS from Stytch c.logger.Info("fetching JWKS from Stytch", logger.Fields{ "jwks_url": c.jwksURL, "kid": kid, }) jwks, err := c.fetchJWKS(ctx) if err != nil { return nil, fmt.Errorf("failed to fetch JWKS: %w", err) } // Find the key with matching kid for _, jwk := range jwks.Keys { if jwk.Kid == kid { publicKey, err := c.jwkToPublicKey(&jwk) if err != nil { return nil, fmt.Errorf("failed to convert JWK to public key: %w", err) } // Cache the key c.cachePublicKey(ctx, kid, &jwk) c.logger.Info("public key fetched and cached", logger.Fields{ "kid": kid, }) return publicKey, nil } } // Log available keys for debugging availableKids := make([]string, 0, len(jwks.Keys)) for _, jwk := range jwks.Keys { availableKids = append(availableKids, jwk.Kid) } c.logger.Error("key not found in JWKS", logger.Fields{ "kid": kid, "available_kids": availableKids, }) return nil, fmt.Errorf("key with ID %s not found in JWKS", kid) } // fetchJWKS fetches the JWKS from Stytch's endpoint. func (c *JWKSCache) fetchJWKS(ctx context.Context) (*JWKS, error) { req, err := http.NewRequestWithContext(ctx, "GET", c.jwksURL, nil) if err != nil { return nil, fmt.Errorf("failed to create JWKS request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("JWKS HTTP request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) } var jwks JWKS if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { return nil, fmt.Errorf("failed to decode JWKS JSON: %w", err) } c.logger.Debug("successfully fetched JWKS", logger.Fields{ "keys_count": len(jwks.Keys), }) return &jwks, nil } // jwkToPublicKey converts a JWK to an RSA public key. func (c *JWKSCache) jwkToPublicKey(jwk *JWK) (*rsa.PublicKey, error) { // Decode modulus (n) nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) if err != nil { return nil, fmt.Errorf("failed to decode modulus: %w", err) } // Decode exponent (e) eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) if err != nil { return nil, fmt.Errorf("failed to decode exponent: %w", err) } // Convert to RSA public key n := new(big.Int).SetBytes(nBytes) // Convert exponent bytes to int var e int for i := 0; i < len(eBytes); i++ { e = e<<8 + int(eBytes[i]) } return &rsa.PublicKey{N: n, E: e}, nil } // cachePublicKey stores a public key in Redis. func (c *JWKSCache) cachePublicKey(ctx context.Context, kid string, jwk *JWK) { serialized := &serializedPublicKey{N: jwk.N, E: jwk.E} data, err := json.Marshal(serialized) if err != nil { c.logger.Warn("failed to marshal public key for caching", logger.Fields{ "kid": kid, "error": err.Error(), }) return } cacheKey := fmt.Sprintf(jwksCacheKeyPattern, kid) if err := c.redis.Set(ctx, cacheKey, string(data), jwksCacheTTL); err != nil { c.logger.Warn("failed to cache public key in Redis", logger.Fields{ "kid": kid, "error": err.Error(), }) } } // deserializePublicKey converts serialized key components back to RSA public key. func (c *JWKSCache) deserializePublicKey(serialized *serializedPublicKey) (*rsa.PublicKey, error) { nBytes, err := base64.RawURLEncoding.DecodeString(serialized.N) if err != nil { return nil, fmt.Errorf("failed to decode cached modulus: %w", err) } eBytes, err := base64.RawURLEncoding.DecodeString(serialized.E) if err != nil { return nil, fmt.Errorf("failed to decode cached exponent: %w", err) } n := new(big.Int).SetBytes(nBytes) var e int for i := 0; i < len(eBytes); i++ { e = e<<8 + int(eBytes[i]) } return &rsa.PublicKey{N: n, E: e}, nil } ================================================ FILE: go-b2b-starter/internal/modules/auth/adapters/stytch/jwt_parser.go ================================================ package stytch import ( "encoding/base64" "encoding/json" "fmt" "strings" ) // JWTParser provides JWT token parsing utilities. // // This parser can decode JWT tokens without verifying the signature, // which is useful for extracting the key ID (kid) from the header // before signature verification. type JWTParser struct{} func NewJWTParser() *JWTParser { return &JWTParser{} } // ParseWithoutVerification decodes a JWT token without verifying the signature. // // This extracts the header and claims from the token for inspection. // The signature is NOT verified - use this only for extracting metadata // like the key ID (kid) before performing proper verification. // // Returns: // - header: JWT header containing algorithm (alg), key ID (kid), etc. // - claims: JWT claims containing user information // - err: Error if the token format is invalid func (p *JWTParser) ParseWithoutVerification(token string) (header map[string]any, claims map[string]any, err error) { // JWT format: header.payload.signature parts := strings.Split(token, ".") if len(parts) != 3 { return nil, nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) } // Decode header (first part) headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, nil, fmt.Errorf("failed to decode JWT header: %w", err) } if err := json.Unmarshal(headerBytes, &header); err != nil { return nil, nil, fmt.Errorf("failed to parse JWT header JSON: %w", err) } // Decode payload (second part) payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, nil, fmt.Errorf("failed to decode JWT payload: %w", err) } if err := json.Unmarshal(payloadBytes, &claims); err != nil { return nil, nil, fmt.Errorf("failed to parse JWT claims JSON: %w", err) } return header, claims, nil } // ExtractKeyID extracts the key ID (kid) from a JWT token header. // // This is a convenience method for getting the kid without needing // to handle the full header map. func (p *JWTParser) ExtractKeyID(token string) (string, error) { header, _, err := p.ParseWithoutVerification(token) if err != nil { return "", err } kid, ok := header["kid"].(string) if !ok { return "", fmt.Errorf("kid not found in JWT header") } return kid, nil } ================================================ FILE: go-b2b-starter/internal/modules/auth/adapters/stytch/mock_adapter.go ================================================ package stytch import ( "context" "fmt" "time" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/platform/logger" ) // MockAuthAdapter is a development-only auth adapter that bypasses Stytch. // // WARNING: This should NEVER be used in production. It accepts any token // and returns a mock identity. It's only for local development when // Stytch credentials are not configured. type MockAuthAdapter struct { logger logger.Logger } // Ensure MockAuthAdapter implements auth.AuthProvider. var _ auth.AuthProvider = (*MockAuthAdapter)(nil) func NewMockAuthAdapter(log logger.Logger) *MockAuthAdapter { return &MockAuthAdapter{ logger: log, } } // VerifyToken accepts any token and returns a mock identity. // This is for development only and should never be used in production. func (m *MockAuthAdapter) VerifyToken(ctx context.Context, token string) (*auth.Identity, error) { m.logger.Warn("Using mock auth adapter - accepting any token", map[string]any{ "warning": "This is for development only. Configure real Stytch credentials for production.", }) // Return a mock identity for development return &auth.Identity{ UserID: "mock-user-123", Email: "dev@example.com", EmailVerified: true, OrganizationID: "mock-org-stytch-id", Roles: []auth.Role{ auth.RoleOwner, auth.RoleAdmin, }, Permissions: []auth.Permission{ auth.NewPermission("*", "*"), // Wildcard permission for development }, ExpiresAt: time.Now().Add(24 * time.Hour), Raw: map[string]any{ "mock": true, "session_id": "mock-session-123", "member_id": "mock-member-123", }, }, nil } // GetRolePermissions returns empty permissions for the mock adapter. func (m *MockAuthAdapter) GetRolePermissions(ctx context.Context, roleID string) ([]auth.Permission, error) { m.logger.Debug("Mock adapter returning all permissions for role", map[string]any{ "role_id": roleID, }) // Return wildcard permission for development return []auth.Permission{ auth.NewPermission("*", "*"), }, nil } // ValidatePermission always returns true in mock mode. func (m *MockAuthAdapter) ValidatePermission(ctx context.Context, identity *auth.Identity, resource, action string) error { m.logger.Debug("Mock adapter allowing all permissions", map[string]any{ "resource": resource, "action": action, "user_id": identity.UserID, }) return nil } // RefreshSession is not implemented in mock mode. func (m *MockAuthAdapter) RefreshSession(ctx context.Context, sessionToken string) (*auth.Identity, error) { return nil, fmt.Errorf("mock adapter: RefreshSession not implemented") } ================================================ FILE: go-b2b-starter/internal/modules/auth/adapters/stytch/rbac_policy.go ================================================ package stytch import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/moasq/go-b2b-starter/internal/platform/redis" "github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi" "github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac" ) const ( // Redis cache key for RBAC policy rbacPolicyCacheKey = "auth:stytch:rbac:policy" // Cache TTL matches Stytch SDK default (5 minutes) rbacPolicyCacheTTL = 5 * time.Minute ) // RBACPolicyService fetches and caches the Stytch RBAC policy. // // It retrieves the role-permission mappings from Stytch and caches them // in Redis to avoid API calls on every request. type RBACPolicyService struct { client *b2bstytchapi.API redis redis.Client logger logger.Logger } func NewRBACPolicyService(client *b2bstytchapi.API, redisClient redis.Client, logger logger.Logger) *RBACPolicyService { return &RBACPolicyService{ client: client, redis: redisClient, logger: logger, } } // GetRolePermissions returns all permissions for a given role from Stytch RBAC policy. // // Returns permissions in "resource:action" format (e.g., "invoice:create"). func (s *RBACPolicyService) GetRolePermissions(ctx context.Context, roleID string) ([]auth.Permission, error) { // Normalize role ID normalizedRoleID := normalizeRoleID(roleID) // Get policy from cache or Stytch policy, err := s.getPolicy(ctx) if err != nil { return nil, fmt.Errorf("failed to get RBAC policy: %w", err) } // Find role in policy for _, role := range policy.Roles { if strings.EqualFold(role.RoleID, normalizedRoleID) { return s.convertPermissions(role.Permissions, policy), nil } } // Role not found in policy s.logger.Debug("role not found in Stytch RBAC policy", logger.Fields{ "role_id": roleID, "normalized": normalizedRoleID, }) return nil, nil } // getPolicy fetches policy from Redis cache or Stytch API. func (s *RBACPolicyService) getPolicy(ctx context.Context) (*rbac.Policy, error) { // Try cache first cached, err := s.redis.Get(ctx, rbacPolicyCacheKey) if err == nil && cached != "" { var policy rbac.Policy if unmarshalErr := json.Unmarshal([]byte(cached), &policy); unmarshalErr == nil { s.logger.Debug("RBAC policy fetched from cache", logger.Fields{}) return &policy, nil } else { s.logger.Warn("failed to unmarshal cached RBAC policy", logger.Fields{ "error": unmarshalErr.Error(), }) } } // Cache miss - fetch from Stytch policy, err := s.fetchPolicyFromStytch(ctx) if err != nil { return nil, err } // Cache the policy s.cachePolicy(ctx, policy) return policy, nil } // fetchPolicyFromStytch fetches RBAC policy from Stytch API. func (s *RBACPolicyService) fetchPolicyFromStytch(ctx context.Context) (*rbac.Policy, error) { s.logger.Info("fetching RBAC policy from Stytch", logger.Fields{}) resp, err := s.client.RBAC.Policy(ctx, &rbac.PolicyParams{}) if err != nil { return nil, fmt.Errorf("stytch RBAC policy API call failed: %w", err) } if resp.Policy == nil { return nil, fmt.Errorf("stytch returned empty policy") } s.logger.Info("successfully fetched RBAC policy", logger.Fields{ "roles_count": len(resp.Policy.Roles), "resources_count": len(resp.Policy.Resources), }) return resp.Policy, nil } // cachePolicy stores policy in Redis. func (s *RBACPolicyService) cachePolicy(ctx context.Context, policy *rbac.Policy) { data, err := json.Marshal(policy) if err != nil { s.logger.Warn("failed to marshal RBAC policy for caching", logger.Fields{ "error": err.Error(), }) return } if err := s.redis.Set(ctx, rbacPolicyCacheKey, string(data), rbacPolicyCacheTTL); err != nil { s.logger.Warn("failed to cache RBAC policy in Redis", logger.Fields{ "error": err.Error(), }) } } // convertPermissions converts Stytch permission format to auth.Permission slice. // // Handles wildcard expansion: // // Input: []PolicyRolePermission{{ResourceID: "invoice", Actions: ["view", "create"]}} // Output: [Permission("invoice:view"), Permission("invoice:create")] func (s *RBACPolicyService) convertPermissions(permissions []rbac.PolicyRolePermission, policy *rbac.Policy) []auth.Permission { if len(permissions) == 0 { return nil } result := make([]auth.Permission, 0, len(permissions)*5) for _, perm := range permissions { resourceID := strings.ToLower(perm.ResourceID) if resourceID == "" { continue } // Expand wildcard actions expandedActions := s.expandWildcardActions(perm.ResourceID, perm.Actions, policy) // Convert each action to Permission for _, action := range expandedActions { if action == "" { continue } result = append(result, auth.NewPermission(resourceID, strings.ToLower(action))) } } return result } // expandWildcardActions expands wildcard (*) to all resource actions from policy. func (s *RBACPolicyService) expandWildcardActions(resourceID string, actions []string, policy *rbac.Policy) []string { // Check if actions contain wildcard hasWildcard := false for _, action := range actions { if action == "*" { hasWildcard = true break } } if !hasWildcard { return actions } // Find resource definition to get all actions for _, resource := range policy.Resources { if strings.EqualFold(resource.ResourceID, resourceID) { if len(resource.Actions) > 0 { s.logger.Debug("expanded wildcard permission", logger.Fields{ "resource": resourceID, "actions_count": len(resource.Actions), }) return resource.Actions } } } // Resource not found, keep wildcard as-is return actions } // normalizeRoleID removes common prefixes from role IDs. func normalizeRoleID(roleID string) string { roleID = strings.TrimSpace(roleID) roleID = strings.TrimPrefix(roleID, "stytch_") return roleID } ================================================ FILE: go-b2b-starter/internal/modules/auth/adapters/stytch/token_verifier.go ================================================ package stytch import ( "context" "errors" "fmt" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi" "github.com/stytchauth/stytch-go/v16/stytch/b2b/sessions" "github.com/stytchauth/stytch-go/v16/stytch/stytcherror" ) // TokenVerifier verifies Stytch session JWTs using a two-tier strategy: // 1. Fast Path: Local JWT verification using cached JWKS (no API calls) // 2. Slow Path: Stytch API verification (fallback when local fails) // // This optimization saves 300-500ms per request for the common case. type TokenVerifier struct { client *b2bstytchapi.API jwksCache *JWKSCache jwtParser *JWTParser policyService *RBACPolicyService cfg *Config logger logger.Logger } func NewTokenVerifier( client *b2bstytchapi.API, jwksCache *JWKSCache, policyService *RBACPolicyService, cfg *Config, logger logger.Logger, ) *TokenVerifier { return &TokenVerifier{ client: client, jwksCache: jwksCache, jwtParser: NewJWTParser(), policyService: policyService, cfg: cfg, logger: logger, } } // internalClaims holds parsed JWT claims before conversion to auth.Identity. type internalClaims struct { Subject string Email string EmailVerified bool OrganizationID string Roles []string Permissions []auth.Permission IssuedAt time.Time ExpiresAt time.Time NotBefore time.Time Issuer string Audience []string Raw map[string]any } // Verify validates the token and returns an Identity. // // It tries local verification first (fast path), falling back to // Stytch API verification if local verification fails. func (v *TokenVerifier) Verify(ctx context.Context, token string) (*auth.Identity, error) { // Check for test mode (DANGEROUS - only for development) if v.cfg.DisableSessionVerification { v.logger.Warn("session verification disabled - test mode only", logger.Fields{}) return v.verifyWithoutSignature(ctx, token) } // Fast path: Local JWT verification identity, err := v.verifyLocally(ctx, token) if err == nil { v.logger.Debug("token verified locally (fast path)", logger.Fields{ "user_id": identity.UserID, "email": identity.Email, }) return identity, nil } // Log fast path failure v.logger.Warn("local verification failed, trying Stytch API", logger.Fields{ "error": err.Error(), }) // Slow path: Stytch API verification return v.verifyViaAPI(ctx, token) } // verifyLocally verifies the token using cached JWKS (fast path). func (v *TokenVerifier) verifyLocally(ctx context.Context, token string) (*auth.Identity, error) { // 1. Parse token header to get key ID kid, err := v.jwtParser.ExtractKeyID(token) if err != nil { return nil, fmt.Errorf("failed to extract key ID: %w", err) } // 2. Get public key from cache publicKey, err := v.jwksCache.GetPublicKey(ctx, kid) if err != nil { return nil, fmt.Errorf("failed to get public key: %w", err) } // 3. Verify token signature jwtToken, err := jwt.Parse(token, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return publicKey, nil }) if err != nil { if strings.Contains(err.Error(), "expired") { return nil, auth.ErrTokenExpired } return nil, auth.ErrInvalidToken } if !jwtToken.Valid { return nil, auth.ErrInvalidToken } // 4. Parse claims from token _, claimsMap, err := v.jwtParser.ParseWithoutVerification(token) if err != nil { return nil, auth.ErrInvalidToken } claims := v.parseClaimsFromMap(claimsMap) // 5. Validate claims if err := v.validateClaims(claims); err != nil { return nil, err } // 6. Derive permissions from roles permissions := v.derivePermissions(ctx, claims.Roles) // 7. Convert to Identity return &auth.Identity{ UserID: claims.Subject, Email: claims.Email, EmailVerified: claims.EmailVerified, OrganizationID: claims.OrganizationID, Roles: v.convertRoles(claims.Roles), Permissions: permissions, ExpiresAt: claims.ExpiresAt, Raw: claims.Raw, }, nil } // verifyViaAPI verifies the token using Stytch API (slow path). func (v *TokenVerifier) verifyViaAPI(ctx context.Context, token string) (*auth.Identity, error) { // Add timeout ctx, cancel := context.WithTimeout(ctx, v.cfg.APITimeout) defer cancel() req := &sessions.AuthenticateParams{ SessionJWT: token, } if v.cfg.SessionDurationMinutes > 0 { req.SessionDurationMinutes = v.cfg.SessionDurationMinutes } resp, err := v.client.Sessions.Authenticate(ctx, req) if err != nil { return nil, v.translateStytchError(err) } member := resp.Member session := resp.MemberSession // Check email verification if !member.EmailAddressVerified { return nil, auth.ErrEmailNotVerified } // Derive permissions from roles permissions := v.derivePermissions(ctx, session.Roles) // Build identity identity := &auth.Identity{ UserID: session.MemberID, Email: member.EmailAddress, EmailVerified: member.EmailAddressVerified, OrganizationID: session.OrganizationID, Roles: v.convertRoles(session.Roles), Permissions: permissions, ExpiresAt: timeValue(session.ExpiresAt), Raw: map[string]any{ "member_session": session, "member": member, "custom_claims": session.CustomClaims, }, } v.logger.Debug("token verified via Stytch API (slow path)", logger.Fields{ "user_id": identity.UserID, "email": identity.Email, }) return identity, nil } // verifyWithoutSignature parses the token without verifying signature (test mode only). func (v *TokenVerifier) verifyWithoutSignature(ctx context.Context, token string) (*auth.Identity, error) { _, claimsMap, err := v.jwtParser.ParseWithoutVerification(token) if err != nil { return nil, auth.ErrInvalidToken } claims := v.parseClaimsFromMap(claimsMap) permissions := v.derivePermissions(ctx, claims.Roles) return &auth.Identity{ UserID: claims.Subject, Email: claims.Email, EmailVerified: claims.EmailVerified, OrganizationID: claims.OrganizationID, Roles: v.convertRoles(claims.Roles), Permissions: permissions, ExpiresAt: claims.ExpiresAt, Raw: claims.Raw, }, nil } // parseClaimsFromMap extracts claims from JWT payload. func (v *TokenVerifier) parseClaimsFromMap(claimsMap map[string]any) *internalClaims { claims := &internalClaims{ Raw: claimsMap, } // Extract subject if sub, ok := claimsMap["sub"].(string); ok { claims.Subject = sub } // Extract email from Stytch session authentication factors // Format: https://stytch.com/session.authentication_factors[].email_factor.email_address if sessionObj, ok := claimsMap["https://stytch.com/session"].(map[string]any); ok { if factors, ok := sessionObj["authentication_factors"].([]any); ok { for _, factor := range factors { if factorMap, ok := factor.(map[string]any); ok { if emailFactor, ok := factorMap["email_factor"].(map[string]any); ok { if emailAddr, ok := emailFactor["email_address"].(string); ok { claims.Email = emailAddr break } } } } } // Extract roles from session if rolesIface, ok := sessionObj["roles"].([]any); ok { roles := make([]string, 0, len(rolesIface)) for _, r := range rolesIface { if roleStr, ok := r.(string); ok { roles = append(roles, roleStr) } } claims.Roles = roles } } // Fallback to standard email claim if claims.Email == "" { if email, ok := claimsMap["email"].(string); ok { claims.Email = email } } // Extract email_verified if verified, ok := claimsMap["email_verified"].(bool); ok { claims.EmailVerified = verified } else if verified, ok := claimsMap["https://stytch.com/email_verified"].(bool); ok { claims.EmailVerified = verified } // Extract organization ID from Stytch custom claim // Format: https://stytch.com/organization.organization_id if orgObj, ok := claimsMap["https://stytch.com/organization"].(map[string]any); ok { if orgID, ok := orgObj["organization_id"].(string); ok { claims.OrganizationID = orgID } } // Fallback to standard claims if claims.OrganizationID == "" { if orgID, ok := claimsMap["organization_id"].(string); ok { claims.OrganizationID = orgID } else if orgID, ok := claimsMap["org_id"].(string); ok { claims.OrganizationID = orgID } } // Parse timestamps claims.IssuedAt = parseNumericTime(claimsMap["iat"]) claims.ExpiresAt = parseNumericTime(claimsMap["exp"]) claims.NotBefore = parseNumericTime(claimsMap["nbf"]) // Parse issuer if iss, ok := claimsMap["iss"].(string); ok { claims.Issuer = iss } // Parse audience claims.Audience = parseStringSlice(claimsMap["aud"]) // Fallback for roles if len(claims.Roles) == 0 { claims.Roles = parseStringSlice(claimsMap["roles"]) } return claims } // validateClaims validates security-critical claims. func (v *TokenVerifier) validateClaims(claims *internalClaims) error { now := time.Now() // Check expiry if !claims.ExpiresAt.IsZero() && now.After(claims.ExpiresAt) { return auth.ErrTokenExpired } // Check not before if !claims.NotBefore.IsZero() && now.Before(claims.NotBefore) { return auth.ErrInvalidToken } // Validate issuer (must be from Stytch) if claims.Issuer != "" && !strings.Contains(strings.ToLower(claims.Issuer), "stytch.com") { return auth.ErrIssuerMismatch } // Check email verification if claim is present if _, hasEmailVerified := claims.Raw["email_verified"]; hasEmailVerified { if !claims.EmailVerified { return auth.ErrEmailNotVerified } } return nil } // derivePermissions derives permissions from roles. // // Fast path: Use hardcoded permissions for standard roles (no API calls). // Slow path: Fetch from Stytch RBAC policy for custom roles. func (v *TokenVerifier) derivePermissions(ctx context.Context, roles []string) []auth.Permission { permSet := make(map[auth.Permission]struct{}) for _, roleStr := range roles { if roleStr == "" { continue } // Normalize role normalizedRole := auth.NormalizeRole(roleStr) // Fast path: Use hardcoded permissions for standard roles if perms := auth.GetRolePermissions(normalizedRole); len(perms) > 0 { for _, p := range perms { permSet[p] = struct{}{} } continue } // Slow path: Fetch from Stytch RBAC policy if v.policyService != nil { stytchPerms, err := v.policyService.GetRolePermissions(ctx, roleStr) if err != nil { v.logger.Warn("failed to get role permissions from Stytch", logger.Fields{ "role": roleStr, "error": err.Error(), }) continue } for _, p := range stytchPerms { permSet[p] = struct{}{} } } } // Convert set to slice if len(permSet) == 0 { return nil } permissions := make([]auth.Permission, 0, len(permSet)) for p := range permSet { permissions = append(permissions, p) } return permissions } // convertRoles converts string role names to auth.Role. func (v *TokenVerifier) convertRoles(roles []string) []auth.Role { if len(roles) == 0 { return nil } result := make([]auth.Role, 0, len(roles)) seen := make(map[auth.Role]struct{}) for _, roleStr := range roles { role := auth.NormalizeRole(roleStr) if _, exists := seen[role]; !exists { seen[role] = struct{}{} result = append(result, role) } } return result } // translateStytchError converts Stytch errors to auth errors. func (v *TokenVerifier) translateStytchError(err error) error { var stErr *stytcherror.Error if errors.As(err, &stErr) { if strings.Contains(strings.ToLower(string(stErr.ErrorType)), "expired") { return auth.ErrTokenExpired } switch stErr.StatusCode { case 401, 403, 404: return auth.ErrInvalidToken default: if stErr.StatusCode >= 500 { return fmt.Errorf("stytch service error: %w", err) } return auth.ErrInvalidToken } } return fmt.Errorf("stytch error: %w", err) } // Helper functions func parseStringSlice(value any) []string { switch v := value.(type) { case string: if v == "" { return nil } return []string{v} case []string: return v case []any: res := make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok && s != "" { res = append(res, s) } } return res default: return nil } } func parseNumericTime(value any) time.Time { switch v := value.(type) { case float64: return time.Unix(int64(v), 0) case int64: return time.Unix(v, 0) case int: return time.Unix(int64(v), 0) default: return time.Time{} } } func timeValue(ts *time.Time) time.Time { if ts == nil { return time.Time{} } return ts.UTC() } ================================================ FILE: go-b2b-starter/internal/modules/auth/auth.go ================================================ // Package auth provides a unified authentication and authorization layer. // // This package abstracts away the authentication provider (Stytch, Auth0, etc.) // and provides a clean interface for the rest of the application to use. // // # Architecture // // The auth package follows the adapter pattern: // // ┌─────────────────────────────────────────────────────────────────┐ // │ Application Layer │ // │ (handlers, services - use auth.GetRequestContext, auth.RequirePermission) │ // └─────────────────────────────────────────────────────────────────┘ // │ // ▼ // ┌─────────────────────────────────────────────────────────────────┐ // │ auth package │ // │ • AuthProvider interface │ // │ • Identity (provider-agnostic user representation) │ // │ • RequestContext (resolved database IDs) │ // │ • Middleware (RequireAuth, RequireOrganization, RequirePermission) │ // │ • Type-safe context helpers │ // └─────────────────────────────────────────────────────────────────┘ // │ // ▼ // ┌─────────────────────────────────────────────────────────────────┐ // │ auth/adapters/stytch │ // │ (Stytch-specific implementation - hidden from app layer) │ // └─────────────────────────────────────────────────────────────────┘ // // # Usage // // In routes: // // router.Use( // auth.RequireAuth(authProvider), // auth.RequireOrganization(orgRepo, accountRepo, logger), // ) // router.GET("/resource", auth.RequirePermission("resource", "view"), handler) // // In handlers: // // func Handler(c *gin.Context) { // reqCtx := auth.GetRequestContext(c) // orgID := reqCtx.OrganizationID // int32, type-safe // accountID := reqCtx.AccountID // int32, type-safe // } // // # Adding a New Auth Provider // // To add a new authentication provider (e.g., Auth0, Firebase): // // 1. Create a new adapter in auth/adapters// // 2. Implement the AuthProvider interface // 3. Map provider-specific claims to auth.Identity // 4. Register the adapter in the DI container // // See auth/adapters/stytch/ for a reference implementation. package auth import ( "context" "time" ) // AuthProvider abstracts the authentication provider (Stytch, Auth0, Firebase, etc.). // // Implementations must: // - Verify the token signature and validity // - Extract user identity information // - Derive permissions from roles (if applicable) // - Return appropriate errors for invalid/expired tokens // // The application layer should only depend on this interface, never on // provider-specific implementations. type AuthProvider interface { // VerifyToken validates the provided token and returns the user's identity. // // The token is typically a JWT from the Authorization header. // Returns ErrInvalidToken, ErrTokenExpired, or other auth errors on failure. VerifyToken(ctx context.Context, token string) (*Identity, error) } // Identity represents an authenticated user in a provider-agnostic way. // // This struct contains all the information needed by the application // after a user has been authenticated. Provider-specific data is stored // in the Raw field for debugging or advanced use cases. type Identity struct { // UserID is the unique identifier for the user from the auth provider. // For Stytch, this is the member_id. For Auth0, this is the sub claim. UserID string `json:"user_id"` // Email is the user's email address. Email string `json:"email"` // EmailVerified indicates whether the email has been verified. EmailVerified bool `json:"email_verified"` // OrganizationID is the auth provider's organization/tenant identifier. // This is a string UUID from the provider, NOT the database int32 ID. // Use RequestContext.OrganizationID for the database ID. OrganizationID string `json:"organization_id"` // Roles contains the user's role assignments (e.g., "admin", "member"). Roles []Role `json:"roles"` // Permissions contains the derived permissions in "resource:action" format. // These are derived from roles by the auth provider or adapter. Permissions []Permission `json:"permissions"` // ExpiresAt is when the token/session expires. ExpiresAt time.Time `json:"expires_at"` // Raw contains provider-specific data for debugging or advanced use cases. // This should NOT be used in normal application logic. Raw map[string]any `json:"raw,omitempty"` } // HasRole checks if the identity has a specific role. func (i *Identity) HasRole(role Role) bool { for _, r := range i.Roles { if r == role { return true } } return false } // HasPermission checks if the identity has a specific permission. func (i *Identity) HasPermission(permission Permission) bool { for _, p := range i.Permissions { if p == permission { return true } } return false } // HasResourcePermission checks if the identity has permission for a resource and action. func (i *Identity) HasResourcePermission(resource, action string) bool { return i.HasPermission(NewPermission(resource, action)) } // RequestContext holds the resolved database IDs for the current request. // // This is set by the RequireOrganization middleware after looking up // the organization and account in the database using the Identity's // provider-specific IDs. // // Use auth.GetRequestContext(c) to retrieve this in handlers. type RequestContext struct { // Identity contains the authenticated user information from the auth provider. Identity *Identity `json:"identity"` // OrganizationID is the database primary key (int32) for the organization. // This is resolved from Identity.OrganizationID by the middleware. OrganizationID int32 `json:"organization_id"` // AccountID is the database primary key (int32) for the user's account. // This is resolved from Identity.Email by the middleware. AccountID int32 `json:"account_id"` // ProviderOrgID preserves the original provider organization ID for reference. // Use this when making calls back to the auth provider. ProviderOrgID string `json:"provider_org_id,omitempty"` } // OrganizationRepository defines the interface for looking up organizations. // // This is used by the RequireOrganization middleware to resolve // the auth provider's organization ID to a database ID. type OrganizationRepository interface { // GetByProviderID looks up an organization by the auth provider's organization ID. // Returns the organization with its database ID, or an error if not found. GetByProviderID(ctx context.Context, providerOrgID string) (*Organization, error) } // AccountRepository defines the interface for looking up accounts. // // This is used by the RequireOrganization middleware to resolve // the user's email to a database account ID within an organization. type AccountRepository interface { // GetByEmail looks up an account by email within an organization. // Returns the account with its database ID, or an error if not found. GetByEmail(ctx context.Context, orgID int32, email string) (*Account, error) } // Organization represents the minimal organization data needed by the auth package. type Organization struct { ID int32 `json:"id"` Name string `json:"name"` } // Account represents the minimal account data needed by the auth package. type Account struct { ID int32 `json:"id"` Email string `json:"email"` } ================================================ FILE: go-b2b-starter/internal/modules/auth/cmd/init.go ================================================ // Package cmd provides initialization for the auth module. package cmd import ( "fmt" "strings" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/modules/auth/adapters/stytch" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/moasq/go-b2b-starter/internal/platform/redis" "go.uber.org/dig" ) // // This sets up: // - stytch.Config // - auth.AuthProvider (Stytch adapter) // // Note: The auth middleware is NOT initialized here because it requires // organization/account resolvers from the organizations module. // Use InitMiddleware after the organizations module is initialized. // // # Prerequisites // // The following modules must be initialized first: // - redis (for caching) // - logger // // # Usage // // // In main/cmd/init_mods.go: // if err := authCmd.Init(container); err != nil { // panic(err) // } func Init(container *dig.Container) error { // Stytch configuration if err := container.Provide(func() (*stytch.Config, error) { return stytch.LoadConfig() }); err != nil { return fmt.Errorf("failed to provide stytch config: %w", err) } // Stytch Auth Adapter (implements auth.AuthProvider) if err := container.Provide(func( cfg *stytch.Config, redisClient redis.Client, log logger.Logger, ) (auth.AuthProvider, error) { // Check for placeholder credentials if isPlaceholderCredentials(cfg) { log.Warn("Stytch credentials are placeholders - using development mode", map[string]any{ "project_id": cfg.ProjectID, "message": "Update STYTCH_PROJECT_ID and STYTCH_SECRET in app.env with real credentials", }) return stytch.NewMockAuthAdapter(log), nil } adapter, err := stytch.NewStytchAuthAdapter(cfg, redisClient, log) if err != nil { return nil, fmt.Errorf("failed to create stytch adapter: %w", err) } return adapter, nil }); err != nil { return fmt.Errorf("failed to provide auth provider: %w", err) } return nil } // InitMiddleware initializes the auth middleware with resolvers. // // This must be called after the organizations module is initialized, // as it depends on organization and account repositories. // // # Prerequisites // // The following must be available in the container: // - auth.AuthProvider (from Init) // - auth.OrganizationResolver // - auth.AccountResolver // - serverDomain.Server (for registering named middlewares) // // # Usage // // // After organizations module init: // if err := authCmd.InitMiddleware(container); err != nil { // panic(err) // } func InitMiddleware(container *dig.Container) error { if err := auth.SetupMiddleware(container); err != nil { return fmt.Errorf("failed to setup auth middleware: %w", err) } return nil } // isPlaceholderCredentials checks if the Stytch credentials are placeholder values. func isPlaceholderCredentials(cfg *stytch.Config) bool { return strings.Contains(cfg.ProjectID, "REPLACE") || strings.Contains(cfg.Secret, "REPLACE") || cfg.ProjectID == "" || cfg.Secret == "" } ================================================ FILE: go-b2b-starter/internal/modules/auth/context.go ================================================ package auth import ( "context" "github.com/gin-gonic/gin" ) // Context keys for storing auth data. // Using unexported type to prevent collisions with other packages. type contextKey string const ( // identityKey is the context key for storing the authenticated Identity. identityKey contextKey = "auth_identity" // requestContextKey is the context key for storing the RequestContext. requestContextKey contextKey = "auth_request_context" ) // SetIdentity stores the Identity in the Gin context. // // This is called by the RequireAuth middleware after successful authentication. // Application code should not call this directly. func SetIdentity(c *gin.Context, identity *Identity) { c.Set(string(identityKey), identity) } // GetIdentity retrieves the Identity from the Gin context. // // Returns nil if no identity is set (user not authenticated). // Use MustGetIdentity if you expect authentication middleware to have run. // // Example: // // identity := auth.GetIdentity(c) // if identity == nil { // // Handle unauthenticated request // } func GetIdentity(c *gin.Context) *Identity { if val, exists := c.Get(string(identityKey)); exists { if identity, ok := val.(*Identity); ok { return identity } } return nil } // MustGetIdentity retrieves the Identity from the Gin context. // // Panics if no identity is set. Only use this after RequireAuth middleware. // For handlers where authentication is optional, use GetIdentity instead. func MustGetIdentity(c *gin.Context) *Identity { identity := GetIdentity(c) if identity == nil { panic("auth: MustGetIdentity called without Identity in context - ensure RequireAuth middleware is applied") } return identity } // SetRequestContext stores the RequestContext in the Gin context. // // This is called by the RequireOrganization middleware after resolving // the database IDs. Application code should not call this directly. func SetRequestContext(c *gin.Context, reqCtx *RequestContext) { c.Set(string(requestContextKey), reqCtx) } // GetRequestContext retrieves the RequestContext from the Gin context. // // Returns nil if no request context is set. // Use MustGetRequestContext if you expect the organization middleware to have run. // // Example: // // reqCtx := auth.GetRequestContext(c) // if reqCtx == nil { // // Handle request without organization context // } // orgID := reqCtx.OrganizationID // int32, type-safe func GetRequestContext(c *gin.Context) *RequestContext { if val, exists := c.Get(string(requestContextKey)); exists { if reqCtx, ok := val.(*RequestContext); ok { return reqCtx } } return nil } // MustGetRequestContext retrieves the RequestContext from the Gin context. // // Panics if no request context is set. Only use this after RequireOrganization middleware. // For handlers where organization context is optional, use GetRequestContext instead. // // Example: // // reqCtx := auth.MustGetRequestContext(c) // orgID := reqCtx.OrganizationID // accountID := reqCtx.AccountID func MustGetRequestContext(c *gin.Context) *RequestContext { reqCtx := GetRequestContext(c) if reqCtx == nil { panic("auth: MustGetRequestContext called without RequestContext in context - ensure RequireOrganization middleware is applied") } return reqCtx } // GetOrganizationID is a convenience function to get the database organization ID. // // Returns 0 if no request context is set. // Use MustGetRequestContext().OrganizationID if you expect the middleware to have run. func GetOrganizationID(c *gin.Context) int32 { if reqCtx := GetRequestContext(c); reqCtx != nil { return reqCtx.OrganizationID } return 0 } // GetAccountID is a convenience function to get the database account ID. // // Returns 0 if no request context is set. // Use MustGetRequestContext().AccountID if you expect the middleware to have run. func GetAccountID(c *gin.Context) int32 { if reqCtx := GetRequestContext(c); reqCtx != nil { return reqCtx.AccountID } return 0 } // WithIdentity adds the Identity to a context.Context. // // This is useful for passing auth context through service layers // that don't use Gin context directly. func WithIdentity(ctx context.Context, identity *Identity) context.Context { return context.WithValue(ctx, identityKey, identity) } // IdentityFromContext retrieves the Identity from a context.Context. // // Returns nil if no identity is set. func IdentityFromContext(ctx context.Context) *Identity { if val := ctx.Value(identityKey); val != nil { if identity, ok := val.(*Identity); ok { return identity } } return nil } // WithRequestContext adds the RequestContext to a context.Context. // // This is useful for passing auth context through service layers // that don't use Gin context directly. func WithRequestContext(ctx context.Context, reqCtx *RequestContext) context.Context { return context.WithValue(ctx, requestContextKey, reqCtx) } // RequestContextFromContext retrieves the RequestContext from a context.Context. // // Returns nil if no request context is set. func RequestContextFromContext(ctx context.Context) *RequestContext { if val := ctx.Value(requestContextKey); val != nil { if reqCtx, ok := val.(*RequestContext); ok { return reqCtx } } return nil } ================================================ FILE: go-b2b-starter/internal/modules/auth/errors.go ================================================ package auth import "errors" // Authentication and authorization errors. // // These errors are returned by the auth package and can be checked // by application code to handle specific error cases. var ( // ErrUnauthorized is returned when authentication is required but not provided. // HTTP status: 401 Unauthorized ErrUnauthorized = errors.New("authentication required") // ErrInvalidToken is returned when the provided token is malformed or invalid. // HTTP status: 401 Unauthorized ErrInvalidToken = errors.New("invalid token") // ErrTokenExpired is returned when the token has expired. // HTTP status: 401 Unauthorized ErrTokenExpired = errors.New("token expired") // ErrEmailNotVerified is returned when the user's email is not verified. // HTTP status: 403 Forbidden ErrEmailNotVerified = errors.New("email not verified") // ErrForbidden is returned when the user lacks required permissions. // HTTP status: 403 Forbidden ErrForbidden = errors.New("insufficient permissions") // ErrOrganizationNotFound is returned when the organization cannot be found. // This typically means the organization in the token doesn't exist in our database. // HTTP status: 403 Forbidden ErrOrganizationNotFound = errors.New("organization not found") // ErrAccountNotFound is returned when the user's account cannot be found. // This typically means the user exists in the auth provider but not in our database. // HTTP status: 403 Forbidden ErrAccountNotFound = errors.New("account not found") // ErrMissingOrganization is returned when the token doesn't contain an organization ID. // HTTP status: 403 Forbidden ErrMissingOrganization = errors.New("no organization in token") // ErrMissingEmail is returned when the token doesn't contain an email. // HTTP status: 403 Forbidden ErrMissingEmail = errors.New("no email in token") // ErrAudienceMismatch is returned when the token audience doesn't match. // HTTP status: 401 Unauthorized ErrAudienceMismatch = errors.New("token audience mismatch") // ErrIssuerMismatch is returned when the token issuer doesn't match. // HTTP status: 401 Unauthorized ErrIssuerMismatch = errors.New("token issuer mismatch") ) // IsAuthError returns true if the error is an authentication error (401). func IsAuthError(err error) bool { return errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrTokenExpired) || errors.Is(err, ErrAudienceMismatch) || errors.Is(err, ErrIssuerMismatch) } // IsForbiddenError returns true if the error is an authorization error (403). func IsForbiddenError(err error) bool { return errors.Is(err, ErrForbidden) || errors.Is(err, ErrEmailNotVerified) || errors.Is(err, ErrOrganizationNotFound) || errors.Is(err, ErrAccountNotFound) || errors.Is(err, ErrMissingOrganization) || errors.Is(err, ErrMissingEmail) } // HTTPStatusCode returns the appropriate HTTP status code for an auth error. // // Returns: // - 401 for authentication errors // - 403 for authorization errors // - 500 for unknown errors func HTTPStatusCode(err error) int { if IsAuthError(err) { return 401 } if IsForbiddenError(err) { return 403 } return 500 } ================================================ FILE: go-b2b-starter/internal/modules/auth/handler.go ================================================ package auth import ( "net/http" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/pkg/response" ) // Handler handles RBAC API endpoints type Handler struct { service RBACService } func NewHandler(service RBACService) *Handler { return &Handler{ service: service, } } // GetRoles godoc // @Summary Get all roles with permissions // @Description Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery. // @Tags RBAC // @Produce json // @Success 200 {object} RolesResponse "Roles with permissions" // @Failure 500 {object} map[string]string "Internal error" // @Router /rbac/roles [get] func (h *Handler) GetRoles(c *gin.Context) { roles := h.service.GetAllRoles() roleDTOs := make([]RoleDTO, len(roles)) for i, role := range roles { roleDTOs[i] = NewRoleDTO(role) } response.Success(c, http.StatusOK, RolesResponse{ Roles: roleDTOs, }) } // GetPermissions godoc // @Summary Get all permissions // @Description Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering. // @Tags RBAC // @Produce json // @Success 200 {object} PermissionsResponse "All permissions" // @Failure 500 {object} map[string]string "Internal error" // @Router /rbac/permissions [get] func (h *Handler) GetPermissions(c *gin.Context) { permissions := h.service.GetAllPermissions() permDTOs := make([]PermissionDTO, len(permissions)) for i, perm := range permissions { permDTOs[i] = NewPermissionDTO(perm) } response.Success(c, http.StatusOK, PermissionsResponse{ Permissions: permDTOs, }) } // GetPermissionsByCategory godoc // @Summary Get permissions grouped by category // @Description Returns all permissions organized by their category for better UI organization. // @Tags RBAC // @Produce json // @Success 200 {object} PermissionsByCategoryResponse "Permissions by category" // @Failure 500 {object} map[string]string "Internal error" // @Router /rbac/permissions/by-category [get] func (h *Handler) GetPermissionsByCategory(c *gin.Context) { categoriesMap := h.service.GetPermissionsByCategory() // Convert to DTO format result := make(map[string][]PermissionDTO) for category, perms := range categoriesMap { permDTOs := make([]PermissionDTO, len(perms)) for i, perm := range perms { permDTOs[i] = NewPermissionDTO(perm) } result[category] = permDTOs } response.Success(c, http.StatusOK, PermissionsByCategoryResponse{ Categories: result, }) } // GetRoleDetails godoc // @Summary Get detailed information about a specific role // @Description Returns comprehensive information about a role including permissions, statistics, and restrictions. // @Tags RBAC // @Produce json // @Param role_id path string true "Role ID (member, approver, admin)" // @Success 200 {object} RolePermissionsResponse "Role details with statistics" // @Failure 400 {object} map[string]string "Invalid role ID" // @Failure 404 {object} map[string]string "Role not found" // @Router /rbac/roles/{role_id} [get] func (h *Handler) GetRoleDetails(c *gin.Context) { roleID := c.Param("role_id") if roleID == "" { response.Error(c, http.StatusBadRequest, "role_id_required", nil) return } roleResp := NewRolePermissionsResponse(roleID) if roleResp == nil { response.Error(c, http.StatusNotFound, "role_not_found", nil) return } response.Success(c, http.StatusOK, roleResp) } // CheckPermission godoc // @Summary Check if a role has a specific permission // @Description Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering. // @Tags RBAC // @Accept json // @Produce json // @Param body body PermissionCheckRequest true "Role and permission to check" // @Success 200 {object} PermissionCheckResponse "Permission check result" // @Failure 400 {object} map[string]string "Invalid request" // @Router /rbac/check-permission [post] func (h *Handler) CheckPermission(c *gin.Context) { var req PermissionCheckRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "invalid_request", err) return } if req.RoleID == "" || req.PermissionID == "" { response.Error(c, http.StatusBadRequest, "missing_parameters", nil) return } hasPermission := h.service.HasPermission(req.RoleID, req.PermissionID) response.Success(c, http.StatusOK, PermissionCheckResponse{ RoleID: req.RoleID, PermissionID: req.PermissionID, HasPermission: hasPermission, }) } // GetMetadata godoc // @Summary Get RBAC system metadata // @Description Returns summary information about the RBAC system including total roles, permissions, and categories. // @Tags RBAC // @Produce json // @Success 200 {object} RBACMetadata "RBAC system metadata" // @Router /rbac/metadata [get] func (h *Handler) GetMetadata(c *gin.Context) { metadata := h.service.GetRBACMetadata() response.Success(c, http.StatusOK, metadata) } ================================================ FILE: go-b2b-starter/internal/modules/auth/middleware.go ================================================ package auth import ( "context" "net/http" "strings" "github.com/gin-gonic/gin" ) // OrganizationResolver looks up organization by provider org ID. // // This interface decouples auth middleware from the organizations domain. // Implement this interface by wrapping your organization repository. type OrganizationResolver interface { // ResolveByProviderID looks up organization by the auth provider's org ID (e.g., Stytch org UUID). // Returns the database organization ID (int32) or error if not found. ResolveByProviderID(ctx context.Context, providerOrgID string) (int32, error) } // AccountResolver looks up account by email within an organization. // // This interface decouples auth middleware from the organizations domain. // Implement this interface by wrapping your account repository. type AccountResolver interface { // ResolveByEmail looks up account by email within the given organization. // Returns the database account ID (int32) or error if not found. ResolveByEmail(ctx context.Context, orgID int32, email string) (int32, error) } // MiddlewareConfig configures the auth middleware behavior. type MiddlewareConfig struct { // ErrorHandler is called when an error occurs. If nil, default JSON responses are used. ErrorHandler func(c *gin.Context, statusCode int, message string, err error) } // DefaultMiddlewareConfig returns the default middleware configuration. func DefaultMiddlewareConfig() *MiddlewareConfig { return &MiddlewareConfig{ ErrorHandler: defaultErrorHandler, } } // defaultErrorHandler sends JSON error responses. func defaultErrorHandler(c *gin.Context, statusCode int, message string, err error) { response := gin.H{ "error": message, "success": false, } if err != nil && statusCode >= 500 { response["detail"] = err.Error() } c.JSON(statusCode, response) } // Middleware provides auth middleware functions. // // Use NewMiddleware to create an instance with proper dependencies. type Middleware struct { provider AuthProvider orgResolver OrganizationResolver accResolver AccountResolver config *MiddlewareConfig } // Parameters: // - provider: The auth provider for token verification (e.g., Stytch adapter) // - orgResolver: Resolves org by provider ID (optional, required for RequireOrganization) // - accResolver: Resolves account by email (optional, required for RequireOrganization) // - config: Middleware configuration (optional, uses defaults if nil) func NewMiddleware( provider AuthProvider, orgResolver OrganizationResolver, accResolver AccountResolver, config *MiddlewareConfig, ) *Middleware { if config == nil { config = DefaultMiddlewareConfig() } return &Middleware{ provider: provider, orgResolver: orgResolver, accResolver: accResolver, config: config, } } // RequireAuth returns middleware that verifies the JWT token. // // This middleware: // 1. Extracts Bearer token from Authorization header // 2. Verifies token using the AuthProvider // 3. Sets Identity in Gin context (accessible via GetIdentity) // // Must be called before any middleware that requires authentication. // // Usage: // // router.Use(authMiddleware.RequireAuth()) func (m *Middleware) RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { // Skip OPTIONS requests (CORS preflight) if c.Request.Method == "OPTIONS" { c.Next() return } // Extract Bearer token token, err := extractBearerToken(c) if err != nil { m.config.ErrorHandler(c, http.StatusUnauthorized, "missing or invalid authorization header", err) c.Abort() return } // Verify token identity, err := m.provider.VerifyToken(c.Request.Context(), token) if err != nil { statusCode := HTTPStatusCode(err) message := errorMessage(err) m.config.ErrorHandler(c, statusCode, message, err) c.Abort() return } // Set identity in context SetIdentity(c, identity) c.Next() } } // RequireOrganization returns middleware that resolves org/account from Identity. // // This middleware: // 1. Gets Identity from context (requires RequireAuth to run first) // 2. Looks up organization by provider org ID // 3. Looks up account by email within organization // 4. Sets RequestContext in Gin context (accessible via GetRequestContext) // // Must be called after RequireAuth middleware. // // Usage: // // router.Use(authMiddleware.RequireAuth()) // router.Use(authMiddleware.RequireOrganization()) func (m *Middleware) RequireOrganization() gin.HandlerFunc { return func(c *gin.Context) { // Get identity from context identity := GetIdentity(c) if identity == nil { m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) c.Abort() return } // Validate required fields if identity.OrganizationID == "" { m.config.ErrorHandler(c, http.StatusForbidden, "no organization in token", ErrMissingOrganization) c.Abort() return } if identity.Email == "" { m.config.ErrorHandler(c, http.StatusForbidden, "no email in token", ErrMissingEmail) c.Abort() return } // Resolve organization orgID, err := m.orgResolver.ResolveByProviderID(c.Request.Context(), identity.OrganizationID) if err != nil { m.config.ErrorHandler(c, http.StatusForbidden, "organization not found", err) c.Abort() return } // Resolve account accountID, err := m.accResolver.ResolveByEmail(c.Request.Context(), orgID, identity.Email) if err != nil { m.config.ErrorHandler(c, http.StatusForbidden, "account not found", err) c.Abort() return } // Set request context reqCtx := &RequestContext{ Identity: identity, OrganizationID: orgID, AccountID: accountID, ProviderOrgID: identity.OrganizationID, } SetRequestContext(c, reqCtx) // Also set individual values for backward compatibility c.Set("organization_id", orgID) c.Set("account_id", accountID) c.Set("stytch_org_id", identity.OrganizationID) c.Next() } } // RequirePermission returns middleware that checks for a specific permission. // // This middleware: // 1. Gets Identity from context (requires RequireAuth to run first) // 2. Checks if user has the required permission // 3. Falls back to role-based permissions if not found in Identity // // Must be called after RequireAuth middleware. // // Usage: // // router.GET("/invoices", authMiddleware.RequirePermission("invoice", "view"), handler) // router.POST("/invoices", authMiddleware.RequirePermission("invoice", "create"), handler) func (m *Middleware) RequirePermission(resource, action string) gin.HandlerFunc { return func(c *gin.Context) { identity := GetIdentity(c) if identity == nil { m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) c.Abort() return } if !hasPermission(identity, resource, action) { m.config.ErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) c.Abort() return } c.Next() } } // RequireAnyPermission returns middleware that checks for any of the given permissions. // // This middleware succeeds if the user has at least one of the specified permissions. // Useful when multiple permissions can grant access to the same resource. // // Must be called after RequireAuth middleware. // // Usage: // // router.GET("/reports", authMiddleware.RequireAnyPermission( // auth.PermPaymentOptSchedule, // auth.PermPaymentOptExport, // ), handler) func (m *Middleware) RequireAnyPermission(permissions ...Permission) gin.HandlerFunc { return func(c *gin.Context) { identity := GetIdentity(c) if identity == nil { m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) c.Abort() return } for _, perm := range permissions { if hasPermission(identity, perm.Resource(), perm.Action()) { c.Next() return } } m.config.ErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) c.Abort() } } // RequireAllPermissions returns middleware that checks for all given permissions. // // This middleware succeeds only if the user has all of the specified permissions. // // Must be called after RequireAuth middleware. // // Usage: // // router.DELETE("/org", authMiddleware.RequireAllPermissions( // auth.NewPermission("org", "view"), // auth.NewPermission("org", "manage"), // ), handler) func (m *Middleware) RequireAllPermissions(permissions ...Permission) gin.HandlerFunc { return func(c *gin.Context) { identity := GetIdentity(c) if identity == nil { m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) c.Abort() return } for _, perm := range permissions { if !hasPermission(identity, perm.Resource(), perm.Action()) { m.config.ErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) c.Abort() return } } c.Next() } } // RequireRole returns middleware that checks for a specific role. // // Must be called after RequireAuth middleware. // // Usage: // // router.POST("/admin", authMiddleware.RequireRole(auth.RoleAdmin), handler) func (m *Middleware) RequireRole(role Role) gin.HandlerFunc { return func(c *gin.Context) { identity := GetIdentity(c) if identity == nil { m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) c.Abort() return } if !hasRole(identity, role) { m.config.ErrorHandler(c, http.StatusForbidden, "insufficient role", nil) c.Abort() return } c.Next() } } // RequireAnyRole returns middleware that checks for any of the given roles. // // Must be called after RequireAuth middleware. // // Usage: // // router.GET("/dashboard", authMiddleware.RequireAnyRole(auth.RoleAdmin, auth.RoleApprover), handler) func (m *Middleware) RequireAnyRole(roles ...Role) gin.HandlerFunc { return func(c *gin.Context) { identity := GetIdentity(c) if identity == nil { m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) c.Abort() return } for _, role := range roles { if hasRole(identity, role) { c.Next() return } } m.config.ErrorHandler(c, http.StatusForbidden, "insufficient role", nil) c.Abort() } } // Helper functions // extractBearerToken extracts the JWT from Authorization header. func extractBearerToken(c *gin.Context) (string, error) { header := c.GetHeader("Authorization") if header == "" { return "", ErrUnauthorized } fields := strings.Fields(header) if len(fields) != 2 || !strings.EqualFold(fields[0], "bearer") { return "", ErrInvalidToken } return fields[1], nil } // hasPermission checks if identity has the required permission. func hasPermission(identity *Identity, resource, action string) bool { perm := NewPermission(resource, action) // Check explicit permissions in identity for _, p := range identity.Permissions { if p == perm || p.MatchesWithWildcard(perm) { return true } } // Fallback: Check role-based permissions for _, role := range identity.Roles { if HasRolePermission(role, resource, action) { return true } } return false } // hasRole checks if identity has the required role. func hasRole(identity *Identity, role Role) bool { normalized := NormalizeRole(string(role)) for _, r := range identity.Roles { if NormalizeRole(string(r)) == normalized { return true } } return false } // errorMessage returns a user-friendly message for auth errors. func errorMessage(err error) string { switch err { case ErrTokenExpired: return "token expired" case ErrInvalidToken: return "invalid token" case ErrEmailNotVerified: return "email not verified" case ErrAudienceMismatch: return "invalid token audience" case ErrIssuerMismatch: return "invalid token issuer" default: return "authentication failed" } } // Standalone middleware functions for simpler usage // RequirePermissionFunc returns a standalone middleware that checks permissions. // // This is a convenience function that doesn't require a Middleware instance. // It reads Identity directly from Gin context. // // Usage: // // router.GET("/invoices", auth.RequirePermissionFunc("invoice", "view"), handler) func RequirePermissionFunc(resource, action string) gin.HandlerFunc { return func(c *gin.Context) { identity := GetIdentity(c) if identity == nil { defaultErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) c.Abort() return } if !hasPermission(identity, resource, action) { defaultErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) c.Abort() return } c.Next() } } // RequireAnyPermissionFunc returns a standalone middleware that checks for any permission. // // Usage: // // router.GET("/reports", auth.RequireAnyPermissionFunc( // auth.PermPaymentOptSchedule, // auth.PermPaymentOptExport, // ), handler) func RequireAnyPermissionFunc(permissions ...Permission) gin.HandlerFunc { return func(c *gin.Context) { identity := GetIdentity(c) if identity == nil { defaultErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) c.Abort() return } for _, perm := range permissions { if hasPermission(identity, perm.Resource(), perm.Action()) { c.Next() return } } defaultErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) c.Abort() } } ================================================ FILE: go-b2b-starter/internal/modules/auth/permissions.go ================================================ package auth import ( "fmt" "strings" ) // Permission represents an authorization permission in "resource:action" format. // // Permissions follow the pattern "resource:action" where: // - resource: The entity being accessed (e.g., "invoice", "org", "approval") // - action: The operation being performed (e.g., "view", "create", "manage") // // # Examples // // auth.NewPermission("invoice", "create") // "invoice:create" // auth.NewPermission("org", "manage") // "org:manage" // auth.NewPermission("approval", "approve") // "approval:approve" // // # Adding New Permissions // // To add a new permission: // 1. Add it to the appropriate role in DefaultRolePermissions (roles.go) // 2. Configure it in your auth provider (e.g., Stytch RBAC policy) // 3. Use RequirePermission("resource", "action") in your routes type Permission string // NewPermission creates a permission from resource and action. // // Example: // // perm := auth.NewPermission("invoice", "create") // // perm = "invoice:create" func NewPermission(resource, action string) Permission { return Permission(fmt.Sprintf("%s:%s", resource, action)) } // String returns the string representation of the permission. func (p Permission) String() string { return string(p) } // Resource returns the resource part of the permission. // // Example: // // perm := auth.NewPermission("invoice", "create") // perm.Resource() // "invoice" func (p Permission) Resource() string { parts := strings.SplitN(string(p), ":", 2) if len(parts) > 0 { return parts[0] } return "" } // Action returns the action part of the permission. // // Example: // // perm := auth.NewPermission("invoice", "create") // perm.Action() // "create" func (p Permission) Action() string { parts := strings.SplitN(string(p), ":", 2) if len(parts) > 1 { return parts[1] } return "" } // IsValid checks if the permission has both resource and action parts. func (p Permission) IsValid() bool { parts := strings.SplitN(string(p), ":", 2) return len(parts) == 2 && parts[0] != "" && parts[1] != "" } // Matches checks if this permission matches another permission. // // This is a simple equality check. For wildcard matching, // use MatchesWithWildcard. func (p Permission) Matches(other Permission) bool { return p == other } // MatchesWithWildcard checks if this permission matches another, // supporting wildcards. // // Wildcards: // - "*:*" matches any permission // - "resource:*" matches any action on that resource // - "*:action" matches that action on any resource // // Example: // // auth.Permission("invoice:*").MatchesWithWildcard("invoice:create") // true // auth.Permission("*:view").MatchesWithWildcard("invoice:view") // true func (p Permission) MatchesWithWildcard(other Permission) bool { if p == other { return true } myResource := p.Resource() myAction := p.Action() otherResource := other.Resource() otherAction := other.Action() // Check for wildcards resourceMatch := myResource == "*" || myResource == otherResource actionMatch := myAction == "*" || myAction == otherAction return resourceMatch && actionMatch } // PermissionSet is a helper for checking multiple permissions efficiently. type PermissionSet map[Permission]struct{} // NewPermissionSet creates a permission set from a slice of permissions. func NewPermissionSet(permissions []Permission) PermissionSet { set := make(PermissionSet, len(permissions)) for _, p := range permissions { set[p] = struct{}{} } return set } // NewPermissionSetFromStrings creates a permission set from string permissions. func NewPermissionSetFromStrings(permissions []string) PermissionSet { set := make(PermissionSet, len(permissions)) for _, p := range permissions { set[Permission(p)] = struct{}{} } return set } // Contains checks if the set contains a permission. func (ps PermissionSet) Contains(permission Permission) bool { _, exists := ps[permission] return exists } // ContainsResourceAction checks if the set contains a resource:action permission. func (ps PermissionSet) ContainsResourceAction(resource, action string) bool { return ps.Contains(NewPermission(resource, action)) } // ContainsAny checks if the set contains any of the given permissions. func (ps PermissionSet) ContainsAny(permissions ...Permission) bool { for _, p := range permissions { if ps.Contains(p) { return true } } return false } // ContainsAll checks if the set contains all of the given permissions. func (ps PermissionSet) ContainsAll(permissions ...Permission) bool { for _, p := range permissions { if !ps.Contains(p) { return false } } return true } // ToSlice converts the permission set to a slice. func (ps PermissionSet) ToSlice() []Permission { result := make([]Permission, 0, len(ps)) for p := range ps { result = append(result, p) } return result } // PermissionsToStrings converts a slice of Permission to a slice of strings. func PermissionsToStrings(permissions []Permission) []string { result := make([]string, len(permissions)) for i, p := range permissions { result[i] = string(p) } return result } // StringsToPermissions converts a slice of strings to a slice of Permission. func StringsToPermissions(permissions []string) []Permission { result := make([]Permission, len(permissions)) for i, p := range permissions { result[i] = Permission(p) } return result } ================================================ FILE: go-b2b-starter/internal/modules/auth/provider.go ================================================ package auth import ( "fmt" "github.com/gin-gonic/gin" "go.uber.org/dig" ) // ServerMiddlewareRegistrar is the interface for registering named middleware. // This matches the server.Server interface's RegisterNamedMiddleware method. type ServerMiddlewareRegistrar interface { RegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc) } // Provider handles dependency injection for the RBAC module type Provider struct { container *dig.Container } func NewProvider(container *dig.Container) *Provider { return &Provider{ container: container, } } // RegisterDependencies registers all RBAC dependencies in the container func (p *Provider) RegisterDependencies() error { // Provide RBAC Service if err := p.container.Provide(func() RBACService { return NewRBACService() }); err != nil { return fmt.Errorf("failed to provide rbac service: %w", err) } // Provide RBAC Handler if err := p.container.Provide(func(service RBACService) *Handler { return NewHandler(service) }); err != nil { return fmt.Errorf("failed to provide rbac handler: %w", err) } // Provide RBAC Routes if err := p.container.Provide(func(handler *Handler) *Routes { return NewRoutes(handler) }); err != nil { return fmt.Errorf("failed to provide rbac routes: %w", err) } return nil } // SetupMiddleware wires the auth middleware into the DI container. // // This must be called after the auth provider and resolvers are available. // // # Prerequisites // // The following must be available in the container: // - auth.AuthProvider // - auth.OrganizationResolver // - auth.AccountResolver // // # Usage // // if err := auth.SetupMiddleware(container); err != nil { // return err // } func SetupMiddleware(container *dig.Container) error { if err := container.Provide(func( provider AuthProvider, orgResolver OrganizationResolver, accResolver AccountResolver, ) *Middleware { return NewMiddleware(provider, orgResolver, accResolver, nil) }); err != nil { return fmt.Errorf("failed to provide auth middleware: %w", err) } return nil } // RegisterNamedMiddlewares registers the auth middleware functions with the server. // // This should be called after SetupMiddleware and the server is available. // It registers the following named middlewares: // - "auth": RequireAuth middleware (verifies JWT token) // - "org_context": RequireOrganization middleware (resolves org/account IDs) // // # Usage // // if err := auth.RegisterNamedMiddlewares(container); err != nil { // return err // } func RegisterNamedMiddlewares(container *dig.Container) error { return container.Invoke(func( middleware *Middleware, server ServerMiddlewareRegistrar, ) { // Register auth middleware (verifies JWT and sets Identity) server.RegisterNamedMiddleware("auth", func() gin.HandlerFunc { return middleware.RequireAuth() }) // Register organization context middleware (resolves database IDs) server.RegisterNamedMiddleware("org_context", func() gin.HandlerFunc { return middleware.RequireOrganization() }) }) } // ProvideResolvers provides Organization and Account resolvers. // // This is a convenience function for when you have repositories that // need to be adapted to the resolver interfaces. // // # Example // // auth.ProvideResolvers(container, func(orgRepo domain.OrganizationRepository) auth.OrganizationResolver { // return auth.NewOrganizationResolver(repo) // }, func(accRepo domain.AccountRepository) auth.AccountResolver { // return auth.NewAccountResolver(repo) // }) func ProvideResolvers( container *dig.Container, orgResolverProvider any, accResolverProvider any, ) error { if err := container.Provide(orgResolverProvider); err != nil { return fmt.Errorf("failed to provide organization resolver: %w", err) } if err := container.Provide(accResolverProvider); err != nil { return fmt.Errorf("failed to provide account resolver: %w", err) } return nil } ================================================ FILE: go-b2b-starter/internal/modules/auth/rbac.go ================================================ package auth // ============================================================================= // RBAC DEFINITIONS - Roles and Permissions // ============================================================================= // // This file is the SINGLE SOURCE OF TRUTH for all role and permission definitions. // Customize these for your business domain. // // ============================================================================= // ============================================================================= // PERMISSIONS - Customize for your business domain // ============================================================================= // // The default uses "resource" as a generic placeholder. // Change "resource" to match your domain entity: // // E-commerce: "product:view", "product:create", "order:manage" // Healthcare: "patient:view", "records:manage", "prescription:create" // Project Mgmt: "project:view", "task:create", "task:assign" // CRM: "contact:view", "deal:create", "deal:close" // Invoice System: "invoice:view", "invoice:create", "invoice:approve" // // Simply rename "resource" to your domain entity! // // ============================================================================= var ( // Resource permissions - rename "resource" to your domain entity PermResourceView = NewPermission("resource", "view") PermResourceCreate = NewPermission("resource", "create") PermResourceEdit = NewPermission("resource", "edit") PermResourceDelete = NewPermission("resource", "delete") PermResourceApprove = NewPermission("resource", "approve") // Organization permissions PermOrgView = NewPermission("org", "view") PermOrgManage = NewPermission("org", "manage") ) // AllPermissions is the complete list of all permissions in the system. // Update this when you add or remove permissions. var AllPermissions = []Permission{ PermResourceView, PermResourceCreate, PermResourceEdit, PermResourceDelete, PermResourceApprove, PermOrgView, PermOrgManage, } // ============================================================================= // ROLES - Customize role names and permissions as needed // ============================================================================= // // Default roles follow a simple hierarchy: // - Member: Basic access (view, create) // - Manager: Elevated access (edit, delete, approve) // - Admin: Full control (everything + org management) // // To customize: // 1. Change role IDs if needed (must match Stytch configuration) // 2. Adjust permissions for each role // 3. Add new roles if needed // // ============================================================================= // RoleInfo contains complete information about a role including its permissions. // Used for API responses and role lookups. type RoleInfo struct { // ID is the unique identifier for the role (e.g., "member", "manager", "admin") ID string // Name is the display name for the role Name string // Description explains the purpose and scope of the role Description string // Permissions is the list of permissions granted to this role Permissions []Permission } var ( // RoleMemberInfo - Basic user access // Typical users: Employees, staff, basic users RoleMemberInfo = RoleInfo{ ID: "member", Name: "Member", Description: "Basic access. Can view and create resources.", Permissions: []Permission{ PermResourceView, PermResourceCreate, }, } // RoleManagerInfo - Elevated access with approval rights // Typical users: Team leads, supervisors, managers RoleManagerInfo = RoleInfo{ ID: "manager", Name: "Manager", Description: "Elevated access. Can edit, delete, and approve resources.", Permissions: []Permission{ PermResourceView, PermResourceCreate, PermResourceEdit, PermResourceDelete, PermResourceApprove, PermOrgView, }, } // RoleAdminInfo - Full system control // Typical users: Business owners, administrators RoleAdminInfo = RoleInfo{ ID: "admin", Name: "Admin", Description: "Full control. Can manage organization settings and users.", Permissions: []Permission{ PermResourceView, PermResourceCreate, PermResourceEdit, PermResourceDelete, PermResourceApprove, PermOrgView, PermOrgManage, }, } ) // AllRoles is the complete list of all roles in the RBAC system. // Update this when you add or remove roles. var AllRoles = []RoleInfo{ RoleMemberInfo, RoleManagerInfo, RoleAdminInfo, } // ============================================================================= // HELPER FUNCTIONS // ============================================================================= // GetRoleInfo retrieves role information by role ID. // Returns nil if the role is not found. func GetRoleInfo(roleID string) *RoleInfo { for i := range AllRoles { if AllRoles[i].ID == roleID { return &AllRoles[i] } } return nil } // GetRolePermissionIDs returns just the permission IDs (strings) for a given role. // Useful for Stytch integration and API responses. func GetRolePermissionIDs(roleID string) []string { role := GetRoleInfo(roleID) if role == nil { return []string{} } ids := make([]string, len(role.Permissions)) for i, perm := range role.Permissions { ids[i] = string(perm) } return ids } // HasPermission checks if a role has a specific permission. func HasPermission(roleID string, permission Permission) bool { role := GetRoleInfo(roleID) if role == nil { return false } for _, perm := range role.Permissions { if perm == permission { return true } } return false } // ============================================================================= // PERMISSION MATRIX (for reference) // ============================================================================= // // | Permission | Member | Manager | Admin | // |-------------------|--------|---------|-------| // | resource:view | ✓ | ✓ | ✓ | // | resource:create | ✓ | ✓ | ✓ | // | resource:edit | | ✓ | ✓ | // | resource:delete | | ✓ | ✓ | // | resource:approve | | ✓ | ✓ | // | org:view | | ✓ | ✓ | // | org:manage | | | ✓ | // // Role totals: // - Member: 2 permissions // - Manager: 6 permissions // - Admin: 7 permissions (all) // // ============================================================================= // ============================================================================= // API RESPONSE TYPES (DTOs) // ============================================================================= // PermissionDTO represents a permission in API responses type PermissionDTO struct { ID string `json:"id"` Resource string `json:"resource"` Action string `json:"action"` DisplayName string `json:"display_name"` Description string `json:"description"` Category string `json:"category"` } // NewPermissionDTO converts a Permission to a DTO func NewPermissionDTO(perm Permission) PermissionDTO { return PermissionDTO{ ID: string(perm), Resource: perm.Resource(), Action: perm.Action(), // Generic display name and description for simple permissions DisplayName: perm.Resource() + " " + perm.Action(), Description: "Can " + perm.Action() + " " + perm.Resource(), Category: "General", } } // RoleDTO represents a role with its permissions in API responses type RoleDTO struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Permissions []PermissionDTO `json:"permissions"` } // NewRoleDTO converts a RoleInfo to a DTO func NewRoleDTO(role RoleInfo) RoleDTO { permDTOs := make([]PermissionDTO, len(role.Permissions)) for i, perm := range role.Permissions { permDTOs[i] = NewPermissionDTO(perm) } return RoleDTO{ ID: role.ID, Name: role.Name, Description: role.Description, Permissions: permDTOs, } } // RolesResponse is the response body for GET /rbac/roles type RolesResponse struct { Roles []RoleDTO `json:"roles"` } // PermissionsResponse is the response body for GET /rbac/permissions type PermissionsResponse struct { Permissions []PermissionDTO `json:"permissions"` } // PermissionsByCategoryResponse is the response body for GET /rbac/permissions/by-category type PermissionsByCategoryResponse struct { Categories map[string][]PermissionDTO `json:"categories"` } // RolePermissionsResponse contains role information with detailed metadata type RolePermissionsResponse struct { Role RoleDTO `json:"role"` Statistics RoleStatistics `json:"statistics"` Restrictions RoleRestrictions `json:"restrictions"` } // RoleStatistics provides summary information about a role type RoleStatistics struct { TotalPermissions int `json:"total_permissions"` CanApprove bool `json:"can_approve"` CanManageOrg bool `json:"can_manage_org"` Description string `json:"description"` } // RoleRestrictions documents what a role cannot do type RoleRestrictions struct { CannotDo []string `json:"cannot_do"` DataAccessLevel string `json:"data_access_level"` Scope string `json:"scope"` } // NewRolePermissionsResponse creates a detailed response for a role func NewRolePermissionsResponse(roleID string) *RolePermissionsResponse { role := GetRoleInfo(roleID) if role == nil { return nil } stats := RoleStatistics{ TotalPermissions: len(role.Permissions), CanApprove: HasPermission(roleID, PermResourceApprove), CanManageOrg: HasPermission(roleID, PermOrgManage), Description: role.Description, } // Define restrictions based on role var restrictions RoleRestrictions switch roleID { case "member": restrictions = RoleRestrictions{ CannotDo: []string{"Edit resources", "Delete resources", "Approve requests", "Manage organization"}, DataAccessLevel: "Basic - view and create only", Scope: "Limited to own resources", } case "manager": restrictions = RoleRestrictions{ CannotDo: []string{"Manage organization settings"}, DataAccessLevel: "Elevated - full resource access", Scope: "Team-wide access", } case "admin": restrictions = RoleRestrictions{ CannotDo: []string{}, DataAccessLevel: "Full - all data access", Scope: "Organization-wide", } default: restrictions = RoleRestrictions{ CannotDo: []string{}, DataAccessLevel: "Unknown", Scope: "Unknown", } } return &RolePermissionsResponse{ Role: NewRoleDTO(*role), Statistics: stats, Restrictions: restrictions, } } // PermissionCheckRequest is used to verify if a role has a permission type PermissionCheckRequest struct { RoleID string `json:"role_id" binding:"required"` PermissionID string `json:"permission_id" binding:"required"` } // PermissionCheckResponse indicates whether a role has a permission type PermissionCheckResponse struct { RoleID string `json:"role_id"` PermissionID string `json:"permission_id"` HasPermission bool `json:"has_permission"` } // RBACMetadata provides summary information about the RBAC system type RBACMetadata struct { TotalRoles int `json:"total_roles"` TotalPermissions int `json:"total_permissions"` PermissionsByRole map[string]int `json:"permissions_by_role"` Description string `json:"description"` } // NewRBACMetadata creates metadata about the RBAC system func NewRBACMetadata() RBACMetadata { permsByRole := make(map[string]int) for _, role := range AllRoles { permsByRole[role.ID] = len(role.Permissions) } return RBACMetadata{ TotalRoles: len(AllRoles), TotalPermissions: len(AllPermissions), PermissionsByRole: permsByRole, Description: "Simple RBAC system with 3 roles (Member, Manager, Admin) and 7 generic permissions", } } // ============================================================================= // SERVICE INTERFACE AND IMPLEMENTATION // ============================================================================= // RBACService provides business logic for RBAC operations type RBACService interface { GetAllRoles() []RoleInfo GetRoleInfo(roleID string) *RoleInfo GetAllPermissions() []Permission GetRolePermissions(roleID string) []Permission GetPermissionsByCategory() map[string][]Permission GetPermissionsByRoleID(roleID string) []string HasPermission(roleID string, permissionID string) bool GetRBACMetadata() RBACMetadata } // defaultRBACService implements the RBACService interface type defaultRBACService struct{} func NewRBACService() RBACService { return &defaultRBACService{} } func (s *defaultRBACService) GetAllRoles() []RoleInfo { return AllRoles } func (s *defaultRBACService) GetRoleInfo(roleID string) *RoleInfo { return GetRoleInfo(roleID) } func (s *defaultRBACService) GetAllPermissions() []Permission { return AllPermissions } func (s *defaultRBACService) GetRolePermissions(roleID string) []Permission { role := GetRoleInfo(roleID) if role == nil { return []Permission{} } return role.Permissions } func (s *defaultRBACService) GetPermissionsByCategory() map[string][]Permission { // For simplicity, return all permissions in one "General" category return map[string][]Permission{ "General": AllPermissions, } } func (s *defaultRBACService) GetPermissionsByRoleID(roleID string) []string { return GetRolePermissionIDs(roleID) } func (s *defaultRBACService) HasPermission(roleID string, permissionID string) bool { return HasPermission(roleID, Permission(permissionID)) } func (s *defaultRBACService) GetRBACMetadata() RBACMetadata { return NewRBACMetadata() } ================================================ FILE: go-b2b-starter/internal/modules/auth/resolvers.go ================================================ package auth import ( "context" "fmt" ) // OrganizationLookup is the minimal interface needed to resolve organizations. // // This interface should be implemented by your organization repository. // It abstracts the specific repository implementation from the auth package. type OrganizationLookup interface { // GetByStytchID returns an organization by Stytch organization ID. // The returned value must have an ID field (int32). GetByStytchID(ctx context.Context, stytchOrgID string) (OrganizationEntity, error) } // OrganizationEntity is the minimal interface for an organization entity. type OrganizationEntity interface { GetID() int32 } // AccountLookup is the minimal interface needed to resolve accounts. // // This interface should be implemented by your account repository. // It abstracts the specific repository implementation from the auth package. type AccountLookup interface { // GetByEmail returns an account by email within an organization. // The returned value must have an ID field (int32). GetByEmail(ctx context.Context, orgID int32, email string) (AccountEntity, error) } // AccountEntity is the minimal interface for an account entity. type AccountEntity interface { GetID() int32 } // NewOrganizationResolver creates an OrganizationResolver from an OrganizationLookup. // // This is a convenience function for creating resolvers from repositories // that implement the OrganizationLookup interface. // // # Usage // // // In your organizations module provider: // container.Provide(func(repo domain.OrganizationRepository) auth.OrganizationResolver { // return auth.NewOrganizationResolver(repo) // }) func NewOrganizationResolver(lookup OrganizationLookup) OrganizationResolver { return &orgResolverAdapter{lookup: lookup} } // NewAccountResolver creates an AccountResolver from an AccountLookup. // // # Usage // // // In your organizations module provider: // container.Provide(func(repo domain.AccountRepository) auth.AccountResolver { // return auth.NewAccountResolver(repo) // }) func NewAccountResolver(lookup AccountLookup) AccountResolver { return &accResolverAdapter{lookup: lookup} } // orgResolverAdapter adapts OrganizationLookup to OrganizationResolver. type orgResolverAdapter struct { lookup OrganizationLookup } func (a *orgResolverAdapter) ResolveByProviderID(ctx context.Context, providerOrgID string) (int32, error) { org, err := a.lookup.GetByStytchID(ctx, providerOrgID) if err != nil { return 0, fmt.Errorf("organization not found for provider ID %s: %w", providerOrgID, err) } return org.GetID(), nil } // accResolverAdapter adapts AccountLookup to AccountResolver. type accResolverAdapter struct { lookup AccountLookup } func (a *accResolverAdapter) ResolveByEmail(ctx context.Context, orgID int32, email string) (int32, error) { acc, err := a.lookup.GetByEmail(ctx, orgID, email) if err != nil { return 0, fmt.Errorf("account not found for email %s in org %d: %w", email, orgID, err) } return acc.GetID(), nil } // SimpleOrganization is a simple implementation of OrganizationEntity. // Use this if your domain entity doesn't already implement GetID(). type SimpleOrganization struct { ID int32 } func (o *SimpleOrganization) GetID() int32 { return o.ID } // SimpleAccount is a simple implementation of AccountEntity. // Use this if your domain entity doesn't already implement GetID(). type SimpleAccount struct { ID int32 } func (a *SimpleAccount) GetID() int32 { return a.ID } ================================================ FILE: go-b2b-starter/internal/modules/auth/roles.go ================================================ package auth // Role represents a user role in the system. // // Roles are assigned to users and determine their base permissions. // The application uses a three-tier RBAC system: // // - Member: Basic access (view and create) // - Manager: Elevated access (edit, delete, approve) // - Admin: Full system control // // # Adding New Roles // // To add a new role: // 1. Add the role constant below // 2. Add role info in rbac.go (AllRoles) // 3. Configure the role in your auth provider (e.g., Stytch dashboard) // // # Role Source of Truth // // The auth provider (e.g., Stytch) is the source of truth for roles at runtime. // The definitions in rbac.go are used as a fallback when the auth provider // doesn't provide explicit permissions. type Role string // Core RBAC roles. // // These must match the roles configured in the auth provider. const ( // RoleMember is for basic users with view and create access. // Can: View resources, create resources // Cannot: Edit, delete, approve, manage org RoleMember Role = "member" // RoleManager is for users with elevated access. // Can: View, create, edit, delete, approve resources // Cannot: Manage organization settings RoleManager Role = "manager" // RoleAdmin has full system control. // Can: Everything - no restrictions RoleAdmin Role = "admin" ) // Legacy role aliases for backward compatibility. // // These map to the new role constants and will be removed in a future version. const ( // RoleOwner is a legacy alias for RoleAdmin. // Deprecated: Use RoleAdmin instead. RoleOwner Role = "owner" // RoleApprover is a legacy alias for RoleManager. // Deprecated: Use RoleManager instead. RoleApprover Role = "approver" // RoleReviewer is a legacy alias for RoleManager. // Deprecated: Use RoleManager instead. RoleReviewer Role = "reviewer" // RoleEmployee is a legacy alias for RoleMember. // Deprecated: Use RoleMember instead. RoleEmployee Role = "employee" ) // String returns the string representation of the role. func (r Role) String() string { return string(r) } // IsValid checks if the role is a known role. func (r Role) IsValid() bool { normalized := NormalizeRole(string(r)) roleInfo := GetRoleInfo(string(normalized)) return roleInfo != nil } // NormalizeRole converts legacy role names to current ones. // // This handles backward compatibility for old role assignments: // - "owner" -> "admin" // - "approver" -> "manager" // - "reviewer" -> "manager" // - "employee" -> "member" // - "stytch_member" -> "member" (strips provider prefix) func NormalizeRole(roleStr string) Role { role := Role(roleStr) // Handle provider-prefixed roles (e.g., "stytch_member") switch role { case "stytch_member": return RoleMember case "stytch_admin": return RoleAdmin case "stytch_manager": return RoleManager } // Handle legacy roles switch role { case RoleOwner: return RoleAdmin case RoleApprover, RoleReviewer: return RoleManager case RoleEmployee: return RoleMember } return role } // GetRolePermissions returns the default permissions for a role. // // Returns nil if the role is not recognized. // This is used as a fallback when the auth provider doesn't provide permissions. // // Permissions are defined in rbac.go which is the single source of truth. func GetRolePermissions(role Role) []Permission { // Normalize the role to handle legacy names normalized := NormalizeRole(string(role)) // Get role info from rbac.go (single source of truth) roleInfo := GetRoleInfo(string(normalized)) if roleInfo == nil { return nil } return roleInfo.Permissions } // HasRolePermission checks if a role has a specific permission. // // This uses the role definitions from rbac.go and is used as a fallback // when the auth provider doesn't include explicit permissions. func HasRolePermission(role Role, resource, action string) bool { perms := GetRolePermissions(role) target := NewPermission(resource, action) for _, p := range perms { if p == target { return true } } return false } ================================================ FILE: go-b2b-starter/internal/modules/auth/routes.go ================================================ package auth import ( "github.com/gin-gonic/gin" serverDomain "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) // Routes handles RBAC API routes registration type Routes struct { handler *Handler } func NewRoutes(handler *Handler) *Routes { return &Routes{ handler: handler, } } // RegisterRoutes registers RBAC routes on the router // Note: RBAC endpoints are public and do NOT require authentication // These endpoints are used by frontend for role/permission discovery func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { // RBAC info endpoints - NO authentication required for role/permission discovery rbacGroup := router.Group("/rbac") { // Get all roles with their permissions - single source of truth for frontend // GET /api/rbac/roles rbacGroup.GET("/roles", r.handler.GetRoles) // Get all permissions - useful for permission checkers // GET /api/rbac/permissions rbacGroup.GET("/permissions", r.handler.GetPermissions) // Get permissions organized by category - for structured UI display // GET /api/rbac/permissions/by-category rbacGroup.GET("/permissions/by-category", r.handler.GetPermissionsByCategory) // Get detailed information about a specific role with statistics // GET /api/rbac/roles/{role_id} rbacGroup.GET("/roles/:role_id", r.handler.GetRoleDetails) // Check if a role has a specific permission - for conditional UI rendering // POST /api/rbac/check-permission rbacGroup.POST("/check-permission", r.handler.CheckPermission) // Get RBAC system metadata // GET /api/rbac/metadata rbacGroup.GET("/metadata", r.handler.GetMetadata) } } // Routes satisfies the RouteRegistrar interface // This allows the routes to be registered by the server func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { r.RegisterRoutes(router, resolver) } ================================================ FILE: go-b2b-starter/internal/modules/billing/README.md ================================================ # Billing Module Hybrid subscription lifecycle management for B2B SaaS applications. This module combines **event-driven webhooks** with **active verification** for maximum reliability. ## Key Principle: Hybrid Synchronization Strategy ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ HYBRID BILLING SYNC (This Module) │ │ │ │ "Primary: Webhooks + Fallback: Active Verification + Self-Healing" │ │ │ │ 1. VERIFICATION ON REDIRECT (Initial Payment): │ │ User pays → Frontend calls /verify-payment → Instant access │ │ │ │ 2. WEBHOOKS (Renewals): │ │ Polar.sh sends webhook → Billing module processes → DB updated │ │ │ │ 3. LAZY GUARDING (Missed Webhooks): │ │ DB says expired → Middleware checks Polar API → Self-healing │ │ │ │ Result: Fast, reliable, self-healing subscription management │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ## Architecture ``` EXTERNAL ┌────────────────────────────────────────────────────────────────────────────┐ │ Polar.sh │ │ │ │ - Handles checkout, payment processing, subscription management │ │ - Sends webhooks on state changes │ └────────────────────────────────────────────────────────────────────────────┘ │ │ Webhooks (subscription.created, subscription.updated, etc.) ▼ ┌────────────────────────────────────────────────────────────────────────────┐ │ BILLING MODULE │ ├────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ │ Webhook Handler │ ──► │ BillingService │ ──► │ Repository │ │ │ │ (API Layer) │ │ (Domain Logic) │ │ (Infra Layer) │ │ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ Quota Tracking │ │ Local DB │ │ │ └──────────────────┘ └──────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────────────────┘ │ │ Reads subscription status ▼ ┌────────────────────────────────────────────────────────────────────────────┐ │ PAYWALL MIDDLEWARE (pkg/paywall) │ │ │ │ - Reads from local DB (fast, no external calls) │ │ - Blocks requests if subscription inactive (402) │ │ - Provider-agnostic access gating │ └────────────────────────────────────────────────────────────────────────────┘ ``` ## Synchronization Mechanisms ### 1. Verification on Redirect (Initial Payment) **Use Case:** User completes payment and returns to app **Problem:** Webhooks may not arrive immediately (delays, failures) **Solution:** Frontend triggers backend verification ``` User Pays → Polar Redirects → Frontend → POST /verify-payment → Backend → Polar API → DB Updated → Instant Access ``` **Benefits:** - ✅ Instant access (5 seconds vs. minutes) - ✅ No webhook dependency - ✅ User sees immediate result **Implementation:** - Endpoint: `POST /api/subscriptions/verify-payment` - Service: `src/app/billing/app/services/verify_payment_service.go` - Adapter: `src/app/billing/infra/polar/polar_adapter.go` → `GetCheckoutSession()` ### 2. Webhooks (Renewals & Updates) **Use Case:** Monthly renewals, cancellations, plan changes **Standard Flow:** Polar sends webhook → Backend processes → DB updated **Supported Events:** - `subscription.created`, `subscription.updated`, `subscription.canceled` - `customer.updated`, `meter.grant.updated` ### 3. Lazy Guarding (Self-Healing) **Use Case:** Webhook failed or delayed for renewal **How It Works:** 1. User makes request 2. Middleware checks DB → Status: "expired" 3. Middleware calls Polar API to verify 4. If Polar says "active" → Grant access + Update DB 5. If Polar says "inactive" → Block access (truly expired) **Code (Automatic in Middleware):** ```go if !status.IsActive && status.Status != StatusNone { freshStatus, err := provider.RefreshSubscriptionStatus(ctx, orgID) if err == nil && freshStatus.IsActive { status = freshStatus // Self-healed! } } ``` **Benefits:** - ✅ Self-healing: No manual intervention - ✅ Fast: Only calls API in edge cases (<1% of requests) - ✅ Reliable: Paying users never locked out ## Why Hybrid Approach? | Scenario | Mechanism | Benefit | |----------|-----------|---------| | Initial Payment | Verification on Redirect | Instant access | | Monthly Renewal | Webhooks | No user action needed | | Missed Webhook | Lazy Guarding | Self-healing | | Normal Requests | Database Read | Fast (no API calls) | ## Module Structure ``` src/app/billing/ ├── domain/ │ ├── subscription.go # Subscription entity │ ├── quota.go # Quota tracking entity │ ├── billing_status.go # Combined status for API responses │ ├── repository.go # Repository interfaces │ ├── service.go # Service interface │ └── errors.go # Domain errors │ ├── app/services/ │ ├── subscription_service_dec.go # BillingService interface │ ├── sync_service.go # Sync subscription from Polar │ ├── webhook_service.go # Process webhook events │ └── quota_service.go # Quota management │ ├── infra/ │ ├── adapters/ │ │ └── status_provider.go # Bridge to paywall middleware │ ├── repositories/ │ │ ├── subscription_repository.go # Subscription DB operations │ │ └── organization_adapter.go # Org ID lookups │ └── polar/ │ └── polar_adapter.go # Polar API client (webhook only) │ └── cmd/ └── init.go # DI initialization ``` ## Data Flow ### 1. Subscription Created (Webhook) ``` Polar.sh Billing Module Local DB │ │ │ │ subscription.created │ │ │ ────────────────────────► │ │ │ │ UpsertSubscription() │ │ │ ────────────────────────► │ │ │ │ │ │ UpsertQuota() │ │ │ ────────────────────────► │ │ │ │ │ 200 OK │ │ │ ◄──────────────────────── │ │ ``` ### 2. User Accesses Premium Feature ``` User Paywall Local DB │ │ │ │ GET /ai/generate │ │ │ ────────────────────────► │ │ │ │ GetSubscriptionStatus() │ │ │ ──────────────────────► │ │ │ │ │ │ {status: "active"} │ │ │ ◄────────────────────── │ │ │ │ │ Pass through │ │ │ ◄──────────────────────── │ │ ``` ### 3. Quota Consumption (Invoice Processing) ``` User BillingService Local DB │ │ │ │ POST /invoices/process │ │ │ ────────────────────────► │ │ │ │ DecrementInvoiceCount() │ │ │ ──────────────────────► │ │ │ │ │ │ {remaining: 42} │ │ │ ◄────────────────────── │ │ │ │ │ 200 OK │ │ │ ◄──────────────────────── │ │ ``` ## Key Components ### BillingService ```go // BillingService handles subscription management and quota verification. // // This service manages the billing lifecycle with Polar.sh via event-driven webhooks. // It does NOT expose direct API calls to Polar during request handling: // // 1. WEBHOOK PROCESSING (async, event-driven): // - subscription.created, subscription.updated, subscription.canceled // - Updates local database with subscription state // // 2. LOCAL DB QUERIES (sync, during requests): // - GetBillingStatus: Check subscription status from local DB // - GetQuotaStatus: Check quota limits from local DB // // 3. QUOTA CONSUMPTION (sync, during requests): // - ConsumeInvoiceQuota: Decrement invoice count in local DB type BillingService interface { // Webhook processing (called by webhook handler) ProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error // Status queries (from local DB only) GetBillingStatus(ctx context.Context, organizationID int32) (*BillingStatus, error) CheckQuotaAvailability(ctx context.Context, organizationID int32) (*BillingStatus, error) // Quota consumption (local DB update) ConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*BillingStatus, error) // NEW: Verification on Redirect (makes Polar API call) VerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*BillingStatus, error) // NEW: Lazy Guarding (makes Polar API call when DB says expired) RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*BillingStatus, error) // Manual sync (for admin/debug - makes Polar API call) SyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error } ``` ### StatusProviderAdapter Bridges the billing module to the paywall middleware: ```go // In app/billing/infra/adapters/status_provider.go type StatusProviderAdapter struct { service services.BillingService } // Implements paywall.SubscriptionStatusProvider func (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) { billingStatus, err := a.service.GetBillingStatus(ctx, orgID) if err != nil { return nil, err } return &paywall.SubscriptionStatus{ OrganizationID: orgID, Status: billingStatus.SubscriptionStatus, IsActive: billingStatus.HasActiveSubscription, // Maps billing status to access status }, nil } // NEW: Implements lazy guarding - refreshes from Polar API when DB says expired func (a *StatusProviderAdapter) RefreshSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) { billingStatus, err := a.service.RefreshSubscriptionStatus(ctx, orgID) if err != nil { return nil, err } return &paywall.SubscriptionStatus{ OrganizationID: orgID, Status: billingStatus.SubscriptionStatus, IsActive: billingStatus.HasActiveSubscription, }, nil } ``` ### Webhook Events Supported Polar.sh webhook events: | Event | Description | Action | |-------|-------------|--------| | `subscription.created` | New subscription | Create/update subscription + quota | | `subscription.updated` | Status change | Update subscription status | | `subscription.canceled` | Subscription canceled | Mark as canceled | | `checkout.completed` | Checkout finished | Trigger subscription sync | ## Usage ### Webhook Handler (API Layer) ```go // POST /api/webhooks/polar func (h *Handler) HandlePolarWebhook(c *gin.Context) { var event domain.WebhookEvent if err := c.ShouldBindJSON(&event); err != nil { c.JSON(400, gin.H{"error": "invalid payload"}) return } if err := h.billingService.ProcessSubscriptionWebhook(c.Request.Context(), &event); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"status": "processed"}) } ``` ### Getting Billing Status ```go // In any handler that needs billing info func (h *Handler) GetBillingStatus(c *gin.Context) { reqCtx := auth.GetRequestContext(c) status, err := h.billingService.GetBillingStatus(c.Request.Context(), reqCtx.OrganizationID) if err != nil { if err == domain.ErrSubscriptionNotFound { // No subscription yet - return appropriate response c.JSON(200, domain.BillingStatus{ HasActiveSubscription: false, CanProcessInvoices: false, Reason: "No active subscription", }) return } c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, status) } ``` ### Consuming Quota ```go // Before processing an invoice func (h *Handler) ProcessInvoice(c *gin.Context) { reqCtx := auth.GetRequestContext(c) // Check quota quotaStatus, err := h.billingService.GetQuotaStatus(c.Request.Context(), reqCtx.OrganizationID) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } if !quotaStatus.CanProcessInvoice { c.JSON(402, gin.H{ "error": "quota_exceeded", "message": "Invoice processing quota exhausted", "upgrade_url": "/billing", }) return } // Process the invoice... // Consume quota if err := h.billingService.ConsumeInvoiceQuota(c.Request.Context(), reqCtx.OrganizationID); err != nil { // Log but don't fail - invoice was processed log.Printf("failed to consume quota: %v", err) } } ``` ## Configuration Environment variables for Polar.sh integration: ```env POLAR_API_KEY=your_polar_api_key POLAR_WEBHOOK_SECRET=your_webhook_secret POLAR_ORGANIZATION_ID=your_polar_org_id ``` ## Database Schema ```sql -- Subscription tracking CREATE TABLE subscription_billing.subscriptions ( id SERIAL PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id), external_customer_id TEXT NOT NULL, -- Polar customer ID subscription_id TEXT NOT NULL, -- Polar subscription ID subscription_status TEXT NOT NULL, -- active, trialing, past_due, canceled, unpaid product_id TEXT NOT NULL, product_name TEXT, plan_name TEXT, current_period_start TIMESTAMP, current_period_end TIMESTAMP, cancel_at_period_end BOOLEAN DEFAULT FALSE, canceled_at TIMESTAMP, metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- Quota tracking CREATE TABLE subscription_billing.quota_tracking ( id SERIAL PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id), invoice_count INTEGER DEFAULT 0, -- Remaining invoices max_seats INTEGER, -- Seat limit period_start TIMESTAMP, period_end TIMESTAMP, last_synced_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); ``` ## Related Modules - **pkg/paywall**: Access gating middleware (reads from this module's DB) - **pkg/polar**: Polar.sh API client (used for webhook validation) - **app/organizations**: Organization management (links subscription to org) ## Testing ```go // Mock the billing service for unit tests type MockBillingService struct { mock.Mock } func (m *MockBillingService) GetBillingStatus(ctx context.Context, orgID int32) (*domain.BillingStatus, error) { args := m.Called(ctx, orgID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.BillingStatus), args.Error(1) } // In your test func TestHandler_RequiresActiveSubscription(t *testing.T) { mockService := new(MockBillingService) mockService.On("GetBillingStatus", mock.Anything, int32(1)).Return(&domain.BillingStatus{ HasActiveSubscription: false, }, nil) // Test that handler returns 402 } ``` ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/check_quota_availability_service.go ================================================ package services import ( "context" "fmt" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) // CheckQuotaAvailability performs a read-only verification of quota availability // This method does NOT consume quota - it only checks if processing is allowed // Use ConsumeInvoiceQuota after successful invoice processing to actually decrement the quota func (s *billingService) CheckQuotaAvailability(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { // Step 1: Check database quota status (read-only) quotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID) if err != nil { return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: false, CanProcessInvoices: false, Reason: "no active subscription", CheckedAt: time.Now(), }, domain.ErrSubscriptionNotFound } // Step 2: Check if we need fallback API verification needsFallback := s.needsFallbackVerification(quotaStatus) if needsFallback { s.logger.Info("Quota near limit or stale, performing fallback API verification", map[string]any{ "organization_id": organizationID, "invoice_count": quotaStatus.InvoiceCount, }) // Sync from Polar and re-check if err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil { s.logger.Error("Fallback sync failed, using database data", map[string]any{ "organization_id": organizationID, "error": err.Error(), }) } else { // Re-fetch quota status after sync quotaStatus, err = s.repo.GetQuotaStatus(ctx, organizationID) if err != nil { return nil, fmt.Errorf("failed to get quota after sync: %w", err) } } } // Step 3: Verify quota is available (NO consumption here) if !quotaStatus.CanProcessInvoice { return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: quotaStatus.SubscriptionStatus == "active", CanProcessInvoices: false, InvoiceCount: quotaStatus.InvoiceCount, Reason: "quota exceeded or subscription inactive", CheckedAt: time.Now(), }, domain.ErrQuotaExceeded } // Step 4: Return success status (quota NOT consumed yet) return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: true, CanProcessInvoices: true, InvoiceCount: quotaStatus.InvoiceCount, // Current count, NOT decremented Reason: "quota available", CheckedAt: time.Now(), }, nil } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/consume_invoice_quota_service.go ================================================ package services import ( "context" "fmt" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) // ConsumeInvoiceQuota explicitly consumes one invoice quota after successful processing // This should be called after the invoice has been successfully processed // Can be safely called in a background goroutine for better performance func (s *billingService) ConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { s.logger.Info("Consuming invoice quota for organization", map[string]any{ "organization_id": organizationID, }) // Step 1: Get current quota status before consumption quotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID) if err != nil { s.logger.Error("Failed to get quota status before consumption", map[string]any{ "organization_id": organizationID, "error": err.Error(), }) return nil, fmt.Errorf("failed to get quota status: %w", err) } // Step 2: Decrement quota count (atomic database operation) updatedQuota, err := s.repo.DecrementInvoiceCount(ctx, organizationID) if err != nil { s.logger.Error("Failed to decrement invoice count", map[string]any{ "organization_id": organizationID, "error": err.Error(), }) return nil, fmt.Errorf("failed to decrement invoice count: %w", err) } s.logger.Info("Successfully consumed invoice quota locally", map[string]any{ "organization_id": organizationID, "previous_count": quotaStatus.InvoiceCount, "new_count": updatedQuota.InvoiceCount, "remaining_invoices": updatedQuota.InvoiceCount, }) // Step 3: Ingest meter event to Polar to consume credits (best-effort) // This notifies Polar about the invoice processing usage // Local tracking is maintained for fast quota checks, Polar tracks actual billing go s.ingestMeterEventToPolar(context.Background(), organizationID) // Step 4: Return updated billing status return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: quotaStatus.SubscriptionStatus == "active", CanProcessInvoices: updatedQuota.InvoiceCount > 0, InvoiceCount: updatedQuota.InvoiceCount, Reason: "quota consumed successfully", CheckedAt: time.Now(), }, nil } // ingestMeterEventToPolar ingests a meter event to Polar for usage-based billing // This runs in a background goroutine and uses best-effort approach // Failures are logged but don't affect the main operation since local tracking is maintained func (s *billingService) ingestMeterEventToPolar(ctx context.Context, organizationID int32) { // Use background context with timeout (independent of request context) ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() // Get organization's external customer ID (Stytch org ID) externalID, err := s.orgAdapter.GetStytchOrgID(ctx, organizationID) if err != nil { s.logger.Error("Failed to get external customer ID for Polar meter event", map[string]any{ "organization_id": organizationID, "error": err.Error(), }) return } // Ingest meter event to Polar // Meter: "Invoice Processing" (configured in Polar dashboard) // Filter: name equals "invoice.processed" // Amount: 1 (one invoice processed) meterSlug := invoicesProcessedMeterSlug // Event name MUST match meter filter exactly (with dot) if err := s.billingProvider.IngestMeterEvent(ctx, externalID, meterSlug, 1); err != nil { s.logger.Error("Failed to ingest meter event to Polar", map[string]any{ "organization_id": organizationID, "external_id": externalID, "meter_slug": meterSlug, "error": err.Error(), }) return } // Log success s.logger.Info("Successfully ingested event to Polar", map[string]any{ "organization_id": organizationID, "external_id": externalID, "event_name": meterSlug, "amount": 1, }) } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/get_billing_status_service.go ================================================ package services import ( "context" "fmt" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) func (s *billingService) GetBillingStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { // Get quota status from database quotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID) if err != nil { // No subscription found return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: false, CanProcessInvoices: false, InvoiceCount: 0, Reason: "no active subscription found", CheckedAt: time.Now(), }, nil } // Build billing status from quota status return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: quotaStatus.SubscriptionStatus == "active", CanProcessInvoices: quotaStatus.CanProcessInvoice, InvoiceCount: quotaStatus.InvoiceCount, Reason: s.buildStatusReason(quotaStatus), CheckedAt: time.Now(), }, nil } func (s *billingService) buildStatusReason(status *domain.QuotaStatus) string { if !status.CanProcessInvoice { if status.SubscriptionStatus != "active" { return fmt.Sprintf("subscription status: %s", status.SubscriptionStatus) } return "invoice quota exceeded" } return "ok" } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/module.go ================================================ package services import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" "github.com/moasq/go-b2b-starter/internal/modules/billing/infra/polar" "github.com/moasq/go-b2b-starter/internal/modules/billing/infra/repositories" "github.com/moasq/go-b2b-starter/internal/db/adapters" logger "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" polarpkg "github.com/moasq/go-b2b-starter/internal/platform/polar" ) // Module handles dependency injection for billing services // Note: SubscriptionRepository is registered in internal/db/inject.go type Module struct{} func NewModule() *Module { return &Module{} } // Configure registers all services in the dependency container func (m *Module) Configure(container *dig.Container) error { // Register OrganizationAdapter (uses legacy adapter store for now) if err := container.Provide(func(orgStore adapters.OrganizationStore) domain.OrganizationAdapter { return repositories.NewOrganizationAdapter(orgStore) }); err != nil { return err } // Register BillingProvider (Polar implementation) if err := container.Provide(func(client *polarpkg.Client, log logger.Logger) domain.BillingProvider { return polar.NewPolarAdapter(client, log) }); err != nil { return err } // Register BillingService if err := container.Provide(func( repo domain.SubscriptionRepository, orgAdapter domain.OrganizationAdapter, billingProvider domain.BillingProvider, logger logger.Logger, ) BillingService { return NewBillingService(repo, orgAdapter, billingProvider, logger) }); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/process_webhook_event_service.go ================================================ package services import ( "context" "errors" "fmt" "math" "strconv" "strings" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) const invoicesProcessedMeterSlug = "invoice.processed" func (s *billingService) ProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error { s.logger.Info("Processing webhook event", map[string]any{ "event_type": eventType, "payload_keys": mapKeys(payload), }) // Update subscription based on event type switch eventType { case "subscription.created", "subscription.updated": eventData, err := s.parseSubscriptionWebhookPayload(payload) if err != nil { return fmt.Errorf("failed to parse subscription webhook payload: %w", err) } return s.handleSubscriptionUpsert(ctx, eventData) case "subscription.canceled": eventData, err := s.parseSubscriptionWebhookPayload(payload) if err != nil { return fmt.Errorf("failed to parse subscription webhook payload: %w", err) } return s.handleSubscriptionCanceled(ctx, eventData) case "customer.updated": eventData, err := s.parseSubscriptionWebhookPayload(payload) if err != nil { return fmt.Errorf("failed to parse subscription webhook payload: %w", err) } return s.handleCustomerUpdated(ctx, eventData) case "meter.grant.updated", "meter.grant.created", "entitlement.grant.updated": if err := s.handleMeterGrantEvent(ctx, payload); err != nil { return fmt.Errorf("failed to handle meter grant webhook: %w", err) } return nil default: s.logger.Warn("Unhandled webhook event type", map[string]any{ "event_type": eventType, }) return nil // Don't fail on unknown events } } func (s *billingService) parseSubscriptionWebhookPayload(payload map[string]any) (*domain.SubscriptionEventData, error) { normalized := normalizePolarObject(payload) if normalized == nil { return nil, fmt.Errorf("webhook payload missing subscription object") } data := &domain.SubscriptionEventData{} if subID, ok := normalized["id"].(string); ok { data.SubscriptionID = subID } else if subID, ok := normalized["subscription_id"].(string); ok { data.SubscriptionID = subID } if status, ok := normalized["status"].(string); ok { data.Status = status } if t, ok := parseISOTime(normalized["current_period_start"]); ok { data.CurrentPeriodStart = t } else if t, ok := parseISOTime(normalized["current_period_start_at"]); ok { data.CurrentPeriodStart = t } if t, ok := parseISOTime(normalized["current_period_end"]); ok { data.CurrentPeriodEnd = t } else if t, ok := parseISOTime(normalized["current_period_end_at"]); ok { data.CurrentPeriodEnd = t } if value, exists := normalized["cancel_at_period_end"]; exists { if v, ok := toBool(value); ok { data.CancelAtPeriodEnd = v } } if value, exists := normalized["canceled_at"]; exists { if t, ok := parseISOTime(value); ok { data.CanceledAt = &t } } product := extractProductMap(normalized) if product == nil { product = extractProductMap(payload) } if product != nil { if productID, ok := product["id"].(string); ok && data.ProductID == "" { data.ProductID = productID } if productName, ok := product["name"].(string); ok && data.ProductName == "" { data.ProductName = productName } if metadata := stringMapFrom(product["metadata"]); len(metadata) > 0 { data.ProductMetadata = metadata } } if data.ProductID == "" { if productID, ok := normalized["product_id"].(string); ok { data.ProductID = productID } else if productID, ok := payload["product_id"].(string); ok { data.ProductID = productID } } if data.ProductName == "" { if productName, ok := normalized["product_name"].(string); ok { data.ProductName = productName } } if len(data.ProductMetadata) == 0 { if metadata := stringMapFrom(normalized["product_metadata"]); len(metadata) > 0 { data.ProductMetadata = metadata } else if metadata := stringMapFrom(payload["product_metadata"]); len(metadata) > 0 { data.ProductMetadata = metadata } } if product != nil { if invoiceCount := extractInvoiceCountFromProduct(product); invoiceCount != "" { if data.ProductMetadata == nil { data.ProductMetadata = make(map[string]string) } if existing, ok := data.ProductMetadata["invoice_count"]; !ok || existing == "" { data.ProductMetadata["invoice_count"] = invoiceCount } } } if metadata := stringMapFrom(normalized["metadata"]); len(metadata) > 0 { data.CustomerMetadata = metadata } if len(data.CustomerMetadata) == 0 { if customer, ok := normalized["customer"].(map[string]any); ok { if metadata := stringMapFrom(customer["metadata"]); len(metadata) > 0 { data.CustomerMetadata = metadata } if data.ExternalCustomerID == "" { if externalID, ok := customer["external_id"].(string); ok && externalID != "" { data.ExternalCustomerID = externalID } else if externalID, ok := customer["id"].(string); ok && externalID != "" { data.ExternalCustomerID = externalID } } } } if len(data.CustomerMetadata) == 0 { if metadata := stringMapFrom(payload["metadata"]); len(metadata) > 0 { data.CustomerMetadata = metadata } } if data.ExternalCustomerID == "" { if externalID, ok := normalized["customer_external_id"].(string); ok && externalID != "" { data.ExternalCustomerID = externalID } else if externalID, ok := normalized["external_customer_id"].(string); ok && externalID != "" { data.ExternalCustomerID = externalID } else if externalID, ok := payload["customer_external_id"].(string); ok && externalID != "" { data.ExternalCustomerID = externalID } } if data.ExternalCustomerID == "" && len(data.CustomerMetadata) > 0 { if externalID, ok := data.CustomerMetadata["organization_id"]; ok && externalID != "" { data.ExternalCustomerID = externalID } else if externalID, ok := data.CustomerMetadata["external_customer_id"]; ok && externalID != "" { data.ExternalCustomerID = externalID } } if data.ExternalCustomerID == "" { s.logger.Warn("Subscription webhook payload missing external customer identifier", map[string]any{ "payload_keys": mapKeys(normalized), }) return nil, fmt.Errorf("webhook payload missing organization_id") } s.logger.Info("Parsed subscription webhook payload", map[string]any{ "subscription_id": data.SubscriptionID, "external_customer_id": data.ExternalCustomerID, "status": data.Status, "product_id": data.ProductID, "product_metadata_keys": len(data.ProductMetadata), "customer_metadata_keys": len(data.CustomerMetadata), }) return data, nil } func (s *billingService) handleSubscriptionUpsert(ctx context.Context, eventData *domain.SubscriptionEventData) error { // Step 1: Map Polar organization_id to internal organization ID organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID) if err != nil { return fmt.Errorf("failed to map organization: %w", err) } s.logger.Info("Mapped organization", map[string]any{ "external_customer_id": eventData.ExternalCustomerID, "organization_id": organizationID, }) // Step 2: Parse quota limits from product metadata (remaining invoices) var invoiceCount int32 = 0 if val, ok := eventData.ProductMetadata["invoice_count"]; ok { if count, err := strconv.ParseInt(val, 10, 32); err == nil { invoiceCount = int32(count) } else { s.logger.Warn("Failed to parse invoice_count from product metadata", map[string]any{ "value": val, "error": err.Error(), }) } } else { s.logger.Warn("invoice_count not found in product metadata", map[string]any{ "product_metadata": eventData.ProductMetadata, }) } var maxSeats int32 = 0 if val, ok := eventData.ProductMetadata["max_seats"]; ok { if count, err := strconv.ParseInt(val, 10, 32); err == nil { maxSeats = int32(count) } } s.logger.Info("Parsed quota limits from metadata", map[string]any{ "invoice_count": invoiceCount, "max_seats": maxSeats, }) // Step 4: Create subscription domain object subscription := &domain.Subscription{ OrganizationID: organizationID, ExternalCustomerID: eventData.ExternalCustomerID, SubscriptionID: eventData.SubscriptionID, SubscriptionStatus: eventData.Status, ProductID: eventData.ProductID, ProductName: eventData.ProductName, CurrentPeriodStart: eventData.CurrentPeriodStart, CurrentPeriodEnd: eventData.CurrentPeriodEnd, CancelAtPeriodEnd: eventData.CancelAtPeriodEnd, CanceledAt: eventData.CanceledAt, } // Step 5: Upsert subscription to database _, err = s.repo.UpsertSubscription(ctx, subscription) if err != nil { return fmt.Errorf("failed to upsert subscription: %w", err) } s.logger.Info("Upserted subscription", map[string]any{ "organization_id": organizationID, "subscription_id": eventData.SubscriptionID, "status": eventData.Status, }) // Step 6: Create quota tracking domain object now := time.Now() quota := &domain.QuotaTracking{ OrganizationID: organizationID, InvoiceCount: invoiceCount, MaxSeats: maxSeats, PeriodStart: eventData.CurrentPeriodStart, PeriodEnd: eventData.CurrentPeriodEnd, LastSyncedAt: &now, } // Step 7: Upsert quota tracking to database _, err = s.repo.UpsertQuota(ctx, quota) if err != nil { return fmt.Errorf("failed to upsert quota: %w", err) } s.logger.Info("Upserted quota tracking", map[string]any{ "organization_id": organizationID, "invoice_count": invoiceCount, "max_seats": maxSeats, }) return nil } func (s *billingService) handleSubscriptionCanceled(ctx context.Context, eventData *domain.SubscriptionEventData) error { // Step 1: Map Polar organization_id to internal organization ID organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID) if err != nil { return fmt.Errorf("failed to map organization: %w", err) } s.logger.Info("Processing subscription cancellation", map[string]any{ "organization_id": organizationID, "subscription_id": eventData.SubscriptionID, }) // Step 2: Create subscription object with canceled status now := time.Now() subscription := &domain.Subscription{ OrganizationID: organizationID, ExternalCustomerID: eventData.ExternalCustomerID, SubscriptionID: eventData.SubscriptionID, SubscriptionStatus: "canceled", ProductID: eventData.ProductID, ProductName: eventData.ProductName, CurrentPeriodStart: eventData.CurrentPeriodStart, CurrentPeriodEnd: eventData.CurrentPeriodEnd, CancelAtPeriodEnd: false, // Already canceled CanceledAt: &now, } // If webhook includes canceled_at timestamp, use it if eventData.CanceledAt != nil { subscription.CanceledAt = eventData.CanceledAt } // Step 3: Upsert subscription with canceled status _, err = s.repo.UpsertSubscription(ctx, subscription) if err != nil { return fmt.Errorf("failed to update subscription to canceled: %w", err) } s.logger.Info("Subscription marked as canceled", map[string]any{ "organization_id": organizationID, "subscription_id": eventData.SubscriptionID, "canceled_at": subscription.CanceledAt, }) return nil } func (s *billingService) handleCustomerUpdated(ctx context.Context, eventData *domain.SubscriptionEventData) error { // Step 1: Map Polar organization_id to internal organization ID organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID) if err != nil { return fmt.Errorf("failed to map organization: %w", err) } s.logger.Info("Processing customer update", map[string]any{ "organization_id": organizationID, "metadata_keys": len(eventData.CustomerMetadata), }) // Step 2: Parse invoice count from customer metadata (remaining count) var invoiceCount int32 = 0 if val, ok := eventData.CustomerMetadata["invoice_count"]; ok { if count, err := strconv.ParseInt(val, 10, 32); err == nil { invoiceCount = int32(count) } } // Step 3: Get existing quota to preserve other fields existingQuota, err := s.repo.GetQuotaByOrgID(ctx, organizationID) if err != nil { // If no quota exists, create a minimal one with just the invoice count s.logger.Warn("No existing quota found, creating new quota entry", map[string]any{ "organization_id": organizationID, }) now := time.Now() quota := &domain.QuotaTracking{ OrganizationID: organizationID, InvoiceCount: invoiceCount, MaxSeats: 0, PeriodStart: now, PeriodEnd: now, LastSyncedAt: &now, } _, err = s.repo.UpsertQuota(ctx, quota) if err != nil { return fmt.Errorf("failed to create quota: %w", err) } s.logger.Info("Created new quota with invoice count", map[string]any{ "organization_id": organizationID, "invoice_count": invoiceCount, }) return nil } // Step 4: Update existing quota with new invoice count now := time.Now() _ = existingQuota.InvoiceCount existingQuota.InvoiceCount = invoiceCount existingQuota.LastSyncedAt = &now _, err = s.repo.UpsertQuota(ctx, existingQuota) if err != nil { return fmt.Errorf("failed to update quota: %w", err) } s.logger.Info("Updated quota with invoice count from customer metadata", map[string]any{ "organization_id": organizationID, "invoice_count": invoiceCount, }) return nil } func (s *billingService) handleMeterGrantEvent(ctx context.Context, payload map[string]any) error { eventData, err := s.parseMeterGrantPayload(payload) if err != nil { return fmt.Errorf("failed to parse meter grant payload: %w", err) } if !strings.EqualFold(eventData.MeterSlug, invoicesProcessedMeterSlug) { s.logger.Info("Ignoring meter grant event for unrelated meter", map[string]any{ "meter_slug": eventData.MeterSlug, }) return nil } organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID) if err != nil { return fmt.Errorf("failed to map organization for meter grant: %w", err) } now := time.Now() quota, err := s.repo.GetQuotaByOrgID(ctx, organizationID) if err != nil { if errors.Is(err, domain.ErrQuotaNotFound) { newQuota := &domain.QuotaTracking{ OrganizationID: organizationID, InvoiceCount: eventData.AvailableCredits, MaxSeats: 0, PeriodStart: now, PeriodEnd: now, LastSyncedAt: &now, } if _, err := s.repo.UpsertQuota(ctx, newQuota); err != nil { return fmt.Errorf("failed to create quota from meter grant: %w", err) } s.logger.Info("Created quota from meter grant event", map[string]any{ "organization_id": organizationID, "meter_slug": eventData.MeterSlug, "invoice_count": eventData.AvailableCredits, }) return nil } return fmt.Errorf("failed to get quota for meter grant: %w", err) } previous := quota.InvoiceCount quota.InvoiceCount = eventData.AvailableCredits quota.LastSyncedAt = &now if _, err := s.repo.UpsertQuota(ctx, quota); err != nil { return fmt.Errorf("failed to update quota from meter grant: %w", err) } s.logger.Info("Updated quota from meter grant event", map[string]any{ "organization_id": organizationID, "meter_slug": eventData.MeterSlug, "invoice_count": quota.InvoiceCount, "previous_count": previous, }) return nil } func (s *billingService) parseMeterGrantPayload(payload map[string]any) (*domain.MeterGrantEventData, error) { normalized := normalizePolarObject(payload) if normalized == nil { return nil, fmt.Errorf("meter grant payload missing object") } data := &domain.MeterGrantEventData{} if slug, ok := toString(normalized["meter_slug"]); ok { data.MeterSlug = strings.TrimSpace(slug) } if data.MeterSlug == "" { if slug, ok := toString(normalized["slug"]); ok { data.MeterSlug = strings.TrimSpace(slug) } } if data.MeterSlug == "" { if meter, ok := normalized["meter"].(map[string]any); ok { if slug, ok := toString(meter["slug"]); ok { data.MeterSlug = strings.TrimSpace(slug) } else if slug, ok := toString(meter["meter_slug"]); ok { data.MeterSlug = strings.TrimSpace(slug) } else if slug, ok := toString(meter["name"]); ok { data.MeterSlug = strings.TrimSpace(slug) } } } if externalID, ok := toString(normalized["external_customer_id"]); ok && strings.TrimSpace(externalID) != "" { data.ExternalCustomerID = strings.TrimSpace(externalID) } if data.ExternalCustomerID == "" { if externalID, ok := toString(normalized["customer_external_id"]); ok && strings.TrimSpace(externalID) != "" { data.ExternalCustomerID = strings.TrimSpace(externalID) } } if data.ExternalCustomerID == "" { if customer, ok := normalized["customer"].(map[string]any); ok { if externalID, ok := toString(customer["external_id"]); ok && strings.TrimSpace(externalID) != "" { data.ExternalCustomerID = strings.TrimSpace(externalID) } else if externalID, ok := toString(customer["id"]); ok && strings.TrimSpace(externalID) != "" { data.ExternalCustomerID = strings.TrimSpace(externalID) } else if metadata := stringMapFrom(customer["metadata"]); len(metadata) > 0 { if externalID := strings.TrimSpace(metadata["organization_id"]); externalID != "" { data.ExternalCustomerID = externalID } } } } if data.ExternalCustomerID == "" { if metadata := stringMapFrom(normalized["metadata"]); len(metadata) > 0 { if externalID := strings.TrimSpace(metadata["organization_id"]); externalID != "" { data.ExternalCustomerID = externalID } } } var ( available int32 hasBalance bool ) if balanceMap, ok := normalized["balance"].(map[string]any); ok { for _, key := range []string{"available", "remaining", "quantity", "value"} { if value, exists := balanceMap[key]; exists { if count, ok := toInt32(value); ok { available = count hasBalance = true break } } } } if !hasBalance { if creditBalance, ok := normalized["credit_balance"].(map[string]any); ok { for _, key := range []string{"available", "remaining", "quantity"} { if value, exists := creditBalance[key]; exists { if count, ok := toInt32(value); ok { available = count hasBalance = true break } } } } } if !hasBalance { for _, key := range []string{"available", "remaining", "balance", "quantity"} { if value, exists := normalized[key]; exists { if count, ok := toInt32(value); ok { available = count hasBalance = true break } } } } if !hasBalance { s.logger.Warn("Meter grant payload missing available balance", map[string]any{ "payload_keys": mapKeys(normalized), }) return nil, fmt.Errorf("meter grant payload missing available balance") } data.AvailableCredits = available if data.MeterSlug == "" { s.logger.Warn("Meter grant payload missing meter slug", map[string]any{ "payload_keys": mapKeys(normalized), }) return nil, fmt.Errorf("meter grant payload missing meter slug") } if data.ExternalCustomerID == "" { s.logger.Warn("Meter grant payload missing external customer identifier", map[string]any{ "payload_keys": mapKeys(normalized), }) return nil, fmt.Errorf("meter grant payload missing external customer id") } s.logger.Info("Parsed meter grant payload", map[string]any{ "meter_slug": data.MeterSlug, "external_customer_id": data.ExternalCustomerID, "available_invoice_cnt": data.AvailableCredits, }) return data, nil } func normalizePolarObject(payload map[string]any) map[string]any { if payload == nil { return nil } if object, ok := payload["object"].(map[string]any); ok && len(object) > 0 { return object } if data, ok := payload["data"].(map[string]any); ok { if object, ok := data["object"].(map[string]any); ok && len(object) > 0 { return object } } if dataSlice, ok := payload["data"].([]any); ok && len(dataSlice) > 0 { for _, item := range dataSlice { if itemMap, ok := item.(map[string]any); ok { if object, ok := itemMap["object"].(map[string]any); ok && len(object) > 0 { return object } } } } return payload } func extractProductMap(input map[string]any) map[string]any { if input == nil { return nil } if product, ok := input["product"].(map[string]any); ok { return product } if price, ok := input["price"].(map[string]any); ok { if product, ok := price["product"].(map[string]any); ok { return product } } if plan, ok := input["plan"].(map[string]any); ok { if product, ok := plan["product"].(map[string]any); ok { return product } } if itemsMap := firstMapFromSlice(input["items"]); itemsMap != nil { if product, ok := itemsMap["product"].(map[string]any); ok { return product } if price, ok := itemsMap["price"].(map[string]any); ok { if product, ok := price["product"].(map[string]any); ok { return product } } } return nil } func firstMapFromSlice(value any) map[string]any { items, ok := value.([]any) if !ok { return nil } for _, item := range items { if itemMap, ok := item.(map[string]any); ok { return itemMap } } return nil } func stringMapFrom(value any) map[string]string { source, ok := value.(map[string]any) if !ok || len(source) == 0 { return nil } result := toStringMap(source) if len(result) == 0 { return nil } return result } func toStringMap(input map[string]any) map[string]string { result := make(map[string]string, len(input)) for key, value := range input { if str, ok := toString(value); ok { result[key] = str } } return result } func toString(value any) (string, bool) { switch v := value.(type) { case string: return v, true case fmt.Stringer: return v.String(), true case bool: return strconv.FormatBool(v), true case int: if v > math.MaxInt32 || v < math.MinInt32 { return "", false } return strconv.Itoa(v), true case int8: return strconv.FormatInt(int64(v), 10), true case int16: return strconv.FormatInt(int64(v), 10), true case int32: return strconv.FormatInt(int64(v), 10), true case int64: return strconv.FormatInt(v, 10), true case uint: if v > uint(math.MaxInt32) { return "", false } return strconv.FormatUint(uint64(v), 10), true case uint8: return strconv.FormatUint(uint64(v), 10), true case uint16: return strconv.FormatUint(uint64(v), 10), true case uint32: return strconv.FormatUint(uint64(v), 10), true case uint64: return strconv.FormatUint(v, 10), true case float32: f := float64(v) if math.Mod(f, 1) == 0 { return strconv.FormatInt(int64(f), 10), true } return strconv.FormatFloat(f, 'f', -1, 32), true case float64: if math.Mod(v, 1) == 0 { return strconv.FormatInt(int64(v), 10), true } return strconv.FormatFloat(v, 'f', -1, 64), true default: return "", false } } func toInt32(value any) (int32, bool) { switch v := value.(type) { case int: if v > math.MaxInt32 || v < math.MinInt32 { return 0, false } return int32(v), true case int8: return int32(v), true case int16: return int32(v), true case int32: return v, true case int64: if v > int64(math.MaxInt32) || v < int64(math.MinInt32) { return 0, false } return int32(v), true case uint: if v > uint(math.MaxInt32) { return 0, false } return int32(v), true case uint8: return int32(v), true case uint16: return int32(v), true case uint32: if v > uint32(math.MaxInt32) { return 0, false } return int32(v), true case uint64: if v > uint64(math.MaxInt32) { return 0, false } return int32(v), true case float32: f := float64(v) if math.Mod(f, 1) != 0 { return 0, false } if f > float64(math.MaxInt32) || f < float64(math.MinInt32) { return 0, false } return int32(f), true case float64: if math.Mod(v, 1) != 0 { return 0, false } if v > float64(math.MaxInt32) || v < float64(math.MinInt32) { return 0, false } return int32(v), true case string: if strings.TrimSpace(v) == "" { return 0, false } if strings.Contains(v, ".") { f, err := strconv.ParseFloat(v, 64) if err != nil { return 0, false } if math.Mod(f, 1) != 0 { return 0, false } if f > float64(math.MaxInt32) || f < float64(math.MinInt32) { return 0, false } return int32(f), true } i, err := strconv.ParseInt(v, 10, 32) if err != nil { return 0, false } return int32(i), true default: return 0, false } } func parseISOTime(value any) (time.Time, bool) { switch v := value.(type) { case string: if strings.TrimSpace(v) == "" { return time.Time{}, false } t, err := time.Parse(time.RFC3339, v) if err != nil { return time.Time{}, false } return t, true case time.Time: return v, true default: return time.Time{}, false } } func toBool(value any) (bool, bool) { switch v := value.(type) { case bool: return v, true case string: if strings.TrimSpace(v) == "" { return false, false } parsed, err := strconv.ParseBool(v) if err != nil { return false, false } return parsed, true case int: return v != 0, true case int32: return v != 0, true case int64: return v != 0, true case float32: return v != 0, true case float64: return v != 0, true default: return false, false } } func mapKeys(m map[string]any) []string { if m == nil { return nil } keys := make([]string, 0, len(m)) for key := range m { keys = append(keys, key) } return keys } func extractInvoiceCountFromProduct(product map[string]any) string { if product == nil { return "" } if metadata := stringMapFrom(product["metadata"]); len(metadata) > 0 { if value := strings.TrimSpace(metadata["invoice_count"]); value != "" { return value } } benefits, ok := product["benefits"].([]any) if !ok || len(benefits) == 0 { return "" } for _, item := range benefits { benefit, ok := item.(map[string]any) if !ok { continue } benefitType, _ := toString(benefit["type"]) if !strings.EqualFold(strings.TrimSpace(benefitType), "meter_credit") { continue } if properties, ok := benefit["properties"].(map[string]any); ok { if count, ok := toInt32(properties["units"]); ok && count > 0 { return strconv.FormatInt(int64(count), 10) } } if metadata := stringMapFrom(benefit["metadata"]); len(metadata) > 0 { if value := strings.TrimSpace(metadata["units"]); value != "" { return value } } } return "" } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/refresh_subscription_status_service.go ================================================ package services import ( "context" "fmt" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) // RefreshSubscriptionStatus forces a sync with Polar API and returns updated status. // This is the lazy guarding mechanism - used when DB says expired but we want // to double-check with the provider in case we missed a webhook. func (s *billingService) RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { // Step 1: Check if subscription exists in database _, err := s.repo.GetSubscriptionByOrgID(ctx, organizationID) if err != nil { // No subscription exists - don't call Polar API s.logger.Info("No subscription found for refresh", map[string]any{ "organization_id": organizationID, }) return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: false, CanProcessInvoices: false, InvoiceCount: 0, Reason: "no active subscription found", CheckedAt: time.Now(), }, nil } // Step 2: Sync subscription from Polar API if err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil { // Sync failed - return error return nil, fmt.Errorf("failed to refresh subscription from Polar: %w", err) } // Step 3: Get fresh billing status from database (after sync) billingStatus, err := s.GetBillingStatus(ctx, organizationID) if err != nil { return nil, fmt.Errorf("failed to get billing status after refresh: %w", err) } s.logger.Info("Subscription status refreshed", map[string]any{ "organization_id": organizationID, "has_active_subscription": billingStatus.HasActiveSubscription, "invoice_count": billingStatus.InvoiceCount, }) // Console log for refresh completion fmt.Printf("🔄 SUBSCRIPTION REFRESHED - Org: %d | Active: %v | Invoice Count: %d | Reason: %s\n", organizationID, billingStatus.HasActiveSubscription, billingStatus.InvoiceCount, billingStatus.Reason) return billingStatus, nil } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/subscription_service_dec.go ================================================ package services import ( "context" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" logger "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) // BillingService handles subscription management and quota verification. // // This service manages the billing lifecycle with Polar.sh via event-driven webhooks. // It does NOT expose direct API calls to Polar during request handling - instead, // subscription state is synced via webhooks and stored locally for fast reads. // // Architecture: // // ┌───────────────┐ webhooks ┌─────────────────┐ reads ┌─────────────┐ // │ Polar.sh │ ─────────────► │ BillingService │ ──────────► │ Local DB │ // └───────────────┘ └─────────────────┘ └─────────────┘ // │ // ▼ // ┌─────────────────┐ // │ Paywall reads │ // │ from local DB │ // └─────────────────┘ type BillingService interface { // ProcessWebhookEvent processes a Polar webhook event and updates local database // Handles: subscription.created, subscription.updated, subscription.canceled, customer.updated ProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error // GetBillingStatus retrieves the current billing and quota status for an organization // This is a read-only operation from the local database GetBillingStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) // CheckQuotaAvailability performs a read-only check of quota availability // Does NOT consume quota - use ConsumeInvoiceQuota after successful processing // Performs database-first check with fallback to Polar API if needed // Returns BillingStatus indicating if invoice processing is allowed CheckQuotaAvailability(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) // ConsumeInvoiceQuota explicitly consumes one invoice quota after successful processing // Should be called after invoice has been successfully processed // Can be called asynchronously in background for better performance // Returns updated quota status ConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) // VerifyAndConsumeQuota verifies quota availability and consumes one invoice quota // Performs database-first check with fallback to Polar API if needed // Returns BillingStatus with detailed verification result // Automatically increments quota count on success // DEPRECATED: Use CheckQuotaAvailability + ConsumeInvoiceQuota pattern for better control VerifyAndConsumeQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) // SyncSubscriptionFromPolar forces a sync of subscription data from Polar API // Used as fallback when webhook data is missing or stale // TODO: Implement periodic background sync job for all subscriptions SyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error // VerifyPaymentFromCheckout verifies a payment by checking the Polar checkout session // This is the primary mechanism for "Verification on Redirect" pattern // Called when user returns from payment page with session_id // Returns BillingStatus after updating database with latest subscription info VerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*domain.BillingStatus, error) // RefreshSubscriptionStatus forces a sync with Polar API and returns updated status // This is the lazy guarding mechanism - used when DB says expired but we want // to double-check with the provider in case we missed a webhook // Returns updated BillingStatus after syncing with provider RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) } type billingService struct { repo domain.SubscriptionRepository orgAdapter domain.OrganizationAdapter billingProvider domain.BillingProvider logger logger.Logger } func NewBillingService( repo domain.SubscriptionRepository, orgAdapter domain.OrganizationAdapter, billingProvider domain.BillingProvider, logger logger.Logger, ) BillingService { return &billingService{ repo: repo, orgAdapter: orgAdapter, billingProvider: billingProvider, logger: logger, } } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/sync_subscription_service.go ================================================ package services import ( "context" "fmt" "strconv" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) func (s *billingService) SyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error { // Get organization's external customer ID externalID, err := s.orgAdapter.GetStytchOrgID(ctx, organizationID) if err != nil { return fmt.Errorf("failed to get organization external ID: %w", err) } // Fetch subscription from Polar subscription, err := s.billingProvider.GetSubscription(ctx, externalID) if err != nil { return fmt.Errorf("failed to fetch subscription from Polar: %w", err) } // Upsert subscription to database subscription.OrganizationID = organizationID _, err = s.repo.UpsertSubscription(ctx, subscription) if err != nil { return fmt.Errorf("failed to save subscription: %w", err) } // Extract and upsert quota information invoiceCountMax := int32(0) if metadata, ok := subscription.Metadata["invoice_count_max"].(int32); ok { invoiceCountMax = metadata } else if val, ok := subscription.Metadata["invoice_count_max"].(string); ok { if count, err := strconv.ParseInt(val, 10, 32); err == nil { invoiceCountMax = int32(count) } } // Create or update quota tracking with synced data quota := &domain.QuotaTracking{ OrganizationID: organizationID, InvoiceCount: invoiceCountMax, PeriodStart: subscription.CurrentPeriodStart, PeriodEnd: subscription.CurrentPeriodEnd, LastSyncedAt: &time.Time{}, } *quota.LastSyncedAt = time.Now() _, err = s.repo.UpsertQuota(ctx, quota) if err != nil { return fmt.Errorf("failed to save quota: %w", err) } s.logger.Info("Synced subscription and quota from Polar", map[string]any{ "organization_id": organizationID, "subscription_id": subscription.SubscriptionID, "invoice_count": invoiceCountMax, "synced_at": quota.LastSyncedAt, }) // Console log for sync completion fmt.Printf("🔄 SYNC COMPLETED - Org: %d | Subscription: %s | Invoice Count: %d | Status: %s | Synced at: %s\n", organizationID, subscription.SubscriptionID, invoiceCountMax, subscription.SubscriptionStatus, quota.LastSyncedAt.Format(time.RFC3339)) return nil } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/verify_and_consume_quota_service.go ================================================ package services import ( "context" "fmt" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) func (s *billingService) VerifyAndConsumeQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { // Step 1: Check database quota status quotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID) if err != nil { return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: false, CanProcessInvoices: false, Reason: "no active subscription", CheckedAt: time.Now(), }, domain.ErrSubscriptionNotFound } // Step 2: Check if we need fallback API verification needsFallback := s.needsFallbackVerification(quotaStatus) if needsFallback { s.logger.Info("Quota near limit or stale, performing fallback API verification", map[string]any{ "organization_id": organizationID, "invoice_count": quotaStatus.InvoiceCount, }) // Sync from Polar and re-check if err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil { s.logger.Error("Fallback sync failed, using database data", map[string]any{ "organization_id": organizationID, "error": err.Error(), }) } else { // Re-fetch quota status after sync quotaStatus, err = s.repo.GetQuotaStatus(ctx, organizationID) if err != nil { return nil, fmt.Errorf("failed to get quota after sync: %w", err) } } } // Step 3: Verify quota is available if !quotaStatus.CanProcessInvoice { return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: quotaStatus.SubscriptionStatus == "active", CanProcessInvoices: false, InvoiceCount: quotaStatus.InvoiceCount, Reason: "quota exceeded or subscription inactive", CheckedAt: time.Now(), }, domain.ErrQuotaExceeded } // Step 4: Decrement quota count (consume one invoice) _, err = s.repo.DecrementInvoiceCount(ctx, organizationID) if err != nil { return nil, fmt.Errorf("failed to decrement invoice count: %w", err) } // Step 5: Return success status return &domain.BillingStatus{ OrganizationID: organizationID, HasActiveSubscription: true, CanProcessInvoices: true, InvoiceCount: quotaStatus.InvoiceCount - 1, // Already decremented Reason: "quota verified and consumed", CheckedAt: time.Now(), }, nil } func (s *billingService) needsFallbackVerification(status *domain.QuotaStatus) bool { // Perform fallback if: // 1. Very few invoices remaining (< 10) // 2. Subscription is inactive but we're checking return status.InvoiceCount < 10 || status.SubscriptionStatus != "active" } ================================================ FILE: go-b2b-starter/internal/modules/billing/app/services/verify_payment_service.go ================================================ package services import ( "context" "fmt" "strconv" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) func (s *billingService) VerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*domain.BillingStatus, error) { // Step 1: Get checkout session from Polar with polling checkoutSession, err := s.billingProvider.GetCheckoutSessionWithPolling(ctx, sessionID) if err != nil { fmt.Printf("❌ [VerifyPayment] Failed to verify checkout session %s: %v\n", sessionID, err) return nil, fmt.Errorf("failed to get checkout session: %w", err) } fmt.Printf("✅ [VerifyPayment] Checkout session %s verified with status: %s\n", sessionID, checkoutSession.Status) // Step 2: Verify checkout status if checkoutSession.Status != "succeeded" { return &domain.BillingStatus{ HasActiveSubscription: false, CanProcessInvoices: false, Reason: fmt.Sprintf("checkout session status is %s (expected: succeeded)", checkoutSession.Status), CheckedAt: time.Now(), }, nil } // Step 3: Extract customer ID (this is the Stytch org ID) externalCustomerID := checkoutSession.CustomerID if externalCustomerID == "" { return nil, fmt.Errorf("checkout session has no customer_id") } // Step 4: Map external customer ID to internal organization ID organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, externalCustomerID) if err != nil { return nil, fmt.Errorf("failed to map customer ID to organization: %w", err) } // Step 5: Fetch full subscription details from Polar subscription, err := s.billingProvider.GetSubscription(ctx, externalCustomerID) if err != nil { return nil, fmt.Errorf("failed to fetch subscription from Polar: %w", err) } // Step 6: Upsert subscription to database subscription.OrganizationID = organizationID _, err = s.repo.UpsertSubscription(ctx, subscription) if err != nil { return nil, fmt.Errorf("failed to save subscription: %w", err) } // Step 7: Extract and upsert quota information invoiceCountMax := int32(0) if metadata, ok := subscription.Metadata["invoice_count_max"].(int32); ok { invoiceCountMax = metadata } else if val, ok := subscription.Metadata["invoice_count_max"].(string); ok { if count, err := strconv.ParseInt(val, 10, 32); err == nil { invoiceCountMax = int32(count) } } // Create or update quota tracking quota := &domain.QuotaTracking{ OrganizationID: organizationID, InvoiceCount: invoiceCountMax, PeriodStart: subscription.CurrentPeriodStart, PeriodEnd: subscription.CurrentPeriodEnd, LastSyncedAt: &time.Time{}, } *quota.LastSyncedAt = time.Now() _, err = s.repo.UpsertQuota(ctx, quota) if err != nil { return nil, fmt.Errorf("failed to save quota: %w", err) } s.logger.Info("Payment verified from checkout session", map[string]any{ "session_id": sessionID, "organization_id": organizationID, "subscription_id": subscription.SubscriptionID, "invoice_count": invoiceCountMax, }) // Console log for verification completion fmt.Printf("✅ PAYMENT VERIFIED - Session: %s | Org: %d | Subscription: %s | Invoice Count: %d | Status: %s\n", sessionID, organizationID, subscription.SubscriptionID, invoiceCountMax, subscription.SubscriptionStatus) // Step 8: Return billing status return &domain.BillingStatus{ OrganizationID: organizationID, ExternalID: externalCustomerID, HasActiveSubscription: subscription.SubscriptionStatus == "active" || subscription.SubscriptionStatus == "trialing", CanProcessInvoices: (subscription.SubscriptionStatus == "active" || subscription.SubscriptionStatus == "trialing") && invoiceCountMax > 0, InvoiceCount: invoiceCountMax, Reason: "Payment verified successfully", CheckedAt: time.Now(), }, nil } ================================================ FILE: go-b2b-starter/internal/modules/billing/cmd/init.go ================================================ package cmd import ( "go.uber.org/dig" ) // // The billing module handles subscription lifecycle management with Polar.sh: // - Webhook processing for subscription events // - Quota tracking and consumption // - Billing status queries // // Communication is event-driven: // - Polar sends webhook → billing processes event → updates local DB // - Paywall middleware reads from local DB (no external API calls) func Init(container *dig.Container) error { // Register all dependencies if err := ProvideDependencies(container); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/modules/billing/cmd/provider.go ================================================ package cmd import ( "fmt" "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/billing/app/services" "github.com/moasq/go-b2b-starter/internal/modules/billing/infra/adapters" "github.com/moasq/go-b2b-starter/internal/modules/paywall" ) // ProvideDependencies registers all billing module dependencies func ProvideDependencies(container *dig.Container) error { // Use the services module for dependency injection servicesModule := services.NewModule() if err := servicesModule.Configure(container); err != nil { return fmt.Errorf("failed to configure billing services: %w", err) } // Register SubscriptionStatusProvider for the paywall middleware // This adapter bridges the billing module to the pkg/paywall middleware // Communication is event-driven: webhooks → billing → DB → paywall reads if err := container.Provide(func(svc services.BillingService) paywall.SubscriptionStatusProvider { return adapters.NewStatusProviderAdapter(svc) }); err != nil { return fmt.Errorf("failed to provide subscription status provider: %w", err) } return nil } ================================================ FILE: go-b2b-starter/internal/modules/billing/domain/errors.go ================================================ package domain import "errors" var ( // ErrSubscriptionNotFound is returned when a subscription cannot be found ErrSubscriptionNotFound = errors.New("subscription not found") // ErrSubscriptionNotActive is returned when a subscription exists but is not active ErrSubscriptionNotActive = errors.New("subscription is not active") // ErrQuotaNotFound is returned when quota tracking record cannot be found ErrQuotaNotFound = errors.New("quota not found") // ErrQuotaExceeded is returned when invoice quota has been exceeded ErrQuotaExceeded = errors.New("invoice quota exceeded") // ErrInvalidWebhookPayload is returned when webhook payload cannot be parsed ErrInvalidWebhookPayload = errors.New("invalid webhook payload") // ErrWebhookSignatureInvalid is returned when webhook signature verification fails ErrWebhookSignatureInvalid = errors.New("webhook signature invalid") // ErrQuotaDataStale is returned when quota data hasn't been synced recently ErrQuotaDataStale = errors.New("quota data is stale") // ErrCheckoutSessionNotFound is returned when a checkout session cannot be found ErrCheckoutSessionNotFound = errors.New("checkout session not found") ) ================================================ FILE: go-b2b-starter/internal/modules/billing/domain/repository.go ================================================ package domain import "context" // SubscriptionRepository provides database operations for subscriptions and quotas type SubscriptionRepository interface { // Subscription operations GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (*Subscription, error) UpsertSubscription(ctx context.Context, subscription *Subscription) (*Subscription, error) DeleteSubscription(ctx context.Context, organizationID int32) error // Quota operations GetQuotaByOrgID(ctx context.Context, organizationID int32) (*QuotaTracking, error) UpsertQuota(ctx context.Context, quota *QuotaTracking) (*QuotaTracking, error) DecrementInvoiceCount(ctx context.Context, organizationID int32) (*QuotaTracking, error) // Combined operations GetQuotaStatus(ctx context.Context, organizationID int32) (*QuotaStatus, error) } // OrganizationAdapter provides access to organization data type OrganizationAdapter interface { GetStytchOrgID(ctx context.Context, organizationID int32) (string, error) GetOrganizationIDByStytchOrgID(ctx context.Context, stytchOrgID string) (int32, error) } // BillingProvider defines operations for external billing providers // This interface abstracts the billing provider (e.g., Polar.sh) from the app layer type BillingProvider interface { GetSubscription(ctx context.Context, externalCustomerID string) (*Subscription, error) GetCheckoutSession(ctx context.Context, sessionID string) (*CheckoutSessionResponse, error) GetCheckoutSessionWithPolling(ctx context.Context, sessionID string) (*CheckoutSessionResponse, error) IngestMeterEvent(ctx context.Context, externalCustomerID string, meterSlug string, amount int32) error } ================================================ FILE: go-b2b-starter/internal/modules/billing/domain/types.go ================================================ package domain import "time" // Subscription represents a billing subscription from Polar type Subscription struct { ID int32 OrganizationID int32 ExternalCustomerID string SubscriptionID string SubscriptionStatus string ProductID string ProductName string PlanName string CurrentPeriodStart time.Time CurrentPeriodEnd time.Time CancelAtPeriodEnd bool CanceledAt *time.Time Metadata map[string]any CreatedAt time.Time UpdatedAt time.Time } // QuotaTracking represents usage quota tracking for an organization type QuotaTracking struct { ID int32 OrganizationID int32 InvoiceCount int32 // Remaining invoices (decremented on use) MaxSeats int32 PeriodStart time.Time PeriodEnd time.Time LastSyncedAt *time.Time CreatedAt time.Time UpdatedAt time.Time } // QuotaStatus represents the combined subscription and quota status // This is returned from the GetQuotaStatus database query type QuotaStatus struct { SubscriptionStatus string CurrentPeriodStart time.Time CurrentPeriodEnd time.Time CancelAtPeriodEnd bool InvoiceCount int32 // Remaining invoices MaxSeats int32 CanProcessInvoice bool } // BillingStatus represents the overall billing status for quota verification type BillingStatus struct { OrganizationID int32 ExternalID string HasActiveSubscription bool CanProcessInvoices bool InvoiceCount int32 // Remaining invoices Reason string CheckedAt time.Time } // WebhookEvent represents a Polar webhook event type WebhookEvent struct { EventType string Payload map[string]any } // SubscriptionEventData represents parsed subscription data from webhook type SubscriptionEventData struct { SubscriptionID string ExternalCustomerID string ProductID string ProductName string Status string CurrentPeriodStart time.Time CurrentPeriodEnd time.Time CancelAtPeriodEnd bool CanceledAt *time.Time ProductMetadata map[string]string CustomerMetadata map[string]string } // MeterGrantEventData represents meter grant payload details from Polar webhooks type MeterGrantEventData struct { MeterSlug string ExternalCustomerID string AvailableCredits int32 } // CheckoutSessionResponse represents a Polar checkout session type CheckoutSessionResponse struct { ID string Status string // "succeeded", "pending", "expired", "failed" CustomerID string SubscriptionID string ProductID string Amount int64 CreatedAt time.Time } ================================================ FILE: go-b2b-starter/internal/modules/billing/handler.go ================================================ package billing import ( "errors" "fmt" "net/http" "time" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" billingServices "github.com/moasq/go-b2b-starter/internal/modules/billing/app/services" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/moasq/go-b2b-starter/pkg/httperr" ) type Handler struct { billingService billingServices.BillingService logger logger.Logger } func NewHandler(billingService billingServices.BillingService, log logger.Logger) *Handler { return &Handler{ billingService: billingService, logger: log, } } // GetBillingStatus godoc // @Summary Get current billing and quota status // @Description Retrieve the current subscription billing status and invoice quota information for the organization // @Tags subscriptions // @Accept json // @Produce json // @Success 200 {object} domain.BillingStatus "Current billing and quota status" // @Failure 400 {object} httperr.HTTPError "Invalid request parameters or missing organization context" // @Failure 500 {object} httperr.HTTPError "Internal server error" // @Router /api/subscriptions/status [get] func (h *Handler) GetBillingStatus(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "missing_context", "Organization context is required", )) return } // Call service layer to get billing status billingStatus, err := h.billingService.GetBillingStatus(c.Request.Context(), reqCtx.OrganizationID) if err != nil { // Check if subscription not found - this is not necessarily an error // Organization might not have a subscription yet if err == domain.ErrSubscriptionNotFound { // Return a response indicating no active subscription c.JSON(http.StatusOK, domain.BillingStatus{ OrganizationID: reqCtx.OrganizationID, HasActiveSubscription: false, CanProcessInvoices: false, InvoiceCount: 0, Reason: "No active subscription found", CheckedAt: time.Now(), }) return } c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "billing_status_failed", fmt.Sprintf("Failed to retrieve billing status: %v", err), )) return } c.JSON(http.StatusOK, billingStatus) } // VerifyPaymentRequest represents the request payload for verifying a payment type VerifyPaymentRequest struct { SessionID string `json:"session_id" binding:"required"` } // VerifyPayment godoc // @Summary Verify payment from checkout session // @Description Verifies a payment by checking the Polar checkout session and updates subscription status. This is the primary mechanism for "Verification on Redirect" pattern when user returns from payment page. // @Tags subscriptions // @Accept json // @Produce json // @Param request body VerifyPaymentRequest true "Checkout session ID" // @Success 200 {object} domain.BillingStatus "Verification result with updated billing status" // @Failure 400 {object} httperr.HTTPError "Invalid request parameters or checkout session failed" // @Failure 404 {object} httperr.HTTPError "Checkout session not found" // @Failure 500 {object} httperr.HTTPError "Internal server error" // @Router /api/subscriptions/verify-payment [post] func (h *Handler) VerifyPayment(c *gin.Context) { h.logger.Info("[VerifyPayment] Starting payment verification request", nil) // Bind request var req VerifyPaymentRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("[VerifyPayment] Failed to bind request JSON", map[string]any{ "error": err.Error(), }) c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "invalid_request", fmt.Sprintf("Invalid request: %v", err), )) return } h.logger.Info("[VerifyPayment] Request parsed successfully", map[string]any{ "session_id": req.SessionID, }) // Validate session_id is not empty if req.SessionID == "" { h.logger.Warn("[VerifyPayment] Missing session_id in request", nil) c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "missing_session_id", "Checkout session ID is required", )) return } h.logger.Info("[VerifyPayment] Calling billing service to verify payment", map[string]any{ "session_id": req.SessionID, }) // Call service to verify payment billingStatus, err := h.billingService.VerifyPaymentFromCheckout(c.Request.Context(), req.SessionID) if err != nil { // Check if it's a checkout session not found error if errors.Is(err, domain.ErrCheckoutSessionNotFound) { h.logger.Warn("[VerifyPayment] Checkout session not found", map[string]any{ "session_id": req.SessionID, }) c.JSON(http.StatusNotFound, httperr.NewHTTPError( http.StatusNotFound, "session_not_found", fmt.Sprintf("Checkout session not found: %s", req.SessionID), )) return } h.logger.Error("[VerifyPayment] Failed to verify payment", map[string]any{ "session_id": req.SessionID, "error": err.Error(), }) c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "verification_failed", fmt.Sprintf("Failed to verify payment: %v", err), )) return } h.logger.Info("[VerifyPayment] Billing service returned status", map[string]any{ "session_id": req.SessionID, "has_active_subscription": billingStatus.HasActiveSubscription, "can_process_invoices": billingStatus.CanProcessInvoices, "invoice_count": billingStatus.InvoiceCount, "reason": billingStatus.Reason, }) // If checkout session is not succeeded, return 400 with reason if !billingStatus.HasActiveSubscription && billingStatus.Reason != "Payment verified successfully" { h.logger.Warn("[VerifyPayment] Payment not completed", map[string]any{ "session_id": req.SessionID, "reason": billingStatus.Reason, }) c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "payment_not_completed", billingStatus.Reason, )) return } h.logger.Info("[VerifyPayment] Payment verification completed successfully", map[string]any{ "session_id": req.SessionID, "organization_id": billingStatus.OrganizationID, "invoice_count": billingStatus.InvoiceCount, }) c.JSON(http.StatusOK, billingStatus) } ================================================ FILE: go-b2b-starter/internal/modules/billing/infra/adapters/status_provider.go ================================================ // Package adapters provides adapter implementations for external interfaces. package adapters import ( "context" "github.com/moasq/go-b2b-starter/internal/modules/billing/app/services" "github.com/moasq/go-b2b-starter/internal/modules/paywall" ) // StatusProviderAdapter adapts the BillingService to the SubscriptionStatusProvider interface. // // This adapter allows the paywall middleware to check subscription status // without depending directly on the billing service implementation. // Communication is event-driven: Polar webhooks → billing module → local DB → paywall reads. type StatusProviderAdapter struct { service services.BillingService } func NewStatusProviderAdapter(service services.BillingService) paywall.SubscriptionStatusProvider { return &StatusProviderAdapter{service: service} } // GetSubscriptionStatus implements paywall.SubscriptionStatusProvider. // // It delegates to the BillingService.GetBillingStatus method and converts // the BillingStatus to a SubscriptionStatus for the middleware to use. func (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, organizationID int32) (*paywall.SubscriptionStatus, error) { billingStatus, err := a.service.GetBillingStatus(ctx, organizationID) if err != nil { return nil, err } // Map BillingStatus to SubscriptionStatus status := &paywall.SubscriptionStatus{ OrganizationID: billingStatus.OrganizationID, IsActive: billingStatus.HasActiveSubscription, Reason: billingStatus.Reason, } // Determine status string from reason if billingStatus.HasActiveSubscription { status.Status = paywall.StatusActive } else if billingStatus.Reason == "no active subscription found" { status.Status = paywall.StatusNone } else { // Parse status from reason if available, otherwise default to inactive status.Status = parseStatusFromReason(billingStatus.Reason) } return status, nil } // RefreshSubscriptionStatus implements paywall.SubscriptionStatusProvider. // // It forces a sync with the payment provider API and returns the updated status. // This is the lazy guarding mechanism - used when DB says expired but we want // to double-check with the provider in case we missed a webhook. func (a *StatusProviderAdapter) RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*paywall.SubscriptionStatus, error) { // Delegate to the BillingService.RefreshSubscriptionStatus method billingStatus, err := a.service.RefreshSubscriptionStatus(ctx, organizationID) if err != nil { return nil, err } // Map BillingStatus to SubscriptionStatus status := &paywall.SubscriptionStatus{ OrganizationID: billingStatus.OrganizationID, IsActive: billingStatus.HasActiveSubscription, Reason: billingStatus.Reason, } // Determine status string from reason if billingStatus.HasActiveSubscription { status.Status = paywall.StatusActive } else if billingStatus.Reason == "no active subscription found" { status.Status = paywall.StatusNone } else { // Parse status from reason if available, otherwise default to inactive status.Status = parseStatusFromReason(billingStatus.Reason) } return status, nil } // parseStatusFromReason attempts to extract a subscription status from the reason string. func parseStatusFromReason(reason string) string { // Check for common status patterns in reason switch { case containsStatus(reason, "past_due"): return paywall.StatusPastDue case containsStatus(reason, "canceled"): return paywall.StatusCanceled case containsStatus(reason, "unpaid"): return paywall.StatusUnpaid case containsStatus(reason, "trialing"): return paywall.StatusTrialing default: return paywall.StatusNone } } // containsStatus checks if the reason contains a specific status. func containsStatus(reason, status string) bool { return len(reason) >= len(status) && contains(reason, status) } // contains is a simple substring check. func contains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } ================================================ FILE: go-b2b-starter/internal/modules/billing/infra/polar/polar_adapter.go ================================================ package polar import ( "context" "encoding/json" "fmt" "io" "strconv" "strings" "time" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" "github.com/moasq/go-b2b-starter/internal/platform/logger" loggerdomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" polarpkg "github.com/moasq/go-b2b-starter/internal/platform/polar" ) // Ensure polarAdapter implements domain.BillingProvider at compile time var _ domain.BillingProvider = (*polarAdapter)(nil) type polarAdapter struct { client *polarpkg.Client logger logger.Logger } func NewPolarAdapter(client *polarpkg.Client, log logger.Logger) domain.BillingProvider { return &polarAdapter{ client: client, logger: log, } } func (p *polarAdapter) GetSubscription(ctx context.Context, externalCustomerID string) (*domain.Subscription, error) { // Call Polar API to get subscription by customer external ID endpoint := fmt.Sprintf("/v1/subscriptions?customer_external_id=%s", externalCustomerID) resp, err := p.client.Get(ctx, endpoint) if err != nil { return nil, fmt.Errorf("failed to call Polar API: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("polar API returned status %d: %s", resp.StatusCode, string(body)) } // Parse response var result struct { Items []struct { ID string `json:"id"` CustomerID string `json:"customer_id"` ProductID string `json:"product_id"` Status string `json:"status"` CurrentPeriodStart string `json:"current_period_start"` CurrentPeriodEnd string `json:"current_period_end"` CanceledAt *string `json:"canceled_at"` Customer struct { ID string `json:"id"` Metadata map[string]string `json:"metadata"` } `json:"customer"` Product struct { ID string `json:"id"` Name string `json:"name"` Metadata map[string]string `json:"metadata"` } `json:"product"` } `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } if len(result.Items) == 0 { return nil, domain.ErrSubscriptionNotFound } polarSub := result.Items[0] // Parse timestamps currentPeriodStart, _ := parseTime(polarSub.CurrentPeriodStart) currentPeriodEnd, _ := parseTime(polarSub.CurrentPeriodEnd) var canceledAt *time.Time if polarSub.CanceledAt != nil { t, _ := parseTime(*polarSub.CanceledAt) canceledAt = &t } // Parse quota limit from product metadata invoiceCountMax := int32(0) if val, ok := polarSub.Product.Metadata["invoice_count"]; ok { if count, err := strconv.ParseInt(val, 10, 32); err == nil { invoiceCountMax = int32(count) } } // Log subscription sync p.logger.Info("polar subscription sync completed", loggerdomain.Fields{ "customer_id": externalCustomerID, "subscription_id": polarSub.ID, "invoice_count_max": invoiceCountMax, "status": polarSub.Status, "product_name": polarSub.Product.Name, }) // Create domain subscription (organizationID will be set by caller) subscription := &domain.Subscription{ ExternalCustomerID: externalCustomerID, SubscriptionID: polarSub.ID, SubscriptionStatus: polarSub.Status, ProductID: polarSub.ProductID, ProductName: polarSub.Product.Name, CurrentPeriodStart: currentPeriodStart, CurrentPeriodEnd: currentPeriodEnd, CanceledAt: canceledAt, Metadata: map[string]any{ "invoice_count_max": invoiceCountMax, "product_metadata": polarSub.Product.Metadata, "customer_metadata": polarSub.Customer.Metadata, }, } return subscription, nil } // GetCheckoutSession retrieves checkout session details from Polar func (p *polarAdapter) GetCheckoutSession(ctx context.Context, sessionID string) (*domain.CheckoutSessionResponse, error) { // Call Polar API to get checkout session details endpoint := fmt.Sprintf("/v1/checkouts/custom/%s", sessionID) resp, err := p.client.Get(ctx, endpoint) if err != nil { return nil, fmt.Errorf("failed to call Polar checkout API: %w", err) } defer resp.Body.Close() if resp.StatusCode == 404 { return nil, fmt.Errorf("%w: %s", domain.ErrCheckoutSessionNotFound, sessionID) } if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("polar checkout API returned status %d: %s", resp.StatusCode, string(body)) } // Parse response - Polar returns customer_external_id at root level var result struct { ID string `json:"id"` Status string `json:"status"` Amount int64 `json:"amount"` CustomerExternalID string `json:"customer_external_id"` // The Stytch org ID we passed during checkout CustomerID string `json:"customer_id"` // Polar internal customer ID Product struct { ID string `json:"id"` } `json:"product"` Customer struct { ID string `json:"id"` ExternalID string `json:"external_id"` // Also available in nested customer object } `json:"customer"` Subscription struct { ID string `json:"id"` } `json:"subscription"` CreatedAt string `json:"created_at"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode checkout response: %w", err) } // Parse timestamp createdAt, _ := parseTime(result.CreatedAt) // Resolve external customer ID - try multiple fields externalCustomerID := result.CustomerExternalID if externalCustomerID == "" { externalCustomerID = result.Customer.ExternalID } // Log checkout session retrieval p.logger.Info("polar checkout session retrieved", loggerdomain.Fields{ "session_id": result.ID, "status": result.Status, "external_customer_id": externalCustomerID, "customer_id": result.CustomerID, "subscription_id": result.Subscription.ID, }) // Create domain checkout session response checkoutSession := &domain.CheckoutSessionResponse{ ID: result.ID, Status: result.Status, CustomerID: externalCustomerID, // Use external customer ID (Stytch org ID) SubscriptionID: result.Subscription.ID, ProductID: result.Product.ID, Amount: result.Amount, CreatedAt: createdAt, } return checkoutSession, nil } // GetCheckoutSessionWithPolling retrieves checkout session with polling and retry logic // Polls every 2 seconds for up to 10 seconds (5 attempts total) // Continues polling when status is "pending" or on transient errors // Returns immediately on "succeeded" status or non-retryable errors func (p *polarAdapter) GetCheckoutSessionWithPolling(ctx context.Context, sessionID string) (*domain.CheckoutSessionResponse, error) { const ( pollInterval = 2 * time.Second // Poll every 2 seconds maxDuration = 10 * time.Second // Total timeout: 10 seconds ) deadline := time.Now().Add(maxDuration) ticker := time.NewTicker(pollInterval) defer ticker.Stop() // First attempt (immediate) session, err := p.GetCheckoutSession(ctx, sessionID) if err == nil && session.Status == "succeeded" { return session, nil } // Log initial status if err == nil { p.logger.Debug("polar checkout polling started", loggerdomain.Fields{ "session_id": sessionID, "status": session.Status, "max_duration": maxDuration.String(), }) } else if !isRetryableError(err) { // Non-retryable error (e.g., 404) - fail immediately return nil, err } else { p.logger.Debug("polar checkout initial attempt failed, will retry", loggerdomain.Fields{ "session_id": sessionID, "error": err.Error(), }) } // Polling loop attemptCount := 1 for time.Now().Before(deadline) { select { case <-ctx.Done(): return nil, ctx.Err() case <-ticker.C: attemptCount++ session, err := p.GetCheckoutSession(ctx, sessionID) if err == nil { p.logger.Debug("polar checkout polling attempt", loggerdomain.Fields{ "session_id": sessionID, "attempt": attemptCount, "status": session.Status, }) if session.Status == "succeeded" { p.logger.Info("polar checkout polling succeeded", loggerdomain.Fields{ "session_id": sessionID, "attempts": attemptCount, }) return session, nil } // Continue polling for "pending", "processing", etc. continue } // Check if error is retryable if !isRetryableError(err) { p.logger.Warn("polar checkout polling non-retryable error", loggerdomain.Fields{ "session_id": sessionID, "attempt": attemptCount, "error": err.Error(), }) return nil, err } p.logger.Debug("polar checkout polling attempt failed, retrying", loggerdomain.Fields{ "session_id": sessionID, "attempt": attemptCount, "error": err.Error(), }) } } // Timeout reached - get last known status lastStatus := "unknown" if session != nil { lastStatus = session.Status } p.logger.Warn("polar checkout polling timeout", loggerdomain.Fields{ "session_id": sessionID, "attempts": attemptCount, "last_status": lastStatus, }) return nil, fmt.Errorf("checkout verification timed out after 10 seconds (last status: %s)", lastStatus) } // isRetryableError determines if an error should trigger a retry func isRetryableError(err error) bool { if err == nil { return false } errStr := err.Error() // Don't retry 404 (session not found) if strings.Contains(errStr, "checkout session not found") || strings.Contains(errStr, "404") { return false } // Don't retry 4xx client errors (except 429) if strings.Contains(errStr, "returned status 400") || strings.Contains(errStr, "returned status 401") || strings.Contains(errStr, "returned status 403") { return false } // Retry on: // - Network errors // - 5xx server errors // - 429 rate limit errors // - Timeout errors // - Connection errors return true } // IngestMeterEvent ingests a meter event to Polar for usage-based billing // This notifies Polar about invoice processing to consume meter credits // Meter: "Invoice Processing" func (p *polarAdapter) IngestMeterEvent(ctx context.Context, externalCustomerID string, meterSlug string, amount int32) error { // Call Polar API to ingest meter event // POST /v1/events/ingest endpoint for event ingestion endpoint := "/v1/events/ingest" // Prepare request body for event ingestion // Events must be wrapped in "events" array // Meter will automatically aggregate events and decrement credits body := map[string]any{ "events": []map[string]any{ { "name": meterSlug, "external_customer_id": externalCustomerID, "metadata": map[string]any{ "count": amount, }, }, }, } // Log the payload being sent to Polar for debugging bodyJSON, _ := json.Marshal(body) p.logger.Debug("sending meter event to polar", loggerdomain.Fields{ "endpoint": endpoint, "payload": string(bodyJSON), }) resp, err := p.client.Post(ctx, endpoint, body) if err != nil { return fmt.Errorf("failed to call Polar events API: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 && resp.StatusCode != 201 { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("polar events API returned status %d: %s", resp.StatusCode, string(bodyBytes)) } // Log successful event ingestion p.logger.Info("meter event ingested successfully", loggerdomain.Fields{ "customer_id": externalCustomerID, "meter_slug": meterSlug, "amount": amount, }) return nil } func parseTime(s string) (time.Time, error) { // Parse ISO 8601 timestamp return time.Parse(time.RFC3339, s) } ================================================ FILE: go-b2b-starter/internal/modules/billing/infra/repositories/organization_adapter.go ================================================ package repositories import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" "github.com/moasq/go-b2b-starter/internal/db/adapters" "github.com/jackc/pgx/v5/pgtype" ) type organizationAdapter struct { orgStore adapters.OrganizationStore } func NewOrganizationAdapter(orgStore adapters.OrganizationStore) domain.OrganizationAdapter { return &organizationAdapter{ orgStore: orgStore, } } func (a *organizationAdapter) GetStytchOrgID(ctx context.Context, organizationID int32) (string, error) { org, err := a.orgStore.GetOrganizationByID(ctx, organizationID) if err != nil { return "", fmt.Errorf("failed to get organization: %w", err) } if !org.StytchOrgID.Valid || org.StytchOrgID.String == "" { return "", fmt.Errorf("organization has no Stytch org ID") } return org.StytchOrgID.String, nil } func (a *organizationAdapter) GetOrganizationIDByStytchOrgID(ctx context.Context, stytchOrgID string) (int32, error) { stytchOrgIDText := pgtype.Text{ String: stytchOrgID, Valid: true, } org, err := a.orgStore.GetOrganizationByStytchID(ctx, stytchOrgIDText) if err != nil { return 0, fmt.Errorf("failed to get organization by Stytch org ID: %w", err) } return org.ID, nil } ================================================ FILE: go-b2b-starter/internal/modules/billing/infra/repositories/subscription_repository.go ================================================ package repositories import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/moasq/go-b2b-starter/internal/db/helpers" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/modules/billing/domain" ) // subscriptionRepository implements domain.SubscriptionRepository using SQLC internally. // SQLC types are never exposed outside this package. type subscriptionRepository struct { store sqlc.Store } // NewSubscriptionRepository creates a new SubscriptionRepository implementation. func NewSubscriptionRepository(store sqlc.Store) domain.SubscriptionRepository { return &subscriptionRepository{store: store} } func (r *subscriptionRepository) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (*domain.Subscription, error) { result, err := r.store.GetSubscriptionByOrgID(ctx, organizationID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrSubscriptionNotFound } return nil, fmt.Errorf("failed to get subscription: %w", err) } return r.mapToDomainSubscription(&result), nil } func (r *subscriptionRepository) UpsertSubscription(ctx context.Context, subscription *domain.Subscription) (*domain.Subscription, error) { // Marshal metadata to JSONB metadataJSON, err := json.Marshal(subscription.Metadata) if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } params := sqlc.UpsertSubscriptionParams{ OrganizationID: subscription.OrganizationID, ExternalCustomerID: subscription.ExternalCustomerID, SubscriptionID: subscription.SubscriptionID, SubscriptionStatus: subscription.SubscriptionStatus, ProductID: subscription.ProductID, ProductName: helpers.ToPgText(subscription.ProductName), PlanName: helpers.ToPgText(subscription.PlanName), CurrentPeriodStart: toPgTimestamp(subscription.CurrentPeriodStart), CurrentPeriodEnd: toPgTimestamp(subscription.CurrentPeriodEnd), CancelAtPeriodEnd: helpers.ToPgBool(subscription.CancelAtPeriodEnd), CanceledAt: toPgTimestampPtr(subscription.CanceledAt), Metadata: metadataJSON, } result, err := r.store.UpsertSubscription(ctx, params) if err != nil { return nil, fmt.Errorf("failed to upsert subscription: %w", err) } return r.mapToDomainSubscription(&result), nil } func (r *subscriptionRepository) DeleteSubscription(ctx context.Context, organizationID int32) error { if err := r.store.DeleteSubscription(ctx, organizationID); err != nil { return fmt.Errorf("failed to delete subscription: %w", err) } return nil } func (r *subscriptionRepository) GetQuotaByOrgID(ctx context.Context, organizationID int32) (*domain.QuotaTracking, error) { result, err := r.store.GetQuotaByOrgID(ctx, organizationID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrQuotaNotFound } return nil, fmt.Errorf("failed to get quota: %w", err) } return r.mapToDomainQuota(&result), nil } func (r *subscriptionRepository) UpsertQuota(ctx context.Context, quota *domain.QuotaTracking) (*domain.QuotaTracking, error) { params := sqlc.UpsertQuotaParams{ OrganizationID: quota.OrganizationID, InvoiceCount: quota.InvoiceCount, MaxSeats: helpers.ToPgInt4(quota.MaxSeats), PeriodStart: toPgTimestamp(quota.PeriodStart), PeriodEnd: toPgTimestamp(quota.PeriodEnd), } result, err := r.store.UpsertQuota(ctx, params) if err != nil { return nil, fmt.Errorf("failed to upsert quota: %w", err) } return r.mapToDomainQuota(&result), nil } func (r *subscriptionRepository) DecrementInvoiceCount(ctx context.Context, organizationID int32) (*domain.QuotaTracking, error) { result, err := r.store.DecrementInvoiceCount(ctx, organizationID) if err != nil { return nil, fmt.Errorf("failed to decrement invoice count: %w", err) } return r.mapToDomainQuota(&result), nil } func (r *subscriptionRepository) GetQuotaStatus(ctx context.Context, organizationID int32) (*domain.QuotaStatus, error) { result, err := r.store.GetQuotaStatus(ctx, organizationID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrSubscriptionNotFound } return nil, fmt.Errorf("failed to get quota status: %w", err) } return r.mapToDomainQuotaStatus(&result), nil } // Mapping functions func (r *subscriptionRepository) mapToDomainSubscription(s *sqlc.SubscriptionBillingSubscription) *domain.Subscription { var metadata map[string]any if len(s.Metadata) > 0 { json.Unmarshal(s.Metadata, &metadata) } subscription := &domain.Subscription{ ID: s.ID, OrganizationID: s.OrganizationID, ExternalCustomerID: s.ExternalCustomerID, SubscriptionID: s.SubscriptionID, SubscriptionStatus: s.SubscriptionStatus, ProductID: s.ProductID, ProductName: helpers.FromPgText(s.ProductName), PlanName: helpers.FromPgText(s.PlanName), CurrentPeriodStart: s.CurrentPeriodStart.Time, CurrentPeriodEnd: s.CurrentPeriodEnd.Time, Metadata: metadata, CreatedAt: s.CreatedAt.Time, UpdatedAt: s.UpdatedAt.Time, } // Handle nullable fields if s.CancelAtPeriodEnd.Valid { subscription.CancelAtPeriodEnd = s.CancelAtPeriodEnd.Bool } if s.CanceledAt.Valid { subscription.CanceledAt = &s.CanceledAt.Time } return subscription } func (r *subscriptionRepository) mapToDomainQuota(q *sqlc.SubscriptionBillingQuotaTracking) *domain.QuotaTracking { quota := &domain.QuotaTracking{ ID: q.ID, OrganizationID: q.OrganizationID, InvoiceCount: q.InvoiceCount, MaxSeats: helpers.FromPgInt4(q.MaxSeats), PeriodStart: q.PeriodStart.Time, PeriodEnd: q.PeriodEnd.Time, CreatedAt: q.CreatedAt.Time, UpdatedAt: q.UpdatedAt.Time, } // Handle nullable LastSyncedAt if q.LastSyncedAt.Valid { quota.LastSyncedAt = &q.LastSyncedAt.Time } return quota } func (r *subscriptionRepository) mapToDomainQuotaStatus(qs *sqlc.GetQuotaStatusRow) *domain.QuotaStatus { status := &domain.QuotaStatus{ SubscriptionStatus: qs.SubscriptionStatus, CurrentPeriodStart: qs.CurrentPeriodStart.Time, CurrentPeriodEnd: qs.CurrentPeriodEnd.Time, InvoiceCount: qs.InvoiceCount, CanProcessInvoice: qs.CanProcessInvoice, } // Handle nullable fields if qs.CancelAtPeriodEnd.Valid { status.CancelAtPeriodEnd = qs.CancelAtPeriodEnd.Bool } if qs.MaxSeats.Valid { status.MaxSeats = qs.MaxSeats.Int32 } return status } // Helper functions for timestamp conversion func toPgTimestamp(t time.Time) pgtype.Timestamp { if t.IsZero() { return pgtype.Timestamp{Valid: false} } return pgtype.Timestamp{Time: t, Valid: true} } func toPgTimestampPtr(t *time.Time) pgtype.Timestamp { if t == nil { return pgtype.Timestamp{Valid: false} } return pgtype.Timestamp{Time: *t, Valid: true} } ================================================ FILE: go-b2b-starter/internal/modules/billing/provider.go ================================================ package billing import ( "go.uber.org/dig" ) // RegisterHandlers registers subscription API handlers in the DI container func RegisterHandlers(container *dig.Container) error { if err := container.Provide(NewHandler); err != nil { return err } return nil } // ProvideHandler is an alias for RegisterHandlers for consistency func ProvideHandler(container *dig.Container) error { return RegisterHandlers(container) } ================================================ FILE: go-b2b-starter/internal/modules/billing/routes.go ================================================ package billing import ( "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" serverDomain "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) // Routes registers subscription endpoints func (h *Handler) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { // Subscription endpoints subscriptions := router.Group("/subscriptions") subscriptions.Use( resolver.Get("auth"), resolver.Get("org_context"), ) { // Get billing status - requires resource:view permission subscriptions.GET("/status", auth.RequirePermissionFunc("resource", "view"), h.GetBillingStatus) } // Verify payment endpoint - auth only (session_id identifies org) // This is separate from the main group to avoid requiring org_context middleware // The session_id from the checkout contains the customer_id which maps to the org router.POST("/subscriptions/verify-payment", resolver.Get("auth"), h.VerifyPayment) } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/app/services/document_listener.go ================================================ package services import ( "context" "fmt" ) type documentListener struct { embeddingService EmbeddingService } func NewDocumentListener( embeddingService EmbeddingService, ) DocumentListener { return &documentListener{ embeddingService: embeddingService, } } func (l *documentListener) HandleDocumentUploaded(ctx context.Context, documentID, orgID int32, text string) error { // Skip if no text to embed if text == "" { return nil } // Create embedding for the document _, err := l.embeddingService.EmbedDocument(ctx, orgID, documentID, text) if err != nil { return fmt.Errorf("failed to embed document: %w", err) } return nil } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/app/services/embedding_service.go ================================================ package services import ( "context" "crypto/sha256" "encoding/hex" "fmt" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" ) const ( // MaxChunkSize is the maximum number of characters per chunk MaxChunkSize = 8000 // ContentPreviewLength is the length of content preview to store ContentPreviewLength = 500 ) type embeddingService struct { embeddingRepo domain.EmbeddingRepository textVectorizer domain.TextVectorizer } func NewEmbeddingService( embeddingRepo domain.EmbeddingRepository, textVectorizer domain.TextVectorizer, ) EmbeddingService { return &embeddingService{ embeddingRepo: embeddingRepo, textVectorizer: textVectorizer, } } func (s *embeddingService) EmbedDocument(ctx context.Context, orgID, documentID int32, text string) (*domain.DocumentEmbedding, error) { // Generate embedding using text vectorizer embedding, err := s.textVectorizer.Vectorize(ctx, text) if err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrEmbeddingGenerationFailed, err) } // Create content hash for deduplication contentHash := s.hashContent(text) // Create content preview contentPreview := text if len(contentPreview) > ContentPreviewLength { contentPreview = contentPreview[:ContentPreviewLength] } // Create embedding record docEmbedding := &domain.DocumentEmbedding{ DocumentID: documentID, OrganizationID: orgID, Embedding: embedding, ContentHash: contentHash, ContentPreview: contentPreview, ChunkIndex: 0, // Single chunk for now } result, err := s.embeddingRepo.Create(ctx, docEmbedding) if err != nil { return nil, fmt.Errorf("failed to store embedding: %w", err) } return result, nil } func (s *embeddingService) GetDocumentEmbeddings(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error) { return s.embeddingRepo.GetByDocumentID(ctx, orgID, documentID) } func (s *embeddingService) SearchSimilarDocuments(ctx context.Context, orgID int32, text string, limit int32) ([]*domain.SimilarDocument, error) { // Generate embedding for the search query embedding, err := s.textVectorizer.Vectorize(ctx, text) if err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrEmbeddingGenerationFailed, err) } // Search for similar documents return s.embeddingRepo.SearchSimilar(ctx, orgID, embedding, limit) } func (s *embeddingService) DeleteDocumentEmbeddings(ctx context.Context, orgID, documentID int32) error { if err := s.embeddingRepo.Delete(ctx, orgID, documentID); err != nil { return fmt.Errorf("failed to delete embeddings: %w", err) } return nil } func (s *embeddingService) GetStats(ctx context.Context, orgID int32) (*domain.EmbeddingStats, error) { count, err := s.embeddingRepo.Count(ctx, orgID) if err != nil { return nil, fmt.Errorf("failed to get embedding count: %w", err) } return &domain.EmbeddingStats{ TotalEmbeddings: count, TotalDocuments: count, // For now, 1:1 relationship }, nil } // hashContent creates a SHA-256 hash of the content for deduplication func (s *embeddingService) hashContent(content string) string { hash := sha256.Sum256([]byte(content)) return hex.EncodeToString(hash[:]) } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/app/services/interface.go ================================================ package services import ( "context" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" ) // EmbeddingService defines the interface for embedding operations type EmbeddingService interface { // EmbedDocument generates and stores embeddings for a document EmbedDocument(ctx context.Context, orgID, documentID int32, text string) (*domain.DocumentEmbedding, error) // GetDocumentEmbeddings retrieves embeddings for a document GetDocumentEmbeddings(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error) // SearchSimilarDocuments finds documents similar to the given text SearchSimilarDocuments(ctx context.Context, orgID int32, text string, limit int32) ([]*domain.SimilarDocument, error) // DeleteDocumentEmbeddings removes embeddings for a document DeleteDocumentEmbeddings(ctx context.Context, orgID, documentID int32) error // GetStats retrieves embedding statistics GetStats(ctx context.Context, orgID int32) (*domain.EmbeddingStats, error) } // RAGService defines the interface for RAG (Retrieval-Augmented Generation) operations type RAGService interface { // Chat sends a message and gets a response, optionally using RAG Chat(ctx context.Context, orgID, accountID int32, req *domain.ChatRequest) (*domain.ChatResponse, error) // GetSession retrieves a chat session GetSession(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error) // ListSessions lists chat sessions for an account ListSessions(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error) // DeleteSession deletes a chat session DeleteSession(ctx context.Context, orgID, sessionID int32) error // GetSessionHistory retrieves messages for a session GetSessionHistory(ctx context.Context, orgID, sessionID int32) ([]*domain.ChatMessage, error) // UpdateSessionTitle updates the title of a chat session UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error) } // DocumentListener handles document events from the documents module type DocumentListener interface { // HandleDocumentUploaded processes the DocumentUploaded event HandleDocumentUploaded(ctx context.Context, documentID, orgID int32, text string) error } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/app/services/rag_service.go ================================================ package services import ( "context" "fmt" "strings" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" ) const ( // DefaultMaxDocuments is the default number of documents to retrieve for RAG DefaultMaxDocuments = 3 // DefaultContextHistory is the default number of messages to include in context DefaultContextHistory = 10 // SystemPrompt is the default system prompt for RAG SystemPrompt = `You are a helpful assistant that answers questions based on the provided context. If the context doesn't contain relevant information, say so clearly. Always cite which documents you used to answer the question.` ) type ragService struct { chatRepo domain.ChatRepository embeddingRepo domain.EmbeddingRepository textVectorizer domain.TextVectorizer assistantProvider domain.AssistantProvider } func NewRAGService( chatRepo domain.ChatRepository, embeddingRepo domain.EmbeddingRepository, textVectorizer domain.TextVectorizer, assistantProvider domain.AssistantProvider, ) RAGService { return &ragService{ chatRepo: chatRepo, embeddingRepo: embeddingRepo, textVectorizer: textVectorizer, assistantProvider: assistantProvider, } } func (s *ragService) Chat(ctx context.Context, orgID, accountID int32, req *domain.ChatRequest) (*domain.ChatResponse, error) { var session *domain.ChatSession var err error // Get or create session if req.SessionID > 0 { session, err = s.chatRepo.GetSessionByID(ctx, orgID, req.SessionID) if err != nil { return nil, fmt.Errorf("failed to get session: %w", err) } } else { // Create new session session = &domain.ChatSession{ OrganizationID: orgID, AccountID: accountID, Title: generateSessionTitle(req.Message), } session, err = s.chatRepo.CreateSession(ctx, session) if err != nil { return nil, fmt.Errorf("failed to create session: %w", err) } } // Save user message userMessage := &domain.ChatMessage{ SessionID: session.ID, Role: domain.ChatRoleUser, Content: req.Message, } userMessage, err = s.chatRepo.CreateMessage(ctx, userMessage) if err != nil { return nil, fmt.Errorf("failed to save user message: %w", err) } // Build context and generate response var referencedDocs []*domain.SimilarDocument var prompt string if req.UseRAG { // Search for similar documents maxDocs := req.MaxDocuments if maxDocs <= 0 { maxDocs = DefaultMaxDocuments } // Generate embedding for the query and search embedding, err := s.textVectorizer.Vectorize(ctx, req.Message) if err == nil { docs, err := s.embeddingRepo.SearchSimilar(ctx, orgID, embedding, int32(maxDocs)) if err == nil { referencedDocs = docs } } // Build RAG prompt prompt = s.buildRAGPrompt(req.Message, referencedDocs) } else { prompt = req.Message } // Get conversation history for context contextHistory := req.ContextHistory if contextHistory <= 0 { contextHistory = DefaultContextHistory } history, _ := s.chatRepo.GetRecentMessages(ctx, session.ID, int32(contextHistory)) // Build full prompt with history fullPrompt := s.buildPromptWithHistory(prompt, history) // Generate response using AI assistant response, err := s.assistantProvider.GenerateResponse(ctx, fullPrompt) if err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrRAGCompletionFailed, err) } // Extract document IDs from referenced docs var docIDs []int32 for _, doc := range referencedDocs { docIDs = append(docIDs, doc.DocumentID) } // Save assistant response assistantMessage := &domain.ChatMessage{ SessionID: session.ID, Role: domain.ChatRoleAssistant, Content: response.Content, ReferencedDocs: docIDs, TokensUsed: int32(response.TokensUsed), } assistantMessage, err = s.chatRepo.CreateMessage(ctx, assistantMessage) if err != nil { return nil, fmt.Errorf("failed to save assistant message: %w", err) } // Convert []*SimilarDocument to []SimilarDocument var docs []domain.SimilarDocument for _, doc := range referencedDocs { if doc != nil { docs = append(docs, *doc) } } return &domain.ChatResponse{ SessionID: session.ID, Message: assistantMessage, ReferencedDocs: docs, TokensUsed: int32(response.TokensUsed), }, nil } func (s *ragService) GetSession(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error) { return s.chatRepo.GetSessionByID(ctx, orgID, sessionID) } func (s *ragService) ListSessions(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error) { return s.chatRepo.ListSessionsByAccount(ctx, orgID, accountID, limit, offset) } func (s *ragService) DeleteSession(ctx context.Context, orgID, sessionID int32) error { return s.chatRepo.DeleteSession(ctx, orgID, sessionID) } func (s *ragService) GetSessionHistory(ctx context.Context, orgID, sessionID int32) ([]*domain.ChatMessage, error) { // Verify session belongs to organization _, err := s.chatRepo.GetSessionByID(ctx, orgID, sessionID) if err != nil { return nil, fmt.Errorf("failed to verify session: %w", err) } return s.chatRepo.GetMessagesBySession(ctx, sessionID) } func (s *ragService) UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error) { return s.chatRepo.UpdateSessionTitle(ctx, orgID, sessionID, title) } // buildRAGPrompt builds a prompt with RAG context func (s *ragService) buildRAGPrompt(query string, docs []*domain.SimilarDocument) string { if len(docs) == 0 { return fmt.Sprintf("%s\n\nUser Question: %s", SystemPrompt, query) } var contextBuilder strings.Builder contextBuilder.WriteString(SystemPrompt) contextBuilder.WriteString("\n\n--- CONTEXT FROM DOCUMENTS ---\n") for i, doc := range docs { contextBuilder.WriteString(fmt.Sprintf("\n[Document %d (similarity: %.2f)]:\n%s\n", i+1, doc.SimilarityScore, doc.ContentPreview)) } contextBuilder.WriteString("\n--- END OF CONTEXT ---\n\n") contextBuilder.WriteString(fmt.Sprintf("User Question: %s", query)) return contextBuilder.String() } // buildPromptWithHistory builds a prompt including conversation history func (s *ragService) buildPromptWithHistory(prompt string, history []*domain.ChatMessage) string { if len(history) == 0 { return prompt } var builder strings.Builder builder.WriteString("Previous conversation:\n") // History is in descending order, so reverse it for i := len(history) - 1; i >= 0; i-- { msg := history[i] role := "User" if msg.Role == domain.ChatRoleAssistant { role = "Assistant" } builder.WriteString(fmt.Sprintf("%s: %s\n", role, msg.Content)) } builder.WriteString("\nCurrent prompt:\n") builder.WriteString(prompt) return builder.String() } // generateSessionTitle generates a title from the first message func generateSessionTitle(message string) string { // Take first 50 characters of the message as title if len(message) <= 50 { return message } return message[:50] + "..." } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/cmd/init.go ================================================ package cmd import ( "context" "fmt" "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/cognitive" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/app/services" docEvents "github.com/moasq/go-b2b-starter/internal/modules/documents/domain/events" "github.com/moasq/go-b2b-starter/internal/platform/eventbus" ) func Init(container *dig.Container) error { module := cognitive.NewModule(container) if err := module.RegisterDependencies(); err != nil { return fmt.Errorf("failed to register cognitive dependencies: %w", err) } // Wire up event listener for document uploads if err := container.Invoke(func( bus eventbus.EventBus, listener services.DocumentListener, ) error { // Subscribe to DocumentUploaded events return bus.Subscribe(docEvents.DocumentUploadedEventType, func(ctx context.Context, event eventbus.Event) error { // Type assert to get the specific event docEvent, ok := event.(*docEvents.DocumentUploaded) if !ok { return fmt.Errorf("unexpected event type: %T", event) } // Handle the event return listener.HandleDocumentUploaded(ctx, docEvent.DocumentID, docEvent.OrganizationID, docEvent.ExtractedText) }) }); err != nil { return fmt.Errorf("failed to wire document event listener: %w", err) } return nil } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/domain/ai_provider.go ================================================ package domain import "context" // TextVectorizer creates searchable vector representations of text content. // This enables semantic document search and similarity matching. // Implementation details (embedding models, providers) are in the infra layer. type TextVectorizer interface { // Vectorize converts text content into a searchable vector representation Vectorize(ctx context.Context, text string) ([]float64, error) } // AssistantProvider provides AI-powered conversational assistance. // This enables intelligent responses based on context and user queries. // Implementation details (LLM providers, models) are in the infra layer. type AssistantProvider interface { // GenerateResponse creates an AI response for the given prompt with context GenerateResponse(ctx context.Context, prompt string) (*AssistantResponse, error) } // AssistantResponse contains the result of an AI assistance request type AssistantResponse struct { Content string // The generated response text TokensUsed int // Tokens consumed (for usage tracking) } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/domain/entity.go ================================================ package domain import ( "time" ) // ChatRole represents the role of a message sender type ChatRole string const ( ChatRoleUser ChatRole = "user" ChatRoleAssistant ChatRole = "assistant" ChatRoleSystem ChatRole = "system" ) // DocumentEmbedding represents a vector embedding for a document type DocumentEmbedding struct { ID int32 `json:"id"` DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` Embedding []float64 `json:"embedding,omitempty"` // 1536 dimensions for OpenAI ContentHash string `json:"content_hash,omitempty"` ContentPreview string `json:"content_preview,omitempty"` ChunkIndex int32 `json:"chunk_index"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // SimilarDocument represents a document found through similarity search type SimilarDocument struct { DocumentEmbedding SimilarityScore float64 `json:"similarity_score"` } // ChatSession represents a conversation session type ChatSession struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` AccountID int32 `json:"account_id"` Title string `json:"title,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func (s *ChatSession) GetID() int32 { return s.ID } // Validate validates the chat session entity func (s *ChatSession) Validate() error { if s.OrganizationID == 0 { return ErrSessionOrganizationRequired } if s.AccountID == 0 { return ErrSessionAccountRequired } return nil } // ChatMessage represents a message within a chat session type ChatMessage struct { ID int32 `json:"id"` SessionID int32 `json:"session_id"` Role ChatRole `json:"role"` Content string `json:"content"` ReferencedDocs []int32 `json:"referenced_docs,omitempty"` TokensUsed int32 `json:"tokens_used,omitempty"` CreatedAt time.Time `json:"created_at"` } func (m *ChatMessage) GetID() int32 { return m.ID } // Validate validates the chat message entity func (m *ChatMessage) Validate() error { if m.SessionID == 0 { return ErrMessageSessionRequired } if m.Content == "" { return ErrMessageContentRequired } if m.Role == "" { return ErrMessageRoleRequired } return nil } func (m *ChatMessage) IsUserMessage() bool { return m.Role == ChatRoleUser } func (m *ChatMessage) IsAssistantMessage() bool { return m.Role == ChatRoleAssistant } // RAGContext represents context retrieved for RAG type RAGContext struct { Documents []SimilarDocument `json:"documents"` Query string `json:"query"` } // ChatRequest represents a request to send a chat message type ChatRequest struct { SessionID int32 `json:"session_id,omitempty"` // Optional - create new session if not provided Message string `json:"message"` UseRAG bool `json:"use_rag,omitempty"` // Whether to use RAG for context MaxDocuments int `json:"max_documents,omitempty"` ContextHistory int `json:"context_history,omitempty"` // Number of previous messages to include } // ChatResponse represents a response from the chat service type ChatResponse struct { SessionID int32 `json:"session_id"` Message *ChatMessage `json:"message"` ReferencedDocs []SimilarDocument `json:"referenced_docs,omitempty"` TokensUsed int32 `json:"tokens_used,omitempty"` } // EmbeddingStats represents embedding statistics type EmbeddingStats struct { TotalEmbeddings int64 `json:"total_embeddings"` TotalDocuments int64 `json:"total_documents"` } // ChatStats represents chat statistics type ChatStats struct { TotalSessions int64 `json:"total_sessions"` TotalMessages int64 `json:"total_messages"` } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/domain/errors.go ================================================ package domain import "errors" // Domain errors for cognitive module var ( // Embedding errors ErrEmbeddingNotFound = errors.New("embedding not found") ErrEmbeddingGenerationFailed = errors.New("failed to generate embedding") ErrEmbeddingAlreadyExists = errors.New("embedding already exists for this document") // Session errors ErrSessionNotFound = errors.New("chat session not found") ErrSessionOrganizationRequired = errors.New("session organization ID is required") ErrSessionAccountRequired = errors.New("session account ID is required") // Message errors ErrMessageNotFound = errors.New("chat message not found") ErrMessageSessionRequired = errors.New("message session ID is required") ErrMessageContentRequired = errors.New("message content is required") ErrMessageRoleRequired = errors.New("message role is required") // RAG errors ErrRAGContextEmpty = errors.New("no relevant documents found for RAG context") ErrRAGSearchFailed = errors.New("RAG similarity search failed") ErrRAGCompletionFailed = errors.New("RAG completion generation failed") // LLM errors ErrLLMUnavailable = errors.New("LLM service is unavailable") ErrLLMRequestFailed = errors.New("LLM request failed") ErrLLMResponseInvalid = errors.New("LLM response is invalid") ) ================================================ FILE: go-b2b-starter/internal/modules/cognitive/domain/repository.go ================================================ package domain import "context" // EmbeddingRepository defines the interface for embedding data operations type EmbeddingRepository interface { // Create creates a new document embedding Create(ctx context.Context, embedding *DocumentEmbedding) (*DocumentEmbedding, error) // GetByID retrieves an embedding by ID GetByID(ctx context.Context, orgID, embeddingID int32) (*DocumentEmbedding, error) // GetByDocumentID retrieves all embeddings for a document GetByDocumentID(ctx context.Context, orgID, documentID int32) ([]*DocumentEmbedding, error) // SearchSimilar finds similar documents using vector similarity SearchSimilar(ctx context.Context, orgID int32, embedding []float64, limit int32) ([]*SimilarDocument, error) // Delete removes embeddings for a document Delete(ctx context.Context, orgID, documentID int32) error // Count returns the total count of embeddings for an organization Count(ctx context.Context, orgID int32) (int64, error) } // ChatRepository defines the interface for chat session and message operations type ChatRepository interface { // Sessions CreateSession(ctx context.Context, session *ChatSession) (*ChatSession, error) GetSessionByID(ctx context.Context, orgID, sessionID int32) (*ChatSession, error) ListSessionsByAccount(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*ChatSession, error) UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*ChatSession, error) DeleteSession(ctx context.Context, orgID, sessionID int32) error // Messages CreateMessage(ctx context.Context, message *ChatMessage) (*ChatMessage, error) GetMessagesBySession(ctx context.Context, sessionID int32) ([]*ChatMessage, error) GetRecentMessages(ctx context.Context, sessionID int32, limit int32) ([]*ChatMessage, error) CountMessagesBySession(ctx context.Context, sessionID int32) (int64, error) DeleteMessage(ctx context.Context, messageID int32) error } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/handler.go ================================================ package cognitive import ( "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/app/services" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/pkg/httperr" ) type Handler struct { ragService services.RAGService embeddingService services.EmbeddingService } func NewHandler(ragService services.RAGService, embeddingService services.EmbeddingService) *Handler { return &Handler{ ragService: ragService, embeddingService: embeddingService, } } // ChatRequest represents the JSON request body for chat type ChatRequest struct { SessionID int32 `json:"session_id,omitempty"` Message string `json:"message" binding:"required"` UseRAG bool `json:"use_rag,omitempty"` MaxDocuments int `json:"max_documents,omitempty"` ContextHistory int `json:"context_history,omitempty"` } // Chat sends a message and gets a response // @Summary Chat with AI // @Description Sends a message to the AI and gets a response, optionally using RAG // @Tags Cognitive // @Accept json // @Produce json // @Param request body ChatRequest true "Chat request" // @Success 200 {object} domain.ChatResponse // @Failure 400 {object} httperr.HTTPError // @Failure 500 {object} httperr.HTTPError // @Router /example_cognitive/chat [post] func (h *Handler) Chat(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "missing_context", "Organization context is required", )) return } var req ChatRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "invalid_request", "Invalid JSON format: "+err.Error(), )) return } // Create domain request chatReq := &domain.ChatRequest{ SessionID: req.SessionID, Message: req.Message, UseRAG: req.UseRAG, MaxDocuments: req.MaxDocuments, ContextHistory: req.ContextHistory, } response, err := h.ragService.Chat(c.Request.Context(), reqCtx.OrganizationID, reqCtx.AccountID, chatReq) if err != nil { c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "chat_failed", "Failed to process chat: "+err.Error(), )) return } c.JSON(http.StatusOK, response) } // ListSessions lists chat sessions for the current user // @Summary List chat sessions // @Description Lists chat sessions for the current user with pagination // @Tags Cognitive // @Produce json // @Param limit query int false "Limit" default(10) // @Param offset query int false "Offset" default(0) // @Success 200 {object} map[string]interface{} // @Failure 500 {object} httperr.HTTPError // @Router /example_cognitive/sessions [get] func (h *Handler) ListSessions(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "missing_context", "Organization context is required", )) return } // Parse query parameters limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) sessions, err := h.ragService.ListSessions(c.Request.Context(), reqCtx.OrganizationID, reqCtx.AccountID, int32(limit), int32(offset)) if err != nil { c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "list_failed", "Failed to list sessions: "+err.Error(), )) return } c.JSON(http.StatusOK, gin.H{ "sessions": sessions, "limit": limit, "offset": offset, }) } // GetSessionHistory retrieves messages for a session // @Summary Get session history // @Description Retrieves all messages for a chat session // @Tags Cognitive // @Produce json // @Param id path int true "Session ID" // @Success 200 {array} domain.ChatMessage // @Failure 400 {object} httperr.HTTPError // @Failure 500 {object} httperr.HTTPError // @Router /example_cognitive/sessions/{id}/messages [get] func (h *Handler) GetSessionHistory(c *gin.Context) { idParam := c.Param("id") var sessionID int32 if _, err := fmt.Sscanf(idParam, "%d", &sessionID); err != nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "invalid_id", "Session ID must be a valid number", )) return } reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "missing_context", "Organization context is required", )) return } messages, err := h.ragService.GetSessionHistory(c.Request.Context(), reqCtx.OrganizationID, sessionID) if err != nil { c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "fetch_failed", "Failed to fetch session history: "+err.Error(), )) return } c.JSON(http.StatusOK, messages) } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/infra/ai/assistant_provider.go ================================================ package ai import ( "context" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" llmdomain "github.com/moasq/go-b2b-starter/internal/platform/llm/domain" ) type openAIAssistantProvider struct { llmClient llmdomain.LLMClient } // NewAssistantProvider creates an AssistantProvider backed by OpenAI func NewAssistantProvider(llmClient llmdomain.LLMClient) domain.AssistantProvider { return &openAIAssistantProvider{llmClient: llmClient} } func (p *openAIAssistantProvider) GenerateResponse(ctx context.Context, prompt string) (*domain.AssistantResponse, error) { req := llmdomain.CompletionRequest{Prompt: prompt} resp, err := p.llmClient.Complete(ctx, req) if err != nil { return nil, err } return &domain.AssistantResponse{ Content: resp.Text, TokensUsed: resp.TokensUsed, }, nil } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/infra/ai/text_vectorizer.go ================================================ package ai import ( "context" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" llmdomain "github.com/moasq/go-b2b-starter/internal/platform/llm/domain" ) const embeddingModel = "text-embedding-3-small" type openAITextVectorizer struct { llmClient llmdomain.LLMClient } func NewTextVectorizer(llmClient llmdomain.LLMClient) domain.TextVectorizer { return &openAITextVectorizer{llmClient: llmClient} } func (v *openAITextVectorizer) Vectorize(ctx context.Context, text string) ([]float64, error) { return v.llmClient.GenerateEmbedding(ctx, text, embeddingModel) } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/infra/repositories/chat_repository.go ================================================ package repositories import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/db/helpers" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" ) // chatRepository implements domain.ChatRepository using SQLC internally. // SQLC types are never exposed outside this package. type chatRepository struct { store sqlc.Store } // NewChatRepository creates a new ChatRepository implementation. func NewChatRepository(store sqlc.Store) domain.ChatRepository { return &chatRepository{store: store} } // Sessions func (r *chatRepository) CreateSession(ctx context.Context, session *domain.ChatSession) (*domain.ChatSession, error) { params := sqlc.CreateChatSessionParams{ OrganizationID: session.OrganizationID, AccountID: session.AccountID, Title: helpers.ToPgText(session.Title), } result, err := r.store.CreateChatSession(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create chat session: %w", err) } return r.mapSessionToDomain(&result), nil } func (r *chatRepository) GetSessionByID(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error) { params := sqlc.GetChatSessionByIDParams{ ID: sessionID, OrganizationID: orgID, } result, err := r.store.GetChatSessionByID(ctx, params) if err != nil { return nil, fmt.Errorf("failed to get chat session: %w", err) } return r.mapSessionToDomain(&result), nil } func (r *chatRepository) ListSessionsByAccount(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error) { params := sqlc.ListChatSessionsByAccountParams{ OrganizationID: orgID, AccountID: accountID, Limit: limit, Offset: offset, } results, err := r.store.ListChatSessionsByAccount(ctx, params) if err != nil { return nil, fmt.Errorf("failed to list chat sessions: %w", err) } sessions := make([]*domain.ChatSession, len(results)) for i, result := range results { sessions[i] = r.mapSessionToDomain(&result) } return sessions, nil } func (r *chatRepository) UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error) { params := sqlc.UpdateChatSessionTitleParams{ ID: sessionID, OrganizationID: orgID, Title: helpers.ToPgText(title), } result, err := r.store.UpdateChatSessionTitle(ctx, params) if err != nil { return nil, fmt.Errorf("failed to update chat session title: %w", err) } return r.mapSessionToDomain(&result), nil } func (r *chatRepository) DeleteSession(ctx context.Context, orgID, sessionID int32) error { params := sqlc.DeleteChatSessionParams{ ID: sessionID, OrganizationID: orgID, } if err := r.store.DeleteChatSession(ctx, params); err != nil { return fmt.Errorf("failed to delete chat session: %w", err) } return nil } // Messages func (r *chatRepository) CreateMessage(ctx context.Context, message *domain.ChatMessage) (*domain.ChatMessage, error) { params := sqlc.CreateChatMessageParams{ SessionID: message.SessionID, Role: string(message.Role), Content: message.Content, ReferencedDocs: message.ReferencedDocs, TokensUsed: helpers.ToPgInt4(message.TokensUsed), } result, err := r.store.CreateChatMessage(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create chat message: %w", err) } return r.mapMessageToDomain(&result), nil } func (r *chatRepository) GetMessagesBySession(ctx context.Context, sessionID int32) ([]*domain.ChatMessage, error) { results, err := r.store.GetChatMessagesBySession(ctx, sessionID) if err != nil { return nil, fmt.Errorf("failed to get chat messages: %w", err) } messages := make([]*domain.ChatMessage, len(results)) for i, result := range results { messages[i] = r.mapMessageToDomain(&result) } return messages, nil } func (r *chatRepository) GetRecentMessages(ctx context.Context, sessionID int32, limit int32) ([]*domain.ChatMessage, error) { params := sqlc.GetRecentChatMessagesParams{ SessionID: sessionID, Limit: limit, } results, err := r.store.GetRecentChatMessages(ctx, params) if err != nil { return nil, fmt.Errorf("failed to get recent chat messages: %w", err) } messages := make([]*domain.ChatMessage, len(results)) for i, result := range results { messages[i] = r.mapMessageToDomain(&result) } return messages, nil } func (r *chatRepository) CountMessagesBySession(ctx context.Context, sessionID int32) (int64, error) { count, err := r.store.CountChatMessagesBySession(ctx, sessionID) if err != nil { return 0, fmt.Errorf("failed to count chat messages: %w", err) } return count, nil } func (r *chatRepository) DeleteMessage(ctx context.Context, messageID int32) error { if err := r.store.DeleteChatMessage(ctx, messageID); err != nil { return fmt.Errorf("failed to delete chat message: %w", err) } return nil } // mapSessionToDomain maps SQLC session type to domain type. // This is the translation boundary - SQLC types never escape this function. func (r *chatRepository) mapSessionToDomain(s *sqlc.CognitiveChatSession) *domain.ChatSession { return &domain.ChatSession{ ID: s.ID, OrganizationID: s.OrganizationID, AccountID: s.AccountID, Title: helpers.FromPgText(s.Title), CreatedAt: s.CreatedAt.Time, UpdatedAt: s.UpdatedAt.Time, } } // mapMessageToDomain maps SQLC message type to domain type. // This is the translation boundary - SQLC types never escape this function. func (r *chatRepository) mapMessageToDomain(m *sqlc.CognitiveChatMessage) *domain.ChatMessage { return &domain.ChatMessage{ ID: m.ID, SessionID: m.SessionID, Role: domain.ChatRole(m.Role), Content: m.Content, ReferencedDocs: m.ReferencedDocs, TokensUsed: helpers.FromPgInt4(m.TokensUsed), CreatedAt: m.CreatedAt.Time, } } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/infra/repositories/embedding_repository.go ================================================ package repositories import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/db/helpers" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" ) // embeddingRepository implements domain.EmbeddingRepository using SQLC internally. // SQLC types are never exposed outside this package. type embeddingRepository struct { store sqlc.Store } // NewEmbeddingRepository creates a new EmbeddingRepository implementation. func NewEmbeddingRepository(store sqlc.Store) domain.EmbeddingRepository { return &embeddingRepository{store: store} } func (r *embeddingRepository) Create(ctx context.Context, embedding *domain.DocumentEmbedding) (*domain.DocumentEmbedding, error) { params := sqlc.CreateDocumentEmbeddingParams{ DocumentID: embedding.DocumentID, OrganizationID: embedding.OrganizationID, Embedding: helpers.ToVector(embedding.Embedding), ContentHash: helpers.ToPgText(embedding.ContentHash), ContentPreview: helpers.ToPgText(embedding.ContentPreview), ChunkIndex: helpers.ToPgInt4(embedding.ChunkIndex), } result, err := r.store.CreateDocumentEmbedding(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create document embedding: %w", err) } return r.mapToDomain(&result), nil } func (r *embeddingRepository) GetByID(ctx context.Context, orgID, embeddingID int32) (*domain.DocumentEmbedding, error) { params := sqlc.GetDocumentEmbeddingByIDParams{ ID: embeddingID, OrganizationID: orgID, } result, err := r.store.GetDocumentEmbeddingByID(ctx, params) if err != nil { return nil, fmt.Errorf("failed to get document embedding: %w", err) } return r.mapToDomain(&result), nil } func (r *embeddingRepository) GetByDocumentID(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error) { params := sqlc.GetDocumentEmbeddingsByDocumentIDParams{ DocumentID: documentID, OrganizationID: orgID, } results, err := r.store.GetDocumentEmbeddingsByDocumentID(ctx, params) if err != nil { return nil, fmt.Errorf("failed to get document embeddings: %w", err) } embeddings := make([]*domain.DocumentEmbedding, len(results)) for i, result := range results { embeddings[i] = r.mapToDomain(&result) } return embeddings, nil } func (r *embeddingRepository) SearchSimilar(ctx context.Context, orgID int32, embedding []float64, limit int32) ([]*domain.SimilarDocument, error) { params := sqlc.SearchSimilarDocumentsParams{ Column1: helpers.ToVector(embedding), OrganizationID: orgID, Limit: limit, } results, err := r.store.SearchSimilarDocuments(ctx, params) if err != nil { return nil, fmt.Errorf("failed to search similar documents: %w", err) } docs := make([]*domain.SimilarDocument, len(results)) for i, result := range results { docs[i] = &domain.SimilarDocument{ DocumentEmbedding: domain.DocumentEmbedding{ ID: result.ID, DocumentID: result.DocumentID, OrganizationID: result.OrganizationID, ContentHash: helpers.FromPgText(result.ContentHash), ContentPreview: helpers.FromPgText(result.ContentPreview), ChunkIndex: helpers.FromPgInt4(result.ChunkIndex), CreatedAt: result.CreatedAt.Time, UpdatedAt: result.UpdatedAt.Time, }, SimilarityScore: result.SimilarityScore, } } return docs, nil } func (r *embeddingRepository) Delete(ctx context.Context, orgID, documentID int32) error { params := sqlc.DeleteDocumentEmbeddingsParams{ DocumentID: documentID, OrganizationID: orgID, } if err := r.store.DeleteDocumentEmbeddings(ctx, params); err != nil { return fmt.Errorf("failed to delete document embeddings: %w", err) } return nil } func (r *embeddingRepository) Count(ctx context.Context, orgID int32) (int64, error) { count, err := r.store.CountDocumentEmbeddingsByOrganization(ctx, orgID) if err != nil { return 0, fmt.Errorf("failed to count document embeddings: %w", err) } return count, nil } // mapToDomain maps SQLC embedding type to domain type. // This is the translation boundary - SQLC types never escape this function. func (r *embeddingRepository) mapToDomain(e *sqlc.CognitiveDocumentEmbedding) *domain.DocumentEmbedding { return &domain.DocumentEmbedding{ ID: e.ID, DocumentID: e.DocumentID, OrganizationID: e.OrganizationID, Embedding: helpers.FromVector(e.Embedding), ContentHash: helpers.FromPgText(e.ContentHash), ContentPreview: helpers.FromPgText(e.ContentPreview), ChunkIndex: helpers.FromPgInt4(e.ChunkIndex), CreatedAt: e.CreatedAt.Time, UpdatedAt: e.UpdatedAt.Time, } } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/infra/repositories/helpers.go ================================================ package repositories import "github.com/jackc/pgx/v5/pgtype" // Helper functions for type conversion func toPgText(s string) pgtype.Text { if s == "" { return pgtype.Text{Valid: false} } return pgtype.Text{String: s, Valid: true} } func fromPgText(t pgtype.Text) string { if !t.Valid { return "" } return t.String } func toPgInt4(i int32) pgtype.Int4 { if i == 0 { return pgtype.Int4{Valid: false} } return pgtype.Int4{Int32: i, Valid: true} } func fromPgInt4(i pgtype.Int4) int32 { if !i.Valid { return 0 } return i.Int32 } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/module.go ================================================ package cognitive import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/app/services" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain" "github.com/moasq/go-b2b-starter/internal/modules/cognitive/infra/ai" llmdomain "github.com/moasq/go-b2b-starter/internal/platform/llm/domain" ) // Module provides cognitive module dependencies type Module struct { container *dig.Container } func NewModule(container *dig.Container) *Module { return &Module{ container: container, } } // RegisterDependencies registers all cognitive module dependencies // Note: Repository implementations are registered in internal/db/inject.go func (m *Module) RegisterDependencies() error { // Register AI adapters (infra layer) if err := m.container.Provide(func( llmClient llmdomain.LLMClient, ) domain.TextVectorizer { return ai.NewTextVectorizer(llmClient) }); err != nil { return err } if err := m.container.Provide(func( llmClient llmdomain.LLMClient, ) domain.AssistantProvider { return ai.NewAssistantProvider(llmClient) }); err != nil { return err } // Register embedding service if err := m.container.Provide(func( embeddingRepo domain.EmbeddingRepository, textVectorizer domain.TextVectorizer, ) services.EmbeddingService { return services.NewEmbeddingService(embeddingRepo, textVectorizer) }); err != nil { return err } // Register RAG service if err := m.container.Provide(func( chatRepo domain.ChatRepository, embeddingRepo domain.EmbeddingRepository, textVectorizer domain.TextVectorizer, assistantProvider domain.AssistantProvider, ) services.RAGService { return services.NewRAGService(chatRepo, embeddingRepo, textVectorizer, assistantProvider) }); err != nil { return err } // Register document listener if err := m.container.Provide(func( embeddingService services.EmbeddingService, ) services.DocumentListener { return services.NewDocumentListener(embeddingService) }); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/provider.go ================================================ package cognitive import ( "go.uber.org/dig" ) type Provider struct { container *dig.Container } func NewProvider(container *dig.Container) *Provider { return &Provider{container: container} } func (p *Provider) RegisterDependencies() error { // Register handler if err := p.container.Provide(NewHandler); err != nil { return err } // Register routes if err := p.container.Provide(NewRoutes); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/modules/cognitive/routes.go ================================================ package cognitive import ( "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" serverDomain "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) type Routes struct { handler *Handler } func NewRoutes(handler *Handler) *Routes { return &Routes{ handler: handler, } } func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { cognitiveGroup := router.Group("/example_cognitive") cognitiveGroup.Use( resolver.Get("auth"), resolver.Get("org_context"), resolver.Get("subscription"), ) { // Chat endpoint cognitiveGroup.POST("/chat", auth.RequirePermissionFunc("resource", "create"), r.handler.Chat) // Chat sessions sessionsGroup := cognitiveGroup.Group("/sessions") { sessionsGroup.GET("", auth.RequirePermissionFunc("resource", "view"), r.handler.ListSessions) sessionsGroup.GET("/:id/messages", auth.RequirePermissionFunc("resource", "view"), r.handler.GetSessionHistory) } } } // Routes returns a RouteRegistrar function compatible with the server interface func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { r.RegisterRoutes(router, resolver) } ================================================ FILE: go-b2b-starter/internal/modules/documents/app/services/document_service.go ================================================ package services import ( "context" "encoding/base64" "fmt" "io" "strings" "time" "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" "github.com/moasq/go-b2b-starter/internal/modules/documents/domain/events" "github.com/moasq/go-b2b-starter/internal/platform/eventbus" filemanager "github.com/moasq/go-b2b-starter/internal/modules/files" filedomain "github.com/moasq/go-b2b-starter/internal/modules/files/domain" "github.com/moasq/go-b2b-starter/internal/platform/logger" loggerdomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ocrdomain "github.com/moasq/go-b2b-starter/internal/platform/ocr/domain" ) type documentService struct { docRepo domain.DocumentRepository fileService filedomain.FileService ocrService ocrdomain.OCRService eventBus eventbus.EventBus logger logger.Logger } func NewDocumentService( docRepo domain.DocumentRepository, fileService filedomain.FileService, ocrService ocrdomain.OCRService, eventBus eventbus.EventBus, logger logger.Logger, ) DocumentService { return &documentService{ docRepo: docRepo, fileService: fileService, ocrService: ocrService, eventBus: eventBus, logger: logger, } } func (s *documentService) UploadDocument(ctx context.Context, orgID int32, req *UploadDocumentRequest, content io.Reader) (*domain.Document, error) { // Validate content type (only PDFs allowed) if !strings.Contains(strings.ToLower(req.ContentType), "pdf") { return nil, domain.ErrInvalidFileType } // Upload file using file manager fileReq := &filedomain.FileUploadRequest{ Filename: req.FileName, Size: req.FileSize, ContentType: req.ContentType, Context: filemanager.ContextGeneral, Metadata: req.Metadata, } fileAsset, err := s.fileService.UploadFile(ctx, fileReq, content) if err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrFileUploadFailed, err) } // Create document record doc := &domain.Document{ OrganizationID: orgID, FileAssetID: fileAsset.ID, Title: req.Title, FileName: req.FileName, ContentType: req.ContentType, FileSize: req.FileSize, Status: domain.DocumentStatusPending, Metadata: req.Metadata, } createdDoc, err := s.docRepo.Create(ctx, doc) if err != nil { return nil, fmt.Errorf("failed to create document: %w", err) } // Process document asynchronously (extract text) go func() { // Create a new context with timeout for background processing // Don't use request context as it will be cancelled when request completes processCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if _, err := s.ProcessDocument(processCtx, orgID, createdDoc.ID); err != nil { s.logger.Error("background document processing failed", loggerdomain.Fields{ "document_id": createdDoc.ID, "organization_id": orgID, "error": err.Error(), }) } }() return createdDoc, nil } func (s *documentService) GetDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) { doc, err := s.docRepo.GetByID(ctx, orgID, docID) if err != nil { return nil, fmt.Errorf("failed to get document: %w", err) } return doc, nil } func (s *documentService) ListDocuments(ctx context.Context, orgID int32, req *ListDocumentsRequest) (*ListDocumentsResponse, error) { var docs []*domain.Document var total int64 var err error if req.Status != nil { docs, err = s.docRepo.ListByStatus(ctx, orgID, *req.Status, req.Limit, req.Offset) if err != nil { return nil, fmt.Errorf("failed to list documents by status: %w", err) } total, err = s.docRepo.CountByStatus(ctx, orgID, *req.Status) } else { docs, err = s.docRepo.List(ctx, orgID, req.Limit, req.Offset) if err != nil { return nil, fmt.Errorf("failed to list documents: %w", err) } total, err = s.docRepo.Count(ctx, orgID) } if err != nil { return nil, fmt.Errorf("failed to count documents: %w", err) } return &ListDocumentsResponse{ Documents: docs, Total: total, Limit: req.Limit, Offset: req.Offset, }, nil } func (s *documentService) UpdateDocument(ctx context.Context, orgID, docID int32, req *UpdateDocumentRequest) (*domain.Document, error) { // Get existing document doc, err := s.docRepo.GetByID(ctx, orgID, docID) if err != nil { return nil, fmt.Errorf("failed to get document: %w", err) } // Update fields if req.Title != "" { doc.Title = req.Title } if req.Metadata != nil { doc.Metadata = req.Metadata } updatedDoc, err := s.docRepo.Update(ctx, doc) if err != nil { return nil, fmt.Errorf("failed to update document: %w", err) } return updatedDoc, nil } func (s *documentService) DeleteDocument(ctx context.Context, orgID, docID int32) error { // Get document to verify it exists doc, err := s.docRepo.GetByID(ctx, orgID, docID) if err != nil { return fmt.Errorf("failed to get document: %w", err) } // Delete the file asset if err := s.fileService.DeleteFile(ctx, doc.FileAssetID); err != nil { // Continue with document deletion even if file deletion fails } // Delete the document record if err := s.docRepo.Delete(ctx, orgID, docID); err != nil { return fmt.Errorf("failed to delete document: %w", err) } return nil } func (s *documentService) GetDocumentStats(ctx context.Context, orgID int32) (*domain.DocumentStats, error) { total, err := s.docRepo.Count(ctx, orgID) if err != nil { return nil, fmt.Errorf("failed to count documents: %w", err) } pending, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusPending) if err != nil { return nil, fmt.Errorf("failed to count pending documents: %w", err) } processed, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusProcessed) if err != nil { return nil, fmt.Errorf("failed to count processed documents: %w", err) } failed, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusFailed) if err != nil { return nil, fmt.Errorf("failed to count failed documents: %w", err) } return &domain.DocumentStats{ TotalCount: total, PendingCount: pending, ProcessedCount: processed, FailedCount: failed, }, nil } func (s *documentService) ProcessDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) { // Update status to processing doc, err := s.docRepo.UpdateStatus(ctx, orgID, docID, domain.DocumentStatusProcessing) if err != nil { return nil, fmt.Errorf("failed to update document status: %w", err) } // Download file content content, _, err := s.fileService.DownloadFile(ctx, doc.FileAssetID) if err != nil { s.markDocumentFailed(ctx, orgID, docID, err.Error()) return nil, fmt.Errorf("%w: %v", domain.ErrFileDownloadFailed, err) } defer content.Close() // Extract text from PDF extractedText, err := s.extractTextFromPDF(content) if err != nil { s.markDocumentFailed(ctx, orgID, docID, err.Error()) return nil, fmt.Errorf("%w: %v", domain.ErrTextExtractionFailed, err) } // Update document with extracted text doc, err = s.docRepo.UpdateExtractedText(ctx, orgID, docID, extractedText) if err != nil { s.markDocumentFailed(ctx, orgID, docID, err.Error()) return nil, fmt.Errorf("failed to update extracted text: %w", err) } // Publish event for cognitive module to pick up event := events.NewDocumentUploaded(docID, orgID, doc.FileAssetID, doc.Title, extractedText) if err := s.eventBus.Publish(ctx, event); err != nil { // Don't fail the operation just because event publishing failed } return doc, nil } // markDocumentFailed marks a document as failed and publishes failure event func (s *documentService) markDocumentFailed(ctx context.Context, orgID, docID int32, errMsg string) { s.docRepo.UpdateStatus(ctx, orgID, docID, domain.DocumentStatusFailed) // Publish failure event event := events.NewDocumentFailed(docID, orgID, errMsg) s.eventBus.Publish(ctx, event) } // extractTextFromPDF extracts text from a PDF file using OCR service func (s *documentService) extractTextFromPDF(content io.Reader) (string, error) { // Read all content into memory data, err := io.ReadAll(content) if err != nil { return "", fmt.Errorf("failed to read PDF content: %w", err) } // Encode to base64 for OCR service base64Data := base64.StdEncoding.EncodeToString(data) // Call OCR service ctx := context.Background() ocrResult, err := s.ocrService.ExtractText(ctx, base64Data, "application/pdf") if err != nil { s.logger.Error("OCR extraction failed", loggerdomain.Fields{"error": err.Error()}) return "", fmt.Errorf("OCR extraction failed: %w", err) } // Check confidence score const MinOCRConfidence = 0.7 if ocrResult.Confidence < MinOCRConfidence { s.logger.Warn("OCR confidence below threshold", loggerdomain.Fields{ "confidence": ocrResult.Confidence, "pages": ocrResult.Pages, "min_threshold": MinOCRConfidence, }) // Still proceed but log the warning } // Log success s.logger.Info("Successfully extracted PDF text via OCR", loggerdomain.Fields{ "pages": ocrResult.Pages, "chars": len(ocrResult.Text), "confidence": ocrResult.Confidence, }) // Return extracted text (already in markdown format from Mistral) return ocrResult.Text, nil } ================================================ FILE: go-b2b-starter/internal/modules/documents/app/services/interface.go ================================================ package services import ( "context" "io" "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" ) // DocumentService defines the interface for document operations type DocumentService interface { // UploadDocument uploads a new document and extracts text from it UploadDocument(ctx context.Context, orgID int32, req *UploadDocumentRequest, content io.Reader) (*domain.Document, error) // GetDocument retrieves a document by ID GetDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) // ListDocuments lists documents with pagination ListDocuments(ctx context.Context, orgID int32, req *ListDocumentsRequest) (*ListDocumentsResponse, error) // UpdateDocument updates document metadata UpdateDocument(ctx context.Context, orgID, docID int32, req *UpdateDocumentRequest) (*domain.Document, error) // DeleteDocument deletes a document DeleteDocument(ctx context.Context, orgID, docID int32) error // GetDocumentStats retrieves document statistics GetDocumentStats(ctx context.Context, orgID int32) (*domain.DocumentStats, error) // ProcessDocument processes a document (extract text, etc.) ProcessDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) } // UploadDocumentRequest represents a request to upload a document type UploadDocumentRequest struct { Title string `json:"title"` FileName string `json:"file_name"` ContentType string `json:"content_type"` FileSize int64 `json:"file_size"` Metadata map[string]interface{} `json:"metadata,omitempty"` } // ListDocumentsRequest represents a request to list documents type ListDocumentsRequest struct { Status *domain.DocumentStatus `json:"status,omitempty"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } // ListDocumentsResponse represents the response for listing documents type ListDocumentsResponse struct { Documents []*domain.Document `json:"documents"` Total int64 `json:"total"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } // UpdateDocumentRequest represents a request to update a document type UpdateDocumentRequest struct { Title string `json:"title,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } ================================================ FILE: go-b2b-starter/internal/modules/documents/cmd/init.go ================================================ package cmd import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/documents" ) func Init(container *dig.Container) error { module := documents.NewModule(container) return module.RegisterDependencies() } ================================================ FILE: go-b2b-starter/internal/modules/documents/domain/entity.go ================================================ package domain import ( "time" ) // DocumentStatus represents the processing status of a document type DocumentStatus string const ( DocumentStatusPending DocumentStatus = "pending" DocumentStatusProcessing DocumentStatus = "processing" DocumentStatusProcessed DocumentStatus = "processed" DocumentStatusFailed DocumentStatus = "failed" ) // Document represents an uploaded document (PDF) type Document struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` FileAssetID int32 `json:"file_asset_id"` Title string `json:"title"` FileName string `json:"file_name"` ContentType string `json:"content_type"` FileSize int64 `json:"file_size"` ExtractedText string `json:"extracted_text,omitempty"` Status DocumentStatus `json:"status"` Metadata map[string]interface{} `json:"metadata,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func (d *Document) GetID() int32 { return d.ID } // Validate validates the document entity func (d *Document) Validate() error { if d.OrganizationID == 0 { return ErrDocumentOrganizationRequired } if d.Title == "" { return ErrDocumentTitleRequired } if d.FileName == "" { return ErrDocumentFileNameRequired } if d.FileAssetID == 0 { return ErrDocumentFileAssetRequired } return nil } func (d *Document) IsProcessed() bool { return d.Status == DocumentStatusProcessed } func (d *Document) IsPending() bool { return d.Status == DocumentStatusPending } func (d *Document) HasText() bool { return d.ExtractedText != "" } // DocumentUploadRequest represents a request to upload a new document type DocumentUploadRequest struct { OrganizationID int32 `json:"organization_id"` Title string `json:"title"` FileName string `json:"file_name"` ContentType string `json:"content_type"` FileSize int64 `json:"file_size"` Metadata map[string]interface{} `json:"metadata,omitempty"` } // DocumentFilter represents filter options for listing documents type DocumentFilter struct { Status *DocumentStatus `json:"status,omitempty"` } // DocumentStats represents document statistics type DocumentStats struct { TotalCount int64 `json:"total_count"` PendingCount int64 `json:"pending_count"` ProcessedCount int64 `json:"processed_count"` FailedCount int64 `json:"failed_count"` } ================================================ FILE: go-b2b-starter/internal/modules/documents/domain/errors.go ================================================ package domain import "errors" // Domain errors for documents var ( // Validation errors ErrDocumentOrganizationRequired = errors.New("document organization ID is required") ErrDocumentTitleRequired = errors.New("document title is required") ErrDocumentFileNameRequired = errors.New("document file name is required") ErrDocumentFileAssetRequired = errors.New("document file asset ID is required") // Not found errors ErrDocumentNotFound = errors.New("document not found") // Processing errors ErrDocumentAlreadyProcessed = errors.New("document has already been processed") ErrDocumentProcessingFailed = errors.New("document processing failed") ErrTextExtractionFailed = errors.New("text extraction from document failed") // File errors ErrInvalidFileType = errors.New("invalid file type: only PDF files are allowed") ErrFileTooLarge = errors.New("file size exceeds maximum allowed limit") ErrFileUploadFailed = errors.New("failed to upload file") ErrFileDownloadFailed = errors.New("failed to download file") ) ================================================ FILE: go-b2b-starter/internal/modules/documents/domain/events/document_events.go ================================================ package events import ( "time" "github.com/google/uuid" "github.com/moasq/go-b2b-starter/internal/platform/eventbus" ) const ( DocumentUploadedEventType = "document.uploaded" DocumentProcessedEventType = "document.processed" DocumentFailedEventType = "document.failed" ) // DocumentUploaded is published when a document has been uploaded and text extracted type DocumentUploaded struct { eventbus.BaseEvent DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` FileAssetID int32 `json:"file_asset_id"` Title string `json:"title"` ExtractedText string `json:"extracted_text"` } func NewDocumentUploaded(documentID, organizationID, fileAssetID int32, title, extractedText string) *DocumentUploaded { return &DocumentUploaded{ BaseEvent: eventbus.BaseEvent{ ID: uuid.New().String(), Name: DocumentUploadedEventType, CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, DocumentID: documentID, OrganizationID: organizationID, FileAssetID: fileAssetID, Title: title, ExtractedText: extractedText, } } // DocumentProcessed is published when a document embedding has been created type DocumentProcessed struct { eventbus.BaseEvent DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` EmbeddingID int32 `json:"embedding_id"` } func NewDocumentProcessed(documentID, organizationID, embeddingID int32) *DocumentProcessed { return &DocumentProcessed{ BaseEvent: eventbus.BaseEvent{ ID: uuid.New().String(), Name: DocumentProcessedEventType, CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, DocumentID: documentID, OrganizationID: organizationID, EmbeddingID: embeddingID, } } // DocumentFailed is published when document processing fails type DocumentFailed struct { eventbus.BaseEvent DocumentID int32 `json:"document_id"` OrganizationID int32 `json:"organization_id"` Error string `json:"error"` } func NewDocumentFailed(documentID, organizationID int32, err string) *DocumentFailed { return &DocumentFailed{ BaseEvent: eventbus.BaseEvent{ ID: uuid.New().String(), Name: DocumentFailedEventType, CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, DocumentID: documentID, OrganizationID: organizationID, Error: err, } } ================================================ FILE: go-b2b-starter/internal/modules/documents/domain/repository.go ================================================ package domain import "context" // DocumentRepository defines the interface for document data operations type DocumentRepository interface { // Create creates a new document Create(ctx context.Context, doc *Document) (*Document, error) // GetByID retrieves a document by ID GetByID(ctx context.Context, orgID, docID int32) (*Document, error) // GetByFileAssetID retrieves a document by file asset ID GetByFileAssetID(ctx context.Context, orgID, fileAssetID int32) (*Document, error) // List retrieves documents with pagination List(ctx context.Context, orgID int32, limit, offset int32) ([]*Document, error) // ListByStatus retrieves documents by status with pagination ListByStatus(ctx context.Context, orgID int32, status DocumentStatus, limit, offset int32) ([]*Document, error) // UpdateStatus updates the document status UpdateStatus(ctx context.Context, orgID, docID int32, status DocumentStatus) (*Document, error) // UpdateExtractedText updates the extracted text and sets status to processed UpdateExtractedText(ctx context.Context, orgID, docID int32, text string) (*Document, error) // Update updates document metadata Update(ctx context.Context, doc *Document) (*Document, error) // Delete removes a document Delete(ctx context.Context, orgID, docID int32) error // Count returns the total count of documents for an organization Count(ctx context.Context, orgID int32) (int64, error) // CountByStatus returns the count of documents with a specific status CountByStatus(ctx context.Context, orgID int32, status DocumentStatus) (int64, error) } ================================================ FILE: go-b2b-starter/internal/modules/documents/handler.go ================================================ package documents import ( "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/modules/documents/app/services" _ "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" // for swagger "github.com/moasq/go-b2b-starter/pkg/httperr" ) type Handler struct { service services.DocumentService } func NewHandler(service services.DocumentService) *Handler { return &Handler{service: service} } // UploadDocument uploads a new PDF document // @Summary Upload PDF document // @Description Uploads a PDF document, extracts text, and creates embeddings // @Tags Documents // @Accept multipart/form-data // @Produce json // @Param file formData file true "PDF file to upload" // @Param title formData string true "Document title" // @Success 201 {object} domain.Document // @Failure 400 {object} httperr.HTTPError // @Failure 500 {object} httperr.HTTPError // @Router /example_documents/upload [post] func (h *Handler) UploadDocument(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "missing_context", "Organization context is required", )) return } // Get uploaded file file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "invalid_file", "Failed to read file: "+err.Error(), )) return } defer file.Close() // Get title from form title := c.PostForm("title") if title == "" { title = header.Filename } // Create upload request req := &services.UploadDocumentRequest{ Title: title, FileName: header.Filename, ContentType: header.Header.Get("Content-Type"), FileSize: header.Size, } // Upload document document, err := h.service.UploadDocument(c.Request.Context(), reqCtx.OrganizationID, req, file) if err != nil { c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "upload_failed", "Failed to upload document: "+err.Error(), )) return } c.JSON(http.StatusCreated, document) } // ListDocuments lists documents with pagination // @Summary List documents // @Description Lists documents with optional filtering and pagination // @Tags Documents // @Produce json // @Param limit query int false "Limit" default(10) // @Param offset query int false "Offset" default(0) // @Param status query string false "Filter by status (pending, processing, processed, failed)" // @Success 200 {object} services.ListDocumentsResponse // @Failure 500 {object} httperr.HTTPError // @Router /example_documents [get] func (h *Handler) ListDocuments(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "missing_context", "Organization context is required", )) return } // Parse query parameters limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) req := &services.ListDocumentsRequest{ Limit: int32(limit), Offset: int32(offset), } // Optional status filter // Note: Status filtering would need to be added if needed response, err := h.service.ListDocuments(c.Request.Context(), reqCtx.OrganizationID, req) if err != nil { c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "list_failed", "Failed to list documents: "+err.Error(), )) return } c.JSON(http.StatusOK, response) } // @Summary Delete document // @Description Deletes a document and its associated file // @Tags Documents // @Param id path int true "Document ID" // @Success 204 // @Failure 400 {object} httperr.HTTPError // @Failure 500 {object} httperr.HTTPError // @Router /example_documents/{id} [delete] func (h *Handler) DeleteDocument(c *gin.Context) { idParam := c.Param("id") var docID int32 if _, err := fmt.Sscanf(idParam, "%d", &docID); err != nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "invalid_id", "Document ID must be a valid number", )) return } reqCtx := auth.GetRequestContext(c) if reqCtx == nil { c.JSON(http.StatusBadRequest, httperr.NewHTTPError( http.StatusBadRequest, "missing_context", "Organization context is required", )) return } if err := h.service.DeleteDocument(c.Request.Context(), reqCtx.OrganizationID, docID); err != nil { c.JSON(http.StatusInternalServerError, httperr.NewHTTPError( http.StatusInternalServerError, "delete_failed", "Failed to delete document: "+err.Error(), )) return } c.Status(http.StatusNoContent) } ================================================ FILE: go-b2b-starter/internal/modules/documents/infra/repositories/document_repository.go ================================================ package repositories import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/db/helpers" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" ) // documentRepository implements domain.DocumentRepository using SQLC internally. // SQLC types are never exposed outside this package. type documentRepository struct { store sqlc.Store } // NewDocumentRepository creates a new DocumentRepository implementation. func NewDocumentRepository(store sqlc.Store) domain.DocumentRepository { return &documentRepository{store: store} } func (r *documentRepository) Create(ctx context.Context, doc *domain.Document) (*domain.Document, error) { params := sqlc.CreateDocumentParams{ OrganizationID: doc.OrganizationID, FileAssetID: doc.FileAssetID, Title: doc.Title, FileName: doc.FileName, ContentType: doc.ContentType, FileSize: doc.FileSize, ExtractedText: helpers.ToPgText(doc.ExtractedText), Status: string(doc.Status), Metadata: helpers.ToJSONB(doc.Metadata), } result, err := r.store.CreateDocument(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create document: %w", err) } return r.mapToDomain(&result), nil } func (r *documentRepository) GetByID(ctx context.Context, orgID, docID int32) (*domain.Document, error) { params := sqlc.GetDocumentByIDParams{ ID: docID, OrganizationID: orgID, } result, err := r.store.GetDocumentByID(ctx, params) if err != nil { return nil, fmt.Errorf("failed to get document: %w", err) } return r.mapToDomain(&result), nil } func (r *documentRepository) GetByFileAssetID(ctx context.Context, orgID, fileAssetID int32) (*domain.Document, error) { params := sqlc.GetDocumentByFileAssetIDParams{ FileAssetID: fileAssetID, OrganizationID: orgID, } result, err := r.store.GetDocumentByFileAssetID(ctx, params) if err != nil { return nil, fmt.Errorf("failed to get document by file asset: %w", err) } return r.mapToDomain(&result), nil } func (r *documentRepository) List(ctx context.Context, orgID int32, limit, offset int32) ([]*domain.Document, error) { params := sqlc.ListDocumentsByOrganizationParams{ OrganizationID: orgID, Limit: limit, Offset: offset, } results, err := r.store.ListDocumentsByOrganization(ctx, params) if err != nil { return nil, fmt.Errorf("failed to list documents: %w", err) } docs := make([]*domain.Document, len(results)) for i, result := range results { docs[i] = r.mapToDomain(&result) } return docs, nil } func (r *documentRepository) ListByStatus(ctx context.Context, orgID int32, status domain.DocumentStatus, limit, offset int32) ([]*domain.Document, error) { params := sqlc.ListDocumentsByStatusParams{ OrganizationID: orgID, Status: string(status), Limit: limit, Offset: offset, } results, err := r.store.ListDocumentsByStatus(ctx, params) if err != nil { return nil, fmt.Errorf("failed to list documents by status: %w", err) } docs := make([]*domain.Document, len(results)) for i, result := range results { docs[i] = r.mapToDomain(&result) } return docs, nil } func (r *documentRepository) UpdateStatus(ctx context.Context, orgID, docID int32, status domain.DocumentStatus) (*domain.Document, error) { params := sqlc.UpdateDocumentStatusParams{ ID: docID, OrganizationID: orgID, Status: string(status), } result, err := r.store.UpdateDocumentStatus(ctx, params) if err != nil { return nil, fmt.Errorf("failed to update document status: %w", err) } return r.mapToDomain(&result), nil } func (r *documentRepository) UpdateExtractedText(ctx context.Context, orgID, docID int32, text string) (*domain.Document, error) { params := sqlc.UpdateDocumentExtractedTextParams{ ID: docID, OrganizationID: orgID, ExtractedText: helpers.ToPgText(text), } result, err := r.store.UpdateDocumentExtractedText(ctx, params) if err != nil { return nil, fmt.Errorf("failed to update extracted text: %w", err) } return r.mapToDomain(&result), nil } func (r *documentRepository) Update(ctx context.Context, doc *domain.Document) (*domain.Document, error) { params := sqlc.UpdateDocumentParams{ ID: doc.ID, OrganizationID: doc.OrganizationID, Title: doc.Title, Metadata: helpers.ToJSONB(doc.Metadata), } result, err := r.store.UpdateDocument(ctx, params) if err != nil { return nil, fmt.Errorf("failed to update document: %w", err) } return r.mapToDomain(&result), nil } func (r *documentRepository) Delete(ctx context.Context, orgID, docID int32) error { params := sqlc.DeleteDocumentParams{ ID: docID, OrganizationID: orgID, } if err := r.store.DeleteDocument(ctx, params); err != nil { return fmt.Errorf("failed to delete document: %w", err) } return nil } func (r *documentRepository) Count(ctx context.Context, orgID int32) (int64, error) { count, err := r.store.CountDocumentsByOrganization(ctx, orgID) if err != nil { return 0, fmt.Errorf("failed to count documents: %w", err) } return count, nil } func (r *documentRepository) CountByStatus(ctx context.Context, orgID int32, status domain.DocumentStatus) (int64, error) { params := sqlc.CountDocumentsByStatusParams{ OrganizationID: orgID, Status: string(status), } count, err := r.store.CountDocumentsByStatus(ctx, params) if err != nil { return 0, fmt.Errorf("failed to count documents by status: %w", err) } return count, nil } // mapToDomain converts SQLC document type to domain type. // This is the translation boundary - SQLC types never escape this function. func (r *documentRepository) mapToDomain(doc *sqlc.DocumentsDocument) *domain.Document { return &domain.Document{ ID: doc.ID, OrganizationID: doc.OrganizationID, FileAssetID: doc.FileAssetID, Title: doc.Title, FileName: doc.FileName, ContentType: doc.ContentType, FileSize: doc.FileSize, ExtractedText: helpers.FromPgText(doc.ExtractedText), Status: domain.DocumentStatus(doc.Status), Metadata: helpers.FromJSONB(doc.Metadata), CreatedAt: doc.CreatedAt.Time, UpdatedAt: doc.UpdatedAt.Time, } } ================================================ FILE: go-b2b-starter/internal/modules/documents/module.go ================================================ package documents import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/documents/app/services" "github.com/moasq/go-b2b-starter/internal/modules/documents/domain" "github.com/moasq/go-b2b-starter/internal/platform/eventbus" filedomain "github.com/moasq/go-b2b-starter/internal/modules/files/domain" "github.com/moasq/go-b2b-starter/internal/platform/logger" ocrdomain "github.com/moasq/go-b2b-starter/internal/platform/ocr/domain" ) // Module provides documents module dependencies type Module struct { container *dig.Container } func NewModule(container *dig.Container) *Module { return &Module{ container: container, } } // RegisterDependencies registers all documents module dependencies // Note: Repository implementations are registered in internal/db/inject.go func (m *Module) RegisterDependencies() error { // Register document service if err := m.container.Provide(func( docRepo domain.DocumentRepository, fileService filedomain.FileService, ocrService ocrdomain.OCRService, eventBus eventbus.EventBus, logger logger.Logger, ) services.DocumentService { return services.NewDocumentService(docRepo, fileService, ocrService, eventBus, logger) }); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/modules/documents/provider.go ================================================ package documents import ( "go.uber.org/dig" ) type Provider struct { container *dig.Container } func NewProvider(container *dig.Container) *Provider { return &Provider{container: container} } func (p *Provider) RegisterDependencies() error { // Register handler if err := p.container.Provide(NewHandler); err != nil { return err } // Register routes if err := p.container.Provide(NewRoutes); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/modules/documents/routes.go ================================================ package documents import ( "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" serverDomain "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) type Routes struct { handler *Handler } func NewRoutes(handler *Handler) *Routes { return &Routes{ handler: handler, } } func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { docsGroup := router.Group("/example_documents") docsGroup.Use( resolver.Get("auth"), resolver.Get("org_context"), resolver.Get("subscription"), ) { // Upload document docsGroup.POST("/upload", auth.RequirePermissionFunc("resource", "create"), r.handler.UploadDocument) // List documents docsGroup.GET("", auth.RequirePermissionFunc("resource", "view"), r.handler.ListDocuments) // Delete document docsGroup.DELETE("/:id", auth.RequirePermissionFunc("resource", "delete"), r.handler.DeleteDocument) } } // Routes returns a RouteRegistrar function compatible with the server interface func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { r.RegisterRoutes(router, resolver) } ================================================ FILE: go-b2b-starter/internal/modules/files/README.md ================================================ # File Manager Guide Simple guide for uploading and managing files with Cloudflare R2 (S3-compatible storage). ## Setup ### R2 Configuration Add to your `.env`: ```bash R2_ACCOUNT_ID=your-cloudflare-account-id R2_ACCESS_KEY_ID=your-r2-access-key R2_SECRET_ACCESS_KEY=your-r2-secret-key R2_BUCKET=your-bucket-name R2_REGION=auto # Default ``` ### Get R2 Credentials 1. Go to Cloudflare Dashboard → R2 2. Create a bucket or use existing one 3. Go to "Manage R2 API Tokens" 4. Create API token with read/write permissions 5. Copy Account ID, Access Key ID, and Secret Access Key ## Usage in Your Module ### 1. Inject File Service ```go import "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" type InvoiceService struct { fileService domain.FileService } func NewInvoiceService(fileService domain.FileService) *InvoiceService { return &InvoiceService{fileService: fileService} } ``` ### 2. Upload a File ```go func (s *InvoiceService) UploadInvoice(ctx context.Context, file io.Reader, filename string, size int64) (*domain.FileAsset, error) { // Create upload request req := &domain.FileUploadRequest{ Filename: filename, Size: size, ContentType: "application/pdf", Context: file_manager.ContextInvoice, // Business context Metadata: map[string]any{ "uploaded_by": userID, "invoice_number": "INV-2024-001", }, } // Upload to R2 fileAsset, err := s.fileService.UploadFile(ctx, req, file) if err != nil { return nil, fmt.Errorf("upload failed: %w", err) } s.logger.Info("File uploaded", map[string]any{ "file_id": fileAsset.ID, "size": fileAsset.Size, "path": fileAsset.StoragePath, }) return fileAsset, nil } ``` ### 3. Download a File ```go func (s *InvoiceService) DownloadInvoice(ctx context.Context, fileID int32) (io.ReadCloser, error) { content, fileAsset, err := s.fileService.DownloadFile(ctx, fileID) if err != nil { return nil, fmt.Errorf("download failed: %w", err) } defer content.Close() // Use the file content data, err := io.ReadAll(content) if err != nil { return nil, err } return content, nil } ``` ### 4. Get Presigned URL Get a temporary signed URL for direct browser access: ```go func (s *InvoiceService) GetInvoiceURL(ctx context.Context, fileID int32) (string, error) { // Generate URL valid for 24 hours url, err := s.fileService.GetFileURL(ctx, fileID, 24) if err != nil { return "", err } return url, nil } ``` ### 5. Delete a File ```go func (s *InvoiceService) DeleteInvoice(ctx context.Context, fileID int32) error { return s.fileService.DeleteFile(ctx, fileID) } ``` ### 6. List Files with Filter ```go func (s *InvoiceService) ListInvoices(ctx context.Context) ([]*domain.FileAsset, error) { // Filter by context invoiceContext := file_manager.ContextInvoice filter := &domain.FileSearchFilter{ Context: &invoiceContext, } files, err := s.fileService.ListFiles(ctx, filter, 50, 0) if err != nil { return nil, err } return files, nil } ``` ## File Contexts Organize files by business purpose: ```go file_manager.ContextInvoice // Invoices file_manager.ContextReceipt // Receipts file_manager.ContextContract // Contracts file_manager.ContextReport // Reports file_manager.ContextProfile // User profiles file_manager.ContextPaymentInstruction // Payment instructions file_manager.ContextPaymentBatch // Payment batches file_manager.ContextGeneral // General files ``` ## File Categories & Limits **Documents (PDFs):** - Allowed: `.pdf` - Max size: 2 MB - Category: `file_manager.CategoryDocument` **Images:** - Allowed: `.jpg`, `.jpeg`, `.png` - Max size: 1 MB - Category: `file_manager.CategoryImage` ## Security Features The file manager automatically: - ✅ Sanitizes filenames (prevents path traversal) - ✅ Validates file types (magic byte detection) - ✅ Enforces size limits - ✅ Validates content matches extension - ✅ Stores metadata in PostgreSQL - ✅ Organizes files in R2 by context and date ## Real-World Example: Complete Upload Flow ```go func (s *InvoiceService) ProcessInvoiceUpload(ctx context.Context, r *http.Request) (*Invoice, error) { // 1. Parse multipart form file, header, err := r.FormFile("invoice") if err != nil { return nil, err } defer file.Close() // 2. Upload to R2 via file manager req := &domain.FileUploadRequest{ Filename: header.Filename, Size: header.Size, ContentType: header.Header.Get("Content-Type"), Context: file_manager.ContextInvoice, Metadata: map[string]any{ "uploaded_by": ctx.Value("user_id"), "organization_id": ctx.Value("organization_id"), }, } fileAsset, err := s.fileService.UploadFile(ctx, req, file) if err != nil { return nil, err } // 3. Create invoice record with file reference invoice := &Invoice{ Number: "INV-2024-001", FileID: fileAsset.ID, FileName: fileAsset.Filename, OrganizationID: organizationID, } err = s.repo.CreateInvoice(ctx, invoice) if err != nil { // Rollback: delete the uploaded file s.fileService.DeleteFile(ctx, fileAsset.ID) return nil, err } return invoice, nil } ``` ## Storage Structure Files are organized in R2 with this pattern: ``` {category}/{context}/{date}/{filename} ``` Example: ``` document/invoice/2024/12/09/invoice-12345.pdf image/receipt/2024/12/09/receipt-photo.jpg ``` ## Configuration Reference | Variable | Required | Description | |----------|----------|-------------| | `R2_ACCOUNT_ID` | Yes | Your Cloudflare account ID | | `R2_ACCESS_KEY_ID` | Yes | R2 API access key ID | | `R2_SECRET_ACCESS_KEY` | Yes | R2 API secret key | | `R2_BUCKET` | Yes | R2 bucket name | | `R2_REGION` | No | Region (default: `auto`) | ## Best Practices **1. Always use contexts:** ```go // ✅ Good - organized by purpose Context: file_manager.ContextInvoice // ❌ Bad - generic context Context: file_manager.ContextGeneral ``` **2. Store file references:** ```go type Invoice struct { FileID int32 `json:"file_id"` FileName string `json:"file_name"` } ``` **3. Handle deletions:** ```go // Delete invoice and its file s.invoiceRepo.Delete(ctx, invoiceID) s.fileService.DeleteFile(ctx, invoice.FileID) ``` **4. Use presigned URLs for downloads:** ```go // Generate temporary URL instead of downloading in backend url, _ := s.fileService.GetFileURL(ctx, fileID, 1) // 1 hour // Return URL to frontend ``` ## Why R2? - **S3-compatible**: Use standard AWS SDK - **No egress fees**: Free bandwidth - **Global CDN**: Fast downloads worldwide - **Cost-effective**: Lower storage costs than S3 That's it! Just inject `FileService` and manage files with R2. ================================================ FILE: go-b2b-starter/internal/modules/files/cmd/init.go ================================================ package cmd import ( "log" "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/files/config" ) func Init(container *dig.Container) { if err := container.Provide(config.LoadConfig); err != nil { log.Fatalf("Failed to provide file_manager config: %v", err) } SetupDependencies(container) } ================================================ FILE: go-b2b-starter/internal/modules/files/cmd/provider.go ================================================ package cmd import ( "fmt" "strings" "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/files/config" "github.com/moasq/go-b2b-starter/internal/modules/files/domain" "github.com/moasq/go-b2b-starter/internal/modules/files/internal/infra" "github.com/moasq/go-b2b-starter/internal/platform/logger" ) func SetupDependencies(container *dig.Container) error { // Provider for R2 repository with development mode support if err := container.Provide(func(cfg *config.Config, log logger.Logger) (domain.R2Repository, error) { // Check for placeholder credentials (development mode) if isPlaceholderR2Credentials(cfg) { log.Warn("R2 credentials are placeholders - using mock file storage (development mode)", map[string]any{ "account_id": cfg.R2.AccountID, "message": "File upload/download will not work. Update R2_* variables in app.env with real credentials", }) // Return mock repository for development mode return infra.NewMockR2Repository(log), nil } return infra.NewR2Repository(cfg) }); err != nil { fmt.Printf("Error providing R2 repository: %v", err) return err } // Note: FileMetadataRepository is registered in internal/db/inject.go // Provider for composite file repository if err := container.Provide(infra.NewCompositeRepository); err != nil { fmt.Printf("Error providing composite file repository: %v", err) return err } // Provider for file service if err := container.Provide(domain.NewFileService); err != nil { fmt.Printf("Error providing file service: %v", err) return err } return nil } // isPlaceholderR2Credentials checks if the R2 credentials are placeholder values. func isPlaceholderR2Credentials(cfg *config.Config) bool { return strings.Contains(cfg.R2.AccountID, "REPLACE") || strings.Contains(cfg.R2.AccessKeyID, "REPLACE") || strings.Contains(cfg.R2.SecretAccessKey, "REPLACE") || cfg.R2.AccountID == "" || cfg.R2.AccessKeyID == "" } ================================================ FILE: go-b2b-starter/internal/modules/files/config/config.go ================================================ package config import ( "github.com/spf13/viper" ) type Config struct { R2 R2Config } type R2Config struct { AccountID string AccessKeyID string SecretAccessKey string BucketName string Region string } func LoadConfig() (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("json") viper.AddConfigPath(".") viper.AddConfigPath("./config") // Set environment variable overrides viper.SetEnvPrefix("APCASH") viper.AutomaticEnv() // Set default values for R2 viper.SetDefault("r2.region", "auto") viper.SetDefault("r2.bucketName", "invoices") if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return nil, err } } // Bind environment variables to viper keys for R2 viper.BindEnv("r2.accountID", "R2_ACCOUNT_ID") viper.BindEnv("r2.accessKeyID", "R2_ACCESS_KEY_ID") viper.BindEnv("r2.secretAccessKey", "R2_SECRET_ACCESS_KEY") viper.BindEnv("r2.bucketName", "R2_BUCKET") viper.BindEnv("r2.region", "R2_REGION") config := &Config{ R2: R2Config{ AccountID: viper.GetString("r2.accountID"), AccessKeyID: viper.GetString("r2.accessKeyID"), SecretAccessKey: viper.GetString("r2.secretAccessKey"), BucketName: viper.GetString("r2.bucketName"), Region: viper.GetString("r2.region"), }, } return config, nil } ================================================ FILE: go-b2b-starter/internal/modules/files/constants.go ================================================ package files import ( "strings" ) // File Type Categories type FileCategory string const ( CategoryDocument FileCategory = "document" CategoryImage FileCategory = "image" CategoryArchive FileCategory = "archive" ) // Supported file types // SECURITY: Restricted to invoice-safe formats only (PDF and common image formats) // Removed: Office documents (.doc, .docx, .xls, .xlsx), text files (.txt, .csv), // archives (.zip, .rar, etc.), and risky image formats (.svg, .gif) var ( DocumentTypes = []string{".pdf"} ImageTypes = []string{".jpg", ".jpeg", ".png"} ArchiveTypes = []string{} // Archives disabled for security ) // Business file contexts type FileContext string const ( ContextInvoice FileContext = "invoice" ContextReceipt FileContext = "receipt" ContextContract FileContext = "contract" ContextReport FileContext = "report" ContextProfile FileContext = "profile" ContextGeneral FileContext = "general" ContextPaymentInstruction FileContext = "payment_instruction" ContextPaymentBatch FileContext = "payment_batch" ) // File size limits (in bytes) // SECURITY: Strict limits for invoice processing to minimize attack surface const ( MaxDocumentSize = 2 * 1024 * 1024 // 2MB - sufficient for most invoice PDFs MaxImageSize = 1 * 1024 * 1024 // 1MB - sufficient for scanned invoices MaxArchiveSize = 0 // Archives disabled ) // GetFileCategory determines the category based on file extension func GetFileCategory(filename string) FileCategory { ext := strings.ToLower(getFileExtension(filename)) for _, docType := range DocumentTypes { if ext == docType { return CategoryDocument } } for _, imgType := range ImageTypes { if ext == imgType { return CategoryImage } } for _, archType := range ArchiveTypes { if ext == archType { return CategoryArchive } } return CategoryDocument // Default to document } // GetMaxFileSize returns the maximum file size for a given category func GetMaxFileSize(category FileCategory) int64 { switch category { case CategoryImage: return MaxImageSize case CategoryArchive: return MaxArchiveSize default: return MaxDocumentSize } } // IsAllowedFileType checks if the file type is allowed func IsAllowedFileType(filename string) bool { ext := strings.ToLower(getFileExtension(filename)) allTypes := append(DocumentTypes, ImageTypes...) allTypes = append(allTypes, ArchiveTypes...) for _, allowedType := range allTypes { if ext == allowedType { return true } } return false } func getFileExtension(filename string) string { if idx := strings.LastIndex(filename, "."); idx != -1 { return filename[idx:] } return "" } ================================================ FILE: go-b2b-starter/internal/modules/files/domain/entity.go ================================================ package domain import ( "time" "github.com/moasq/go-b2b-starter/internal/modules/files" "github.com/google/uuid" ) type FileAsset struct { ID int32 `json:"id"` // Database ID UUID uuid.UUID `json:"uuid"` // UUID for external reference Filename string `json:"filename"` OriginalFilename string `json:"original_filename"` Size int64 `json:"size"` ContentType string `json:"content_type"` Category files.FileCategory `json:"category"` Context files.FileContext `json:"context"` StoragePath string `json:"storage_path"` // R2 object path BucketName string `json:"bucket_name"` IsPublic bool `json:"is_public"` EntityType string `json:"entity_type,omitempty"` EntityID int32 `json:"entity_id,omitempty"` Purpose string `json:"purpose,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` URL string `json:"url,omitempty"` // Presigned URL CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type FileUploadRequest struct { Filename string `json:"filename"` Size int64 `json:"size"` ContentType string `json:"content_type"` Context files.FileContext `json:"context"` Metadata map[string]any `json:"metadata,omitempty"` } type FileSearchFilter struct { Category *files.FileCategory `json:"category,omitempty"` Context *files.FileContext `json:"context,omitempty"` MinSize *int64 `json:"min_size,omitempty"` MaxSize *int64 `json:"max_size,omitempty"` DateFrom *time.Time `json:"date_from,omitempty"` DateTo *time.Time `json:"date_to,omitempty"` } ================================================ FILE: go-b2b-starter/internal/modules/files/domain/helpers.go ================================================ package domain import ( "context" "encoding/base64" "fmt" "io" ) // ConvertFileToBase64 reads a file from storage and converts it to a base64 data URI // Returns a data URI in the format: data:{mimeType};base64,{encodedContent} func ConvertFileToBase64(ctx context.Context, repo FileRepository, fileID int32) (string, error) { // Download the file content and metadata content, fileAsset, err := repo.Download(ctx, fileID) if err != nil { return "", fmt.Errorf("failed to download file %d: %w", fileID, err) } defer content.Close() // Read all content into memory data, err := io.ReadAll(content) if err != nil { return "", fmt.Errorf("failed to read file content: %w", err) } // Convert to base64 and format as data URI return formatAsDataURI(data, fileAsset.ContentType), nil } // ConvertReaderToBase64 converts an io.Reader to a base64 data URI // Useful for converting files that haven't been stored yet func ConvertReaderToBase64(content io.Reader, contentType string) (string, error) { // Read all content into memory data, err := io.ReadAll(content) if err != nil { return "", fmt.Errorf("failed to read content: %w", err) } // Convert to base64 and format as data URI return formatAsDataURI(data, contentType), nil } // formatAsDataURI creates a properly formatted data URI from binary data func formatAsDataURI(data []byte, contentType string) string { encoded := base64.StdEncoding.EncodeToString(data) return fmt.Sprintf("data:%s;base64,%s", contentType, encoded) } ================================================ FILE: go-b2b-starter/internal/modules/files/domain/repository.go ================================================ package domain import ( "context" "io" "github.com/moasq/go-b2b-starter/internal/modules/files" ) type FileRepository interface { // Combined operations (R2 + Database) Upload(ctx context.Context, file *FileAsset, content io.Reader) error Download(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error) GetByID(ctx context.Context, id int32) (*FileAsset, error) Delete(ctx context.Context, id int32) error List(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) GetURL(ctx context.Context, id int32, expiryHours int) (string, error) Exists(ctx context.Context, id int32) (bool, error) // Additional operations GetByCategory(ctx context.Context, category files.FileCategory, limit, offset int) ([]*FileAsset, error) GetByContext(ctx context.Context, context files.FileContext, limit, offset int) ([]*FileAsset, error) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*FileAsset, error) } // R2Repository handles only object storage operations (Cloudflare R2) type R2Repository interface { UploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error DownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error) DeleteObject(ctx context.Context, objectKey string) error GetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error) ObjectExists(ctx context.Context, objectKey string) (bool, error) } // FileMetadataRepository handles only database operations type FileMetadataRepository interface { Create(ctx context.Context, file *FileAsset) (*FileAsset, error) GetByID(ctx context.Context, id int32) (*FileAsset, error) Update(ctx context.Context, file *FileAsset) error Delete(ctx context.Context, id int32) error List(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) GetByStoragePath(ctx context.Context, storagePath string) (*FileAsset, error) GetByCategory(ctx context.Context, category string, limit, offset int) ([]*FileAsset, error) GetByContext(ctx context.Context, context string, limit, offset int) ([]*FileAsset, error) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*FileAsset, error) } ================================================ FILE: go-b2b-starter/internal/modules/files/domain/service.go ================================================ package domain import ( "bytes" "context" "fmt" "io" "time" "github.com/moasq/go-b2b-starter/internal/modules/files" ) type FileService interface { UploadFile(ctx context.Context, req *FileUploadRequest, content io.Reader) (*FileAsset, error) DownloadFile(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error) GetFile(ctx context.Context, id int32) (*FileAsset, error) DeleteFile(ctx context.Context, id int32) error ListFiles(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) GetFileURL(ctx context.Context, id int32, expiryHours int) (string, error) } type fileService struct { repo FileRepository } func NewFileService(repo FileRepository) FileService { return &fileService{ repo: repo, } } func (s *fileService) UploadFile(ctx context.Context, req *FileUploadRequest, content io.Reader) (*FileAsset, error) { // SECURITY: Sanitize filename to prevent path traversal and dangerous characters sanitizedFilename := SanitizeFilename(req.Filename) // SECURITY: Validate file extension is allowed if !files.IsAllowedFileType(sanitizedFilename) { return nil, fmt.Errorf("file type not allowed: %s", sanitizedFilename) } // Get file category category := files.GetFileCategory(sanitizedFilename) // SECURITY: Check file size limits maxSize := files.GetMaxFileSize(category) if req.Size > maxSize { return nil, fmt.Errorf("file size %d exceeds limit %d for category %s", req.Size, maxSize, category) } // SOLUTION: Read entire file into buffer (makes it seekable for R2 retries) // AWS SDK v2 requires io.ReadSeeker for retryable uploads // bytes.Reader implements io.ReadSeeker, allowing the SDK to rewind the stream fileData, err := io.ReadAll(content) if err != nil { return nil, fmt.Errorf("failed to read file content: %w", err) } // Verify actual size matches declared size if int64(len(fileData)) != req.Size { return nil, fmt.Errorf("file size mismatch: declared %d bytes, actual %d bytes", req.Size, len(fileData)) } // SECURITY: Validate file content matches declared extension using magic bytes validationReader := bytes.NewReader(fileData) if err := ValidateFileContent(validationReader, sanitizedFilename); err != nil { return nil, fmt.Errorf("file validation failed: %w", err) } // Create file asset fileAsset := &FileAsset{ Filename: sanitizedFilename, OriginalFilename: req.Filename, // Keep original for reference Size: req.Size, ContentType: req.ContentType, Category: category, Context: req.Context, Metadata: req.Metadata, CreatedAt: time.Now(), UpdatedAt: time.Now(), } // SOLUTION: Create seekable reader for R2 upload (supports AWS SDK retries) seekableContent := bytes.NewReader(fileData) // Upload to storage if err := s.repo.Upload(ctx, fileAsset, seekableContent); err != nil { return nil, fmt.Errorf("failed to upload file: %w", err) } return fileAsset, nil } func (s *fileService) DownloadFile(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error) { content, fileAsset, err := s.repo.Download(ctx, id) if err != nil { return nil, nil, fmt.Errorf("failed to download file: %w", err) } return content, fileAsset, nil } func (s *fileService) GetFile(ctx context.Context, id int32) (*FileAsset, error) { return s.repo.GetByID(ctx, id) } func (s *fileService) DeleteFile(ctx context.Context, id int32) error { exists, err := s.repo.Exists(ctx, id) if err != nil { return fmt.Errorf("failed to check file existence: %w", err) } if !exists { return fmt.Errorf("file not found") } return s.repo.Delete(ctx, id) } func (s *fileService) ListFiles(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) { return s.repo.List(ctx, filter, limit, offset) } func (s *fileService) GetFileURL(ctx context.Context, id int32, expiryHours int) (string, error) { fmt.Printf("[FILE-SERVICE] ==============================================\n") fmt.Printf("[FILE-SERVICE] GetFileURL requested for file_id=%d, expiry=%dh\n", id, expiryHours) fmt.Printf("[FILE-SERVICE] Checking file existence...\n") exists, err := s.repo.Exists(ctx, id) if err != nil { fmt.Printf("[FILE-SERVICE] Exists check failed: %v\n", err) fmt.Printf("[FILE-SERVICE] Error type: %T\n", err) fmt.Printf("[FILE-SERVICE] ===========================================\n") return "", fmt.Errorf("failed to check file existence: %w", err) } fmt.Printf("[FILE-SERVICE] File exists check result: %v\n", exists) if !exists { fmt.Printf("[FILE-SERVICE] File not found - returning error\n") fmt.Printf("[FILE-SERVICE] This could mean:\n") fmt.Printf(" - File ID %d doesn't exist in database\n", id) fmt.Printf(" - File exists in database but not in R2 storage\n") fmt.Printf(" - Storage path mismatch between database and R2\n") fmt.Printf("[FILE-SERVICE] ===========================================\n") return "", fmt.Errorf("file not found") } fmt.Printf("[FILE-SERVICE] File exists, generating \n presigned URL...\n") url, err := s.repo.GetURL(ctx, id, expiryHours) if err != nil { fmt.Printf("[FILE-SERVICE] URL generation failed: %v\n", err) fmt.Printf("[FILE-SERVICE] Error type: %T\n", err) fmt.Printf("[FILE-SERVICE] ===========================================\n") return "", err } fmt.Printf("[FILE-SERVICE] URL generation successful:\n") fmt.Printf(" - URL length: %d characters\n", len(url)) fmt.Printf(" - URL: %s\n", url) fmt.Printf("[FILE-SERVICE] ===========================================\n") return url, nil } // generateFilePath creates a logical path for organizing files func generateFilePath(category files.FileCategory, context files.FileContext, filename string) string { timestamp := time.Now().Format("2006/01/02") return fmt.Sprintf("%s/%s/%s/%s", category, context, timestamp, filename) } ================================================ FILE: go-b2b-starter/internal/modules/files/domain/validator.go ================================================ package domain import ( "fmt" "io" "path/filepath" "regexp" "strings" "github.com/gabriel-vasile/mimetype" ) // ValidateFileContent verifies that the actual file content matches the declared file type // based on magic bytes inspection. This prevents file extension spoofing attacks. func ValidateFileContent(reader io.Reader, filename string) error { // Detect actual MIME type from file content (magic bytes) mtype, err := mimetype.DetectReader(reader) if err != nil { return fmt.Errorf("failed to detect file MIME type: %w", err) } // Get file extension ext := strings.ToLower(filepath.Ext(filename)) // Get allowed MIME types for this extension allowedMIMEs, ok := getAllowedMIMETypes(ext) if !ok { return fmt.Errorf("unsupported file extension: %s", ext) } // Check if detected MIME type matches expected types detectedMIME := mtype.String() for _, allowed := range allowedMIMEs { if detectedMIME == allowed { return nil } } return fmt.Errorf("file content type (%s) does not match extension (%s)", detectedMIME, ext) } // getAllowedMIMETypes returns the list of allowed MIME types for a given file extension func getAllowedMIMETypes(ext string) ([]string, bool) { // Invoice-specific allowed MIME types mimeMap := map[string][]string{ ".pdf": { "application/pdf", }, ".png": { "image/png", }, ".jpg": { "image/jpeg", }, ".jpeg": { "image/jpeg", }, } mimes, ok := mimeMap[ext] return mimes, ok } // SanitizeFilename removes dangerous characters and path traversal attempts from filenames // to prevent security vulnerabilities. func SanitizeFilename(filename string) string { // Step 1: Remove any path components (prevents path traversal) filename = filepath.Base(filename) // Step 2: Get extension before sanitization ext := filepath.Ext(filename) nameWithoutExt := strings.TrimSuffix(filename, ext) // Step 3: Remove dangerous characters, keep only safe ones // Allow: alphanumeric, dash, underscore, space safePattern := regexp.MustCompile(`[^a-zA-Z0-9\-_ ]`) nameWithoutExt = safePattern.ReplaceAllString(nameWithoutExt, "_") // Step 4: Remove multiple consecutive underscores or spaces multipleUnderscores := regexp.MustCompile(`_+`) nameWithoutExt = multipleUnderscores.ReplaceAllString(nameWithoutExt, "_") multipleSpaces := regexp.MustCompile(`\s+`) nameWithoutExt = multipleSpaces.ReplaceAllString(nameWithoutExt, " ") // Step 5: Trim leading/trailing whitespace and underscores nameWithoutExt = strings.Trim(nameWithoutExt, " _") // Step 6: If filename is empty after sanitization, use default if nameWithoutExt == "" { nameWithoutExt = "file" } // Step 7: Reconstruct filename with extension sanitized := nameWithoutExt + ext // Step 8: Limit total filename length to 255 characters (filesystem limit) maxLength := 255 if len(sanitized) > maxLength { // Keep the extension, truncate the name extLen := len(ext) maxNameLen := maxLength - extLen if maxNameLen > 0 { sanitized = sanitized[:maxNameLen] + ext } else { // If extension itself is too long (unlikely), just truncate sanitized = sanitized[:maxLength] } } return sanitized } // IsInvoiceFileType checks if the file extension is allowed for invoice uploads func IsInvoiceFileType(filename string) bool { ext := strings.ToLower(filepath.Ext(filename)) allowedExtensions := []string{".pdf", ".png", ".jpg", ".jpeg"} for _, allowed := range allowedExtensions { if ext == allowed { return true } } return false } ================================================ FILE: go-b2b-starter/internal/modules/files/infra/file_metadata_repository.go ================================================ package infra import ( "context" "encoding/json" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" file_manager "github.com/moasq/go-b2b-starter/internal/modules/files" "github.com/moasq/go-b2b-starter/internal/modules/files/domain" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" ) // fileMetadataRepository implements domain.FileMetadataRepository using SQLC internally. // SQLC types are never exposed outside this package. type fileMetadataRepository struct { store sqlc.Store } // NewFileMetadataRepository creates a new FileMetadataRepository implementation. func NewFileMetadataRepository(store sqlc.Store) domain.FileMetadataRepository { return &fileMetadataRepository{store: store} } func (r *fileMetadataRepository) Create(ctx context.Context, file *domain.FileAsset) (*domain.FileAsset, error) { // Convert metadata map to JSON bytes metadataBytes, err := json.Marshal(file.Metadata) if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } // Get category and context IDs categoryID, err := r.getCategoryID(ctx, file.Category) if err != nil { return nil, fmt.Errorf("failed to get category ID: %w", err) } contextID, err := r.getContextID(ctx, file.Context) if err != nil { return nil, fmt.Errorf("failed to get context ID: %w", err) } params := sqlc.CreateFileAssetParams{ FileName: file.Filename, OriginalFileName: file.OriginalFilename, StoragePath: file.StoragePath, BucketName: file.BucketName, FileSize: file.Size, MimeType: file.ContentType, FileCategoryID: categoryID, FileContextID: contextID, IsPublic: pgtype.Bool{Bool: file.IsPublic, Valid: true}, EntityType: pgtype.Text{String: file.EntityType, Valid: file.EntityType != ""}, EntityID: pgtype.Int4{Int32: file.EntityID, Valid: file.EntityID != 0}, Purpose: pgtype.Text{String: file.Purpose, Valid: file.Purpose != ""}, Metadata: metadataBytes, } dbFile, err := r.store.CreateFileAsset(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create file asset: %w", err) } return r.convertFromDBModel(&dbFile), nil } func (r *fileMetadataRepository) GetByID(ctx context.Context, id int32) (*domain.FileAsset, error) { dbFile, err := r.store.GetFileAssetByID(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get file asset: %w", err) } return r.convertFromDBModel(&dbFile), nil } func (r *fileMetadataRepository) Update(ctx context.Context, file *domain.FileAsset) error { metadataBytes, err := json.Marshal(file.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } params := sqlc.UpdateFileAssetParams{ ID: file.ID, FileName: file.Filename, StoragePath: file.StoragePath, Purpose: pgtype.Text{String: file.Purpose, Valid: file.Purpose != ""}, Metadata: metadataBytes, } return r.store.UpdateFileAsset(ctx, params) } func (r *fileMetadataRepository) Delete(ctx context.Context, id int32) error { return r.store.DeleteFileAsset(ctx, id) } func (r *fileMetadataRepository) List(ctx context.Context, filter *domain.FileSearchFilter, limit, offset int) ([]*domain.FileAsset, error) { params := sqlc.ListFileAssetsParams{ Limit: int32(limit), Offset: int32(offset), } rows, err := r.store.ListFileAssets(ctx, params) if err != nil { return nil, fmt.Errorf("failed to list file assets: %w", err) } files := make([]*domain.FileAsset, len(rows)) for i, row := range rows { files[i] = r.convertFromListRow(&row) } return files, nil } func (r *fileMetadataRepository) GetByStoragePath(ctx context.Context, storagePath string) (*domain.FileAsset, error) { dbFile, err := r.store.GetFileAssetByStoragePath(ctx, storagePath) if err != nil { return nil, fmt.Errorf("failed to get file asset by storage path: %w", err) } return r.convertFromDBModel(&dbFile), nil } func (r *fileMetadataRepository) GetByCategory(ctx context.Context, category string, limit, offset int) ([]*domain.FileAsset, error) { rows, err := r.store.GetFileAssetsByCategory(ctx, category) if err != nil { return nil, fmt.Errorf("failed to get file assets by category: %w", err) } files := make([]*domain.FileAsset, len(rows)) for i, row := range rows { files[i] = r.convertFromCategoryRow(&row) } return files, nil } func (r *fileMetadataRepository) GetByContext(ctx context.Context, fileContext string, limit, offset int) ([]*domain.FileAsset, error) { rows, err := r.store.GetFileAssetsByContext(ctx, fileContext) if err != nil { return nil, fmt.Errorf("failed to get file assets by context: %w", err) } files := make([]*domain.FileAsset, len(rows)) for i, row := range rows { files[i] = r.convertFromContextRow(&row) } return files, nil } func (r *fileMetadataRepository) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*domain.FileAsset, error) { params := sqlc.GetFileAssetsByEntityParams{ EntityType: pgtype.Text{String: entityType, Valid: true}, EntityID: pgtype.Int4{Int32: entityID, Valid: true}, } dbFiles, err := r.store.GetFileAssetsByEntity(ctx, params) if err != nil { return nil, fmt.Errorf("failed to get file assets by entity: %w", err) } files := make([]*domain.FileAsset, len(dbFiles)) for i, dbFile := range dbFiles { files[i] = r.convertFromDBModel(&dbFile) } return files, nil } // Helper methods for conversion and lookup func (r *fileMetadataRepository) getCategoryID(ctx context.Context, category file_manager.FileCategory) (int16, error) { categories, err := r.store.GetFileCategories(ctx) if err != nil { return 0, err } for _, cat := range categories { if cat.Name == string(category) { return cat.ID, nil } } return 0, fmt.Errorf("category not found: %s", category) } func (r *fileMetadataRepository) getContextID(ctx context.Context, fileContext file_manager.FileContext) (int16, error) { contexts, err := r.store.GetFileContexts(ctx) if err != nil { return 0, err } for _, ctx := range contexts { if ctx.Name == string(fileContext) { return ctx.ID, nil } } return 0, fmt.Errorf("context not found: %s", fileContext) } // Conversion functions - translate SQLC types to domain types func (r *fileMetadataRepository) convertFromDBModel(dbFile *sqlc.FileManagerFileAsset) *domain.FileAsset { var metadata map[string]interface{} if len(dbFile.Metadata) > 0 { json.Unmarshal(dbFile.Metadata, &metadata) } var entityType string if dbFile.EntityType.Valid { entityType = dbFile.EntityType.String } var entityID int32 if dbFile.EntityID.Valid { entityID = dbFile.EntityID.Int32 } var purpose string if dbFile.Purpose.Valid { purpose = dbFile.Purpose.String } var isPublic bool if dbFile.IsPublic.Valid { isPublic = dbFile.IsPublic.Bool } return &domain.FileAsset{ ID: dbFile.ID, UUID: uuid.New(), Filename: dbFile.FileName, OriginalFilename: dbFile.OriginalFileName, Size: dbFile.FileSize, ContentType: dbFile.MimeType, StoragePath: dbFile.StoragePath, BucketName: dbFile.BucketName, IsPublic: isPublic, EntityType: entityType, EntityID: entityID, Purpose: purpose, Metadata: metadata, CreatedAt: dbFile.CreatedAt.Time, UpdatedAt: dbFile.UpdatedAt.Time, } } func (r *fileMetadataRepository) convertFromListRow(row *sqlc.ListFileAssetsRow) *domain.FileAsset { var metadata map[string]interface{} if len(row.Metadata) > 0 { json.Unmarshal(row.Metadata, &metadata) } var entityType string if row.EntityType.Valid { entityType = row.EntityType.String } var entityID int32 if row.EntityID.Valid { entityID = row.EntityID.Int32 } var purpose string if row.Purpose.Valid { purpose = row.Purpose.String } var isPublic bool if row.IsPublic.Valid { isPublic = row.IsPublic.Bool } return &domain.FileAsset{ ID: row.ID, UUID: uuid.New(), Filename: row.FileName, OriginalFilename: row.OriginalFileName, Size: row.FileSize, ContentType: row.MimeType, Category: file_manager.FileCategory(row.CategoryName), Context: file_manager.FileContext(row.ContextName), StoragePath: row.StoragePath, BucketName: row.BucketName, IsPublic: isPublic, EntityType: entityType, EntityID: entityID, Purpose: purpose, Metadata: metadata, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } func (r *fileMetadataRepository) convertFromCategoryRow(row *sqlc.GetFileAssetsByCategoryRow) *domain.FileAsset { var metadata map[string]interface{} if len(row.Metadata) > 0 { json.Unmarshal(row.Metadata, &metadata) } var entityType string if row.EntityType.Valid { entityType = row.EntityType.String } var entityID int32 if row.EntityID.Valid { entityID = row.EntityID.Int32 } var purpose string if row.Purpose.Valid { purpose = row.Purpose.String } var isPublic bool if row.IsPublic.Valid { isPublic = row.IsPublic.Bool } return &domain.FileAsset{ ID: row.ID, UUID: uuid.New(), Filename: row.FileName, OriginalFilename: row.OriginalFileName, Size: row.FileSize, ContentType: row.MimeType, Category: file_manager.FileCategory(row.CategoryName), StoragePath: row.StoragePath, BucketName: row.BucketName, IsPublic: isPublic, EntityType: entityType, EntityID: entityID, Purpose: purpose, Metadata: metadata, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } func (r *fileMetadataRepository) convertFromContextRow(row *sqlc.GetFileAssetsByContextRow) *domain.FileAsset { var metadata map[string]interface{} if len(row.Metadata) > 0 { json.Unmarshal(row.Metadata, &metadata) } var entityType string if row.EntityType.Valid { entityType = row.EntityType.String } var entityID int32 if row.EntityID.Valid { entityID = row.EntityID.Int32 } var purpose string if row.Purpose.Valid { purpose = row.Purpose.String } var isPublic bool if row.IsPublic.Valid { isPublic = row.IsPublic.Bool } return &domain.FileAsset{ ID: row.ID, UUID: uuid.New(), Filename: row.FileName, OriginalFilename: row.OriginalFileName, Size: row.FileSize, ContentType: row.MimeType, Context: file_manager.FileContext(row.ContextName), StoragePath: row.StoragePath, BucketName: row.BucketName, IsPublic: isPublic, EntityType: entityType, EntityID: entityID, Purpose: purpose, Metadata: metadata, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } ================================================ FILE: go-b2b-starter/internal/modules/files/internal/infra/composite_repository.go ================================================ package infra import ( "context" "fmt" "io" "time" "github.com/moasq/go-b2b-starter/internal/modules/files/config" "github.com/moasq/go-b2b-starter/internal/modules/files/domain" file_manager "github.com/moasq/go-b2b-starter/internal/modules/files" ) type compositeRepository struct { r2Repo domain.R2Repository metadataRepo domain.FileMetadataRepository bucketName string } func NewCompositeRepository(cfg *config.Config, r2Repo domain.R2Repository, metadataRepo domain.FileMetadataRepository) domain.FileRepository { return &compositeRepository{ r2Repo: r2Repo, metadataRepo: metadataRepo, bucketName: cfg.R2.BucketName, } } func (r *compositeRepository) Upload(ctx context.Context, file *domain.FileAsset, content io.Reader) error { fmt.Printf(" - Filename: %s\n", file.Filename) fmt.Printf(" - Size: %d bytes\n", file.Size) fmt.Printf(" - Content Type: %s\n", file.ContentType) fmt.Printf(" - Category: %s\n", file.Category) fmt.Printf(" - Context: %s\n", file.Context) // Set default values file.BucketName = r.bucketName file.StoragePath = r.generateStoragePath(file.Category, file.Context, file.Filename) fmt.Printf(" - Bucket Name: %s\n", file.BucketName) fmt.Printf(" - Initial Storage Path: %s\n", file.StoragePath) // First, save metadata to get database ID savedFile, err := r.metadataRepo.Create(ctx, file) if err != nil { fmt.Printf("[UPLOAD-ERROR] Failed to save file metadata: %v\n", err) return fmt.Errorf("failed to save file metadata: %w", err) } fmt.Printf(" - Assigned File ID: %d\n", savedFile.ID) fmt.Printf(" - Database Storage Path: %s\n", savedFile.StoragePath) // Use database ID as part of the R2 object key objectKey := r.generateObjectKey(savedFile.ID, savedFile.Filename) // Upload to R2 fmt.Printf(" - Bucket: %s\n", r.bucketName) fmt.Printf(" - Object Key: %s\n", objectKey) fmt.Printf(" - File Size: %d bytes\n", file.Size) fmt.Printf(" - Content Type: %s\n", file.ContentType) err = r.r2Repo.UploadObject(ctx, objectKey, content, file.Size, file.ContentType) if err != nil { fmt.Printf("[UPLOAD-ERROR] R2 upload failed: %v\n", err) fmt.Printf("[UPLOAD-ERROR] Rolling back database entry...\n") // Rollback: delete metadata if R2 upload fails r.metadataRepo.Delete(ctx, savedFile.ID) return fmt.Errorf("failed to upload file to R2: %w", err) } // Update storage path with the actual object key fmt.Printf(" - Old Path: %s\n", savedFile.StoragePath) fmt.Printf(" - New Path: %s\n", objectKey) savedFile.StoragePath = objectKey err = r.metadataRepo.Update(ctx, savedFile) if err != nil { fmt.Printf("[UPLOAD-ERROR] Database storage path update failed: %v\n", err) fmt.Printf("[UPLOAD-ERROR] Error type: %T\n", err) fmt.Printf("[UPLOAD-ERROR] Rolling back R2 and database...\n") // Rollback: delete from R2 and metadata r.r2Repo.DeleteObject(ctx, objectKey) r.metadataRepo.Delete(ctx, savedFile.ID) return fmt.Errorf("failed to update storage path: %w", err) } fmt.Printf("[UPLOAD-SUCCESS] Database storage path updated successfully\n") // Update the original file with saved data *file = *savedFile fmt.Printf("[UPLOAD-SUCCESS] File upload completed successfully:\n") fmt.Printf(" - File ID: %d\n", savedFile.ID) fmt.Printf(" - Final Storage Path: %s\n", savedFile.StoragePath) fmt.Printf(" - Bucket: %s\n", savedFile.BucketName) fmt.Printf("[UPLOAD-SUCCESS] ============================================\n") return nil } func (r *compositeRepository) Download(ctx context.Context, id int32) (io.ReadCloser, *domain.FileAsset, error) { // Get file metadata file, err := r.metadataRepo.GetByID(ctx, id) if err != nil { return nil, nil, fmt.Errorf("failed to get file metadata: %w", err) } // Download from R2 content, err := r.r2Repo.DownloadObject(ctx, file.StoragePath) if err != nil { return nil, nil, fmt.Errorf("failed to download file from R2: %w", err) } return content, file, nil } func (r *compositeRepository) GetByID(ctx context.Context, id int32) (*domain.FileAsset, error) { return r.metadataRepo.GetByID(ctx, id) } func (r *compositeRepository) Delete(ctx context.Context, id int32) error { // Get file metadata first file, err := r.metadataRepo.GetByID(ctx, id) if err != nil { return fmt.Errorf("failed to get file metadata: %w", err) } // Delete from R2 err = r.r2Repo.DeleteObject(ctx, file.StoragePath) if err != nil { return fmt.Errorf("failed to delete file from R2: %w", err) } // Delete metadata err = r.metadataRepo.Delete(ctx, id) if err != nil { return fmt.Errorf("failed to delete file metadata: %w", err) } return nil } func (r *compositeRepository) List(ctx context.Context, filter *domain.FileSearchFilter, limit, offset int) ([]*domain.FileAsset, error) { return r.metadataRepo.List(ctx, filter, limit, offset) } func (r *compositeRepository) GetURL(ctx context.Context, id int32, expiryHours int) (string, error) { fmt.Printf("[COMPOSITE-REPO] ==============================================\n") fmt.Printf("[COMPOSITE-REPO] GetURL requested for file_id=%d, expiry=%dh\n", id, expiryHours) // Get file metadata fmt.Printf("[COMPOSITE-REPO] Fetching file metadata from database...\n") file, err := r.metadataRepo.GetByID(ctx, id) if err != nil { fmt.Printf("[COMPOSITE-REPO] Failed to get file metadata: %v\n", err) fmt.Printf("[COMPOSITE-REPO] Error type: %T\n", err) fmt.Printf("[COMPOSITE-REPO] ===========================================\n") return "", fmt.Errorf("failed to get file metadata: %w", err) } fmt.Printf("[COMPOSITE-REPO] File metadata retrieved:\n") fmt.Printf(" - Storage Path: %s\n", file.StoragePath) fmt.Printf(" - Bucket Name: %s\n", file.BucketName) fmt.Printf(" - File Name: %s\n", file.Filename) // Get presigned URL from R2 fmt.Printf("[COMPOSITE-REPO] Generating R2 presigned URL...\n") fmt.Printf("[COMPOSITE-REPO] R2 parameters:\n") fmt.Printf(" - Bucket: %s\n", r.bucketName) fmt.Printf(" - Object Key: %s\n", file.StoragePath) fmt.Printf(" - Expiry: %d hours\n", expiryHours) url, err := r.r2Repo.GetPresignedURL(ctx, file.StoragePath, expiryHours) if err != nil { fmt.Printf("[COMPOSITE-REPO] Failed to get presigned URL: %v\n", err) fmt.Printf("[COMPOSITE-REPO] Error type: %T\n", err) fmt.Printf("[COMPOSITE-REPO] This could indicate:\n") fmt.Printf(" - R2 connection issues\n") fmt.Printf(" - Invalid bucket name: %s\n", r.bucketName) fmt.Printf(" - Invalid object key: %s\n", file.StoragePath) fmt.Printf(" - R2 authentication problems\n") fmt.Printf(" - R2 service issues\n") fmt.Printf("[COMPOSITE-REPO] ===========================================\n") return "", fmt.Errorf("failed to get presigned URL: %w", err) } fmt.Printf("[COMPOSITE-REPO] Presigned URL generated successfully:\n") fmt.Printf(" - URL length: %d characters\n", len(url)) fmt.Printf(" - URL: %s\n", url) fmt.Printf("[COMPOSITE-REPO] ===========================================\n") return url, nil } func (r *compositeRepository) Exists(ctx context.Context, id int32) (bool, error) { fmt.Printf("[COMPOSITE-REPO] ==============================================\n") fmt.Printf("[COMPOSITE-REPO] Checking existence for file_id=%d\n", id) // Check if metadata exists fmt.Printf("[COMPOSITE-REPO] Step 1: Checking file metadata in database...\n") file, err := r.metadataRepo.GetByID(ctx, id) if err != nil { fmt.Printf("[COMPOSITE-REPO] Metadata lookup failed: %v\n", err) fmt.Printf("[COMPOSITE-REPO] Error type: %T\n", err) fmt.Printf("[COMPOSITE-REPO] This means file_id=%d does not exist in database\n", id) fmt.Printf("[COMPOSITE-REPO] ===========================================\n") return false, fmt.Errorf("failed to check file metadata: %w", err) // FIX THE BUG } fmt.Printf("[COMPOSITE-REPO] Metadata found successfully:\n") fmt.Printf(" - File ID: %d\n", file.ID) fmt.Printf(" - Filename: %s\n", file.Filename) fmt.Printf(" - Storage Path: %s\n", file.StoragePath) fmt.Printf(" - Bucket Name: %s\n", file.BucketName) fmt.Printf(" - Content Type: %s\n", file.ContentType) fmt.Printf(" - File Size: %d bytes\n", file.Size) // Check if object exists in R2 fmt.Printf("[COMPOSITE-REPO] Step 2: Checking object existence in R2...\n") fmt.Printf("[COMPOSITE-REPO] Looking for object: %s in bucket: %s\n", file.StoragePath, r.bucketName) exists, err := r.r2Repo.ObjectExists(ctx, file.StoragePath) if err != nil { fmt.Printf("[COMPOSITE-REPO] R2 existence check failed: %v\n", err) fmt.Printf("[COMPOSITE-REPO] Error type: %T\n", err) fmt.Printf("[COMPOSITE-REPO] This could indicate:\n") fmt.Printf(" - R2 connection problems\n") fmt.Printf(" - Incorrect bucket name: %s\n", r.bucketName) fmt.Printf(" - Incorrect object path: %s\n", file.StoragePath) fmt.Printf(" - R2 authentication issues\n") fmt.Printf("[COMPOSITE-REPO] ===========================================\n") return false, fmt.Errorf("failed to check R2 object existence: %w", err) } fmt.Printf("[COMPOSITE-REPO] R2 object existence check result: %v\n", exists) if !exists { fmt.Printf("[COMPOSITE-REPO] File metadata exists in database but object missing in R2\n") fmt.Printf("[COMPOSITE-REPO] Expected object path: %s\n", file.StoragePath) fmt.Printf("[COMPOSITE-REPO] Expected bucket: %s\n", r.bucketName) fmt.Printf("[COMPOSITE-REPO] This indicates a storage consistency issue\n") } else { fmt.Printf("[COMPOSITE-REPO] File exists in both database and R2 storage\n") } fmt.Printf("[COMPOSITE-REPO] ===========================================\n") return exists, nil } func (r *compositeRepository) GetByCategory(ctx context.Context, category file_manager.FileCategory, limit, offset int) ([]*domain.FileAsset, error) { return r.metadataRepo.GetByCategory(ctx, string(category), limit, offset) } func (r *compositeRepository) GetByContext(ctx context.Context, context file_manager.FileContext, limit, offset int) ([]*domain.FileAsset, error) { return r.metadataRepo.GetByContext(ctx, string(context), limit, offset) } func (r *compositeRepository) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*domain.FileAsset, error) { return r.metadataRepo.GetByEntity(ctx, entityType, entityID) } // Helper methods func (r *compositeRepository) generateStoragePath(category file_manager.FileCategory, context file_manager.FileContext, filename string) string { timestamp := time.Now().Format("2006/01/02") return fmt.Sprintf("%s/%s/%s/%s", category, context, timestamp, filename) } func (r *compositeRepository) generateObjectKey(id int32, filename string) string { // Use database ID as part of the object key for easy lookup return fmt.Sprintf("files/%d/%s", id, filename) } ================================================ FILE: go-b2b-starter/internal/modules/files/internal/infra/db_repository.go ================================================ package infra import ( "context" "encoding/json" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/db/adapters" file_manager "github.com/moasq/go-b2b-starter/internal/modules/files" "github.com/moasq/go-b2b-starter/internal/modules/files/domain" ) type dbRepository struct { store adapters.FileAssetStore } func NewDBRepository(store adapters.FileAssetStore) domain.FileMetadataRepository { return &dbRepository{ store: store, } } func (r *dbRepository) Create(ctx context.Context, file *domain.FileAsset) (*domain.FileAsset, error) { // Convert metadata map to JSON bytes metadataBytes, err := json.Marshal(file.Metadata) if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } // Get category and context IDs categoryID, err := r.getCategoryID(ctx, file.Category) if err != nil { return nil, fmt.Errorf("failed to get category ID: %w", err) } contextID, err := r.getContextID(ctx, file.Context) if err != nil { return nil, fmt.Errorf("failed to get context ID: %w", err) } params := sqlc.CreateFileAssetParams{ FileName: file.Filename, OriginalFileName: file.OriginalFilename, StoragePath: file.StoragePath, BucketName: file.BucketName, FileSize: file.Size, MimeType: file.ContentType, FileCategoryID: categoryID, FileContextID: contextID, IsPublic: pgtype.Bool{Bool: file.IsPublic, Valid: true}, EntityType: pgtype.Text{String: file.EntityType, Valid: file.EntityType != ""}, EntityID: pgtype.Int4{Int32: file.EntityID, Valid: file.EntityID != 0}, Purpose: pgtype.Text{String: file.Purpose, Valid: file.Purpose != ""}, Metadata: metadataBytes, } dbFile, err := r.store.CreateFileAsset(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create file asset: %w", err) } return r.convertFromDBModel(&dbFile), nil } func (r *dbRepository) GetByID(ctx context.Context, id int32) (*domain.FileAsset, error) { fmt.Printf("[DB-REPO] ==============================================\n") fmt.Printf("[DB-REPO] Querying file_asset table for id=%d\n", id) dbFile, err := r.store.GetFileAssetByID(ctx, id) if err != nil { fmt.Printf("[DB-REPO] Database query failed: %v\n", err) fmt.Printf("[DB-REPO] Error type: %T\n", err) fmt.Printf("[DB-REPO] This could mean:\n") fmt.Printf(" - File ID %d does not exist in database\n", id) fmt.Printf(" - Database connection problems\n") fmt.Printf(" - SQL query execution issues\n") fmt.Printf(" - Table or column structure problems\n") fmt.Printf("[DB-REPO] ===========================================\n") return nil, fmt.Errorf("failed to get file asset: %w", err) } fmt.Printf("[DB-REPO] File found in database:\n") fmt.Printf(" - ID: %d\n", dbFile.ID) fmt.Printf(" - Filename: %s\n", dbFile.FileName) fmt.Printf(" - Original Filename: %s\n", dbFile.OriginalFileName) fmt.Printf(" - Storage Path: %s\n", dbFile.StoragePath) fmt.Printf(" - Bucket Name: %s\n", dbFile.BucketName) fmt.Printf(" - File Size: %d bytes\n", dbFile.FileSize) fmt.Printf(" - MIME Type: %s\n", dbFile.MimeType) fmt.Printf(" - Created At: %v\n", dbFile.CreatedAt.Time) fmt.Printf(" - Updated At: %v\n", dbFile.UpdatedAt.Time) file := r.convertFromDBModel(&dbFile) fmt.Printf("[DB-REPO] File conversion successful\n") fmt.Printf("[DB-REPO] ===========================================\n") return file, nil } func (r *dbRepository) Update(ctx context.Context, file *domain.FileAsset) error { fmt.Printf("[DB-UPDATE] ==============================================\n") fmt.Printf("[DB-UPDATE] Updating file asset in database\n") fmt.Printf("[DB-UPDATE] File details:\n") fmt.Printf(" - File ID: %d\n", file.ID) fmt.Printf(" - Filename: %s\n", file.Filename) fmt.Printf(" - Storage Path: %s\n", file.StoragePath) fmt.Printf(" - Purpose: %s\n", file.Purpose) metadataBytes, err := json.Marshal(file.Metadata) if err != nil { fmt.Printf("[DB-UPDATE-ERROR] Failed to marshal metadata: %v\n", err) return fmt.Errorf("failed to marshal metadata: %w", err) } params := sqlc.UpdateFileAssetParams{ ID: file.ID, FileName: file.Filename, StoragePath: file.StoragePath, // FIX: Add missing StoragePath field Purpose: pgtype.Text{String: file.Purpose, Valid: file.Purpose != ""}, Metadata: metadataBytes, } fmt.Printf("[DB-UPDATE] Executing database update...\n") fmt.Printf(" - Updating Storage Path to: %s\n", file.StoragePath) err = r.store.UpdateFileAsset(ctx, params) if err != nil { fmt.Printf("[DB-UPDATE-ERROR] Database update failed: %v\n", err) fmt.Printf("[DB-UPDATE-ERROR] Error type: %T\n", err) return err } fmt.Printf("[DB-UPDATE-SUCCESS] Database update completed successfully\n") fmt.Printf("[DB-UPDATE-SUCCESS] ==========================================\n") return nil } func (r *dbRepository) Delete(ctx context.Context, id int32) error { return r.store.DeleteFileAsset(ctx, id) } func (r *dbRepository) List(ctx context.Context, filter *domain.FileSearchFilter, limit, offset int) ([]*domain.FileAsset, error) { params := sqlc.ListFileAssetsParams{ Limit: int32(limit), Offset: int32(offset), } rows, err := r.store.ListFileAssets(ctx, params) if err != nil { return nil, fmt.Errorf("failed to list file assets: %w", err) } files := make([]*domain.FileAsset, len(rows)) for i, row := range rows { files[i] = r.convertFromListRow(&row) } return files, nil } func (r *dbRepository) GetByStoragePath(ctx context.Context, storagePath string) (*domain.FileAsset, error) { dbFile, err := r.store.GetFileAssetByStoragePath(ctx, storagePath) if err != nil { return nil, fmt.Errorf("failed to get file asset by storage path: %w", err) } return r.convertFromDBModel(&dbFile), nil } func (r *dbRepository) GetByCategory(ctx context.Context, category string, limit, offset int) ([]*domain.FileAsset, error) { rows, err := r.store.GetFileAssetsByCategory(ctx, category) if err != nil { return nil, fmt.Errorf("failed to get file assets by category: %w", err) } files := make([]*domain.FileAsset, len(rows)) for i, row := range rows { files[i] = r.convertFromCategoryRow(&row) } return files, nil } func (r *dbRepository) GetByContext(ctx context.Context, context string, limit, offset int) ([]*domain.FileAsset, error) { rows, err := r.store.GetFileAssetsByContext(ctx, context) if err != nil { return nil, fmt.Errorf("failed to get file assets by context: %w", err) } files := make([]*domain.FileAsset, len(rows)) for i, row := range rows { files[i] = r.convertFromContextRow(&row) } return files, nil } func (r *dbRepository) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*domain.FileAsset, error) { params := sqlc.GetFileAssetsByEntityParams{ EntityType: pgtype.Text{String: entityType, Valid: true}, EntityID: pgtype.Int4{Int32: entityID, Valid: true}, } dbFiles, err := r.store.GetFileAssetsByEntity(ctx, params) if err != nil { return nil, fmt.Errorf("failed to get file assets by entity: %w", err) } files := make([]*domain.FileAsset, len(dbFiles)) for i, dbFile := range dbFiles { files[i] = r.convertFromDBModel(&dbFile) } return files, nil } // Helper methods for conversion and lookup func (r *dbRepository) getCategoryID(ctx context.Context, category file_manager.FileCategory) (int16, error) { categories, err := r.store.GetFileCategories(ctx) if err != nil { return 0, err } for _, cat := range categories { if cat.Name == string(category) { return cat.ID, nil } } return 0, fmt.Errorf("category not found: %s", category) } func (r *dbRepository) getContextID(ctx context.Context, context file_manager.FileContext) (int16, error) { contexts, err := r.store.GetFileContexts(ctx) if err != nil { return 0, err } for _, ctx := range contexts { if ctx.Name == string(context) { return ctx.ID, nil } } return 0, fmt.Errorf("context not found: %s", context) } func (r *dbRepository) convertFromDBModel(dbFile *sqlc.FileManagerFileAsset) *domain.FileAsset { var metadata map[string]interface{} if len(dbFile.Metadata) > 0 { json.Unmarshal(dbFile.Metadata, &metadata) } var entityType string if dbFile.EntityType.Valid { entityType = dbFile.EntityType.String } var entityID int32 if dbFile.EntityID.Valid { entityID = dbFile.EntityID.Int32 } var purpose string if dbFile.Purpose.Valid { purpose = dbFile.Purpose.String } var isPublic bool if dbFile.IsPublic.Valid { isPublic = dbFile.IsPublic.Bool } return &domain.FileAsset{ ID: dbFile.ID, UUID: uuid.New(), // Generate UUID for external reference Filename: dbFile.FileName, OriginalFilename: dbFile.OriginalFileName, Size: dbFile.FileSize, ContentType: dbFile.MimeType, StoragePath: dbFile.StoragePath, BucketName: dbFile.BucketName, IsPublic: isPublic, EntityType: entityType, EntityID: entityID, Purpose: purpose, Metadata: metadata, CreatedAt: dbFile.CreatedAt.Time, UpdatedAt: dbFile.UpdatedAt.Time, } } func (r *dbRepository) convertFromListRow(row *sqlc.ListFileAssetsRow) *domain.FileAsset { var metadata map[string]interface{} if len(row.Metadata) > 0 { json.Unmarshal(row.Metadata, &metadata) } var entityType string if row.EntityType.Valid { entityType = row.EntityType.String } var entityID int32 if row.EntityID.Valid { entityID = row.EntityID.Int32 } var purpose string if row.Purpose.Valid { purpose = row.Purpose.String } var isPublic bool if row.IsPublic.Valid { isPublic = row.IsPublic.Bool } return &domain.FileAsset{ ID: row.ID, UUID: uuid.New(), Filename: row.FileName, OriginalFilename: row.OriginalFileName, Size: row.FileSize, ContentType: row.MimeType, Category: file_manager.FileCategory(row.CategoryName), Context: file_manager.FileContext(row.ContextName), StoragePath: row.StoragePath, BucketName: row.BucketName, IsPublic: isPublic, EntityType: entityType, EntityID: entityID, Purpose: purpose, Metadata: metadata, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } func (r *dbRepository) convertFromCategoryRow(row *sqlc.GetFileAssetsByCategoryRow) *domain.FileAsset { var metadata map[string]interface{} if len(row.Metadata) > 0 { json.Unmarshal(row.Metadata, &metadata) } var entityType string if row.EntityType.Valid { entityType = row.EntityType.String } var entityID int32 if row.EntityID.Valid { entityID = row.EntityID.Int32 } var purpose string if row.Purpose.Valid { purpose = row.Purpose.String } var isPublic bool if row.IsPublic.Valid { isPublic = row.IsPublic.Bool } return &domain.FileAsset{ ID: row.ID, UUID: uuid.New(), Filename: row.FileName, OriginalFilename: row.OriginalFileName, Size: row.FileSize, ContentType: row.MimeType, Category: file_manager.FileCategory(row.CategoryName), StoragePath: row.StoragePath, BucketName: row.BucketName, IsPublic: isPublic, EntityType: entityType, EntityID: entityID, Purpose: purpose, Metadata: metadata, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } func (r *dbRepository) convertFromContextRow(row *sqlc.GetFileAssetsByContextRow) *domain.FileAsset { var metadata map[string]interface{} if len(row.Metadata) > 0 { json.Unmarshal(row.Metadata, &metadata) } var entityType string if row.EntityType.Valid { entityType = row.EntityType.String } var entityID int32 if row.EntityID.Valid { entityID = row.EntityID.Int32 } var purpose string if row.Purpose.Valid { purpose = row.Purpose.String } var isPublic bool if row.IsPublic.Valid { isPublic = row.IsPublic.Bool } return &domain.FileAsset{ ID: row.ID, UUID: uuid.New(), Filename: row.FileName, OriginalFilename: row.OriginalFileName, Size: row.FileSize, ContentType: row.MimeType, Context: file_manager.FileContext(row.ContextName), StoragePath: row.StoragePath, BucketName: row.BucketName, IsPublic: isPublic, EntityType: entityType, EntityID: entityID, Purpose: purpose, Metadata: metadata, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } ================================================ FILE: go-b2b-starter/internal/modules/files/internal/infra/mock_r2_repository.go ================================================ package infra import ( "context" "fmt" "io" "strings" "github.com/moasq/go-b2b-starter/internal/modules/files/domain" "github.com/moasq/go-b2b-starter/internal/platform/logger" ) type mockR2Repository struct { logger logger.Logger } // NewMockR2Repository creates a mock R2 repository for development mode // This repository simulates R2 operations without actual cloud storage func NewMockR2Repository(log logger.Logger) domain.R2Repository { return &mockR2Repository{ logger: log, } } func (m *mockR2Repository) UploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error { m.logger.Warn("Mock R2: Simulating file upload (no actual storage)", map[string]any{ "object_key": objectKey, "size": size, "content_type": contentType, }) // Drain the reader to simulate upload io.Copy(io.Discard, content) return nil } func (m *mockR2Repository) DownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error) { m.logger.Warn("Mock R2: Simulating file download (returning empty content)", map[string]any{ "object_key": objectKey, }) // Return empty reader return io.NopCloser(strings.NewReader("")), nil } func (m *mockR2Repository) DeleteObject(ctx context.Context, objectKey string) error { m.logger.Warn("Mock R2: Simulating file deletion (no actual storage)", map[string]any{ "object_key": objectKey, }) return nil } func (m *mockR2Repository) GetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error) { m.logger.Warn("Mock R2: Generating mock presigned URL", map[string]any{ "object_key": objectKey, "expiry_hours": expiryHours, }) // Return a mock URL return fmt.Sprintf("https://mock-r2-storage.example.com/%s?expires=%dh", objectKey, expiryHours), nil } func (m *mockR2Repository) ObjectExists(ctx context.Context, objectKey string) (bool, error) { m.logger.Warn("Mock R2: Checking object existence (always returns true)", map[string]any{ "object_key": objectKey, }) // Always return true for mock return true, nil } ================================================ FILE: go-b2b-starter/internal/modules/files/internal/infra/r2_repository.go ================================================ package infra import ( "context" "errors" "fmt" "io" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/smithy-go" fileconfig "github.com/moasq/go-b2b-starter/internal/modules/files/config" "github.com/moasq/go-b2b-starter/internal/modules/files/domain" ) type r2Repository struct { client *s3.Client bucketName string } func NewR2Repository(cfg *fileconfig.Config) (domain.R2Repository, error) { // Create custom AWS config for R2 r2Cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(cfg.R2.Region), // Always "auto" for R2 config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( cfg.R2.AccessKeyID, cfg.R2.SecretAccessKey, "", // No session token needed )), ) if err != nil { return nil, fmt.Errorf("failed to load R2 config: %w", err) } // Create S3 client with R2 endpoint client := s3.NewFromConfig(r2Cfg, func(o *s3.Options) { // R2 endpoint format: https://.r2.cloudflarestorage.com o.BaseEndpoint = aws.String(fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.R2.AccountID)) // R2 only supports path-style URLs (not virtual-host style) o.UsePathStyle = true }) repo := &r2Repository{ client: client, bucketName: cfg.R2.BucketName, } // Ensure bucket exists (R2 doesn't auto-create buckets) if err := repo.ensureBucket(context.Background()); err != nil { return nil, fmt.Errorf("failed to ensure R2 bucket exists: %w", err) } return repo, nil } // ensureBucket checks if bucket exists (R2 requires manual bucket creation) func (r *r2Repository) ensureBucket(ctx context.Context) error { _, err := r.client.HeadBucket(ctx, &s3.HeadBucketInput{ Bucket: aws.String(r.bucketName), }) if err != nil { return fmt.Errorf("bucket '%s' does not exist in R2. Please create it manually in Cloudflare dashboard: %w", r.bucketName, err) } return nil } func (r *r2Repository) UploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error { _, err := r.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(r.bucketName), Key: aws.String(objectKey), Body: content, ContentLength: aws.Int64(size), ContentType: aws.String(contentType), }) if err != nil { return fmt.Errorf("failed to upload object to R2: %w", err) } return nil } // DownloadObject downloads a file from R2 func (r *r2Repository) DownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error) { result, err := r.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(r.bucketName), Key: aws.String(objectKey), }) if err != nil { return nil, fmt.Errorf("failed to get object from R2: %w", err) } return result.Body, nil } func (r *r2Repository) DeleteObject(ctx context.Context, objectKey string) error { _, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(r.bucketName), Key: aws.String(objectKey), }) if err != nil { return fmt.Errorf("failed to delete object from R2: %w", err) } return nil } // GetPresignedURL generates a presigned URL for temporary access func (r *r2Repository) GetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error) { // Create presign client presignClient := s3.NewPresignClient(r.client) // Generate presigned URL request, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(r.bucketName), Key: aws.String(objectKey), }, func(opts *s3.PresignOptions) { opts.Expires = time.Duration(expiryHours) * time.Hour }) if err != nil { return "", fmt.Errorf("failed to generate R2 presigned URL: %w", err) } return request.URL, nil } // ObjectExists checks if an object exists in R2 func (r *r2Repository) ObjectExists(ctx context.Context, objectKey string) (bool, error) { _, err := r.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(r.bucketName), Key: aws.String(objectKey), }) if err != nil { // Check if error is "NotFound" using AWS SDK error handling var apiErr smithy.APIError if errors.As(err, &apiErr) { if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" { return false, nil } } return false, fmt.Errorf("failed to check R2 object existence: %w", err) } return true, nil } ================================================ FILE: go-b2b-starter/internal/modules/organizations/account_handler.go ================================================ package organizations import ( "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" "github.com/moasq/go-b2b-starter/pkg/response" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/platform/logger" ) type AccountHandler struct { orgService services.OrganizationService logger logger.Logger } func NewAccountHandler(orgService services.OrganizationService, logger logger.Logger) *AccountHandler { return &AccountHandler{ orgService: orgService, logger: logger, } } // CreateAccount creates a new account in an organization func (h *AccountHandler) CreateAccount(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } var req services.CreateAccountRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("invalid request payload", map[string]interface{}{"error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid request payload", err) return } domainReq := &req account, err := h.orgService.CreateAccount(c.Request.Context(), reqCtx.OrganizationID, domainReq) if err != nil { if err == domain.ErrOrganizationNotFound { response.Error(c, http.StatusNotFound, "organization not found", err) return } h.logger.Error("failed to create account", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to create account", err) return } response.Success(c, http.StatusCreated, account) } // GetAccount gets an account by ID func (h *AccountHandler) GetAccount(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } // Extract account_id from path parameter accountIDParam := c.Param("id") var accountID int32 if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid account ID format", err) return } account, err := h.orgService.GetAccount(c.Request.Context(), reqCtx.OrganizationID, accountID) if err != nil { if err == domain.ErrAccountNotFound { response.Error(c, http.StatusNotFound, "account not found", err) return } h.logger.Error("failed to get account", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to get account", err) return } response.Success(c, http.StatusOK, account) } // GetAccountByEmail gets an account by email func (h *AccountHandler) GetAccountByEmail(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } email := c.Query("email") if email == "" { response.Error(c, http.StatusBadRequest, "email query parameter is required", nil) return } account, err := h.orgService.GetAccountByEmail(c.Request.Context(), reqCtx.OrganizationID, email) if err != nil { if err == domain.ErrAccountNotFound { response.Error(c, http.StatusNotFound, "account not found", err) return } h.logger.Error("failed to get account by email", map[string]interface{}{"org_id": reqCtx.OrganizationID, "email": email, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to get account", err) return } response.Success(c, http.StatusOK, account) } // ListAccounts lists all accounts in an organization func (h *AccountHandler) ListAccounts(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } accounts, err := h.orgService.ListAccounts(c.Request.Context(), reqCtx.OrganizationID) if err != nil { if err == domain.ErrOrganizationNotFound { response.Error(c, http.StatusNotFound, "organization not found", err) return } h.logger.Error("failed to list accounts", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to list accounts", err) return } response.Success(c, http.StatusOK, accounts) } // UpdateAccount updates an account func (h *AccountHandler) UpdateAccount(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } // Extract account_id from path parameter accountIDParam := c.Param("id") var accountID int32 if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid account ID format", err) return } var req services.UpdateAccountRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("invalid request payload", map[string]interface{}{"error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid request payload", err) return } domainReq := &req account, err := h.orgService.UpdateAccount(c.Request.Context(), reqCtx.OrganizationID, accountID, domainReq) if err != nil { if err == domain.ErrAccountNotFound { response.Error(c, http.StatusNotFound, "account not found", err) return } h.logger.Error("failed to update account", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to update account", err) return } response.Success(c, http.StatusOK, account) } func (h *AccountHandler) DeleteAccount(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } // Extract account_id from path parameter accountIDParam := c.Param("id") var accountID int32 if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid account ID format", err) return } err := h.orgService.DeleteAccount(c.Request.Context(), reqCtx.OrganizationID, accountID) if err != nil { if err == domain.ErrAccountNotFound { response.Error(c, http.StatusNotFound, "account not found", err) return } h.logger.Error("failed to delete account", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to delete account", err) return } response.Success(c, http.StatusNoContent, nil) } // UpdateAccountLastLogin updates account last login timestamp func (h *AccountHandler) UpdateAccountLastLogin(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } // Extract account_id from path parameter accountIDParam := c.Param("id") var accountID int32 if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid account ID format", err) return } account, err := h.orgService.UpdateAccountLastLogin(c.Request.Context(), reqCtx.OrganizationID, accountID) if err != nil { if err == domain.ErrAccountNotFound { response.Error(c, http.StatusNotFound, "account not found", err) return } h.logger.Error("failed to update account last login", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to update account last login", err) return } response.Success(c, http.StatusOK, account) } func (h *AccountHandler) CheckAccountPermission(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } // Extract account_id from path parameter accountIDParam := c.Param("id") var accountID int32 if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid account ID format", err) return } permission, err := h.orgService.CheckAccountPermission(c.Request.Context(), reqCtx.OrganizationID, accountID) if err != nil { if err == domain.ErrAccountNotFound { response.Error(c, http.StatusNotFound, "account not found", err) return } h.logger.Error("failed to check account permission", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to check account permission", err) return } response.Success(c, http.StatusOK, permission) } // GetAccountStats gets account statistics func (h *AccountHandler) GetAccountStats(c *gin.Context) { // Extract account_id from path parameter accountIDParam := c.Param("id") var accountID int32 if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid account ID format", err) return } stats, err := h.orgService.GetAccountStats(c.Request.Context(), accountID) if err != nil { if err == domain.ErrAccountNotFound { response.Error(c, http.StatusNotFound, "account not found", err) return } h.logger.Error("failed to get account stats", map[string]interface{}{"account_id": accountID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to get account stats", err) return } response.Success(c, http.StatusOK, stats) } ================================================ FILE: go-b2b-starter/internal/modules/organizations/app/services/member_service.go ================================================ package services import ( "context" "fmt" "strings" ) // MemberService defines the core authentication and member management operations // This interface focuses on organization bootstrap and member operations type MemberService interface { // BootstrapOrganizationWithOwner creates a new organization with an initial owner user // This is the primary signup flow for new organizations BootstrapOrganizationWithOwner(ctx context.Context, req *BootstrapOrganizationRequest) (*BootstrapOrganizationResponse, error) // AddMemberDirect adds a new member to an existing organization without invitation // Creates the user if they don't exist, then adds them to the organization with specified roles AddMemberDirect(ctx context.Context, req *AddMemberRequest) (*AddMemberResponse, error) // ListOrganizationMembers retrieves all members of an organization // Returns a list of members with their details including roles and status ListOrganizationMembers(ctx context.Context, orgID string) (*ListMembersResponse, error) // GetCurrentUserProfile retrieves the current authenticated user's profile // Returns comprehensive profile information including member, organization, and account details GetCurrentUserProfile(ctx context.Context, orgID, memberID, email string) (*ProfileResponse, error) // DeleteOrganizationMember removes a member from the organization (admin only) // Deletes from both auth provider and internal database DeleteOrganizationMember(ctx context.Context, orgID, memberID string) error // CheckEmailExists checks if an email exists in the system // Returns true if email is found, false otherwise // Used for login flow to verify if user has an account CheckEmailExists(ctx context.Context, email string) (bool, error) } // BootstrapOrganizationRequest represents the request to create a new organization with an owner type BootstrapOrganizationRequest struct { // Organization details OrgDisplayName string `json:"org_display_name" binding:"required"` // Owner member details OwnerEmail string `json:"owner_email" binding:"required,email"` OwnerName string `json:"owner_name" binding:"required"` } // Validate performs business validation on the bootstrap request func (r *BootstrapOrganizationRequest) Validate() error { if strings.TrimSpace(r.OrgDisplayName) == "" { return fmt.Errorf("organization display name cannot be empty") } if strings.TrimSpace(r.OwnerEmail) == "" { return fmt.Errorf("owner email cannot be empty") } if strings.TrimSpace(r.OwnerName) == "" { return fmt.Errorf("owner name cannot be empty") } return nil } // BootstrapOrganizationResponse represents the response after organization bootstrap type BootstrapOrganizationResponse struct { OrganizationID string `json:"organization_id"` OrgSlug string `json:"org_slug"` DisplayName string `json:"display_name"` OwnerMemberID string `json:"owner_member_id"` OwnerEmail string `json:"owner_email"` OwnerName string `json:"owner_name"` InviteSent bool `json:"invite_sent"` MagicLinkSent bool `json:"magic_link_sent"` } // AddMemberRequest represents the request to add a member to an organization type AddMemberRequest struct { // Organization context (populated by handler from JWT middleware, not from request body) OrgID string `json:"-"` // Member user details Email string `json:"email" binding:"required,email"` Name string `json:"name" binding:"required"` // Role assignment (single role per member) RoleSlug string `json:"role_slug"` } // Validate performs business validation on the add member request func (r *AddMemberRequest) Validate() error { if strings.TrimSpace(r.Email) == "" { return fmt.Errorf("email cannot be empty") } if strings.TrimSpace(r.Name) == "" { return fmt.Errorf("name cannot be empty") } // Note: OrgID is validated by handler (extracted from JWT middleware) return nil } // AddMemberResponse represents the response after adding a member type AddMemberResponse struct { MemberID string `json:"member_id"` Email string `json:"email"` Name string `json:"name"` OrgID string `json:"org_id"` RoleSlug string `json:"role_slug"` InviteSent bool `json:"invite_sent"` } // MemberInfo represents a member in the list response type MemberInfo struct { MemberID string `json:"member_id"` Email string `json:"email"` Name string `json:"name"` Roles []string `json:"roles"` Status string `json:"status"` EmailVerified bool `json:"email_verified"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // ListMembersResponse represents the response for listing organization members type ListMembersResponse struct { Members []*MemberInfo `json:"members"` Total int `json:"total"` } // ProfileResponse represents the current user's profile information // This is a composite response combining auth provider member data + internal account + organization type ProfileResponse struct { // Auth provider member details MemberID string `json:"member_id"` Email string `json:"email"` Name string `json:"name"` Roles []string `json:"roles"` Permissions []string `json:"permissions"` EmailVerified bool `json:"email_verified"` Status string `json:"status"` // Organization details Organization ProfileOrganization `json:"organization"` // Internal account details AccountID int32 `json:"account_id"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // ProfileOrganization represents organization info in profile response type ProfileOrganization struct { OrganizationID string `json:"organization_id"` Slug string `json:"slug"` Name string `json:"name"` Status string `json:"status"` } // CheckEmailRequest represents the request to check if an email exists // Used for login flow to verify if user has an account type CheckEmailRequest struct { Email string `form:"email" binding:"required,email"` } ================================================ FILE: go-b2b-starter/internal/modules/organizations/app/services/member_service_impl.go ================================================ package services import ( "context" "errors" "fmt" "strings" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger" ) // rollbackFunc represents a function that can rollback a created resource type rollbackFunc func(context.Context) error // rollbackStack manages rollback functions in LIFO order type rollbackStack []rollbackFunc // add appends a rollback function to the stack func (rs *rollbackStack) add(fn rollbackFunc) { *rs = append(*rs, fn) } // execute runs all rollback functions in reverse order (LIFO) func (rs rollbackStack) execute(ctx context.Context, logger loggerDomain.Logger) { // Execute in reverse order (LIFO - Last In First Out) for i := len(rs) - 1; i >= 0; i-- { if err := rs[i](ctx); err != nil { logger.Error("rollback operation failed", loggerDomain.Fields{ "step": i, "error": err.Error(), }) // Continue with remaining rollbacks even if one fails } } } type memberService struct { authOrgRepo domain.AuthOrganizationRepository authMemberRepo domain.AuthMemberRepository authRoleRepo domain.AuthRoleRepository localOrgRepo domain.OrganizationRepository localAccountRepo domain.AccountRepository logger loggerDomain.Logger } func NewMemberService( authOrgRepo domain.AuthOrganizationRepository, authMemberRepo domain.AuthMemberRepository, authRoleRepo domain.AuthRoleRepository, localOrgRepo domain.OrganizationRepository, localAccountRepo domain.AccountRepository, logger loggerDomain.Logger, ) MemberService { return &memberService{ authOrgRepo: authOrgRepo, authMemberRepo: authMemberRepo, authRoleRepo: authRoleRepo, localOrgRepo: localOrgRepo, localAccountRepo: localAccountRepo, logger: logger, } } // BootstrapOrganizationWithOwner creates a new organization with an initial owner member. // If any step fails, all previously created resources are automatically rolled back. func (s *memberService) BootstrapOrganizationWithOwner( ctx context.Context, req *BootstrapOrganizationRequest, ) (*BootstrapOrganizationResponse, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid bootstrap request: %w", err) } // Always use "admin" role for bootstrap (primary admin user) ownerRoleSlug := "admin" // Initialize rollback stack for transaction-like behavior var rollbacks rollbackStack shouldRollback := true // Defer rollback execution - runs on function exit if shouldRollback is true defer func() { if shouldRollback { s.logger.Warn("bootstrap failed, executing rollback", loggerDomain.Fields{ "org_name": req.OrgDisplayName, "rollback_steps": len(rollbacks), }) rollbacks.execute(context.Background(), s.logger) } }() s.logger.Info("starting organization bootstrap", loggerDomain.Fields{ "org_name": req.OrgDisplayName, "owner_email": req.OwnerEmail, }) // Step 1: Create organization in auth provider // Infrastructure layer handles slug generation and duplicate retry logic authOrg, err := s.authOrgRepo.CreateOrganization(ctx, &domain.CreateAuthOrganizationRequest{ DisplayName: req.OrgDisplayName, EmailInvitesAllowed: true, }) if err != nil { fmt.Println(err) s.logger.Error("failed to create auth organization", loggerDomain.Fields{ "org_name": req.OrgDisplayName, "error": err.Error(), }) return nil, err } // Track auth org creation for rollback rollbacks.add(func(ctx context.Context) error { s.logger.Info("rolling back auth organization", loggerDomain.Fields{ "auth_org_id": authOrg.OrganizationID, }) return s.authOrgRepo.DeleteOrganization(ctx, authOrg.OrganizationID) }) s.logger.Info("organization created in auth provider", loggerDomain.Fields{ "auth_org_id": authOrg.OrganizationID, "org_slug": authOrg.Slug, }) // Step 2: Create local organization record. s.logger.Info("creating local organization", loggerDomain.Fields{ "slug": authOrg.Slug, "display_name": authOrg.DisplayName, }) localOrg, err := s.localOrgRepo.Create(ctx, &domain.Organization{ Slug: authOrg.Slug, Name: authOrg.DisplayName, Status: "active", }) if err != nil { s.logger.Error("failed to create local organization", loggerDomain.Fields{ "org_slug": authOrg.Slug, "display_name": authOrg.DisplayName, "status": "active", "error": err.Error(), "error_type": fmt.Sprintf("%T", err), }) return nil, fmt.Errorf("failed to create local organization: %w", err) } // Track local org creation for rollback rollbacks.add(func(ctx context.Context) error { s.logger.Info("rolling back local organization", loggerDomain.Fields{ "local_org_id": localOrg.ID, }) return s.localOrgRepo.Delete(ctx, localOrg.ID) }) s.logger.Info("local organization created successfully", loggerDomain.Fields{ "local_org_id": localOrg.ID, "slug": localOrg.Slug, }) if _, err := s.localOrgRepo.UpdateStytchInfo(ctx, localOrg.ID, authOrg.OrganizationID, "", ""); err != nil { s.logger.Error("failed to map auth organization locally", loggerDomain.Fields{ "local_org_id": localOrg.ID, "auth_org_id": authOrg.OrganizationID, "error": err.Error(), }) return nil, fmt.Errorf("failed to map auth organization: %w", err) } // Step 3: Create owner member (no invite). createMemberReq := &domain.CreateAuthMemberRequest{ OrganizationID: authOrg.OrganizationID, Email: req.OwnerEmail, Name: req.OwnerName, SendInvite: false, // No magic link invite } member, err := s.authMemberRepo.CreateMember(ctx, createMemberReq) if err != nil { return nil, fmt.Errorf("failed to create owner member: %w", err) } // Track auth member creation for rollback rollbacks.add(func(ctx context.Context) error { s.logger.Info("rolling back auth member", loggerDomain.Fields{ "member_id": member.MemberID, "auth_org_id": authOrg.OrganizationID, }) return s.authMemberRepo.RemoveMembers(ctx, &domain.RemoveAuthMembersRequest{ OrganizationID: authOrg.OrganizationID, MemberIDs: []string{member.MemberID}, }) }) // Step 4: Assign admin role in auth provider. if err := s.authMemberRepo.AssignRoles(ctx, &domain.AssignAuthRolesRequest{ OrganizationID: authOrg.OrganizationID, MemberID: member.MemberID, Roles: []string{ownerRoleSlug}, }); err != nil { return nil, fmt.Errorf("failed to assign admin role: %w", err) } role, err := s.authRoleRepo.GetRoleBySlug(ctx, ownerRoleSlug) if err != nil { return nil, fmt.Errorf("failed to fetch admin role metadata: %w", err) } // Step 5: Create local account record. localAccount, err := s.localAccountRepo.Create(ctx, &domain.Account{ OrganizationID: localOrg.ID, Email: member.Email, FullName: member.Name, Role: mapRoleSlugToAccountRole(ownerRoleSlug), Status: "active", }) if err != nil { return nil, fmt.Errorf("failed to create local account: %w", err) } // Track local account creation for rollback rollbacks.add(func(ctx context.Context) error { s.logger.Info("rolling back local account", loggerDomain.Fields{ "account_id": localAccount.ID, "local_org_id": localOrg.ID, }) return s.localAccountRepo.Delete(ctx, localOrg.ID, localAccount.ID) }) if _, err := s.localAccountRepo.UpdateStytchInfo( ctx, localOrg.ID, localAccount.ID, member.MemberID, role.RoleID, ownerRoleSlug, member.EmailVerified, ); err != nil { return nil, fmt.Errorf("failed to map auth member locally: %w", err) } // Success! Disable rollback shouldRollback = false s.logger.Info("organization bootstrap completed", loggerDomain.Fields{ "stytch_org_id": authOrg.OrganizationID, "owner_member": member.MemberID, }) return &BootstrapOrganizationResponse{ OrganizationID: authOrg.OrganizationID, OrgSlug: authOrg.Slug, DisplayName: authOrg.DisplayName, OwnerMemberID: member.MemberID, OwnerEmail: member.Email, OwnerName: member.Name, InviteSent: false, // No invite sent MagicLinkSent: false, // No magic link sent }, nil } // AddMemberDirect adds a new member to an existing organization without invitation workflows. func (s *memberService) AddMemberDirect( ctx context.Context, req *AddMemberRequest, ) (*AddMemberResponse, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid add member request: %w", err) } roleSlug := strings.ToLower(strings.TrimSpace(req.RoleSlug)) if roleSlug == "" { roleSlug = "member" } orgID := req.OrgID if orgID == "" { return nil, domain.ErrAuthOrganizationIDRequired } localOrgID, err := s.resolveLocalOrganizationID(ctx, orgID) if err != nil { return nil, err } if existingAccount, err := s.localAccountRepo.GetByEmail(ctx, localOrgID, req.Email); err == nil { s.logger.Warn("member email already exists locally", loggerDomain.Fields{ "org_id": localOrgID, "email": req.Email, "status": existingAccount.Status, }) return nil, domain.ErrAuthMemberAlreadyExists } else if !errors.Is(err, domain.ErrAccountNotFound) { return nil, fmt.Errorf("failed to check existing account: %w", err) } createReq := &domain.CreateAuthMemberRequest{ OrganizationID: orgID, Email: req.Email, Name: req.Name, SendInvite: false, // No magic link invite } member, err := s.authMemberRepo.CreateMember(ctx, createReq) if err != nil { return nil, fmt.Errorf("failed to create member: %w", err) } if err := s.authMemberRepo.AssignRoles(ctx, &domain.AssignAuthRolesRequest{ OrganizationID: orgID, MemberID: member.MemberID, Roles: []string{roleSlug}, }); err != nil { return nil, fmt.Errorf("failed to assign member role: %w", err) } role, err := s.authRoleRepo.GetRoleBySlug(ctx, roleSlug) if err != nil { return nil, fmt.Errorf("failed to fetch role metadata: %w", err) } localAccount, err := s.localAccountRepo.Create(ctx, &domain.Account{ OrganizationID: localOrgID, Email: member.Email, FullName: member.Name, Role: mapRoleSlugToAccountRole(roleSlug), Status: "active", }) if err != nil { return nil, fmt.Errorf("failed to create local account: %w", err) } if _, err := s.localAccountRepo.UpdateStytchInfo( ctx, localOrgID, localAccount.ID, member.MemberID, role.RoleID, roleSlug, member.EmailVerified, ); err != nil { return nil, fmt.Errorf("failed to map auth member locally: %w", err) } s.logger.Info("member added successfully", loggerDomain.Fields{ "org_id": orgID, "member_id": member.MemberID, "invite_sent": true, }) return &AddMemberResponse{ MemberID: member.MemberID, Email: member.Email, Name: member.Name, OrgID: orgID, RoleSlug: roleSlug, InviteSent: true, // Always true (passwordless) }, nil } // ListOrganizationMembers retrieves all members of an organization. func (s *memberService) ListOrganizationMembers( ctx context.Context, orgID string, ) (*ListMembersResponse, error) { if orgID == "" { return nil, domain.ErrAuthOrganizationIDRequired } // Retrieve members from repository (no pagination limit) members, err := s.authMemberRepo.ListMembers(ctx, orgID, 0, 0) if err != nil { s.logger.Error("failed to list organization members", loggerDomain.Fields{ "org_id": orgID, "error": err.Error(), }) return nil, fmt.Errorf("failed to list members: %w", err) } // Convert domain members to response info memberInfos := make([]*MemberInfo, 0, len(members)) for _, member := range members { memberInfos = append(memberInfos, &MemberInfo{ MemberID: member.MemberID, Email: member.Email, Name: member.Name, Roles: member.Roles, Status: member.Status, EmailVerified: member.EmailVerified, CreatedAt: member.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: member.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), }) } s.logger.Info("members listed successfully", loggerDomain.Fields{ "org_id": orgID, "count": len(memberInfos), }) return &ListMembersResponse{ Members: memberInfos, Total: len(memberInfos), }, nil } // GetCurrentUserProfile retrieves the current authenticated user's profile. func (s *memberService) GetCurrentUserProfile( ctx context.Context, orgID, memberID, email string, ) (*ProfileResponse, error) { // Validate required parameters if orgID == "" { return nil, domain.ErrAuthOrganizationIDRequired } if memberID == "" { return nil, domain.ErrAuthMemberIDRequired } if email == "" { return nil, domain.ErrAuthEmailRequired } // Get member details from auth provider member, err := s.authMemberRepo.GetMember(ctx, orgID, memberID) if err != nil { s.logger.Error("failed to get member details", loggerDomain.Fields{ "org_id": orgID, "member_id": memberID, "error": err.Error(), }) return nil, fmt.Errorf("failed to get member details: %w", err) } // Get organization details from auth provider organization, err := s.authOrgRepo.GetOrganization(ctx, orgID) if err != nil { s.logger.Error("failed to get organization details", loggerDomain.Fields{ "org_id": orgID, "error": err.Error(), }) return nil, fmt.Errorf("failed to get organization details: %w", err) } // Get local organization details (for database ID) localOrg, err := s.localOrgRepo.GetByStytchID(ctx, orgID) if err != nil { s.logger.Error("failed to get local organization", loggerDomain.Fields{ "auth_org_id": orgID, "error": err.Error(), }) return nil, fmt.Errorf("failed to get local organization: %w", err) } // Get local account details (for database ID) localAccount, err := s.localAccountRepo.GetByEmail(ctx, localOrg.ID, email) if err != nil { s.logger.Error("failed to get local account", loggerDomain.Fields{ "org_id": localOrg.ID, "email": email, "error": err.Error(), }) return nil, fmt.Errorf("failed to get local account: %w", err) } // Build profile response profile := &ProfileResponse{ MemberID: member.MemberID, Email: member.Email, Name: member.Name, Roles: member.Roles, EmailVerified: member.EmailVerified, Status: member.Status, Organization: ProfileOrganization{ OrganizationID: organization.OrganizationID, Slug: organization.Slug, Name: organization.DisplayName, Status: organization.Status, }, AccountID: localAccount.ID, CreatedAt: member.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: member.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), } s.logger.Info("profile retrieved successfully", loggerDomain.Fields{ "member_id": memberID, "org_id": orgID, "email": email, }) return profile, nil } // DeleteOrganizationMember removes a member from the organization // This deletes from both auth provider and the internal database // Admin-only operation (permission check done at handler level) func (s *memberService) DeleteOrganizationMember( ctx context.Context, orgID, memberID string, ) error { if orgID == "" || memberID == "" { return fmt.Errorf("organization ID and member ID are required") } s.logger.Info("deleting organization member", map[string]interface{}{ "org_id": orgID, "member_id": memberID, }) // Create remove members request req := &domain.RemoveAuthMembersRequest{ OrganizationID: orgID, MemberIDs: []string{memberID}, } // Remove from auth organization err := s.authMemberRepo.RemoveMembers(ctx, req) if err != nil { s.logger.Error("failed to remove member from auth organization", map[string]interface{}{ "org_id": orgID, "member_id": memberID, "error": err.Error(), }) return fmt.Errorf("failed to remove member: %w", err) } s.logger.Info("member successfully deleted from organization", map[string]interface{}{ "org_id": orgID, "member_id": memberID, }) return nil } // Returns true if email is found in any organization, false otherwise func (s *memberService) CheckEmailExists(ctx context.Context, email string) (bool, error) { // Validate email format email = strings.TrimSpace(email) if email == "" { return false, fmt.Errorf("email cannot be empty") } s.logger.Info("checking email existence", loggerDomain.Fields{ "email": email, }) // Check using organization repository exists, err := s.authOrgRepo.CheckEmailExists(ctx, email) if err != nil { s.logger.Error("failed to check email existence", loggerDomain.Fields{ "email": email, "error": err.Error(), }) return false, fmt.Errorf("failed to check email existence: %w", err) } s.logger.Info("email existence check completed", loggerDomain.Fields{ "email": email, "exists": exists, }) return exists, nil } func (s *memberService) resolveLocalOrganizationID(ctx context.Context, authOrgID string) (int32, error) { org, err := s.localOrgRepo.GetByStytchID(ctx, authOrgID) if err != nil { return 0, fmt.Errorf("failed to resolve local organization: %w", err) } return org.ID, nil } func mapRoleSlugToAccountRole(slug string) string { switch strings.ToLower(strings.TrimSpace(slug)) { case "owner": // Legacy: map owner to admin return "admin" case "admin": return "admin" case "approver": return "approver" case "reviewer": // Legacy: map reviewer to approver return "approver" case "employee", "member": return "member" default: return slug } } ================================================ FILE: go-b2b-starter/internal/modules/organizations/app/services/organization_service.go ================================================ package services import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" ) type organizationService struct { orgRepo domain.OrganizationRepository accountRepo domain.AccountRepository } func NewOrganizationService(orgRepo domain.OrganizationRepository, accountRepo domain.AccountRepository) OrganizationService { return &organizationService{ orgRepo: orgRepo, accountRepo: accountRepo, } } func (s *organizationService) CreateOrganization(ctx context.Context, req *CreateOrganizationRequest) (*domain.Organization, error) { // Create organization org := &domain.Organization{ Slug: req.Slug, Name: req.Name, Status: "active", StytchOrgID: req.StytchOrgID, StytchConnectionID: req.StytchConnectionID, StytchConnectionName: req.StytchConnectionName, } createdOrg, err := s.orgRepo.Create(ctx, org) if err != nil { return nil, fmt.Errorf("failed to create organization: %w", err) } if req.StytchOrgID != "" || req.StytchConnectionID != "" || req.StytchConnectionName != "" { createdOrg.StytchOrgID = req.StytchOrgID createdOrg.StytchConnectionID = req.StytchConnectionID createdOrg.StytchConnectionName = req.StytchConnectionName createdOrg, err = s.orgRepo.Update(ctx, createdOrg) if err != nil { return nil, fmt.Errorf("failed to persist organization Stytch metadata: %w", err) } } // Create admin account (primary admin user) adminAccount := &domain.Account{ OrganizationID: createdOrg.ID, Email: req.OwnerEmail, FullName: req.OwnerName, Role: "admin", Status: "active", } _, err = s.accountRepo.Create(ctx, adminAccount) if err != nil { return nil, fmt.Errorf("failed to create admin account: %w", err) } return createdOrg, nil } func (s *organizationService) GetOrganization(ctx context.Context, orgID int32) (*domain.Organization, error) { return s.orgRepo.GetByID(ctx, orgID) } func (s *organizationService) GetOrganizationBySlug(ctx context.Context, slug string) (*domain.Organization, error) { return s.orgRepo.GetBySlug(ctx, slug) } func (s *organizationService) GetOrganizationByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error) { return s.orgRepo.GetByStytchID(ctx, stytchOrgID) } func (s *organizationService) GetOrganizationByUserEmail(ctx context.Context, email string) (*domain.Organization, error) { return s.orgRepo.GetByUserEmail(ctx, email) } func (s *organizationService) UpdateOrganization(ctx context.Context, orgID int32, req *UpdateOrganizationRequest) (*domain.Organization, error) { // Get existing organization org, err := s.orgRepo.GetByID(ctx, orgID) if err != nil { return nil, err } // Update fields org.Name = req.Name org.Status = req.Status if req.StytchOrgID != "" { org.StytchOrgID = req.StytchOrgID } if req.StytchConnectionID != "" { org.StytchConnectionID = req.StytchConnectionID } if req.StytchConnectionName != "" { org.StytchConnectionName = req.StytchConnectionName } return s.orgRepo.Update(ctx, org) } func (s *organizationService) ListOrganizations(ctx context.Context, req *ListOrganizationsRequest) (*ListOrganizationsResponse, error) { organizations, err := s.orgRepo.List(ctx, req.Limit, req.Offset) if err != nil { return nil, err } // For simplicity, we're not implementing total count yet // In production, you'd want a separate query for total count total := int32(len(organizations)) return &ListOrganizationsResponse{ Organizations: organizations, Total: total, Limit: req.Limit, Offset: req.Offset, }, nil } func (s *organizationService) GetOrganizationStats(ctx context.Context, orgID int32) (*domain.OrganizationStats, error) { return s.orgRepo.GetStats(ctx, orgID) } func (s *organizationService) CreateAccount(ctx context.Context, orgID int32, req *CreateAccountRequest) (*domain.Account, error) { // Verify organization exists _, err := s.orgRepo.GetByID(ctx, orgID) if err != nil { return nil, err } account := &domain.Account{ OrganizationID: orgID, Email: req.Email, FullName: req.FullName, StytchMemberID: req.StytchMemberID, StytchRoleID: req.StytchRoleID, StytchRoleSlug: req.StytchRoleSlug, StytchEmailVerified: req.StytchEmailVerified, Role: req.Role, Status: "active", } return s.accountRepo.Create(ctx, account) } func (s *organizationService) GetAccount(ctx context.Context, orgID, accountID int32) (*domain.Account, error) { return s.accountRepo.GetByID(ctx, orgID, accountID) } func (s *organizationService) GetAccountByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error) { return s.accountRepo.GetByEmail(ctx, orgID, email) } func (s *organizationService) ListAccounts(ctx context.Context, orgID int32) ([]*domain.Account, error) { // Verify organization exists _, err := s.orgRepo.GetByID(ctx, orgID) if err != nil { return nil, err } return s.accountRepo.ListByOrganization(ctx, orgID) } func (s *organizationService) UpdateAccount(ctx context.Context, orgID, accountID int32, req *UpdateAccountRequest) (*domain.Account, error) { // Get existing account account, err := s.accountRepo.GetByID(ctx, orgID, accountID) if err != nil { return nil, err } // Update fields account.FullName = req.FullName account.Role = req.Role account.Status = req.Status if req.StytchRoleID != "" { account.StytchRoleID = req.StytchRoleID } if req.StytchRoleSlug != "" { account.StytchRoleSlug = req.StytchRoleSlug } if req.StytchEmailVerified != nil { account.StytchEmailVerified = *req.StytchEmailVerified } return s.accountRepo.Update(ctx, account) } func (s *organizationService) DeleteAccount(ctx context.Context, orgID, accountID int32) error { return s.accountRepo.Delete(ctx, orgID, accountID) } func (s *organizationService) UpdateAccountLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error) { return s.accountRepo.UpdateLastLogin(ctx, orgID, accountID) } func (s *organizationService) CheckAccountPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error) { return s.accountRepo.CheckPermission(ctx, orgID, accountID) } func (s *organizationService) GetAccountStats(ctx context.Context, accountID int32) (*domain.AccountStats, error) { return s.accountRepo.GetStats(ctx, accountID) } ================================================ FILE: go-b2b-starter/internal/modules/organizations/app/services/organization_service_interface.go ================================================ package services import ( "context" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" ) // OrganizationService defines the interface for organization business operations type OrganizationService interface { // Organization operations CreateOrganization(ctx context.Context, req *CreateOrganizationRequest) (*domain.Organization, error) GetOrganization(ctx context.Context, orgID int32) (*domain.Organization, error) GetOrganizationBySlug(ctx context.Context, slug string) (*domain.Organization, error) GetOrganizationByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error) GetOrganizationByUserEmail(ctx context.Context, email string) (*domain.Organization, error) UpdateOrganization(ctx context.Context, orgID int32, req *UpdateOrganizationRequest) (*domain.Organization, error) ListOrganizations(ctx context.Context, req *ListOrganizationsRequest) (*ListOrganizationsResponse, error) GetOrganizationStats(ctx context.Context, orgID int32) (*domain.OrganizationStats, error) // Account operations CreateAccount(ctx context.Context, orgID int32, req *CreateAccountRequest) (*domain.Account, error) GetAccount(ctx context.Context, orgID, accountID int32) (*domain.Account, error) GetAccountByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error) ListAccounts(ctx context.Context, orgID int32) ([]*domain.Account, error) UpdateAccount(ctx context.Context, orgID, accountID int32, req *UpdateAccountRequest) (*domain.Account, error) DeleteAccount(ctx context.Context, orgID, accountID int32) error UpdateAccountLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error) // Utility operations CheckAccountPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error) GetAccountStats(ctx context.Context, accountID int32) (*domain.AccountStats, error) } // CreateOrganizationRequest represents data needed to create an organization type CreateOrganizationRequest struct { Slug string `json:"slug" binding:"required,min=3"` Name string `json:"name" binding:"required"` OwnerEmail string `json:"owner_email" binding:"required,email"` OwnerName string `json:"owner_name" binding:"required"` StytchOrgID string `json:"stytch_org_id"` StytchConnectionID string `json:"stytch_connection_id"` StytchConnectionName string `json:"stytch_connection_name"` } // UpdateOrganizationRequest represents data needed to update an organization type UpdateOrganizationRequest struct { Name string `json:"name" binding:"required"` Status string `json:"status" binding:"required,oneof=active suspended"` StytchOrgID string `json:"stytch_org_id"` StytchConnectionID string `json:"stytch_connection_id"` StytchConnectionName string `json:"stytch_connection_name"` } // CreateAccountRequest represents data needed to create an account type CreateAccountRequest struct { Email string `json:"email" binding:"required,email"` FullName string `json:"full_name" binding:"required"` Role string `json:"role" binding:"required,oneof=admin approver member"` StytchMemberID string `json:"stytch_member_id"` StytchRoleID string `json:"stytch_role_id"` StytchRoleSlug string `json:"stytch_role_slug"` StytchEmailVerified bool `json:"stytch_email_verified"` } // UpdateAccountRequest represents data needed to update an account type UpdateAccountRequest struct { FullName string `json:"full_name" binding:"required"` Role string `json:"role" binding:"required,oneof=admin approver member"` Status string `json:"status" binding:"required,oneof=active inactive suspended"` StytchRoleID string `json:"stytch_role_id"` StytchRoleSlug string `json:"stytch_role_slug"` StytchEmailVerified *bool `json:"stytch_email_verified"` } // ListOrganizationsRequest represents parameters for listing organizations type ListOrganizationsRequest struct { Limit int32 `json:"limit" binding:"min=1,max=100"` Offset int32 `json:"offset" binding:"min=0"` } // ListOrganizationsResponse represents the response for listing organizations type ListOrganizationsResponse struct { Organizations []*domain.Organization `json:"organizations"` Total int32 `json:"total"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } ================================================ FILE: go-b2b-starter/internal/modules/organizations/cmd/init.go ================================================ package cmd import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/organizations" ) func Init(container *dig.Container) error { module := organizations.NewModule(container) return module.RegisterDependencies() } ================================================ FILE: go-b2b-starter/internal/modules/organizations/domain/auth_provider.go ================================================ package domain import ( "context" "net/mail" "time" ) // AuthMember represents an authenticated member from the auth provider. type AuthMember struct { MemberID string `json:"member_id"` OrganizationID string `json:"organization_id"` Email string `json:"email"` Name string `json:"name"` Roles []string `json:"roles"` Status string `json:"status"` EmailVerified bool `json:"email_verified"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // AuthOrganization represents an organization (tenant) from the auth provider. type AuthOrganization struct { OrganizationID string `json:"organization_id"` Slug string `json:"slug"` DisplayName string `json:"display_name"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // AuthRole represents an RBAC role from the auth provider. type AuthRole struct { RoleID string `json:"role_id"` Name string `json:"name"` Description string `json:"description"` Permissions []string `json:"permissions"` } // CreateAuthMemberRequest represents the data needed to create a member in the auth provider. type CreateAuthMemberRequest struct { OrganizationID string `json:"organization_id"` Email string `json:"email"` Name string `json:"name"` Roles []string `json:"roles"` SendInvite bool `json:"send_invite"` Password string `json:"password"` } // UpdateAuthMemberRequest represents member profile updates in the auth provider. type UpdateAuthMemberRequest struct { OrganizationID string `json:"organization_id"` MemberID string `json:"member_id"` Name *string `json:"name,omitempty"` Roles []string `json:"roles,omitempty"` TrustedMeta map[string]any `json:"trusted_metadata,omitempty"` UntrustedMeta map[string]any `json:"untrusted_metadata,omitempty"` } // CreateAuthOrganizationRequest represents the data needed to create an organization in the auth provider. type CreateAuthOrganizationRequest struct { DisplayName string `json:"display_name"` EmailInvitesAllowed bool `json:"email_invites_allowed"` } // AssignAuthRolesRequest represents assigning roles to a member in the auth provider. type AssignAuthRolesRequest struct { OrganizationID string `json:"organization_id"` MemberID string `json:"member_id"` Roles []string `json:"roles"` } // RemoveAuthMembersRequest represents removing members from an organization in the auth provider. type RemoveAuthMembersRequest struct { OrganizationID string `json:"organization_id"` MemberIDs []string `json:"member_ids"` } // SendMagicLinkRequest represents the payload required to email a login magic link. type SendMagicLinkRequest struct { OrganizationID string `json:"organization_id"` Email string `json:"email"` LoginRedirectURL string `json:"login_redirect_url"` SignupRedirectURL string `json:"signup_redirect_url"` } // Validate validates the CreateAuthMemberRequest. func (r *CreateAuthMemberRequest) Validate() error { if r.OrganizationID == "" { return ErrAuthOrganizationIDRequired } if r.Email == "" { return ErrAuthEmailRequired } if _, err := mail.ParseAddress(r.Email); err != nil { return ErrAuthInvalidEmail } if r.Name == "" { return ErrAuthNameRequired } return nil } // Validate ensures the SendMagicLinkRequest contains core identifiers. func (r *SendMagicLinkRequest) Validate() error { if r.OrganizationID == "" { return ErrAuthOrganizationIDRequired } if r.Email == "" { return ErrAuthEmailRequired } if _, err := mail.ParseAddress(r.Email); err != nil { return ErrAuthInvalidEmail } return nil } // Validate validates the UpdateAuthMemberRequest. func (r *UpdateAuthMemberRequest) Validate() error { if r.OrganizationID == "" { return ErrAuthOrganizationIDRequired } if r.MemberID == "" { return ErrAuthMemberIDRequired } return nil } // Validate validates the CreateAuthOrganizationRequest. func (r *CreateAuthOrganizationRequest) Validate() error { if r.DisplayName == "" { return ErrAuthOrganizationDisplayNameRequired } if len(r.DisplayName) < 2 { return ErrAuthOrganizationNameTooShort } return nil } // Validate validates the AssignAuthRolesRequest. func (r *AssignAuthRolesRequest) Validate() error { if r.OrganizationID == "" { return ErrAuthOrganizationIDRequired } if r.MemberID == "" { return ErrAuthMemberIDRequired } if len(r.Roles) == 0 { return ErrAuthRoleIDsRequired } return nil } // Validate validates the RemoveAuthMembersRequest. func (r *RemoveAuthMembersRequest) Validate() error { if r.OrganizationID == "" { return ErrAuthOrganizationIDRequired } if len(r.MemberIDs) == 0 { return ErrAuthMemberIDsRequired } return nil } // AuthOrganizationRepository defines auth provider organization operations. type AuthOrganizationRepository interface { CreateOrganization(ctx context.Context, req *CreateAuthOrganizationRequest) (*AuthOrganization, error) GetOrganization(ctx context.Context, organizationID string) (*AuthOrganization, error) DeleteOrganization(ctx context.Context, organizationID string) error CheckEmailExists(ctx context.Context, email string) (bool, error) } // AuthMemberRepository defines auth provider member operations. type AuthMemberRepository interface { CreateMember(ctx context.Context, req *CreateAuthMemberRequest) (*AuthMember, error) UpdateMember(ctx context.Context, req *UpdateAuthMemberRequest) (*AuthMember, error) GetMember(ctx context.Context, organizationID, memberID string) (*AuthMember, error) GetMemberByEmail(ctx context.Context, organizationID, email string) (*AuthMember, error) ListMembers(ctx context.Context, organizationID string, limit, offset int) ([]*AuthMember, error) RemoveMembers(ctx context.Context, req *RemoveAuthMembersRequest) error AssignRoles(ctx context.Context, req *AssignAuthRolesRequest) error SendMagicLink(ctx context.Context, req *SendMagicLinkRequest) error } // AuthRoleRepository defines auth provider RBAC operations. type AuthRoleRepository interface { GetRoleByID(ctx context.Context, roleID string) (*AuthRole, error) GetRoleBySlug(ctx context.Context, slug string) (*AuthRole, error) ListRoles(ctx context.Context, limit, offset int) ([]*AuthRole, error) } ================================================ FILE: go-b2b-starter/internal/modules/organizations/domain/entity.go ================================================ package domain import "time" // Organization represents an organization (tenant) in the system type Organization struct { ID int32 `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Status string `json:"status"` StytchOrgID string `json:"stytch_org_id"` StytchConnectionID string `json:"stytch_connection_id"` StytchConnectionName string `json:"stytch_connection_name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Account represents a user account within an organization type Account struct { ID int32 `json:"id"` OrganizationID int32 `json:"organization_id"` Email string `json:"email"` FullName string `json:"full_name"` StytchMemberID string `json:"stytch_member_id"` StytchRoleID string `json:"stytch_role_id"` StytchRoleSlug string `json:"stytch_role_slug"` StytchEmailVerified bool `json:"stytch_email_verified"` Role string `json:"role"` Status string `json:"status"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // OrganizationContext provides context for operations within an organization type OrganizationContext struct { OrganizationID int32 `json:"organization_id"` AccountID int32 `json:"account_id"` AccountRole string `json:"account_role"` } // Implements auth.OrganizationEntity interface. func (o *Organization) GetID() int32 { return o.ID } // Validate validates the organization entity func (o *Organization) Validate() error { if o.Name == "" { return ErrOrganizationNameRequired } if o.Slug == "" { return ErrOrganizationSlugRequired } if len(o.Slug) < 3 { return ErrOrganizationSlugTooShort } return nil } // Implements auth.AccountEntity interface. func (a *Account) GetID() int32 { return a.ID } // Validate validates the account entity func (a *Account) Validate() error { if a.Email == "" { return ErrAccountEmailRequired } if a.FullName == "" { return ErrAccountFullNameRequired } if a.OrganizationID == 0 { return ErrAccountOrganizationRequired } return nil } // IsOwner checks if the account has admin role (legacy function name, kept for compatibility) func (a *Account) IsOwner() bool { return a.Role == "admin" } // IsAdmin checks if the account has admin role func (a *Account) IsAdmin() bool { return a.Role == "admin" } // CanManageAccounts checks if the account can manage other accounts func (a *Account) CanManageAccounts() bool { return a.IsAdmin() } ================================================ FILE: go-b2b-starter/internal/modules/organizations/domain/errors.go ================================================ package domain import "errors" // Organization errors var ( ErrOrganizationNotFound = errors.New("organization not found") ErrOrganizationNameRequired = errors.New("organization name is required") ErrOrganizationSlugRequired = errors.New("organization slug is required") ErrOrganizationSlugTooShort = errors.New("organization slug must be at least 3 characters") ErrOrganizationSlugTaken = errors.New("organization slug is already taken") ErrOrganizationInactive = errors.New("organization is inactive") ) // Account errors var ( ErrAccountNotFound = errors.New("account not found") ErrAccountEmailRequired = errors.New("account email is required") ErrAccountFullNameRequired = errors.New("account full name is required") ErrAccountOrganizationRequired = errors.New("account organization is required") ErrAccountEmailTaken = errors.New("account email is already taken") ErrAccountInactive = errors.New("account is inactive") ErrAccountInsufficientRole = errors.New("account does not have sufficient permissions") ) // Permission errors var ( ErrPermissionDenied = errors.New("permission denied") ErrInvalidRole = errors.New("invalid role") ) // Auth provider member-related errors var ( ErrAuthMemberNotFound = errors.New("auth member not found") ErrAuthMemberAlreadyExists = errors.New("auth member already exists") ErrAuthEmailRequired = errors.New("email is required") ErrAuthInvalidEmail = errors.New("invalid email format") ErrAuthPasswordRequired = errors.New("password is required") ErrAuthNameRequired = errors.New("name is required") ErrAuthMemberIDRequired = errors.New("member ID is required") ErrAuthMemberIDsRequired = errors.New("member IDs are required") ) // Auth provider organization-related errors var ( ErrAuthOrganizationNotFound = errors.New("auth organization not found") ErrAuthOrganizationAlreadyExists = errors.New("auth organization already exists") ErrAuthOrganizationNameRequired = errors.New("auth organization name is required") ErrAuthOrganizationDisplayNameRequired = errors.New("auth organization display name is required") ErrAuthOrganizationNameTooShort = errors.New("auth organization name must be at least 2 characters") ErrAuthOrganizationIDRequired = errors.New("auth organization ID is required") ) // Auth provider role-related errors var ( ErrAuthRoleNotFound = errors.New("auth role not found") ErrAuthRoleIDsRequired = errors.New("auth role IDs are required") ) // Auth provider integration errors var ( ErrAuthConnection = errors.New("failed to connect to auth provider") ErrAuthOperation = errors.New("auth provider operation failed") ErrAuthUnauthorized = errors.New("unauthorized auth operation") ErrAuthRateLimit = errors.New("auth provider rate limit exceeded") ) // OrganizationError represents a domain-specific organization error type OrganizationError struct { Type string `json:"type"` Message string `json:"message"` OrganizationID *int32 `json:"organization_id,omitempty"` Cause error `json:"-"` } func (e *OrganizationError) Error() string { return e.Message } func (e *OrganizationError) Unwrap() error { return e.Cause } func NewOrganizationError(errorType, message string, orgID *int32, cause error) *OrganizationError { return &OrganizationError{ Type: errorType, Message: message, OrganizationID: orgID, Cause: cause, } } // AccountError represents a domain-specific account error type AccountError struct { Type string `json:"type"` Message string `json:"message"` AccountID *int32 `json:"account_id,omitempty"` OrganizationID *int32 `json:"organization_id,omitempty"` Cause error `json:"-"` } func (e *AccountError) Error() string { return e.Message } func (e *AccountError) Unwrap() error { return e.Cause } func NewAccountError(errorType, message string, accountID, orgID *int32, cause error) *AccountError { return &AccountError{ Type: errorType, Message: message, AccountID: accountID, OrganizationID: orgID, Cause: cause, } } ================================================ FILE: go-b2b-starter/internal/modules/organizations/domain/events/organization_events.go ================================================ package events import ( "time" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" ) const ( OrganizationCreatedEventType = "organization.created" OrganizationUpdatedEventType = "organization.updated" AccountCreatedEventType = "account.created" AccountUpdatedEventType = "account.updated" AccountDeletedEventType = "account.deleted" AccountLoginEventType = "account.login" ) type OrganizationCreatedEvent struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Timestamp time.Time `json:"timestamp"` Organization *domain.Organization `json:"organization"` OwnerAccount *domain.Account `json:"owner_account"` } type OrganizationUpdatedEvent struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Timestamp time.Time `json:"timestamp"` Organization *domain.Organization `json:"organization"` PreviousName string `json:"previous_name"` } type AccountCreatedEvent struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Timestamp time.Time `json:"timestamp"` Account *domain.Account `json:"account"` OrganizationID int32 `json:"organization_id"` } type AccountUpdatedEvent struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Timestamp time.Time `json:"timestamp"` Account *domain.Account `json:"account"` OrganizationID int32 `json:"organization_id"` PreviousRole string `json:"previous_role"` PreviousStatus string `json:"previous_status"` } type AccountDeletedEvent struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Timestamp time.Time `json:"timestamp"` AccountID int32 `json:"account_id"` OrganizationID int32 `json:"organization_id"` Email string `json:"email"` } type AccountLoginEvent struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Timestamp time.Time `json:"timestamp"` AccountID int32 `json:"account_id"` OrganizationID int32 `json:"organization_id"` Email string `json:"email"` } ================================================ FILE: go-b2b-starter/internal/modules/organizations/domain/repository.go ================================================ package domain import "context" // OrganizationRepository defines the interface for organization data operations type OrganizationRepository interface { Create(ctx context.Context, org *Organization) (*Organization, error) GetByID(ctx context.Context, id int32) (*Organization, error) GetBySlug(ctx context.Context, slug string) (*Organization, error) GetByStytchID(ctx context.Context, stytchOrgID string) (*Organization, error) GetByUserEmail(ctx context.Context, email string) (*Organization, error) Update(ctx context.Context, org *Organization) (*Organization, error) UpdateStytchInfo(ctx context.Context, id int32, stytchOrgID, stytchConnectionID, stytchConnectionName string) (*Organization, error) Delete(ctx context.Context, id int32) error List(ctx context.Context, limit, offset int32) ([]*Organization, error) GetStats(ctx context.Context, id int32) (*OrganizationStats, error) } // AccountRepository defines the interface for account data operations type AccountRepository interface { Create(ctx context.Context, account *Account) (*Account, error) GetByID(ctx context.Context, orgID, accountID int32) (*Account, error) GetByEmail(ctx context.Context, orgID int32, email string) (*Account, error) ListByOrganization(ctx context.Context, orgID int32) ([]*Account, error) Update(ctx context.Context, account *Account) (*Account, error) UpdateStytchInfo(ctx context.Context, orgID, accountID int32, stytchMemberID, stytchRoleID, stytchRoleSlug string, stytchEmailVerified bool) (*Account, error) UpdateLastLogin(ctx context.Context, orgID, accountID int32) (*Account, error) Delete(ctx context.Context, orgID, accountID int32) error GetOrganization(ctx context.Context, accountID int32) (*Organization, error) CheckPermission(ctx context.Context, orgID, accountID int32) (*AccountPermission, error) GetStats(ctx context.Context, accountID int32) (*AccountStats, error) } // OrganizationStats represents organization statistics type OrganizationStats struct { Organization *Organization `json:"organization"` AccountCount int64 `json:"account_count"` ActiveAccountCount int64 `json:"active_account_count"` } // AccountStats represents account statistics with organization info type AccountStats struct { Account *Account `json:"account"` OrganizationName string `json:"organization_name"` OrganizationSlug string `json:"organization_slug"` } // AccountPermission represents account permission check result type AccountPermission struct { AccountID int32 `json:"account_id"` Role string `json:"role"` Status string `json:"status"` OrgStatus string `json:"org_status"` } ================================================ FILE: go-b2b-starter/internal/modules/organizations/infra/repositories/account_repository.go ================================================ package repositories import ( "context" "database/sql" "errors" "fmt" "github.com/moasq/go-b2b-starter/internal/db/helpers" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" ) // accountRepository implements domain.AccountRepository using SQLC internally. // SQLC types are never exposed outside this package. type accountRepository struct { store sqlc.Store } // NewAccountRepository creates a new AccountRepository implementation. func NewAccountRepository(store sqlc.Store) domain.AccountRepository { return &accountRepository{store: store} } func (r *accountRepository) Create(ctx context.Context, account *domain.Account) (*domain.Account, error) { params := sqlc.CreateAccountParams{ OrganizationID: account.OrganizationID, Email: account.Email, FullName: account.FullName, StytchMemberID: helpers.ToPgText(account.StytchMemberID), StytchRoleID: helpers.ToPgText(account.StytchRoleID), StytchRoleSlug: helpers.ToPgText(account.StytchRoleSlug), StytchEmailVerified: account.StytchEmailVerified, Role: account.Role, Status: account.Status, } result, err := r.store.CreateAccount(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create account: %w", err) } return r.mapToDomain(&result), nil } func (r *accountRepository) GetByID(ctx context.Context, orgID, accountID int32) (*domain.Account, error) { params := sqlc.GetAccountByIDParams{ ID: accountID, OrganizationID: orgID, } result, err := r.store.GetAccountByID(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrAccountNotFound } return nil, fmt.Errorf("failed to get account by ID: %w", err) } return r.mapToDomain(&result), nil } func (r *accountRepository) GetByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error) { params := sqlc.GetAccountByEmailParams{ Email: email, OrganizationID: orgID, } result, err := r.store.GetAccountByEmail(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrAccountNotFound } return nil, fmt.Errorf("failed to get account by email: %w", err) } return r.mapToDomain(&result), nil } func (r *accountRepository) ListByOrganization(ctx context.Context, orgID int32) ([]*domain.Account, error) { results, err := r.store.ListAccountsByOrganization(ctx, orgID) if err != nil { return nil, fmt.Errorf("failed to list accounts by organization: %w", err) } accounts := make([]*domain.Account, len(results)) for i, result := range results { accounts[i] = r.mapToDomain(&result) } return accounts, nil } func (r *accountRepository) Update(ctx context.Context, account *domain.Account) (*domain.Account, error) { params := sqlc.UpdateAccountParams{ ID: account.ID, OrganizationID: account.OrganizationID, FullName: account.FullName, StytchRoleID: helpers.ToPgText(account.StytchRoleID), StytchRoleSlug: helpers.ToPgText(account.StytchRoleSlug), StytchEmailVerified: account.StytchEmailVerified, Role: account.Role, Status: account.Status, } result, err := r.store.UpdateAccount(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrAccountNotFound } return nil, fmt.Errorf("failed to update account: %w", err) } return r.mapToDomain(&result), nil } func (r *accountRepository) UpdateStytchInfo(ctx context.Context, orgID, accountID int32, stytchMemberID, stytchRoleID, stytchRoleSlug string, stytchEmailVerified bool) (*domain.Account, error) { params := sqlc.UpdateAccountStytchInfoParams{ ID: accountID, OrganizationID: orgID, StytchMemberID: helpers.ToPgText(stytchMemberID), StytchRoleID: helpers.ToPgText(stytchRoleID), StytchRoleSlug: helpers.ToPgText(stytchRoleSlug), StytchEmailVerified: stytchEmailVerified, } result, err := r.store.UpdateAccountStytchInfo(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrAccountNotFound } return nil, fmt.Errorf("failed to update account Stytch info: %w", err) } return r.mapToDomain(&result), nil } func (r *accountRepository) UpdateLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error) { params := sqlc.UpdateAccountLastLoginParams{ ID: accountID, OrganizationID: orgID, } result, err := r.store.UpdateAccountLastLogin(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrAccountNotFound } return nil, fmt.Errorf("failed to update account last login: %w", err) } return r.mapToDomain(&result), nil } func (r *accountRepository) Delete(ctx context.Context, orgID, accountID int32) error { params := sqlc.DeleteAccountParams{ ID: accountID, OrganizationID: orgID, } err := r.store.DeleteAccount(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.ErrAccountNotFound } return fmt.Errorf("failed to delete account: %w", err) } return nil } func (r *accountRepository) GetOrganization(ctx context.Context, accountID int32) (*domain.Organization, error) { result, err := r.store.GetAccountOrganization(ctx, accountID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrOrganizationNotFound } return nil, fmt.Errorf("failed to get account organization: %w", err) } return &domain.Organization{ ID: result.ID, Slug: result.Slug, Name: result.Name, Status: result.Status, CreatedAt: result.CreatedAt.Time, UpdatedAt: result.UpdatedAt.Time, }, nil } func (r *accountRepository) CheckPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error) { params := sqlc.CheckAccountPermissionParams{ ID: accountID, OrganizationID: orgID, } result, err := r.store.CheckAccountPermission(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrAccountNotFound } return nil, fmt.Errorf("failed to check account permission: %w", err) } return &domain.AccountPermission{ AccountID: result.ID, Role: result.Role, Status: result.Status, OrgStatus: result.OrgStatus, }, nil } func (r *accountRepository) GetStats(ctx context.Context, accountID int32) (*domain.AccountStats, error) { result, err := r.store.GetAccountStats(ctx, accountID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrAccountNotFound } return nil, fmt.Errorf("failed to get account stats: %w", err) } account := &domain.Account{ ID: result.ID, OrganizationID: result.OrganizationID, Email: result.Email, FullName: result.FullName, StytchMemberID: helpers.FromPgText(result.StytchMemberID), StytchRoleID: helpers.FromPgText(result.StytchRoleID), StytchRoleSlug: helpers.FromPgText(result.StytchRoleSlug), StytchEmailVerified: result.StytchEmailVerified, Role: result.Role, Status: result.Status, CreatedAt: result.CreatedAt.Time, UpdatedAt: result.UpdatedAt.Time, } if result.LastLoginAt.Valid { account.LastLoginAt = &result.LastLoginAt.Time } stats := &domain.AccountStats{ Account: account, OrganizationName: result.OrganizationName, OrganizationSlug: result.OrganizationSlug, } return stats, nil } // mapToDomain converts SQLC account type to domain type. // This is the translation boundary - SQLC types never escape this function. func (r *accountRepository) mapToDomain(sqlcAccount *sqlc.OrganizationsAccount) *domain.Account { account := &domain.Account{ ID: sqlcAccount.ID, OrganizationID: sqlcAccount.OrganizationID, Email: sqlcAccount.Email, FullName: sqlcAccount.FullName, StytchMemberID: helpers.FromPgText(sqlcAccount.StytchMemberID), StytchRoleID: helpers.FromPgText(sqlcAccount.StytchRoleID), StytchRoleSlug: helpers.FromPgText(sqlcAccount.StytchRoleSlug), StytchEmailVerified: sqlcAccount.StytchEmailVerified, Role: sqlcAccount.Role, Status: sqlcAccount.Status, CreatedAt: sqlcAccount.CreatedAt.Time, UpdatedAt: sqlcAccount.UpdatedAt.Time, } // Handle nullable LastLoginAt if sqlcAccount.LastLoginAt.Valid { account.LastLoginAt = &sqlcAccount.LastLoginAt.Time } return account } ================================================ FILE: go-b2b-starter/internal/modules/organizations/infra/repositories/organization_repository.go ================================================ package repositories import ( "context" "database/sql" "errors" "fmt" "github.com/moasq/go-b2b-starter/internal/db/helpers" sqlc "github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" ) // organizationRepository implements domain.OrganizationRepository using SQLC internally. // SQLC types are never exposed outside this package. type organizationRepository struct { store sqlc.Store } // NewOrganizationRepository creates a new OrganizationRepository implementation. func NewOrganizationRepository(store sqlc.Store) domain.OrganizationRepository { return &organizationRepository{store: store} } func (r *organizationRepository) Create(ctx context.Context, org *domain.Organization) (*domain.Organization, error) { params := sqlc.CreateOrganizationParams{ Slug: org.Slug, Name: org.Name, Status: org.Status, } result, err := r.store.CreateOrganization(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create organization: %w", err) } return r.mapToDomain(&result), nil } func (r *organizationRepository) GetByID(ctx context.Context, id int32) (*domain.Organization, error) { result, err := r.store.GetOrganizationByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrOrganizationNotFound } return nil, fmt.Errorf("failed to get organization by ID: %w", err) } return r.mapToDomain(&result), nil } func (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*domain.Organization, error) { result, err := r.store.GetOrganizationBySlug(ctx, slug) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrOrganizationNotFound } return nil, fmt.Errorf("failed to get organization by slug: %w", err) } return r.mapToDomain(&result), nil } func (r *organizationRepository) GetByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error) { result, err := r.store.GetOrganizationByStytchID(ctx, helpers.ToPgText(stytchOrgID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrOrganizationNotFound } return nil, fmt.Errorf("failed to get organization by Stytch ID: %w", err) } return r.mapToDomain(&result), nil } func (r *organizationRepository) GetByUserEmail(ctx context.Context, email string) (*domain.Organization, error) { result, err := r.store.GetOrganizationByUserEmail(ctx, email) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrOrganizationNotFound } return nil, fmt.Errorf("failed to get organization by user email: %w", err) } return r.mapToDomain(&result), nil } func (r *organizationRepository) Update(ctx context.Context, org *domain.Organization) (*domain.Organization, error) { params := sqlc.UpdateOrganizationParams{ ID: org.ID, Name: org.Name, Status: org.Status, StytchOrgID: helpers.ToPgText(org.StytchOrgID), StytchConnectionID: helpers.ToPgText(org.StytchConnectionID), StytchConnectionName: helpers.ToPgText(org.StytchConnectionName), } result, err := r.store.UpdateOrganization(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrOrganizationNotFound } return nil, fmt.Errorf("failed to update organization: %w", err) } return r.mapToDomain(&result), nil } func (r *organizationRepository) UpdateStytchInfo(ctx context.Context, id int32, stytchOrgID, stytchConnectionID, stytchConnectionName string) (*domain.Organization, error) { params := sqlc.UpdateOrganizationStytchInfoParams{ ID: id, StytchOrgID: helpers.ToPgText(stytchOrgID), StytchConnectionID: helpers.ToPgText(stytchConnectionID), StytchConnectionName: helpers.ToPgText(stytchConnectionName), } result, err := r.store.UpdateOrganizationStytchInfo(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrOrganizationNotFound } return nil, fmt.Errorf("failed to update organization Stytch info: %w", err) } return r.mapToDomain(&result), nil } func (r *organizationRepository) List(ctx context.Context, limit, offset int32) ([]*domain.Organization, error) { params := sqlc.ListOrganizationsParams{ Limit: limit, Offset: offset, } results, err := r.store.ListOrganizations(ctx, params) if err != nil { return nil, fmt.Errorf("failed to list organizations: %w", err) } organizations := make([]*domain.Organization, len(results)) for i, result := range results { organizations[i] = r.mapToDomain(&result) } return organizations, nil } func (r *organizationRepository) Delete(ctx context.Context, id int32) error { if err := r.store.DeleteOrganization(ctx, id); err != nil { return fmt.Errorf("failed to delete organization: %w", err) } return nil } func (r *organizationRepository) GetStats(ctx context.Context, id int32) (*domain.OrganizationStats, error) { result, err := r.store.GetOrganizationStats(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrOrganizationNotFound } return nil, fmt.Errorf("failed to get organization stats: %w", err) } org := &domain.Organization{ ID: result.ID, Slug: result.Slug, Name: result.Name, Status: result.Status, StytchOrgID: helpers.FromPgText(result.StytchOrgID), StytchConnectionID: helpers.FromPgText(result.StytchConnectionID), StytchConnectionName: helpers.FromPgText(result.StytchConnectionName), CreatedAt: result.CreatedAt.Time, UpdatedAt: result.UpdatedAt.Time, } stats := &domain.OrganizationStats{ Organization: org, AccountCount: result.AccountCount, ActiveAccountCount: result.ActiveAccountCount, } return stats, nil } // mapToDomain converts SQLC organization type to domain type. // This is the translation boundary - SQLC types never escape this function. func (r *organizationRepository) mapToDomain(sqlcOrg *sqlc.OrganizationsOrganization) *domain.Organization { org := &domain.Organization{ ID: sqlcOrg.ID, Slug: sqlcOrg.Slug, Name: sqlcOrg.Name, Status: sqlcOrg.Status, CreatedAt: sqlcOrg.CreatedAt.Time, UpdatedAt: sqlcOrg.UpdatedAt.Time, } // Map Stytch fields if sqlcOrg.StytchOrgID.Valid { org.StytchOrgID = sqlcOrg.StytchOrgID.String } if sqlcOrg.StytchConnectionID.Valid { org.StytchConnectionID = sqlcOrg.StytchConnectionID.String } if sqlcOrg.StytchConnectionName.Valid { org.StytchConnectionName = sqlcOrg.StytchConnectionName.String } return org } ================================================ FILE: go-b2b-starter/internal/modules/organizations/infra/repositories/slug_generator.go ================================================ package repositories import ( "fmt" "regexp" "strings" ) // generateSlug creates a URL-safe slug from an organization name // following Stytch requirements: 2-128 chars, alphanumeric + -._~ func generateSlug(name string) string { // Convert to lowercase slug := strings.ToLower(name) // Replace spaces with hyphens slug = strings.ReplaceAll(slug, " ", "-") // Remove characters not allowed by Stytch (keep alphanumeric and -._~) reg := regexp.MustCompile(`[^a-z0-9\-._~]+`) slug = reg.ReplaceAllString(slug, "") // Remove leading and trailing hyphens, dots, underscores, tildes slug = strings.Trim(slug, "-._~") // Ensure minimum length of 2 characters if len(slug) < 2 { slug = "org-" + slug } // Ensure maximum length of 128 characters if len(slug) > 128 { slug = slug[:128] // Re-trim in case we cut off in middle of separator slug = strings.TrimRight(slug, "-._~") } return slug } // generateSlugWithSuffix generates a slug with numeric suffix for retry attempts // attempt 1 returns base slug, attempt 2+ adds suffix func generateSlugWithSuffix(baseSlug string, attempt int) string { if attempt <= 1 { return baseSlug } suffix := fmt.Sprintf("-%d", attempt) slug := baseSlug + suffix // Ensure we don't exceed 128 character limit if len(slug) > 128 { // Truncate base slug to make room for suffix maxBaseLength := 128 - len(suffix) slug = baseSlug[:maxBaseLength] + suffix // Re-trim in case we cut off in middle of separator slug = strings.TrimRight(slug[:len(slug)-len(suffix)], "-._~") + suffix } return slug } ================================================ FILE: go-b2b-starter/internal/modules/organizations/infra/repositories/stytch_member_repository.go ================================================ package repositories import ( "context" "fmt" "strings" "time" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger" stytchcfg "github.com/moasq/go-b2b-starter/internal/platform/stytch" "github.com/stytchauth/stytch-go/v16/stytch/b2b/magiclinks/email" "github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations" "github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations/members" ) type stytchMemberRepository struct { client *stytchcfg.Client config stytchcfg.Config logger loggerDomain.Logger } // NewStytchMemberRepository creates a Stytch-backed member repository. func NewStytchMemberRepository(client *stytchcfg.Client, cfg stytchcfg.Config, logger loggerDomain.Logger) domain.AuthMemberRepository { return &stytchMemberRepository{ client: client, config: cfg, logger: logger, } } func (r *stytchMemberRepository) CreateMember(ctx context.Context, req *domain.CreateAuthMemberRequest) (*domain.AuthMember, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid create member request: %w", err) } stytchReq := &members.CreateParams{ OrganizationID: req.OrganizationID, EmailAddress: req.Email, Name: req.Name, } resp, err := r.client.API().Organizations.Members.Create(ctx, stytchReq) if err != nil { r.logger.Error("failed to create member in Stytch", loggerDomain.Fields{ "org_id": req.OrganizationID, "email": req.Email, "error": err.Error(), }) return nil, fmt.Errorf("stytch create member: %w", stytchcfg.MapError(err)) } member := mapToAuthMember(resp.Member) return member, nil } func (r *stytchMemberRepository) UpdateMember(ctx context.Context, req *domain.UpdateAuthMemberRequest) (*domain.AuthMember, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid update member request: %w", err) } params := &members.UpdateParams{ OrganizationID: req.OrganizationID, MemberID: req.MemberID, } if req.Name != nil { params.Name = *req.Name } if len(req.Roles) > 0 { rolesCopy := append([]string(nil), req.Roles...) params.Roles = &rolesCopy } if req.TrustedMeta != nil { params.TrustedMetadata = req.TrustedMeta } if req.UntrustedMeta != nil { params.UntrustedMetadata = req.UntrustedMeta } resp, err := r.client.API().Organizations.Members.Update(ctx, params) if err != nil { return nil, fmt.Errorf("stytch update member: %w", stytchcfg.MapError(err)) } return mapToAuthMember(resp.Member), nil } func (r *stytchMemberRepository) GetMember(ctx context.Context, organizationID, memberID string) (*domain.AuthMember, error) { if organizationID == "" { return nil, domain.ErrAuthOrganizationIDRequired } if memberID == "" { return nil, domain.ErrAuthMemberIDRequired } resp, err := r.client.API().Organizations.Members.Get(ctx, &members.GetParams{ OrganizationID: organizationID, MemberID: memberID, }) if err != nil { return nil, fmt.Errorf("stytch get member: %w", stytchcfg.MapError(err)) } return mapToAuthMember(resp.Member), nil } func (r *stytchMemberRepository) GetMemberByEmail(ctx context.Context, organizationID, emailAddr string) (*domain.AuthMember, error) { if organizationID == "" { return nil, domain.ErrAuthOrganizationIDRequired } if emailAddr == "" { return nil, domain.ErrAuthEmailRequired } resp, err := r.client.API().Organizations.Members.Get(ctx, &members.GetParams{ OrganizationID: organizationID, EmailAddress: emailAddr, }) if err != nil { return nil, fmt.Errorf("stytch get member by email: %w", stytchcfg.MapError(err)) } return mapToAuthMember(resp.Member), nil } func (r *stytchMemberRepository) ListMembers(ctx context.Context, organizationID string, limit, offset int) ([]*domain.AuthMember, error) { if organizationID == "" { return nil, domain.ErrAuthOrganizationIDRequired } params := &members.SearchParams{ OrganizationIds: []string{organizationID}, } requested := 0 if limit > 0 { requested = limit } if offset > 0 { requested += offset } if requested > 0 { params.Limit = uint32(requested) } resp, err := r.client.API().Organizations.Members.Search(ctx, params) if err != nil { return nil, fmt.Errorf("stytch search members: %w", stytchcfg.MapError(err)) } membersList := resp.Members if offset > 0 { if offset >= len(membersList) { membersList = nil } else { membersList = membersList[offset:] } } if limit > 0 && limit < len(membersList) { membersList = membersList[:limit] } results := make([]*domain.AuthMember, 0, len(membersList)) for _, m := range membersList { results = append(results, mapToAuthMember(m)) } return results, nil } func (r *stytchMemberRepository) RemoveMembers(ctx context.Context, req *domain.RemoveAuthMembersRequest) error { if err := req.Validate(); err != nil { return fmt.Errorf("invalid remove members request: %w", err) } for _, memberID := range req.MemberIDs { _, err := r.client.API().Organizations.Members.Delete(ctx, &members.DeleteParams{ OrganizationID: req.OrganizationID, MemberID: memberID, }) if err != nil { return fmt.Errorf("stytch delete member %s: %w", memberID, stytchcfg.MapError(err)) } } return nil } func (r *stytchMemberRepository) AssignRoles(ctx context.Context, req *domain.AssignAuthRolesRequest) error { if err := req.Validate(); err != nil { return fmt.Errorf("invalid assign roles request: %w", err) } updateParams := &members.UpdateParams{ OrganizationID: req.OrganizationID, MemberID: req.MemberID, } if len(req.Roles) > 0 { rolesCopy := append([]string(nil), req.Roles...) updateParams.Roles = &rolesCopy } _, err := r.client.API().Organizations.Members.Update(ctx, updateParams) if err != nil { return fmt.Errorf("stytch update member roles: %w", stytchcfg.MapError(err)) } return nil } func (r *stytchMemberRepository) SendMagicLink(ctx context.Context, req *domain.SendMagicLinkRequest) error { if err := req.Validate(); err != nil { return fmt.Errorf("invalid magic link request: %w", err) } params := &email.LoginOrSignupParams{ OrganizationID: req.OrganizationID, EmailAddress: req.Email, } loginRedirect := strings.TrimSpace(req.LoginRedirectURL) if loginRedirect == "" { loginRedirect = strings.TrimSpace(r.config.LoginRedirectURL) } if loginRedirect != "" { params.LoginRedirectURL = loginRedirect } signupRedirect := strings.TrimSpace(req.SignupRedirectURL) if signupRedirect == "" { signupRedirect = strings.TrimSpace(r.config.InviteRedirectURL) if signupRedirect == "" { signupRedirect = loginRedirect } } if signupRedirect != "" { params.SignupRedirectURL = signupRedirect } if _, err := r.client.API().MagicLinks.Email.LoginOrSignup(ctx, params); err != nil { return fmt.Errorf("stytch send magic link: %w", stytchcfg.MapError(err)) } return nil } func mapToAuthMember(src organizations.Member) *domain.AuthMember { var createdAt, updatedAt time.Time if src.CreatedAt != nil { createdAt = src.CreatedAt.UTC() } if src.UpdatedAt != nil { updatedAt = src.UpdatedAt.UTC() } roleIDs := make([]string, 0, len(src.Roles)) for _, role := range src.Roles { if role.RoleID != "" { roleIDs = append(roleIDs, role.RoleID) } } return &domain.AuthMember{ MemberID: src.MemberID, OrganizationID: src.OrganizationID, Email: src.EmailAddress, Name: src.Name, Roles: roleIDs, Status: src.Status, EmailVerified: src.EmailAddressVerified, CreatedAt: createdAt, UpdatedAt: updatedAt, } } ================================================ FILE: go-b2b-starter/internal/modules/organizations/infra/repositories/stytch_organization_repository.go ================================================ package repositories import ( "context" "database/sql" "errors" "fmt" "time" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" stytchcfg "github.com/moasq/go-b2b-starter/internal/platform/stytch" "github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations" ) type stytchOrganizationRepository struct { client *stytchcfg.Client logger loggerDomain.Logger localOrgRepo domain.OrganizationRepository } // NewStytchOrganizationRepository creates a Stytch-backed organization repository. func NewStytchOrganizationRepository( client *stytchcfg.Client, logger loggerDomain.Logger, localOrgRepo domain.OrganizationRepository, ) domain.AuthOrganizationRepository { return &stytchOrganizationRepository{ client: client, logger: logger, localOrgRepo: localOrgRepo, } } func (r *stytchOrganizationRepository) CreateOrganization(ctx context.Context, req *domain.CreateAuthOrganizationRequest) (*domain.AuthOrganization, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid create organization request: %w", err) } // Generate base slug from display name (infrastructure concern) baseSlug := generateSlug(req.DisplayName) // Prepare email invites parameter emailInvites := "NOT_ALLOWED" if req.EmailInvitesAllowed { emailInvites = "ALL_ALLOWED" } // Retry loop for duplicate slug handling (infrastructure concern) const maxAttempts = 5 var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { // Generate slug with suffix if needed slug := generateSlugWithSuffix(baseSlug, attempt) r.logger.Debug("attempting to create organization", loggerDomain.Fields{ "display_name": req.DisplayName, "slug": slug, "attempt": attempt, }) // Try to create in Stytch params := &organizations.CreateParams{ OrganizationSlug: slug, OrganizationName: req.DisplayName, EmailInvites: emailInvites, } resp, err := r.client.API().Organizations.Create(ctx, params) // Success - return immediately if err == nil { if attempt > 1 { r.logger.Info("created organization with retry", loggerDomain.Fields{ "display_name": req.DisplayName, "final_slug": slug, "attempts": attempt, }) } return mapToAuthOrganization(resp.Organization), nil } // Check if duplicate slug error - retry if stytchcfg.IsDuplicateSlugError(err) { r.logger.Debug("slug already exists, retrying", loggerDomain.Fields{ "attempted_slug": slug, "attempt": attempt, "max_attempts": maxAttempts, }) lastErr = err continue // Try next suffix } // Other error - fail immediately return nil, fmt.Errorf("stytch create organization: %w", stytchcfg.MapError(err)) } // All attempts exhausted r.logger.Error("failed to create organization after retries", loggerDomain.Fields{ "display_name": req.DisplayName, "base_slug": baseSlug, "attempts": maxAttempts, }) return nil, fmt.Errorf("failed to create organization after %d attempts, slug conflicts: %w", maxAttempts, stytchcfg.MapError(lastErr)) } func (r *stytchOrganizationRepository) GetOrganization(ctx context.Context, organizationID string) (*domain.AuthOrganization, error) { if organizationID == "" { return nil, domain.ErrAuthOrganizationIDRequired } resp, err := r.client.API().Organizations.Get(ctx, &organizations.GetParams{OrganizationID: organizationID}) if err != nil { return nil, fmt.Errorf("stytch get organization: %w", stytchcfg.MapError(err)) } return mapToAuthOrganization(resp.Organization), nil } func (r *stytchOrganizationRepository) DeleteOrganization(ctx context.Context, organizationID string) error { if organizationID == "" { return domain.ErrAuthOrganizationIDRequired } _, err := r.client.API().Organizations.Delete(ctx, &organizations.DeleteParams{OrganizationID: organizationID}) if err != nil { return fmt.Errorf("stytch delete organization: %w", stytchcfg.MapError(err)) } return nil } func (r *stytchOrganizationRepository) CheckEmailExists(ctx context.Context, email string) (bool, error) { if email == "" { return false, fmt.Errorf("email cannot be empty") } r.logger.Debug("checking if email exists", loggerDomain.Fields{ "email": email, }) // Use the local organization repository to check if email exists // GetByUserEmail returns organization if email is found (and account is active) _, err := r.localOrgRepo.GetByUserEmail(ctx, email) if err != nil { // Check if it's a "not found" error using proper error comparison if errors.Is(err, domain.ErrOrganizationNotFound) || errors.Is(err, sql.ErrNoRows) { r.logger.Debug("email not found", loggerDomain.Fields{ "email": email, }) return false, nil } // Other errors are real failures r.logger.Error("failed to check email existence", loggerDomain.Fields{ "email": email, "error": err.Error(), }) return false, fmt.Errorf("failed to check email existence: %w", err) } r.logger.Debug("email exists", loggerDomain.Fields{ "email": email, }) return true, nil } func mapToAuthOrganization(src organizations.Organization) *domain.AuthOrganization { var createdAt, updatedAt time.Time if src.CreatedAt != nil { createdAt = src.CreatedAt.UTC() } if src.UpdatedAt != nil { updatedAt = src.UpdatedAt.UTC() } return &domain.AuthOrganization{ OrganizationID: src.OrganizationID, Slug: src.OrganizationSlug, DisplayName: src.OrganizationName, CreatedAt: createdAt, UpdatedAt: updatedAt, } } ================================================ FILE: go-b2b-starter/internal/modules/organizations/infra/repositories/stytch_role_repository.go ================================================ package repositories import ( "context" "fmt" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" stytchcfg "github.com/moasq/go-b2b-starter/internal/platform/stytch" "github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac" ) type stytchRoleRepository struct { client *stytchcfg.Client logger loggerDomain.Logger } // NewStytchRoleRepository creates a Stytch-backed role repository. func NewStytchRoleRepository(client *stytchcfg.Client, logger loggerDomain.Logger) domain.AuthRoleRepository { return &stytchRoleRepository{ client: client, logger: logger, } } func (r *stytchRoleRepository) GetRoleByID(ctx context.Context, roleID string) (*domain.AuthRole, error) { if roleID == "" { return nil, domain.ErrAuthRoleNotFound } role, err := r.findRole(ctx, func(role *rbac.PolicyRole) bool { return role.RoleID == roleID }) if err != nil { return nil, err } if role == nil { return nil, domain.ErrAuthRoleNotFound } return role, nil } func (r *stytchRoleRepository) GetRoleBySlug(ctx context.Context, slug string) (*domain.AuthRole, error) { if slug == "" { return nil, domain.ErrAuthRoleNotFound } role, err := r.findRole(ctx, func(role *rbac.PolicyRole) bool { return role.RoleID == slug }) if err != nil { return nil, err } if role == nil { return nil, domain.ErrAuthRoleNotFound } return role, nil } func (r *stytchRoleRepository) ListRoles(ctx context.Context, limit, offset int) ([]*domain.AuthRole, error) { policy, err := r.fetchPolicy(ctx) if err != nil { return nil, err } if policy == nil || len(policy.Roles) == 0 { return nil, nil } start := offset if start < 0 { start = 0 } if start > len(policy.Roles) { start = len(policy.Roles) } end := len(policy.Roles) if limit > 0 && start+limit < end { end = start + limit } result := make([]*domain.AuthRole, 0, end-start) roles := policy.Roles[start:end] for i := range roles { role := roles[i] result = append(result, mapToAuthRole(&role)) } return result, nil } func (r *stytchRoleRepository) findRole(ctx context.Context, predicate func(*rbac.PolicyRole) bool) (*domain.AuthRole, error) { policy, err := r.fetchPolicy(ctx) if err != nil { return nil, err } if policy == nil { return nil, nil } for i := range policy.Roles { role := policy.Roles[i] if predicate(&role) { return mapToAuthRole(&role), nil } } return nil, nil } func (r *stytchRoleRepository) fetchPolicy(ctx context.Context) (*rbac.Policy, error) { resp, err := r.client.API().RBAC.Policy(ctx, &rbac.PolicyParams{}) if err != nil { return nil, fmt.Errorf("stytch fetch rbac policy: %w", stytchcfg.MapError(err)) } return resp.Policy, nil } func mapToAuthRole(src *rbac.PolicyRole) *domain.AuthRole { if src == nil { return nil } role := &domain.AuthRole{ RoleID: src.RoleID, Name: src.RoleID, Description: src.Description, } if len(src.Permissions) > 0 { perms := make([]string, 0, len(src.Permissions)) for _, perm := range src.Permissions { // Actions may be empty for resource-only permissions. if len(perm.Actions) == 0 { perms = append(perms, perm.ResourceID) continue } for _, action := range perm.Actions { perms = append(perms, fmt.Sprintf("%s:%s", perm.ResourceID, action)) } } role.Permissions = perms } return role } ================================================ FILE: go-b2b-starter/internal/modules/organizations/member_handler.go ================================================ package organizations import ( "net/http" "strings" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services" "github.com/moasq/go-b2b-starter/pkg/response" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/platform/logger" ) type MemberHandler struct { memberService services.MemberService logger logger.Logger } func NewMemberHandler( memberService services.MemberService, logger logger.Logger, ) *MemberHandler { return &MemberHandler{ memberService: memberService, logger: logger, } } // BootstrapOrganization creates a new organization with an admin member. // @Summary Bootstrap organization // @Description Creates a new organization in Stytch with an initial admin member. The admin receives a magic link invite email to complete passwordless onboarding. Organization slug is auto-generated from the organization name. // @Tags auth // @Accept json // @Produce json // @Param request body services.BootstrapOrganizationRequest true "Organization bootstrap request (passwordless - no password required)" // @Success 201 {object} services.BootstrapOrganizationResponse // @Failure 400 {object} map[string]any "Invalid request payload" // @Failure 500 {object} map[string]any "Failed to bootstrap organization" // @Router /auth/signup [post] func (h *MemberHandler) BootstrapOrganization(c *gin.Context) { var req services.BootstrapOrganizationRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("invalid bootstrap request payload", map[string]any{ "error": err.Error(), }) response.Error(c, http.StatusBadRequest, "invalid request payload", err) return } // Infrastructure layer handles slug generation and duplicate handling result, err := h.memberService.BootstrapOrganizationWithOwner(c.Request.Context(), &req) if err != nil { h.logger.Error("failed to bootstrap organization", map[string]any{ "org_name": req.OrgDisplayName, "error": err.Error(), }) response.Error(c, http.StatusInternalServerError, "failed to bootstrap organization", err) return } h.logger.Info("organization bootstrapped successfully", map[string]any{ "stytch_org_id": result.OrganizationID, "admin_member": result.OwnerMemberID, "magic_link": result.MagicLinkSent, }) response.Success(c, http.StatusCreated, result) } // AddMember adds a new member to an existing organization. // @Summary Add member to organization // @Description Adds a new member to an existing organization with a specified role. Organization ID is automatically extracted from JWT token. Member receives a magic link invite email for passwordless authentication. Request body: {"email": "user@example.com", "name": "Full Name", "role_slug": "member"} // @Tags auth // @Accept json // @Produce json // @Param Authorization header string true "Bearer JWT token" // @Param email body string true "Member email address" // @Param name body string true "Member full name" // @Param role_slug body string false "Role slug (defaults to 'member')" // @Success 201 {object} services.AddMemberResponse // @Failure 400 {object} map[string]any "Invalid request payload or missing organization context" // @Failure 500 {object} map[string]any "Failed to add member" // @Router /auth/members [post] func (h *MemberHandler) AddMember(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("request context not found", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } var req services.AddMemberRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("invalid add member request payload", map[string]any{ "error": err.Error(), }) response.Error(c, http.StatusBadRequest, "invalid request payload", err) return } req.OrgID = reqCtx.ProviderOrgID if strings.TrimSpace(req.RoleSlug) == "" { req.RoleSlug = "member" } result, err := h.memberService.AddMemberDirect(c.Request.Context(), &req) if err != nil { h.logger.Error("failed to add member", map[string]any{ "org_id": reqCtx.ProviderOrgID, "email": req.Email, "error": err.Error(), }) response.Error(c, http.StatusInternalServerError, "failed to add member", err) return } h.logger.Info("member added to organization", map[string]any{ "org_id": result.OrgID, "member_id": result.MemberID, "invite_sent": result.InviteSent, }) response.Success(c, http.StatusCreated, result) } // ListMembers retrieves all members of the current organization. // @Summary List organization members // @Description Retrieves all members of the current organization. Restricted to admin role only. // @Tags auth // @Accept json // @Produce json // @Success 200 {object} services.ListMembersResponse // @Failure 400 {object} map[string]any "Missing organization context" // @Failure 403 {object} map[string]any "Insufficient permissions - admin role required" // @Failure 500 {object} map[string]any "Failed to list members" // @Router /auth/members [get] func (h *MemberHandler) ListMembers(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("request context not found", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } result, err := h.memberService.ListOrganizationMembers(c.Request.Context(), reqCtx.ProviderOrgID) if err != nil { h.logger.Error("failed to list members", map[string]any{ "org_id": reqCtx.ProviderOrgID, "error": err.Error(), }) response.Error(c, http.StatusInternalServerError, "failed to list members", err) return } h.logger.Info("members listed successfully", map[string]any{ "org_id": reqCtx.ProviderOrgID, "count": result.Total, }) response.Success(c, http.StatusOK, result) } // GetProfile retrieves the current authenticated user's profile. // @Summary Get current user profile // @Description Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status. // @Tags auth // @Accept json // @Produce json // @Success 200 {object} services.ProfileResponse // @Failure 400 {object} map[string]any "Missing required context (organization or claims)" // @Failure 401 {object} map[string]any "Authentication required" // @Failure 500 {object} map[string]any "Failed to retrieve profile" // @Router /auth/profile/me [get] func (h *MemberHandler) GetProfile(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("request context not found", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } identity := reqCtx.Identity if identity == nil { h.logger.Error("identity not found in context", nil) response.Error(c, http.StatusUnauthorized, "authentication required", nil) return } // Get profile using service profile, err := h.memberService.GetCurrentUserProfile( c.Request.Context(), reqCtx.ProviderOrgID, identity.UserID, // member_id identity.Email, ) if err != nil { h.logger.Error("failed to get user profile", map[string]any{ "org_id": reqCtx.ProviderOrgID, "member_id": identity.UserID, "email": identity.Email, "error": err.Error(), }) response.Error(c, http.StatusInternalServerError, "failed to retrieve profile", err) return } // Add computed permissions from identity (derived from Stytch RBAC policy) profile.Permissions = auth.PermissionsToStrings(identity.Permissions) h.logger.Info("profile retrieved successfully", map[string]any{ "member_id": identity.UserID, "org_id": reqCtx.ProviderOrgID, "email": identity.Email, "permissions_count": len(profile.Permissions), }) response.Success(c, http.StatusOK, profile) } // @Summary Delete organization member // @Description Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members. // @Tags auth // @Accept json // @Produce json // @Param Authorization header string true "Bearer JWT token" // @Param member_id path string true "Member ID to delete" // @Success 204 {object} map[string]any "Member deleted successfully" // @Failure 400 {object} map[string]any "Invalid member ID or missing organization context" // @Failure 403 {object} map[string]any "Insufficient permissions - admin role required" // @Failure 404 {object} map[string]any "Member not found" // @Failure 500 {object} map[string]any "Failed to delete member" // @Router /auth/members/{member_id} [delete] func (h *MemberHandler) DeleteMember(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("request context not found", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } identity := reqCtx.Identity if identity == nil { h.logger.Error("identity not found in context", nil) response.Error(c, http.StatusUnauthorized, "authentication required", nil) return } // Extract member_id from path parameter memberID := c.Param("member_id") if memberID == "" { h.logger.Error("member_id path parameter is missing", nil) response.Error(c, http.StatusBadRequest, "member_id is required", nil) return } // Business rule: Cannot delete yourself if memberID == identity.UserID { h.logger.Warn("user attempted to delete themselves", map[string]any{ "member_id": memberID, "current_user": identity.UserID, "org_id": reqCtx.ProviderOrgID, }) response.Error(c, http.StatusForbidden, "cannot delete yourself", nil) return } // Delete member using service err := h.memberService.DeleteOrganizationMember(c.Request.Context(), reqCtx.ProviderOrgID, memberID) if err != nil { h.logger.Error("failed to delete member", map[string]any{ "org_id": reqCtx.ProviderOrgID, "member_id": memberID, "error": err.Error(), }) response.Error(c, http.StatusInternalServerError, "failed to delete member", err) return } h.logger.Info("member deleted successfully", map[string]any{ "member_id": memberID, "org_id": reqCtx.ProviderOrgID, "deleted_by": identity.UserID, }) response.Success(c, http.StatusNoContent, nil) } // @Summary Check if email exists // @Description Checks if an email exists in any organization. Returns 200 OK (empty response) if exists, 404 Not Found if doesn't exist. This is a public endpoint used during login flow. // @Tags auth // @Accept json // @Produce json // @Param email query string true "Email address to check" // @Success 200 "Email exists" // @Failure 400 {object} map[string]any "Invalid email format" // @Failure 404 {object} map[string]any "Email not found" // @Failure 500 {object} map[string]any "Internal server error" // @Router /auth/check-email [get] func (h *MemberHandler) CheckEmail(c *gin.Context) { // Extract and validate email from query parameter email := strings.TrimSpace(c.Query("email")) if email == "" { h.logger.Warn("email parameter is missing", nil) response.Error(c, http.StatusBadRequest, "email parameter is required", nil) return } h.logger.Debug("checking email existence", map[string]any{ "email": email, }) // Check if email exists using service exists, err := h.memberService.CheckEmailExists(c.Request.Context(), email) if err != nil { h.logger.Error("failed to check email existence", map[string]any{ "email": email, "error": err.Error(), }) response.Error(c, http.StatusInternalServerError, "failed to check email existence", err) return } // Return 404 if email doesn't exist if !exists { h.logger.Debug("email not found", map[string]any{ "email": email, }) response.Error(c, http.StatusNotFound, "email not found", nil) return } // Return 200 OK with empty response if email exists h.logger.Debug("email exists", map[string]any{ "email": email, }) response.Success(c, http.StatusOK, gin.H{}) } ================================================ FILE: go-b2b-starter/internal/modules/organizations/module.go ================================================ package organizations import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" "github.com/moasq/go-b2b-starter/internal/modules/organizations/infra/repositories" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" stytchcfg "github.com/moasq/go-b2b-starter/internal/platform/stytch" ) // Module provides organization module dependencies type Module struct { container *dig.Container } func NewModule(container *dig.Container) *Module { return &Module{ container: container, } } // RegisterDependencies registers all organization module dependencies // Note: Repository implementations are registered in internal/db/inject.go func (m *Module) RegisterDependencies() error { // Register auth provider repositories (Stytch implementation) if err := m.container.Provide(func( client *stytchcfg.Client, logger loggerDomain.Logger, localOrgRepo domain.OrganizationRepository, ) domain.AuthOrganizationRepository { return repositories.NewStytchOrganizationRepository(client, logger, localOrgRepo) }); err != nil { return err } if err := m.container.Provide(func( client *stytchcfg.Client, cfg *stytchcfg.Config, logger loggerDomain.Logger, ) domain.AuthMemberRepository { return repositories.NewStytchMemberRepository(client, *cfg, logger) }); err != nil { return err } if err := m.container.Provide(func( client *stytchcfg.Client, logger loggerDomain.Logger, ) domain.AuthRoleRepository { return repositories.NewStytchRoleRepository(client, logger) }); err != nil { return err } // Register organization service if err := m.container.Provide(func( orgRepo domain.OrganizationRepository, accountRepo domain.AccountRepository, ) services.OrganizationService { return services.NewOrganizationService(orgRepo, accountRepo) }); err != nil { return err } // Register member service (for auth member operations) if err := m.container.Provide(func( authOrgRepo domain.AuthOrganizationRepository, authMemberRepo domain.AuthMemberRepository, authRoleRepo domain.AuthRoleRepository, localOrgRepo domain.OrganizationRepository, localAccountRepo domain.AccountRepository, logger loggerDomain.Logger, ) services.MemberService { return services.NewMemberService( authOrgRepo, authMemberRepo, authRoleRepo, localOrgRepo, localAccountRepo, logger, ) }); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/modules/organizations/organization_handler.go ================================================ package organizations import ( "net/http" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services" "github.com/moasq/go-b2b-starter/internal/modules/organizations/domain" "github.com/moasq/go-b2b-starter/pkg/response" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/platform/logger" ) type OrganizationHandler struct { orgService services.OrganizationService logger logger.Logger } func NewOrganizationHandler(orgService services.OrganizationService, logger logger.Logger) *OrganizationHandler { return &OrganizationHandler{ orgService: orgService, logger: logger, } } // CreateOrganization creates a new organization func (h *OrganizationHandler) CreateOrganization(c *gin.Context) { var req services.CreateOrganizationRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("invalid request payload", map[string]interface{}{"error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid request payload", err) return } org, err := h.orgService.CreateOrganization(c.Request.Context(), &req) if err != nil { h.logger.Error("failed to create organization", map[string]interface{}{"error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to create organization", err) return } response.Success(c, http.StatusCreated, org) } // GetOrganization gets the current organization (from context) func (h *OrganizationHandler) GetOrganization(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } org, err := h.orgService.GetOrganization(c.Request.Context(), reqCtx.OrganizationID) if err != nil { if err == domain.ErrOrganizationNotFound { response.Error(c, http.StatusNotFound, "organization not found", err) return } h.logger.Error("failed to get organization", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to get organization", err) return } response.Success(c, http.StatusOK, org) } // GetOrganizationBySlug gets an organization by slug func (h *OrganizationHandler) GetOrganizationBySlug(c *gin.Context) { slug := c.Param("slug") if slug == "" { response.Error(c, http.StatusBadRequest, "slug is required", nil) return } org, err := h.orgService.GetOrganizationBySlug(c.Request.Context(), slug) if err != nil { if err == domain.ErrOrganizationNotFound { response.Error(c, http.StatusNotFound, "organization not found", err) return } h.logger.Error("failed to get organization by slug", map[string]interface{}{"slug": slug, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to get organization", err) return } response.Success(c, http.StatusOK, org) } // UpdateOrganization updates the current organization (from context) func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } var req services.UpdateOrganizationRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("invalid request payload", map[string]interface{}{"error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid request payload", err) return } org, err := h.orgService.UpdateOrganization(c.Request.Context(), reqCtx.OrganizationID, &req) if err != nil { if err == domain.ErrOrganizationNotFound { response.Error(c, http.StatusNotFound, "organization not found", err) return } h.logger.Error("failed to update organization", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to update organization", err) return } response.Success(c, http.StatusOK, org) } // ListOrganizations lists organizations with pagination func (h *OrganizationHandler) ListOrganizations(c *gin.Context) { var req services.ListOrganizationsRequest if err := c.ShouldBindQuery(&req); err != nil { h.logger.Error("invalid query parameters", map[string]interface{}{"error": err.Error()}) response.Error(c, http.StatusBadRequest, "invalid query parameters", err) return } // Set defaults if req.Limit == 0 { req.Limit = 10 } orgResponse, err := h.orgService.ListOrganizations(c.Request.Context(), &req) if err != nil { h.logger.Error("failed to list organizations", map[string]interface{}{"error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to list organizations", err) return } response.Success(c, http.StatusOK, orgResponse) } // GetOrganizationStats gets statistics for the current organization (from context) func (h *OrganizationHandler) GetOrganizationStats(c *gin.Context) { reqCtx := auth.GetRequestContext(c) if reqCtx == nil { h.logger.Error("missing request context", nil) response.Error(c, http.StatusBadRequest, "organization context is required", nil) return } stats, err := h.orgService.GetOrganizationStats(c.Request.Context(), reqCtx.OrganizationID) if err != nil { if err == domain.ErrOrganizationNotFound { response.Error(c, http.StatusNotFound, "organization not found", err) return } h.logger.Error("failed to get organization stats", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) response.Error(c, http.StatusInternalServerError, "failed to get organization stats", err) return } response.Success(c, http.StatusOK, stats) } ================================================ FILE: go-b2b-starter/internal/modules/organizations/provider.go ================================================ package organizations import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services" "github.com/moasq/go-b2b-starter/internal/platform/logger" ) // Provider provides organization API dependencies type Provider struct { container *dig.Container } func NewProvider(container *dig.Container) *Provider { return &Provider{ container: container, } } // RegisterDependencies registers organization API dependencies func (p *Provider) RegisterDependencies() error { // Register handlers if err := p.container.Provide(func( orgService services.OrganizationService, logger logger.Logger, ) *OrganizationHandler { return NewOrganizationHandler(orgService, logger) }); err != nil { return err } if err := p.container.Provide(func( orgService services.OrganizationService, logger logger.Logger, ) *AccountHandler { return NewAccountHandler(orgService, logger) }); err != nil { return err } // Register member handler (for auth/member routes) if err := p.container.Provide(func( memberService services.MemberService, logger logger.Logger, ) *MemberHandler { return NewMemberHandler(memberService, logger) }); err != nil { return err } // Register routes if err := p.container.Provide(func( organizationHandler *OrganizationHandler, accountHandler *AccountHandler, memberHandler *MemberHandler, ) *Routes { return NewRoutes(organizationHandler, accountHandler, memberHandler) }); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/modules/organizations/routes.go ================================================ package organizations import ( "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" serverDomain "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ) type Routes struct { organizationHandler *OrganizationHandler accountHandler *AccountHandler memberHandler *MemberHandler } func NewRoutes( organizationHandler *OrganizationHandler, accountHandler *AccountHandler, memberHandler *MemberHandler, ) *Routes { return &Routes{ organizationHandler: organizationHandler, accountHandler: accountHandler, memberHandler: memberHandler, } } // RegisterRoutes registers organization, account, and auth member management routes func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { // Auth routes - member management and authentication authGroup := router.Group("/auth") { // Public endpoint - Organization signup (no authentication required) authGroup.POST("/signup", r.memberHandler.BootstrapOrganization) // Public endpoint - Check if email exists (no authentication required) authGroup.GET("/check-email", r.memberHandler.CheckEmail) // Protected endpoint - Add member (requires JWT authentication) authGroup.POST("/members", resolver.Get("auth"), resolver.Get("org_context"), r.memberHandler.AddMember) // Protected endpoint - List members (requires JWT authentication and org:manage permission) authGroup.GET("/members", resolver.Get("auth"), resolver.Get("org_context"), auth.RequirePermissionFunc("org", "manage"), r.memberHandler.ListMembers) // Protected endpoint - Get current user profile (requires JWT authentication only) authGroup.GET("/profile/me", resolver.Get("auth"), resolver.Get("org_context"), r.memberHandler.GetProfile) // Protected endpoint - Delete organization member (requires JWT authentication and org:manage permission) authGroup.DELETE("/members/:member_id", resolver.Get("auth"), resolver.Get("org_context"), auth.RequirePermissionFunc("org", "manage"), r.memberHandler.DeleteMember) } // Organization routes - require JWT authentication orgGroup := router.Group("/organizations") orgGroup.Use( resolver.Get("auth"), resolver.Get("org_context"), ) { // Current organization endpoints orgGroup.GET("", auth.RequirePermissionFunc("org", "view"), r.organizationHandler.GetOrganization) orgGroup.PUT("", auth.RequirePermissionFunc("org", "manage"), r.organizationHandler.UpdateOrganization) orgGroup.GET("/stats", auth.RequirePermissionFunc("org", "view"), r.organizationHandler.GetOrganizationStats) } // Account routes - require JWT authentication accountGroup := router.Group("/accounts") accountGroup.Use( resolver.Get("auth"), resolver.Get("org_context"), ) { // Account management accountGroup.POST("", auth.RequirePermissionFunc("org", "manage"), r.accountHandler.CreateAccount) accountGroup.GET("", auth.RequirePermissionFunc("org", "view"), r.accountHandler.ListAccounts) accountGroup.GET("/by-email", auth.RequirePermissionFunc("org", "view"), r.accountHandler.GetAccountByEmail) accountGroup.GET("/:id", auth.RequirePermissionFunc("org", "view"), r.accountHandler.GetAccount) accountGroup.PUT("/:id", auth.RequirePermissionFunc("org", "manage"), r.accountHandler.UpdateAccount) accountGroup.DELETE("/:id", auth.RequirePermissionFunc("org", "manage"), r.accountHandler.DeleteAccount) accountGroup.POST("/:id/last-login", auth.RequirePermissionFunc("org", "view"), r.accountHandler.UpdateAccountLastLogin) accountGroup.GET("/:id/permissions", auth.RequirePermissionFunc("org", "view"), r.accountHandler.CheckAccountPermission) accountGroup.GET("/:id/stats", auth.RequirePermissionFunc("org", "view"), r.accountHandler.GetAccountStats) } } // Routes returns a RouteRegistrar function compatible with the server interface func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { r.RegisterRoutes(router, resolver) } ================================================ FILE: go-b2b-starter/internal/modules/paywall/README.md ================================================ # Paywall Middleware Package Provider-agnostic access gating middleware for B2B SaaS applications. This package provides the **"Payment Bouncer"** - checking if an organization has an active subscription before allowing access to protected features. ## Key Concept: Separation of Concerns This package ONLY handles **access gating** (the "can they use this feature?" question). It does NOT manage subscriptions directly. ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ PAYWALL (This Package) │ │ │ │ "Can this organization access premium features right now?" │ │ │ │ - Reads subscription status from LOCAL DATABASE │ │ - Makes NO external API calls (fast, reliable) │ │ - Returns 402 if payment required │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ BILLING MODULE (app/billing) │ │ │ │ "Manage subscription lifecycle via webhooks" │ │ │ │ - Processes Polar.sh webhooks (subscription created, updated, canceled) │ │ - Updates LOCAL DATABASE with subscription state │ │ - Tracks quotas and usage │ │ - No direct API calls during request handling │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ## Architecture: Event-Driven Integration The paywall middleware and billing module are decoupled via **event-driven architecture**: ``` ┌─────────────┐ webhook ┌─────────────────┐ writes ┌─────────────┐ │ Polar.sh │ ─────────► │ Billing Module │ ─────────► │ Local DB │ └─────────────┘ └─────────────────┘ └─────────────┘ │ reads │ ▼ ┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ │ Request │ ─────────► │ Paywall │ ◄───────── │ Local DB │ └─────────────┘ └─────────────────┘ └─────────────┘ │ ▼ Pass (200) or Block (402) ``` **Why Event-Driven?** - No external API calls during request handling (fast responses) - Billing provider outage doesn't block your users - Clean separation between access control and subscription management - Easy to swap billing providers without touching access logic ## Request Flow ``` HTTP Request │ ▼ ┌────────────────┐ │ Auth Middleware│ ── Verify JWT, extract Identity └────────────────┘ │ ▼ ┌────────────────┐ │ Org Middleware │ ── Resolve OrganizationID from Identity └────────────────┘ │ ▼ ┌────────────────┐ │Paywall Middleware│ ── Check subscription status (LOCAL DB) └────────────────┘ │ ├── Active? ──► Handler ──► 200 OK │ └── Inactive? ──► 402 Payment Required ``` ## Usage ### 1. Setup (Already configured in init_mods.go) ```go import ( "github.com/moasq/go-b2b-starter/pkg/paywall" ) // Setup middleware (after billing module is initialized) if err := paywall.SetupMiddleware(container); err != nil { panic(err) } // Register named middlewares for route configuration if err := paywall.RegisterNamedMiddlewares(container); err != nil { panic(err) } ``` ### 2. Protecting Routes **Using Named Middleware (Recommended):** ```go // In routes.go func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { // Premium features - require active subscription premiumGroup := router.Group("/premium") premiumGroup.Use( resolver.Get("auth"), // Verify JWT resolver.Get("org_context"), // Resolve org/account IDs resolver.Get("paywall"), // Block if no active subscription ) { premiumGroup.POST("/ai/generate", r.handler.Generate) premiumGroup.POST("/reports/export", r.handler.Export) } // Basic features - auth only, no paywall basicGroup := router.Group("/basic") basicGroup.Use( resolver.Get("auth"), resolver.Get("org_context"), // No paywall - allow access to fix billing issues ) { basicGroup.GET("/billing/status", r.handler.GetBillingStatus) basicGroup.POST("/billing/portal", r.handler.CreatePortalSession) } } ``` **Direct Middleware Usage:** ```go // Get middleware from DI container var paywallMiddleware *paywall.Middleware container.Invoke(func(m *paywall.Middleware) { paywallMiddleware = m }) // Apply to routes router.Use(paywallMiddleware.RequireActiveSubscription()) ``` ### 3. Accessing Subscription Status in Handlers ```go func (h *Handler) MyHandler(c *gin.Context) { // Safe get - returns nil if not set status := paywall.GetSubscriptionStatus(c) if status != nil { log.Printf("Org %d status: %s", status.OrganizationID, status.Status) } // Quick boolean check if paywall.IsSubscriptionActive(c) { // Show premium features } // Must get - panics if not set (use after RequireActiveSubscription) status := paywall.MustGetSubscriptionStatus(c) } ``` ### 4. Optional Status Check (No Blocking) When you want to know status without blocking access: ```go // Show "upgrade" prompts to free users dashboardGroup.Use( resolver.Get("auth"), resolver.Get("org_context"), resolver.Get("paywall_optional"), // Sets status, doesn't block ) ``` ## Configuration ```go config := &paywall.MiddlewareConfig{ // URL included in 402 responses for upgrading UpgradeURL: "/billing", // Allow trialing subscriptions (default: true) AllowTrialing: true, // Custom error handler (optional) ErrorHandler: func(c *gin.Context, statusCode int, response *paywall.ErrorResponse) { c.JSON(statusCode, response) }, } middleware := paywall.NewMiddleware(provider, config) ``` ## Subscription Status Mapping | DB Status | IsActive | HTTP Response | |---------------|----------|----------------------| | `active` | true | Pass through | | `trialing` | true | Pass through | | `past_due` | false | 402 Payment Required | | `canceled` | false | 402 Payment Required | | `unpaid` | false | 402 Payment Required | | No subscription | false | 402 Payment Required | ## Error Response Format ```json { "error": "subscription_required", "message": "An active subscription is required to access this feature", "upgrade_url": "/billing", "status": "past_due" } ``` ## The "Swiss Cheese" Strategy Not all routes should require a subscription. Allow users to fix billing issues: **Protected (require paywall):** - AI/ML features - OCR processing - Report generation - Premium API endpoints - Advanced analytics **Unprotected (auth only):** - Billing status and portal - Account settings - Profile management - Subscription upgrade flow - Webhooks - Public pages ## Payment Verification Flow ("Verification on Redirect") When users complete payment on Polar, they're redirected back to your app with a checkout session ID. To provide instant access (instead of waiting for webhooks), use the **verification endpoint**. ### Frontend Integration **1. Configure Polar Success URL:** Set your Polar checkout success URL to: ``` https://yoursaas.com/payment/success?session_id={CHECKOUT_SESSION_ID} ``` Polar will replace `{CHECKOUT_SESSION_ID}` with the actual session ID. **2. Handle Redirect (Frontend):** ```javascript // On /payment/success page const params = new URLSearchParams(window.location.search); const sessionId = params.get('session_id'); if (sessionId) { // Show loading spinner const response = await fetch('/api/subscriptions/verify-payment', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }) }); if (response.ok) { // Payment verified! Redirect to dashboard window.location.href = '/dashboard'; } else { // Show error message const error = await response.json(); showError(error.message); } } ``` **3. Expected Response:** ```json { "organization_id": 123, "has_active_subscription": true, "can_process_invoices": true, "invoice_count": 100, "reason": "Payment verified successfully", "checked_at": "2025-12-12T10:30:00Z" } ``` ### Backend Verification Endpoint Endpoint: `POST /api/subscriptions/verify-payment` **Request:** ```json { "session_id": "cs_test_xxx" } ``` **Flow:** 1. Fetch checkout session from Polar API 2. Verify status is "succeeded" 3. Extract customer_id and map to organization 4. Fetch full subscription details from Polar 5. Update local database (subscription + quota) 6. Return updated billing status **Error Codes:** - `400` - Checkout session failed or expired - `404` - Checkout session not found - `500` - Internal error (failed to sync) ### Lazy Guarding (Renewal Webhooks) For monthly renewals, the middleware includes **lazy guarding** to handle missed webhooks: **How It Works:** 1. User makes request → Middleware checks DB status 2. If DB says "expired" BUT subscription exists: - Middleware calls Polar API to refresh status - If Polar says "active" → Grant access and update DB - If Polar says "inactive" → Block access (truly expired) ```go // Automatic in paywall middleware - no configuration needed if !status.IsActive && status.Status != StatusNone { freshStatus, err := provider.RefreshSubscriptionStatus(ctx, orgID) if err == nil && freshStatus.IsActive { // Webhook was missed! Allow access status = freshStatus } } ``` **Benefits:** - Self-healing: Missed webhooks don't lock out paying users - Fast: Only checks API when DB says inactive (edge case) - Reliable: Users always get access if they've paid ### Architecture Overview | Scenario | Primary Mechanism | Fallback Mechanism | |----------|-------------------|-------------------| | **User Just Paid** | **Verification on Redirect** (Frontend → Backend → Polar API) | Webhook (processed if arrives later) | | **Monthly Renewal** | **Webhooks** (Standard processing) | **Lazy Guarding** (Middleware checks API if DB says expired) | **Success Metrics:** - Payment to access time: ~5 seconds (verification) vs. ~minutes (webhook only) - Webhook miss recovery: Automatic via lazy guarding - User complaints: Eliminated "I paid but still locked out" issues ## Implementing SubscriptionStatusProvider The middleware requires a `SubscriptionStatusProvider` implementation. This is provided by the billing module: ```go // Interface defined in this package type SubscriptionStatusProvider interface { GetSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error) } // Implemented in app/billing/infra/adapters/status_provider.go type StatusProviderAdapter struct { service services.BillingService } func (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) { billingStatus, err := a.service.GetBillingStatus(ctx, orgID) if err != nil { return nil, err } return &paywall.SubscriptionStatus{ OrganizationID: orgID, Status: billingStatus.SubscriptionStatus, IsActive: billingStatus.HasActiveSubscription, // ... }, nil } ``` ## Named Middleware Reference | Name | Function | Description | |-----------------------|-----------------------------|--------------------------------| | `paywall` | RequireActiveSubscription | Block if no active subscription| | `paywall_optional` | OptionalSubscriptionStatus | Set status, don't block | | `subscription` (legacy)| RequireActiveSubscription | Deprecated, use `paywall` | ## Files ``` src/pkg/paywall/ ├── subscription.go # Core types and SubscriptionStatusProvider interface ├── middleware.go # Gin middleware (RequireActiveSubscription) ├── context.go # Context helpers (Get/Set SubscriptionStatus) ├── errors.go # Error types (ErrNoSubscription, etc.) ├── provider.go # DI registration and named middleware └── README.md # This file ``` ## Related: Billing Module See `app/billing/README.md` for: - Polar.sh webhook integration - Subscription lifecycle management - Quota tracking - Event-driven architecture details ================================================ FILE: go-b2b-starter/internal/modules/paywall/cmd/init.go ================================================ // Package cmd provides initialization for the paywall module. package cmd import ( "fmt" "github.com/moasq/go-b2b-starter/internal/modules/paywall" "go.uber.org/dig" ) // InitMiddleware initializes the paywall middleware. // // This must be called after the billing module is initialized, // as it depends on the SubscriptionStatusProvider from that module. // // # Prerequisites // // The following must be available in the container: // - paywall.SubscriptionStatusProvider (from app/billing module) // // # Usage // // // After billing module init: // if err := paywallCmd.InitMiddleware(container); err != nil { // panic(err) // } func InitMiddleware(container *dig.Container) error { if err := paywall.SetupMiddleware(container); err != nil { return fmt.Errorf("failed to setup paywall middleware: %w", err) } return nil } // InitMiddlewareWithConfig initializes the paywall middleware with custom configuration. // // # Usage // // config := &paywall.MiddlewareConfig{ // UpgradeURL: "/settings/billing", // } // if err := paywallCmd.InitMiddlewareWithConfig(container, config); err != nil { // panic(err) // } func InitMiddlewareWithConfig(container *dig.Container, config *paywall.MiddlewareConfig) error { if err := paywall.SetupMiddlewareWithConfig(container, config); err != nil { return fmt.Errorf("failed to setup paywall middleware: %w", err) } return nil } // SetupMiddleware is a direct alias to paywall.SetupMiddleware for convenience. func SetupMiddleware(container *dig.Container) error { return paywall.SetupMiddleware(container) } // RegisterNamedMiddlewares is a direct alias to paywall.RegisterNamedMiddlewares for convenience. func RegisterNamedMiddlewares(container *dig.Container) error { return paywall.RegisterNamedMiddlewares(container) } ================================================ FILE: go-b2b-starter/internal/modules/paywall/context.go ================================================ package paywall import ( "context" "github.com/gin-gonic/gin" ) // Context keys for storing subscription data. // Using unexported type to prevent collisions with other packages. type contextKey string const ( // subscriptionStatusKey is the context key for storing the SubscriptionStatus. subscriptionStatusKey contextKey = "subscription_status" ) // SetSubscriptionStatus stores the SubscriptionStatus in the Gin context. // // This is called by the RequireActiveSubscription middleware after checking // subscription status. Application code should not call this directly. func SetSubscriptionStatus(c *gin.Context, status *SubscriptionStatus) { c.Set(string(subscriptionStatusKey), status) } // GetSubscriptionStatus retrieves the SubscriptionStatus from the Gin context. // // Returns nil if no subscription status is set (middleware not applied). // Use MustGetSubscriptionStatus if you expect subscription middleware to have run. // // Example: // // status := subscription.GetSubscriptionStatus(c) // if status == nil || !status.IsActive { // // Handle inactive subscription // } func GetSubscriptionStatus(c *gin.Context) *SubscriptionStatus { if val, exists := c.Get(string(subscriptionStatusKey)); exists { if status, ok := val.(*SubscriptionStatus); ok { return status } } return nil } // MustGetSubscriptionStatus retrieves the SubscriptionStatus from the Gin context. // // Panics if no subscription status is set. Only use this after subscription middleware. // For handlers where subscription status is optional, use GetSubscriptionStatus instead. func MustGetSubscriptionStatus(c *gin.Context) *SubscriptionStatus { status := GetSubscriptionStatus(c) if status == nil { panic("subscription: MustGetSubscriptionStatus called without SubscriptionStatus in context - ensure RequireActiveSubscription middleware is applied") } return status } // IsSubscriptionActive is a convenience function to check if the subscription is active. // // Returns false if no subscription status is set or if the subscription is inactive. // Use this for quick checks in handlers. // // Example: // // if !subscription.IsSubscriptionActive(c) { // // Handle inactive subscription // } func IsSubscriptionActive(c *gin.Context) bool { if status := GetSubscriptionStatus(c); status != nil { return status.IsActive } return false } // WithSubscriptionStatus adds the SubscriptionStatus to a context.Context. // // This is useful for passing subscription context through service layers // that don't use Gin context directly. func WithSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) context.Context { return context.WithValue(ctx, subscriptionStatusKey, status) } // SubscriptionStatusFromContext retrieves the SubscriptionStatus from a context.Context. // // Returns nil if no subscription status is set. func SubscriptionStatusFromContext(ctx context.Context) *SubscriptionStatus { if val := ctx.Value(subscriptionStatusKey); val != nil { if status, ok := val.(*SubscriptionStatus); ok { return status } } return nil } ================================================ FILE: go-b2b-starter/internal/modules/paywall/errors.go ================================================ package paywall import "errors" // Subscription errors. // // These errors are returned by the subscription package and can be checked // by application code to handle specific error cases. var ( // ErrNoSubscription is returned when no subscription exists for the organization. // HTTP status: 402 Payment Required ErrNoSubscription = errors.New("no subscription found") // ErrSubscriptionInactive is returned when the subscription exists but is not active. // This includes statuses like "past_due", "canceled", "unpaid". // HTTP status: 402 Payment Required ErrSubscriptionInactive = errors.New("subscription is not active") // ErrSubscriptionExpired is returned when the subscription's billing period has ended. // HTTP status: 402 Payment Required ErrSubscriptionExpired = errors.New("subscription has expired") // ErrSubscriptionCanceled is returned when the subscription has been explicitly canceled. // HTTP status: 402 Payment Required ErrSubscriptionCanceled = errors.New("subscription has been canceled") // ErrPaymentFailed is returned when the subscription payment has failed. // HTTP status: 402 Payment Required ErrPaymentFailed = errors.New("subscription payment failed") // ErrMissingOrganization is returned when organization ID is not in context. // This means RequireOrganization middleware hasn't run. // HTTP status: 500 Internal Server Error (misconfigured middleware) ErrMissingOrganization = errors.New("organization context required") ) // IsPaymentRequiredError returns true if the error requires payment (402). func IsPaymentRequiredError(err error) bool { return errors.Is(err, ErrNoSubscription) || errors.Is(err, ErrSubscriptionInactive) || errors.Is(err, ErrSubscriptionExpired) || errors.Is(err, ErrSubscriptionCanceled) || errors.Is(err, ErrPaymentFailed) } // HTTPStatusCode returns the appropriate HTTP status code for a subscription error. // // Returns: // - 402 for payment-related errors // - 500 for configuration errors func HTTPStatusCode(err error) int { if IsPaymentRequiredError(err) { return 402 } if errors.Is(err, ErrMissingOrganization) { return 500 } return 500 } // ErrorResponse represents the JSON error response for subscription errors. // // This is used by the middleware to return a consistent error format // that includes helpful information for the client. type ErrorResponse struct { // Error is the error code (e.g., "subscription_required", "payment_failed") Error string `json:"error"` // Message is a human-readable description of the error. Message string `json:"message"` // UpgradeURL is the URL where the user can update their subscription. // Optional - only included when configured. UpgradeURL string `json:"upgrade_url,omitempty"` // Status is the subscription status that caused the error. // Optional - helps the client understand the specific issue. Status string `json:"status,omitempty"` } ================================================ FILE: go-b2b-starter/internal/modules/paywall/middleware.go ================================================ package paywall import ( "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" ) // MiddlewareConfig configures the subscription middleware behavior. type MiddlewareConfig struct { // ErrorHandler is called when subscription check fails. // If nil, default JSON responses are used. ErrorHandler func(c *gin.Context, statusCode int, response *ErrorResponse) // UpgradeURL is the URL to include in error responses for upgrading subscription. // Example: "/billing" or "https://app.example.com/billing" UpgradeURL string // AllowTrialing determines if trialing subscriptions are allowed. // Default: true (trialing is allowed) AllowTrialing bool } // DefaultMiddlewareConfig returns the default middleware configuration. func DefaultMiddlewareConfig() *MiddlewareConfig { return &MiddlewareConfig{ ErrorHandler: defaultErrorHandler, UpgradeURL: "/billing", AllowTrialing: true, } } // defaultErrorHandler sends JSON error responses. func defaultErrorHandler(c *gin.Context, statusCode int, response *ErrorResponse) { c.JSON(statusCode, response) } // Middleware provides subscription middleware functions. // // Use NewMiddleware to create an instance with proper dependencies. type Middleware struct { provider SubscriptionStatusProvider config *MiddlewareConfig } // Parameters: // - provider: The subscription status provider (implements SubscriptionStatusProvider) // - config: Middleware configuration (optional, uses defaults if nil) func NewMiddleware(provider SubscriptionStatusProvider, config *MiddlewareConfig) *Middleware { if config == nil { config = DefaultMiddlewareConfig() } if config.ErrorHandler == nil { config.ErrorHandler = defaultErrorHandler } return &Middleware{ provider: provider, config: config, } } // RequireActiveSubscription returns middleware that checks subscription status. // // This middleware: // 1. Gets OrganizationID from auth context (requires RequireOrganization to run first) // 2. Checks subscription status from the SubscriptionStatusProvider // 3. Sets SubscriptionStatus in Gin context if active // 4. Returns 402 Payment Required if subscription is not active // // Must be called AFTER auth.RequireOrganization middleware. // // Usage: // // router.Use(authMiddleware.RequireAuth()) // router.Use(authMiddleware.RequireOrganization()) // router.Use(subscriptionMiddleware.RequireActiveSubscription()) func (m *Middleware) RequireActiveSubscription() gin.HandlerFunc { return func(c *gin.Context) { // Skip OPTIONS requests (CORS preflight) if c.Request.Method == "OPTIONS" { c.Next() return } // Get organization ID from auth context orgID := auth.GetOrganizationID(c) if orgID == 0 { m.config.ErrorHandler(c, http.StatusInternalServerError, &ErrorResponse{ Error: "configuration_error", Message: "Organization context required - ensure RequireOrganization middleware is applied", }) c.Abort() return } // Check subscription status from database (fast read) status, err := m.provider.GetSubscriptionStatus(c.Request.Context(), orgID) if err != nil { // No subscription found m.config.ErrorHandler(c, http.StatusPaymentRequired, &ErrorResponse{ Error: "subscription_required", Message: "An active subscription is required to access this feature", UpgradeURL: m.config.UpgradeURL, Status: StatusNone, }) c.Abort() return } // Lazy Guarding: If DB says inactive BUT subscription exists (not "none"), // double-check with payment provider in case we missed a webhook if !status.IsActive && status.Status != StatusNone { // Attempt to refresh subscription status from provider freshStatus, refreshErr := m.provider.RefreshSubscriptionStatus(c.Request.Context(), orgID) if refreshErr == nil && freshStatus != nil && freshStatus.IsActive { // Webhook was missed! Provider says active, update our status status = freshStatus // Log this occurrence for monitoring // Console log for lazy guard activation fmt.Printf("🔄 LAZY GUARD ACTIVATED - Org: %d | DB said: %s | Provider says: %s | Access granted\n", orgID, status.Status, freshStatus.Status) } // If refresh fails or still inactive, continue with original status } // Check if subscription is active (after potential refresh) if !status.IsActive { response := m.buildErrorResponse(status) m.config.ErrorHandler(c, http.StatusPaymentRequired, response) c.Abort() return } // Set subscription status in context for downstream handlers SetSubscriptionStatus(c, status) c.Next() } } // buildErrorResponse creates an appropriate error response based on subscription status. func (m *Middleware) buildErrorResponse(status *SubscriptionStatus) *ErrorResponse { response := &ErrorResponse{ UpgradeURL: m.config.UpgradeURL, Status: status.Status, } switch status.Status { case StatusPastDue: response.Error = "payment_failed" response.Message = "Your subscription payment has failed. Please update your payment method." case StatusCanceled: response.Error = "subscription_canceled" response.Message = "Your subscription has been canceled. Please resubscribe to continue." case StatusUnpaid: response.Error = "payment_required" response.Message = "Your subscription is unpaid. Please update your payment method." default: response.Error = "subscription_inactive" response.Message = "An active subscription is required to access this feature" if status.Reason != "" { response.Message = status.Reason } } return response } // RequireActiveSubscriptionFunc is a standalone middleware function. // // This is a convenience function that doesn't require a Middleware instance. // It uses the default configuration for error handling. // // Usage: // // router.GET("/ai/generate", // subscription.RequireActiveSubscriptionFunc(provider), // handler) func RequireActiveSubscriptionFunc(provider SubscriptionStatusProvider) gin.HandlerFunc { m := NewMiddleware(provider, nil) return m.RequireActiveSubscription() } // OptionalSubscriptionStatus returns middleware that checks subscription status // but doesn't block the request if the subscription is inactive. // // This middleware: // 1. Gets OrganizationID from auth context // 2. Checks subscription status from the SubscriptionStatusProvider // 3. Sets SubscriptionStatus in Gin context (active or not) // 4. Always continues to the next handler // // Use this when you want to know subscription status but allow access regardless. // Handlers can then check subscription.GetSubscriptionStatus(c) to adjust behavior. // // Example use case: Show "upgrade" prompts to free users while still allowing access. func (m *Middleware) OptionalSubscriptionStatus() gin.HandlerFunc { return func(c *gin.Context) { // Skip OPTIONS requests (CORS preflight) if c.Request.Method == "OPTIONS" { c.Next() return } // Get organization ID from auth context orgID := auth.GetOrganizationID(c) if orgID == 0 { // No org context, continue without subscription info c.Next() return } // Check subscription status (ignore errors, just set status if available) status, err := m.provider.GetSubscriptionStatus(c.Request.Context(), orgID) if err == nil && status != nil { SetSubscriptionStatus(c, status) } c.Next() } } ================================================ FILE: go-b2b-starter/internal/modules/paywall/provider.go ================================================ package paywall import ( "fmt" "github.com/gin-gonic/gin" "go.uber.org/dig" ) // ServerMiddlewareRegistrar is the interface for registering named middleware. // This matches the server.Server interface's RegisterNamedMiddleware method. type ServerMiddlewareRegistrar interface { RegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc) } // SetupMiddleware wires the subscription middleware into the DI container. // // This must be called after the SubscriptionStatusProvider is available. // // # Prerequisites // // The following must be available in the container: // - subscription.SubscriptionStatusProvider // // # Usage // // if err := subscription.SetupMiddleware(container); err != nil { // return err // } func SetupMiddleware(container *dig.Container) error { if err := container.Provide(func( provider SubscriptionStatusProvider, ) *Middleware { return NewMiddleware(provider, nil) }); err != nil { return fmt.Errorf("failed to provide subscription middleware: %w", err) } return nil } // SetupMiddlewareWithConfig wires the subscription middleware with custom configuration. // // # Usage // // config := &subscription.MiddlewareConfig{ // UpgradeURL: "/settings/billing", // } // if err := subscription.SetupMiddlewareWithConfig(container, config); err != nil { // return err // } func SetupMiddlewareWithConfig(container *dig.Container, config *MiddlewareConfig) error { if err := container.Provide(func( provider SubscriptionStatusProvider, ) *Middleware { return NewMiddleware(provider, config) }); err != nil { return fmt.Errorf("failed to provide subscription middleware: %w", err) } return nil } // RegisterNamedMiddlewares registers the paywall middleware functions with the server. // // This should be called after SetupMiddleware and the server is available. // It registers the following named middlewares: // - "paywall": RequireActiveSubscription middleware (blocks if inactive) // - "paywall_optional": OptionalSubscriptionStatus middleware (sets status, no blocking) // // For backward compatibility, these legacy names are also registered: // - "subscription" (deprecated, use "paywall") // - "subscription_optional" (deprecated, use "paywall_optional") // // # Usage // // if err := paywall.RegisterNamedMiddlewares(container); err != nil { // return err // } func RegisterNamedMiddlewares(container *dig.Container) error { return container.Invoke(func( middleware *Middleware, server ServerMiddlewareRegistrar, ) { // Register paywall middleware (requires active subscription) server.RegisterNamedMiddleware("paywall", func() gin.HandlerFunc { return middleware.RequireActiveSubscription() }) // Register optional paywall middleware (sets status but doesn't block) server.RegisterNamedMiddleware("paywall_optional", func() gin.HandlerFunc { return middleware.OptionalSubscriptionStatus() }) // Backward compatibility: legacy names (deprecated) server.RegisterNamedMiddleware("subscription", func() gin.HandlerFunc { return middleware.RequireActiveSubscription() }) server.RegisterNamedMiddleware("subscription_optional", func() gin.HandlerFunc { return middleware.OptionalSubscriptionStatus() }) }) } ================================================ FILE: go-b2b-starter/internal/modules/paywall/subscription.go ================================================ // Package paywall provides access gating middleware for B2B SaaS applications. // // This package abstracts away the billing provider (Polar, Stripe, Paddle, etc.) // and provides a clean interface for checking subscription status before allowing // access to protected resources. It acts as a "payment bouncer" - checking if an // organization has an active subscription before granting access to premium features. // // # Architecture // // The paywall package follows the adapter pattern, similar to the auth package: // // ┌─────────────────────────────────────────────────────────────────┐ // │ Application Layer │ // │ (handlers, services - use paywall.GetSubscriptionStatus) │ // └─────────────────────────────────────────────────────────────────┘ // │ // ▼ // ┌─────────────────────────────────────────────────────────────────┐ // │ paywall package │ // │ • SubscriptionStatusProvider interface │ // │ • SubscriptionStatus (provider-agnostic status) │ // │ • Middleware (RequireActiveSubscription) │ // │ • Type-safe context helpers │ // └─────────────────────────────────────────────────────────────────┘ // │ // ▼ // ┌─────────────────────────────────────────────────────────────────┐ // │ app/billing module adapter │ // │ (Polar/Stripe-specific - hidden from app layer) │ // └─────────────────────────────────────────────────────────────────┘ // // # Event-Driven Integration // // The paywall package does NOT manage subscriptions directly. It only reads // subscription status from local database. The billing module (app/billing) // handles subscription lifecycle via webhooks and events: // // - Polar/Stripe sends webhook → billing module processes it // - billing module updates local DB → paywall reads from DB // - No direct coupling between paywall and billing providers // // # Usage // // In routes: // // paywallMiddleware := paywall.NewMiddleware(provider, nil) // router.Use( // auth.RequireAuth(authProvider), // auth.RequireOrganization(orgRepo, accountRepo), // paywallMiddleware.RequireActiveSubscription(), // ) // // In handlers: // // func Handler(c *gin.Context) { // status := paywall.GetSubscriptionStatus(c) // if status != nil && status.IsActive { // // Subscription is active // } // } // // # The "Swiss Cheese" Strategy // // NOT all routes should require active subscription. Users with failed payments // need access to billing/settings to fix their payment method: // // - Protected routes: AI features, OCR, reports, expensive operations // - Unprotected routes: Billing portal, settings, profile, webhooks package paywall import ( "context" "time" ) // SubscriptionStatusProvider abstracts how subscription status is retrieved. // // The subscriptions module implements this interface. The middleware package // doesn't know about Polar, Stripe, or any specific provider. // // Implementations should: // - Read from local database only (for speed) // - Never call external APIs during request handling // - Return appropriate errors when subscription is missing type SubscriptionStatusProvider interface { // GetSubscriptionStatus checks if organization has an active subscription. // Returns status from local database only (fast, no external API calls). // The organizationID is the database primary key (int32). GetSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error) // RefreshSubscriptionStatus forces a sync from the payment provider API. // This is the lazy guarding mechanism - used when DB says expired but we want // to double-check with the provider in case we missed a webhook. // Returns updated status after syncing with provider. RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error) } // SubscriptionStatus represents the organization's billing state. // // This is a provider-agnostic representation of subscription status. // The status is typically synced from the payment provider via webhooks. type SubscriptionStatus struct { // OrganizationID is the database primary key for the organization. OrganizationID int32 `json:"organization_id"` // IsActive indicates whether the subscription allows access to protected features. // True for "active" and "trialing" statuses. IsActive bool `json:"is_active"` // Status is the raw subscription status from the provider. // Common values: "active", "trialing", "past_due", "canceled", "unpaid" Status string `json:"status"` // ExpiresAt is when the current billing period ends. // After this time, the subscription may need renewal. ExpiresAt time.Time `json:"expires_at,omitempty"` // Reason provides a human-readable explanation when IsActive is false. // Examples: "subscription expired", "payment failed", "no subscription found" Reason string `json:"reason,omitempty"` } // IsTrialing returns true if the subscription is in a trial period. func (s *SubscriptionStatus) IsTrialing() bool { return s.Status == StatusTrialing } // IsPastDue returns true if the subscription has a failed payment. func (s *SubscriptionStatus) IsPastDue() bool { return s.Status == StatusPastDue } // IsCanceled returns true if the subscription has been canceled. func (s *SubscriptionStatus) IsCanceled() bool { return s.Status == StatusCanceled } // Subscription status constants. // These map to common status values from payment providers. const ( StatusActive = "active" StatusTrialing = "trialing" StatusPastDue = "past_due" StatusCanceled = "canceled" StatusUnpaid = "unpaid" StatusNone = "none" // No subscription exists ) // IsActiveStatus returns true if the given status represents an active subscription. // Active statuses allow access to protected features. func IsActiveStatus(status string) bool { switch status { case StatusActive, StatusTrialing: return true default: return false } } ================================================ FILE: go-b2b-starter/internal/platform/eventbus/bus.go ================================================ package eventbus import ( "context" "fmt" "reflect" "sync" ) // EventBus handles publishing and subscribing to events type EventBus interface { // Publish publishes an event to all subscribers Publish(ctx context.Context, event Event) error // Subscribe registers a handler for a specific event type Subscribe(eventName string, handler EventHandler[Event]) error // Unsubscribe removes a handler for a specific event type Unsubscribe(eventName string, handler EventHandler[Event]) error // Close gracefully shuts down the event bus Close() error } // InMemoryEventBus is an in-memory implementation of EventBus type InMemoryEventBus struct { mu sync.RWMutex subscribers map[string][]EventHandler[Event] middleware []EventMiddleware closed bool } func NewInMemoryEventBus(middleware ...EventMiddleware) EventBus { return &InMemoryEventBus{ subscribers: make(map[string][]EventHandler[Event]), middleware: middleware, closed: false, } } // Publish publishes an event to all registered handlers func (bus *InMemoryEventBus) Publish(ctx context.Context, event Event) error { bus.mu.RLock() if bus.closed { bus.mu.RUnlock() return fmt.Errorf("event bus is closed") } handlers := make([]EventHandler[Event], len(bus.subscribers[event.EventName()])) copy(handlers, bus.subscribers[event.EventName()]) bus.mu.RUnlock() if len(handlers) == 0 { return nil } // Execute handlers concurrently var wg sync.WaitGroup errCh := make(chan error, len(handlers)) for i, handler := range handlers { wg.Add(1) go func(handlerIndex int, h EventHandler[Event]) { defer wg.Done() // Apply middleware chain finalHandler := h for i := len(bus.middleware) - 1; i >= 0; i-- { finalHandler = bus.middleware[i](finalHandler) } if err := finalHandler(ctx, event); err != nil { errCh <- fmt.Errorf("handler error for event %s: %w", event.EventName(), err) } }(i, handler) } // Wait for all handlers to complete wg.Wait() close(errCh) // Collect any errors var errors []error for err := range errCh { errors = append(errors, err) } if len(errors) > 0 { return fmt.Errorf("event handling errors: %v", errors) } return nil } // Subscribe registers a handler for a specific event type func (bus *InMemoryEventBus) Subscribe(eventName string, handler EventHandler[Event]) error { bus.mu.Lock() defer bus.mu.Unlock() if bus.closed { return fmt.Errorf("event bus is closed") } bus.subscribers[eventName] = append(bus.subscribers[eventName], handler) return nil } // Unsubscribe removes a handler for a specific event type func (bus *InMemoryEventBus) Unsubscribe(eventName string, handler EventHandler[Event]) error { bus.mu.Lock() defer bus.mu.Unlock() handlers := bus.subscribers[eventName] for i, h := range handlers { // Compare function pointers if reflect.ValueOf(h).Pointer() == reflect.ValueOf(handler).Pointer() { bus.subscribers[eventName] = append(handlers[:i], handlers[i+1:]...) break } } return nil } // Close gracefully shuts down the event bus func (bus *InMemoryEventBus) Close() error { bus.mu.Lock() defer bus.mu.Unlock() bus.closed = true bus.subscribers = make(map[string][]EventHandler[Event]) return nil } // GetSubscriberCount returns the number of subscribers for an event (for testing/debugging) func (bus *InMemoryEventBus) GetSubscriberCount(eventName string) int { bus.mu.RLock() defer bus.mu.RUnlock() return len(bus.subscribers[eventName]) } ================================================ FILE: go-b2b-starter/internal/platform/eventbus/cmd/init.go ================================================ package cmd import "go.uber.org/dig" func Init(container *dig.Container) error { if err := ProvideEventBus(container); err != nil { return err } return nil } ================================================ FILE: go-b2b-starter/internal/platform/eventbus/cmd/provider.go ================================================ package cmd import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/platform/eventbus" "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) // ProvideEventBus creates and configures the event bus with middleware func ProvideEventBus(container *dig.Container) error { return container.Provide(func(logger domain.Logger) eventbus.EventBus { middleware := []eventbus.EventMiddleware{ eventbus.RecoveryMiddleware(logger), eventbus.LoggingMiddleware(logger), eventbus.MetricsMiddleware(), } return eventbus.NewInMemoryEventBus(middleware...) }) } ================================================ FILE: go-b2b-starter/internal/platform/eventbus/event.go ================================================ package eventbus import ( "context" "time" ) // Event represents a domain event that can be published and subscribed to type Event interface { // EventName returns the unique name/type of the event EventName() string // EventID returns a unique identifier for this specific event instance EventID() string // Timestamp returns when the event was created Timestamp() time.Time // Metadata returns additional event metadata Metadata() map[string]interface{} } // BaseEvent provides common event functionality type BaseEvent struct { ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` Meta map[string]interface{} `json:"metadata,omitempty"` } func (e BaseEvent) EventName() string { return e.Name } func (e BaseEvent) EventID() string { return e.ID } func (e BaseEvent) Timestamp() time.Time { return e.CreatedAt } func (e BaseEvent) Metadata() map[string]interface{} { return e.Meta } // EventHandler represents a function that handles an event type EventHandler[T Event] func(ctx context.Context, event T) error // EventMiddleware can be used to add cross-cutting concerns like logging, metrics, etc. type EventMiddleware func(next EventHandler[Event]) EventHandler[Event] ================================================ FILE: go-b2b-starter/internal/platform/eventbus/events.go ================================================ package eventbus import ( "time" "github.com/google/uuid" "github.com/shopspring/decimal" ) // Common event types used across modules // Invoice Events type InvoiceUploaded struct { BaseEvent InvoiceID int32 `json:"invoice_id"` FileID int32 `json:"file_id"` VendorName string `json:"vendor_name,omitempty"` Amount decimal.Decimal `json:"amount,omitempty"` UserID int32 `json:"user_id"` } func NewInvoiceUploaded(invoiceID, fileID, userID int32, vendorName string, amount decimal.Decimal) *InvoiceUploaded { return &InvoiceUploaded{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "invoice.uploaded", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, FileID: fileID, VendorName: vendorName, Amount: amount, UserID: userID, } } type InvoiceValidated struct { BaseEvent InvoiceID int32 `json:"invoice_id"` FileID int32 `json:"file_id"` ValidationData map[string]interface{} `json:"validation_data"` } func NewInvoiceValidated(invoiceID, fileID int32, validationData map[string]interface{}) *InvoiceValidated { return &InvoiceValidated{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "invoice.validated", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, FileID: fileID, ValidationData: validationData, } } // OCR Events type OCRRequested struct { BaseEvent InvoiceID int32 `json:"invoice_id"` FileID int32 `json:"file_id"` } func NewOCRRequested(invoiceID, fileID int32) *OCRRequested { return &OCRRequested{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "ocr.requested", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, FileID: fileID, } } type TextExtracted struct { BaseEvent InvoiceID int32 `json:"invoice_id"` FileID int32 `json:"file_id"` ExtractedData map[string]interface{} `json:"extracted_data"` Confidence float64 `json:"confidence"` } func NewTextExtracted(invoiceID, fileID int32, extractedData map[string]interface{}, confidence float64) *TextExtracted { return &TextExtracted{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "text.extracted", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, FileID: fileID, ExtractedData: extractedData, Confidence: confidence, } } // Duplicate Detection Events type DuplicateCheckRequested struct { BaseEvent InvoiceID int32 `json:"invoice_id"` Data map[string]interface{} `json:"data"` } func NewDuplicateCheckRequested(invoiceID int32, data map[string]interface{}) *DuplicateCheckRequested { return &DuplicateCheckRequested{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "duplicate.check_requested", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, Data: data, } } type DuplicateDetected struct { BaseEvent InvoiceID int32 `json:"invoice_id"` DuplicateOf int32 `json:"duplicate_of"` SimilarityScore float64 `json:"similarity_score"` RequiresReview bool `json:"requires_review"` } func NewDuplicateDetected(invoiceID, duplicateOf int32, similarityScore float64, requiresReview bool) *DuplicateDetected { return &DuplicateDetected{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "duplicate.detected", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, DuplicateOf: duplicateOf, SimilarityScore: similarityScore, RequiresReview: requiresReview, } } type UniqueConfirmed struct { BaseEvent InvoiceID int32 `json:"invoice_id"` } func NewUniqueConfirmed(invoiceID int32) *UniqueConfirmed { return &UniqueConfirmed{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "duplicate.unique_confirmed", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, } } // Approval Events type ApprovalRequested struct { BaseEvent InvoiceID int32 `json:"invoice_id"` Amount decimal.Decimal `json:"amount"` VendorID int32 `json:"vendor_id"` RequesterID int32 `json:"requester_id"` ApprovalLevel int `json:"approval_level"` } func NewApprovalRequested(invoiceID, vendorID, requesterID int32, amount decimal.Decimal, approvalLevel int) *ApprovalRequested { return &ApprovalRequested{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "approval.requested", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, Amount: amount, VendorID: vendorID, RequesterID: requesterID, ApprovalLevel: approvalLevel, } } type ApprovalGranted struct { BaseEvent InvoiceID int32 `json:"invoice_id"` ApproverID int32 `json:"approver_id"` ApprovalID int32 `json:"approval_id"` Comments string `json:"comments,omitempty"` } func NewApprovalGranted(invoiceID, approverID, approvalID int32, comments string) *ApprovalGranted { return &ApprovalGranted{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "approval.granted", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, ApproverID: approverID, ApprovalID: approvalID, Comments: comments, } } type ApprovalRejected struct { BaseEvent InvoiceID int32 `json:"invoice_id"` ApproverID int32 `json:"approver_id"` ApprovalID int32 `json:"approval_id"` Reason string `json:"reason"` } func NewApprovalRejected(invoiceID, approverID, approvalID int32, reason string) *ApprovalRejected { return &ApprovalRejected{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "approval.rejected", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, ApproverID: approverID, ApprovalID: approvalID, Reason: reason, } } // Payment Events type PaymentScheduled struct { BaseEvent InvoiceID int32 `json:"invoice_id"` PaymentID int32 `json:"payment_id"` ScheduledDate time.Time `json:"scheduled_date"` Amount decimal.Decimal `json:"amount"` DiscountCaptured decimal.Decimal `json:"discount_captured"` OptimalPayment bool `json:"optimal_payment"` } func NewPaymentScheduled(invoiceID, paymentID int32, scheduledDate time.Time, amount, discountCaptured decimal.Decimal, optimalPayment bool) *PaymentScheduled { return &PaymentScheduled{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "payment.scheduled", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, PaymentID: paymentID, ScheduledDate: scheduledDate, Amount: amount, DiscountCaptured: discountCaptured, OptimalPayment: optimalPayment, } } type PaymentExecuted struct { BaseEvent InvoiceID int32 `json:"invoice_id"` PaymentID int32 `json:"payment_id"` OrganizationID int32 `json:"organization_id"` TransactionID string `json:"transaction_id"` Amount decimal.Decimal `json:"amount"` DiscountCaptured decimal.Decimal `json:"discount_captured"` ExecutedDate time.Time `json:"executed_date"` } func NewPaymentExecuted(invoiceID, paymentID, organizationID int32, transactionID string, amount, discountCaptured decimal.Decimal, executedDate time.Time) *PaymentExecuted { return &PaymentExecuted{ BaseEvent: BaseEvent{ ID: uuid.New().String(), Name: "payment.executed", CreatedAt: time.Now(), Meta: make(map[string]interface{}), }, InvoiceID: invoiceID, PaymentID: paymentID, OrganizationID: organizationID, TransactionID: transactionID, Amount: amount, DiscountCaptured: discountCaptured, ExecutedDate: executedDate, } } ================================================ FILE: go-b2b-starter/internal/platform/eventbus/middleware.go ================================================ package eventbus import ( "context" "fmt" "runtime/debug" "time" "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) // LoggingMiddleware adds logging to event handling func LoggingMiddleware(logger domain.Logger) EventMiddleware { return func(next EventHandler[Event]) EventHandler[Event] { return func(ctx context.Context, event Event) error { start := time.Now() logger.Info("Processing event", map[string]interface{}{ "event_name": event.EventName(), "event_id": event.EventID(), "timestamp": event.Timestamp(), }) err := next(ctx, event) duration := time.Since(start) if err != nil { logger.Error("Event processing failed", map[string]interface{}{ "event_name": event.EventName(), "event_id": event.EventID(), "error": err.Error(), "duration": duration, }) } else { logger.Info("Event processed successfully", map[string]interface{}{ "event_name": event.EventName(), "event_id": event.EventID(), "duration": duration, }) } return err } } } // RecoveryMiddleware recovers from panics in event handlers func RecoveryMiddleware(logger domain.Logger) EventMiddleware { return func(next EventHandler[Event]) EventHandler[Event] { return func(ctx context.Context, event Event) (err error) { defer func() { if r := recover(); r != nil { stack := debug.Stack() // Get event metadata size for debugging metadata := event.Metadata() metadataSize := len(fmt.Sprintf("%+v", metadata)) logger.Error("Event handler panicked", map[string]interface{}{ "event_name": event.EventName(), "event_id": event.EventID(), "event_timestamp": event.Timestamp(), "panic": r, "stack_trace": string(stack), "metadata_size": metadataSize, "metadata_keys": getMapKeys(metadata), "recovery_context": "eventbus_middleware", }) err = fmt.Errorf("event handler panicked: %v", r) } }() return next(ctx, event) } } } // Helper function to safely extract map keys func getMapKeys(m map[string]interface{}) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // MetricsMiddleware adds metrics collection to event handling func MetricsMiddleware() EventMiddleware { return func(next EventHandler[Event]) EventHandler[Event] { return func(ctx context.Context, event Event) error { start := time.Now() err := next(ctx, event) duration := time.Since(start) // Here you could send metrics to Prometheus, StatsD, etc. // For now, we'll just log the metrics _ = duration // Placeholder for actual metrics implementation return err } } } ================================================ FILE: go-b2b-starter/internal/platform/llm/README.md ================================================ # LLM Module Guide Simple guide for using AI completions and embeddings in your modules. ## Setup Add to your `.env`: ```bash OPENAI_API_KEY=your-api-key-here OPENAI_MODEL=gpt-4 ``` ## Usage in Your Module ### 1. Inject the LLM Client ```go import "github.com/moasq/go-b2b-starter/pkg/llm/domain" type YourService struct { llmClient domain.LLMClient } func NewYourService(llmClient domain.LLMClient) *YourService { return &YourService{llmClient: llmClient} } ``` ### 2. Use Completions (Prompts) Send a prompt, get AI-generated text: ```go func (s *YourService) GenerateText(ctx context.Context, prompt string) (string, error) { req := domain.CompletionRequest{ Prompt: prompt, } response, err := s.llmClient.Complete(ctx, req) if err != nil { return "", err } return response.Text, nil } ``` With options: ```go maxTokens := 200 temperature := float32(0.7) // 0.0 = focused, 1.0 = creative req := domain.CompletionRequest{ Prompt: prompt, MaxTokens: &maxTokens, Temperature: &temperature, } ``` ### 3. Use Embeddings (Vectors) Convert text to vectors for semantic search: ```go func (s *DocumentService) GenerateEmbedding(ctx context.Context, text string) ([]float32, error) { embedding, err := s.llmClient.GenerateEmbedding( ctx, text, "text-embedding-3-small", ) if err != nil { return nil, err } // Convert []float64 to []float32 for database result := make([]float32, len(embedding)) for i, v := range embedding { result[i] = float32(v) } return result, nil } ``` ## Configuration | Variable | Default | Description | |----------|---------|-------------| | `OPENAI_API_KEY` | *required* | Your OpenAI API key | | `OPENAI_MODEL` | `gpt-4` | AI model | | `OPENAI_MAX_TOKENS` | `500` | Max response length | | `OPENAI_TEMPERATURE` | `0.7` | Creativity level (0.0-1.0) | | `LLM_TIMEOUT_SEC` | `60` | Request timeout | ## Common Models **Completions:** `gpt-4`, `gpt-4-turbo`, `gpt-3.5-turbo` **Embeddings:** `text-embedding-3-small` (recommended), `text-embedding-3-large` That's it! Just inject `LLMClient` and you're ready to use AI in your module. ================================================ FILE: go-b2b-starter/internal/platform/llm/cmd/init.go ================================================ package cmd import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/platform/llm/domain" "github.com/moasq/go-b2b-starter/internal/platform/llm/infra" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) func Init(container *dig.Container) error { // Register LLMClient (which includes LLMService) if err := container.Provide(func(logger loggerDomain.Logger) (domain.LLMClient, error) { config := infra.NewLLMConfig() return infra.NewOpenAIClient(config, logger) }); err != nil { return err } // Also register LLMService for backward compatibility return container.Provide(func(client domain.LLMClient) domain.LLMService { return client }) } ================================================ FILE: go-b2b-starter/internal/platform/llm/domain/errors.go ================================================ package domain import "errors" var ( ErrInvalidPrompt = errors.New("prompt cannot be empty") ErrProviderNotFound = errors.New("LLM provider not found") ErrAPIError = errors.New("LLM API error") ErrTimeout = errors.New("LLM request timeout") ) ================================================ FILE: go-b2b-starter/internal/platform/llm/domain/service.go ================================================ package domain import "context" type CompletionRequest struct { Prompt string MaxTokens *int Temperature *float32 } type CompletionResponse struct { Text string TokensUsed int Model string } type EmbeddingRequest struct { Text string Model string } type EmbeddingResponse struct { Embedding []float64 TokensUsed int Model string } type StreamChunk struct { Content string Done bool } type LLMService interface { Complete(ctx context.Context, request CompletionRequest) (*CompletionResponse, error) CompleteStream(ctx context.Context, request CompletionRequest, callback func(StreamChunk) error) (*CompletionResponse, error) } type LLMClient interface { LLMService GenerateEmbedding(ctx context.Context, text string, model string) ([]float64, error) } ================================================ FILE: go-b2b-starter/internal/platform/llm/infra/openai_client.go ================================================ package infra import ( "bufio" "bytes" "context" "crypto/rand" "encoding/json" "fmt" "io" "math/big" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/moasq/go-b2b-starter/internal/platform/llm/domain" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) type Config struct { APIKey string Model string MaxTokens int Temperature float32 TimeoutSec int MaxRetries int DebugMode bool } func (c Config) Validate() error { if c.APIKey == "" { return fmt.Errorf("API key is required") } if c.Model == "" { return fmt.Errorf("model is required") } return nil } // CircuitBreaker implements a simple circuit breaker pattern type CircuitBreaker struct { mu sync.RWMutex failureCount int64 successCount int64 lastFailureTime time.Time state string // "closed", "open", "half-open" maxFailures int resetTimeout time.Duration } func NewCircuitBreaker(maxFailures int, resetTimeout time.Duration) *CircuitBreaker { return &CircuitBreaker{ maxFailures: maxFailures, resetTimeout: resetTimeout, state: "closed", } } // CanExecute checks if a request can be executed based on circuit breaker state func (cb *CircuitBreaker) CanExecute() bool { cb.mu.Lock() defer cb.mu.Unlock() if cb.state == "closed" { return true } if cb.state == "open" { if time.Since(cb.lastFailureTime) > cb.resetTimeout { cb.state = "half-open" return true } return false } // half-open state - allow one request to test return true } // RecordSuccess records a successful execution func (cb *CircuitBreaker) RecordSuccess() { cb.mu.Lock() defer cb.mu.Unlock() cb.successCount++ if cb.state == "half-open" { cb.state = "closed" cb.failureCount = 0 } } // RecordFailure records a failed execution func (cb *CircuitBreaker) RecordFailure() { cb.mu.Lock() defer cb.mu.Unlock() cb.failureCount++ cb.lastFailureTime = time.Now() if cb.failureCount >= int64(cb.maxFailures) { cb.state = "open" } } // GetStats returns circuit breaker statistics func (cb *CircuitBreaker) GetStats() map[string]interface{} { cb.mu.RLock() defer cb.mu.RUnlock() return map[string]interface{}{ "state": cb.state, "failures": cb.failureCount, "successes": cb.successCount, "last_failure": cb.lastFailureTime, } } type OpenAIClient struct { config Config client *http.Client logger loggerDomain.Logger circuitBreaker *CircuitBreaker } type openAIRequest struct { Model string `json:"model"` Messages []openAIMessage `json:"messages"` MaxTokens int `json:"max_tokens"` Temperature *float32 `json:"temperature,omitempty"` Stop []string `json:"stop,omitempty"` Stream bool `json:"stream,omitempty"` } type ToolCall struct { ID string `json:"id"` Type string `json:"type"` // "function" Function struct { Name string `json:"name"` Arguments string `json:"arguments"` } `json:"function"` } type openAIMessage struct { Role string `json:"role"` Content string `json:"content"` Refusal string `json:"refusal,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` } type openAIResponse struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` Model string `json:"model"` Choices []openAIChoice `json:"choices"` Usage *openAIUsage `json:"usage,omitempty"` Error *openAIError `json:"error,omitempty"` } type openAIChoice struct { Index int `json:"index"` Message openAIMessage `json:"message"` FinishReason string `json:"finish_reason"` } type CompletionTokensDetails struct { ReasoningTokens int `json:"reasoning_tokens"` } type openAIUsage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` CachedTokens int `json:"cached_tokens,omitempty"` CompletionTokensDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"` } type openAIError struct { Message string `json:"message"` Type string `json:"type"` Param any `json:"param"` // can be string or null Code any `json:"code"` // can be string, number, or null } func NewLLMConfig() Config { maxTokens, _ := strconv.Atoi(getEnvOrDefault("OPENAI_MAX_TOKENS", "150")) temperature, _ := strconv.ParseFloat(getEnvOrDefault("OPENAI_TEMPERATURE", "0.1"), 32) timeoutSec, _ := strconv.Atoi(getEnvOrDefault("LLM_TIMEOUT_SEC", "60")) // Increased default for GPT-5 maxRetries, _ := strconv.Atoi(getEnvOrDefault("LLM_MAX_RETRIES", "2")) debugMode, _ := strconv.ParseBool(getEnvOrDefault("LLM_DEBUG_MODE", "false")) return Config{ APIKey: os.Getenv("OPENAI_API_KEY"), Model: getEnvOrDefault("OPENAI_MODEL", "gpt-5-mini"), MaxTokens: maxTokens, Temperature: float32(temperature), TimeoutSec: timeoutSec, MaxRetries: maxRetries, DebugMode: debugMode, } } func NewOpenAIClient(config Config, logger loggerDomain.Logger) (domain.LLMClient, error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } // Configure transport with keep-alive and proper timeouts transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, DisableCompression: false, ForceAttemptHTTP2: true, TLSHandshakeTimeout: 10 * time.Second, } // No global timeout - let per-request context control deadlines client := &http.Client{ Timeout: 0, Transport: transport, } // Initialize circuit breaker if enabled var circuitBreaker *CircuitBreaker if os.Getenv("LLM_CIRCUIT_BREAKER_ENABLED") == "true" { maxFailures := 3 // Default failure threshold resetTimeout := 30 * time.Second // Default reset timeout if val := os.Getenv("LLM_CIRCUIT_BREAKER_MAX_FAILURES"); val != "" { if parsed, err := strconv.Atoi(val); err == nil { maxFailures = parsed } } if val := os.Getenv("LLM_CIRCUIT_BREAKER_RESET_TIMEOUT"); val != "" { if parsed, err := time.ParseDuration(val); err == nil { resetTimeout = parsed } } circuitBreaker = NewCircuitBreaker(maxFailures, resetTimeout) logger.Info("Circuit breaker enabled for OpenAI client", map[string]interface{}{ "max_failures": maxFailures, "reset_timeout": resetTimeout, }) } return &OpenAIClient{ config: config, client: client, logger: logger, circuitBreaker: circuitBreaker, }, nil } func (c *OpenAIClient) Complete(ctx context.Context, request domain.CompletionRequest) (*domain.CompletionResponse, error) { if request.Prompt == "" { return nil, domain.ErrInvalidPrompt } maxTokens := c.config.MaxTokens if request.MaxTokens != nil { maxTokens = *request.MaxTokens } // Right-size tokens for field extraction - avoid excessive budgets if strings.HasPrefix(c.config.Model, "gpt-5") { // For GPT-5, use smaller budgets unless explicitly requested if maxTokens == c.config.MaxTokens && maxTokens > 200 { maxTokens = 128 // Reasonable default for most extraction tasks if c.config.DebugMode { fmt.Println("[DEBUG] Using optimized token budget for GPT-5:", maxTokens) } } } temperature := c.config.Temperature if request.Temperature != nil { temperature = *request.Temperature } openAIReq := openAIRequest{ Model: c.config.Model, Messages: []openAIMessage{ { Role: "user", Content: request.Prompt, }, }, MaxTokens: maxTokens, Stream: false, // Default to non-streaming for backward compatibility } // Only set temperature for models that support it (GPT-5 models don't accept custom temperature) if supportsTemperature(c.config.Model) { openAIReq.Temperature = &temperature } // Only set stop sequences for models that support them (GPT-5 models don't accept stop parameter) if supportsStop(c.config.Model) { openAIReq.Stop = []string{"\n\n", "\n---"} } // Enhanced request logging if c.config.DebugMode { logData := map[string]any{ "endpoint": "https://api.openai.com/v1/chat/completions", "model": c.config.Model, "input_length": len(request.Prompt), "max_tokens": maxTokens, "supports_temperature": supportsTemperature(c.config.Model), "supports_stop": supportsStop(c.config.Model), } if supportsTemperature(c.config.Model) { logData["temperature"] = temperature } if supportsStop(c.config.Model) { logData["stop_sequences"] = []string{"\n\n", "\n---"} } c.logger.Info("Starting OpenAI request", logData) debugMsg := fmt.Sprintf("[DEBUG] Starting OpenAI request - Model: %s | MaxTokens: %d", c.config.Model, maxTokens) if supportsTemperature(c.config.Model) { debugMsg += fmt.Sprintf(" | Temperature: %.1f", temperature) } else { debugMsg += " | Temperature: OMITTED" } if supportsStop(c.config.Model) { debugMsg += " | Stop: [\\n\\n, \\n---]" } else { debugMsg += " | Stop: OMITTED" } fmt.Println(debugMsg) } var response *domain.CompletionResponse var err error // Check circuit breaker before attempting requests if c.circuitBreaker != nil && !c.circuitBreaker.CanExecute() { stats := c.circuitBreaker.GetStats() c.logger.Warn("Circuit breaker is open, request blocked", map[string]any{ "model": c.config.Model, "breaker_state": stats["state"], "failures": stats["failures"], "successes": stats["successes"], }) return nil, fmt.Errorf("circuit breaker is open due to repeated failures") } // Retry with fresh context per attempt and exponential backoff with jitter for i := 0; i <= c.config.MaxRetries; i++ { // Create fresh context per attempt - THIS FIXES THE MAIN BUG callTimeout := time.Duration(c.config.TimeoutSec) * time.Second if strings.HasPrefix(c.config.Model, "gpt-5") { callTimeout += 30 * time.Second // Extra time for reasoning models } callCtx, cancel := context.WithTimeout(ctx, callTimeout) response, err = c.makeRequest(callCtx, openAIReq) cancel() // Always cancel to free resources if err == nil { // Record success in circuit breaker if c.circuitBreaker != nil { c.circuitBreaker.RecordSuccess() } break } // Categorize error and decide on retry strategy isTemp := isTemporaryError(err) isPerm := isPermanentError(err) // Only record failure in circuit breaker for temporary errors // Permanent errors (like invalid API key) shouldn't trip the breaker if c.circuitBreaker != nil && isTemp { c.circuitBreaker.RecordFailure() } // Don't retry permanent errors if isPerm { c.logger.Error("Permanent error detected, not retrying", map[string]any{ "model": c.config.Model, "error": err.Error(), "error_type": "permanent", "attempt": i + 1, }) break } if i < c.config.MaxRetries { c.logger.Warn("OpenAI request failed, retrying", map[string]any{ "attempt": i + 1, "max_retries": c.config.MaxRetries, "model": c.config.Model, "error": err.Error(), "error_type": map[bool]string{true: "temporary", false: "unknown"}[isTemp], "will_retry": true, }) // Exponential backoff with jitter backoff := time.Duration(1< 200 { maxTokens = 128 // Reasonable default for most extraction tasks if c.config.DebugMode { fmt.Println("[DEBUG] Using optimized token budget for GPT-5 streaming:", maxTokens) } } } temperature := c.config.Temperature if request.Temperature != nil { temperature = *request.Temperature } openAIReq := openAIRequest{ Model: c.config.Model, Messages: []openAIMessage{ { Role: "user", Content: request.Prompt, }, }, MaxTokens: maxTokens, Stream: true, // Enable streaming } // Only set temperature for models that support it if supportsTemperature(c.config.Model) { openAIReq.Temperature = &temperature } // Only set stop sequences for models that support them if supportsStop(c.config.Model) { openAIReq.Stop = []string{"\n\n", "\n---"} } if c.config.DebugMode { c.logger.Info("Starting OpenAI streaming request", map[string]any{ "model": c.config.Model, "max_tokens": maxTokens, "stream": true, }) } var response *domain.CompletionResponse var err error // Retry with fresh context per attempt for i := 0; i <= c.config.MaxRetries; i++ { callTimeout := time.Duration(c.config.TimeoutSec) * time.Second if strings.HasPrefix(c.config.Model, "gpt-5") { callTimeout += 30 * time.Second } callCtx, cancel := context.WithTimeout(ctx, callTimeout) response, err = c.makeStreamRequest(callCtx, openAIReq, callback) cancel() if err == nil { break } if i < c.config.MaxRetries { c.logger.Warn("OpenAI streaming request failed, retrying", map[string]any{ "attempt": i + 1, "max_retries": c.config.MaxRetries, "model": c.config.Model, "error": err.Error(), }) backoff := time.Duration(1< 0 { fmt.Println("[WARN] Model returned tool calls, but we don't support tools. Treating as error.") return nil, fmt.Errorf("model returned tool calls but tools are not supported for this operation") } // Handle refusal (model refused to respond) if strings.TrimSpace(msg.Refusal) != "" { fmt.Println("[ERROR] Model refusal:", msg.Refusal) return nil, fmt.Errorf("model refusal: %s", msg.Refusal) } // Only then check for empty content if strings.TrimSpace(msg.Content) == "" { fmt.Println("[ERROR] Empty content returned from OpenAI API, finish_reason:", choice.FinishReason) return nil, fmt.Errorf("empty assistant content (finish_reason=%s)", choice.FinishReason) } var totalTokens int var reasoningTokens int var outputTokens int if openAIResp.Usage != nil { totalTokens = openAIResp.Usage.TotalTokens outputTokens = openAIResp.Usage.CompletionTokens if openAIResp.Usage.CompletionTokensDetails != nil { reasoningTokens = openAIResp.Usage.CompletionTokensDetails.ReasoningTokens } } responseText := msg.Content if c.config.DebugMode { textPreview := responseText if len(textPreview) > 50 { textPreview = textPreview[:50] + "..." } if reasoningTokens > 0 { fmt.Printf("[DEBUG] OpenAI response - Text: %s | Total: %d tokens (Reasoning: %d, Output: %d)\n", textPreview, totalTokens, reasoningTokens, outputTokens) } else { fmt.Printf("[DEBUG] OpenAI response - Text: %s | Tokens: %d\n", textPreview, totalTokens) } } return &domain.CompletionResponse{ Text: responseText, TokensUsed: totalTokens, Model: openAIResp.Model, }, nil } func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } func supportsTemperature(model string) bool { // GPT-5 series (gpt-5, gpt-5-mini, gpt-5-nano) don't support custom temperature return !strings.HasPrefix(model, "gpt-5") } func supportsStop(model string) bool { // GPT-5 series don't support `stop` parameter on Chat Completions return !strings.HasPrefix(model, "gpt-5") } // GenerateEmbedding generates a vector embedding for the given text using OpenAI embeddings API func (c *OpenAIClient) GenerateEmbedding(ctx context.Context, text string, model string) ([]float64, error) { if text == "" { return nil, fmt.Errorf("text cannot be empty") } if model == "" { model = "text-embedding-3-small" // Default embedding model } embeddingReq := map[string]any{ "model": model, "input": text, } jsonData, err := json.Marshal(embeddingReq) if err != nil { return nil, fmt.Errorf("failed to marshal embedding request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/embeddings", bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create embedding request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.config.APIKey) resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make embedding request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) c.logger.Error("OpenAI embeddings API returned non-200 status", map[string]any{ "status_code": resp.StatusCode, "response_body": string(body), "model": model, }) return nil, fmt.Errorf("OpenAI embeddings API error (status %d): %s", resp.StatusCode, string(body)) } var embeddingResp struct { Data []struct { Embedding []float64 `json:"embedding"` } `json:"data"` Usage struct { TotalTokens int `json:"total_tokens"` } `json:"usage"` Error *openAIError `json:"error,omitempty"` } if err := json.NewDecoder(resp.Body).Decode(&embeddingResp); err != nil { return nil, fmt.Errorf("failed to decode embedding response: %w", err) } if embeddingResp.Error != nil { return nil, fmt.Errorf("OpenAI embeddings API error: %s", embeddingResp.Error.Message) } if len(embeddingResp.Data) == 0 { return nil, fmt.Errorf("no embedding data returned from OpenAI") } embedding := embeddingResp.Data[0].Embedding if len(embedding) == 0 { return nil, fmt.Errorf("empty embedding returned from OpenAI") } if c.config.DebugMode { c.logger.Info("Generated embedding", map[string]any{ "model": model, "text_length": len(text), "embedding_dims": len(embedding), "tokens_used": embeddingResp.Usage.TotalTokens, }) } return embedding, nil } type streamResponse struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` Model string `json:"model"` Choices []struct { Index int `json:"index"` Delta struct { Role string `json:"role,omitempty"` Content string `json:"content,omitempty"` } `json:"delta"` FinishReason *string `json:"finish_reason"` } `json:"choices"` } func (c *OpenAIClient) makeStreamRequest(ctx context.Context, request openAIRequest, callback func(domain.StreamChunk) error) (*domain.CompletionResponse, error) { jsonData, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("failed to marshal stream request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create stream request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.config.APIKey) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make stream request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) c.logger.Error("OpenAI streaming API returned non-200 status", map[string]any{ "status_code": resp.StatusCode, "response_body": string(body), }) return nil, fmt.Errorf("OpenAI streaming API error (status %d): %s", resp.StatusCode, string(body)) } var fullContent strings.Builder var totalTokens int var model string scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || line == "data: [DONE]" { continue } if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var streamResp streamResponse if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil { // Skip malformed JSON chunks continue } model = streamResp.Model if len(streamResp.Choices) > 0 { choice := streamResp.Choices[0] content := choice.Delta.Content if content != "" { fullContent.WriteString(content) // Call callback with chunk if callback != nil { if err := callback(domain.StreamChunk{ Content: content, Done: false, }); err != nil { return nil, fmt.Errorf("streaming callback error: %w", err) } } } if choice.FinishReason != nil && *choice.FinishReason != "" { // Final chunk if callback != nil { callback(domain.StreamChunk{ Content: "", Done: true, }) } break } } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("error reading stream: %w", err) } finalContent := fullContent.String() if strings.TrimSpace(finalContent) == "" { return nil, fmt.Errorf("empty content from streaming response") } // Estimate token usage (rough approximation) totalTokens = len(strings.Fields(finalContent)) + 10 // Add some overhead return &domain.CompletionResponse{ Text: finalContent, TokensUsed: totalTokens, Model: model, }, nil } // generateJitter creates random jitter for exponential backoff to avoid thundering herd func generateJitter(maxJitterMs int64) int64 { if maxJitterMs <= 0 { return 0 } // Generate random number between 0 and maxJitterMs n, err := rand.Int(rand.Reader, big.NewInt(maxJitterMs)) if err != nil { return maxJitterMs / 2 // fallback to half the max } return n.Int64() } // isTemporaryError determines if an error is temporary and should be retried func isTemporaryError(err error) bool { errStr := strings.ToLower(err.Error()) // Network-level errors that are typically temporary temporaryErrors := []string{ "connection reset by peer", "connection refused", "timeout", "context deadline exceeded", "temporary failure", "service unavailable", "bad gateway", "gateway timeout", "too many requests", "rate limit", "internal server error", } for _, tempErr := range temporaryErrors { if strings.Contains(errStr, tempErr) { return true } } return false } // isPermanentError determines if an error is permanent and should not be retried func isPermanentError(err error) bool { errStr := strings.ToLower(err.Error()) // Errors that indicate permanent issues permanentErrors := []string{ "invalid api key", "unauthorized", "forbidden", "not found", "bad request", "invalid request", "model not found", "quota exceeded", "billing", } for _, permErr := range permanentErrors { if strings.Contains(errStr, permErr) { return true } } return false } ================================================ FILE: go-b2b-starter/internal/platform/logger/cmd/init.go ================================================ package cmd import ( "go.uber.org/dig" ) func Init(container *dig.Container) { ProvideDependencies(container) } ================================================ FILE: go-b2b-starter/internal/platform/logger/cmd/provider.go ================================================ package cmd import ( "github.com/moasq/go-b2b-starter/internal/platform/logger" "go.uber.org/dig" ) func ProvideDependencies(container *dig.Container) { container.Provide(logger.New) } ================================================ FILE: go-b2b-starter/internal/platform/logger/domain/logger.go ================================================ package domain type Level int const ( DebugLevel Level = iota InfoLevel WarnLevel ErrorLevel FatalLevel ) type OutputType int const ( ConsoleOutput OutputType = iota FileOutput BothOutput ) type Fields = map[string]interface{} type Logger interface { Debug(msg string, fields ...Fields) Info(msg string, fields ...Fields) Warn(msg string, fields ...Fields) Error(msg string, fields ...Fields) Fatal(msg string, fields ...Fields) WithFields(fields Fields) Logger } ================================================ FILE: go-b2b-starter/internal/platform/logger/domain/options.go ================================================ package domain type Option func(*Options) type Options struct { Level Level Output OutputType FileOptions FileOptions } type FileOptions struct { Filename string MaxSize int MaxBackups int MaxAge int Compress bool } func WithLevel(level Level) Option { return func(o *Options) { o.Level = level } } func WithOutput(output OutputType) Option { return func(o *Options) { o.Output = output } } func WithFileOptions(fileOpts FileOptions) Option { return func(o *Options) { o.FileOptions = fileOpts } } ================================================ FILE: go-b2b-starter/internal/platform/logger/factory.go ================================================ package logger import ( "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" zerolog "github.com/moasq/go-b2b-starter/internal/platform/logger/internal/zerologger" ) func New(opts ...domain.Option) domain.Logger { options := &domain.Options{ Level: domain.InfoLevel, Output: domain.ConsoleOutput, FileOptions: domain.FileOptions{ Filename: "app.log", MaxSize: 100, MaxBackups: 3, MaxAge: 28, Compress: true, }, } for _, opt := range opts { opt(options) } return zerolog.NewLogger(options) } // Re-export types and constants for ease of use type ( Logger = domain.Logger Fields = domain.Fields Level = domain.Level Option = domain.Option ) var ( DebugLevel = domain.DebugLevel InfoLevel = domain.InfoLevel WarnLevel = domain.WarnLevel ErrorLevel = domain.ErrorLevel FatalLevel = domain.FatalLevel ConsoleOutput = domain.ConsoleOutput FileOutput = domain.FileOutput BothOutput = domain.BothOutput WithLevel = domain.WithLevel WithOutput = domain.WithOutput WithFileOptions = domain.WithFileOptions ) ================================================ FILE: go-b2b-starter/internal/platform/logger/internal/zerologger/factory.go ================================================ package zerolog import ( "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) func NewLogger(opts *domain.Options) domain.Logger { return newZerologLogger(opts) } ================================================ FILE: go-b2b-starter/internal/platform/logger/internal/zerologger/logger.go ================================================ package zerolog import ( "io" "os" "time" logger "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" "github.com/rs/zerolog" "gopkg.in/natefinch/lumberjack.v2" ) type zerologLogger struct { zl zerolog.Logger } func newZerologLogger(opts *logger.Options) logger.Logger { var output io.Writer switch opts.Output { case logger.ConsoleOutput: output = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} case logger.FileOutput: output = &lumberjack.Logger{ Filename: opts.FileOptions.Filename, MaxSize: opts.FileOptions.MaxSize, MaxBackups: opts.FileOptions.MaxBackups, MaxAge: opts.FileOptions.MaxAge, Compress: opts.FileOptions.Compress, } case logger.BothOutput: consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} fileWriter := &lumberjack.Logger{ Filename: opts.FileOptions.Filename, MaxSize: opts.FileOptions.MaxSize, MaxBackups: opts.FileOptions.MaxBackups, MaxAge: opts.FileOptions.MaxAge, Compress: opts.FileOptions.Compress, } output = zerolog.MultiLevelWriter(consoleWriter, fileWriter) default: output = os.Stdout } zerolog.TimeFieldFormat = time.RFC3339 zl := zerolog.New(output).With().Timestamp().Logger() // Set the log level zl = zl.Level(convertLogLevel(opts.Level)) return &zerologLogger{zl: zl} } func (l *zerologLogger) Debug(msg string, fields ...logger.Fields) { l.log(l.zl.Debug(), msg, fields...) } func (l *zerologLogger) Info(msg string, fields ...logger.Fields) { l.log(l.zl.Info(), msg, fields...) } func (l *zerologLogger) Warn(msg string, fields ...logger.Fields) { l.log(l.zl.Warn(), msg, fields...) } func (l *zerologLogger) Error(msg string, fields ...logger.Fields) { l.log(l.zl.Error(), msg, fields...) } func (l *zerologLogger) Fatal(msg string, fields ...logger.Fields) { l.log(l.zl.Fatal(), msg, fields...) } func (l *zerologLogger) WithFields(fields logger.Fields) logger.Logger { return &zerologLogger{zl: l.zl.With().Fields(fields).Logger()} } func (l *zerologLogger) log(event *zerolog.Event, msg string, fields ...logger.Fields) { if len(fields) > 0 { event.Fields(fields[0]) } event.Msg(msg) } func convertLogLevel(level logger.Level) zerolog.Level { switch level { case logger.DebugLevel: return zerolog.DebugLevel case logger.InfoLevel: return zerolog.InfoLevel case logger.WarnLevel: return zerolog.WarnLevel case logger.ErrorLevel: return zerolog.ErrorLevel case logger.FatalLevel: return zerolog.FatalLevel default: return zerolog.InfoLevel } } ================================================ FILE: go-b2b-starter/internal/platform/ocr/README.md ================================================ # OCR Module Guide Simple guide for extracting text from documents using OCR (Optical Character Recognition). ## Setup Add to your `.env`: ```bash MISTRAL_API_KEY=your-mistral-api-key-here ``` Optional: ```bash MISTRAL_OCR_ENDPOINT=https://api.mistral.ai/v1/ocr # Default OCR_TIMEOUT_SEC=120 # Default ``` ## Usage in Your Module ### 1. Inject the OCR Service ```go import "github.com/moasq/go-b2b-starter/pkg/ocr/domain" type InvoiceService struct { ocrService domain.OCRService } func NewInvoiceService(ocrService domain.OCRService) *InvoiceService { return &InvoiceService{ocrService: ocrService} } ``` ### 2. Extract Text from Document ```go func (s *InvoiceService) ProcessDocument(ctx context.Context, base64File string, mimeType string) (string, error) { // Extract text from the document response, err := s.ocrService.ExtractText(ctx, base64File, mimeType) if err != nil { return "", fmt.Errorf("OCR failed: %w", err) } // Use the extracted text s.logger.Info("OCR completed", map[string]any{ "pages": response.Pages, "confidence": response.Confidence, "text_length": len(response.Text), }) return response.Text, nil } ``` ### 3. Real-World Example: Invoice Processing ```go func (s *InvoiceService) ExtractInvoiceData(ctx context.Context, fileData []byte) (*Invoice, error) { // 1. Convert file to base64 base64File := base64.StdEncoding.EncodeToString(fileData) // 2. Extract text using OCR ocrResponse, err := s.ocrService.ExtractText(ctx, base64File, "application/pdf") if err != nil { return nil, err } // 3. Check confidence score if ocrResponse.Confidence < 0.7 { return nil, fmt.Errorf("OCR confidence too low: %.2f", ocrResponse.Confidence) } // 4. Parse the extracted text invoice := s.parseInvoiceText(ocrResponse.Text) return invoice, nil } ``` ## Response Structure The `OCRResponse` includes: ```go type OCRResponse struct { Text string // Extracted text from the document Pages int // Number of pages processed Confidence float32 // OCR confidence (0.0 to 1.0) } ``` ## Supported File Types - **PDF**: `application/pdf` - **Images**: `image/jpeg`, `image/png` - **Other formats** supported by Mistral OCR API ## Configuration | Variable | Default | Description | |----------|---------|-------------| | `MISTRAL_API_KEY` | *required* | Your Mistral API key | | `MISTRAL_OCR_ENDPOINT` | `https://api.mistral.ai/v1/ocr` | OCR API endpoint | | `OCR_TIMEOUT_SEC` | `120` | Request timeout in seconds | ## Best Practices **1. Validate confidence scores:** ```go if response.Confidence < 0.8 { // Low confidence - may need manual review } ``` **2. Handle timeouts:** ```go ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() response, err := s.ocrService.ExtractText(ctx, base64File, mimeType) ``` **3. Process large files in chunks:** For multi-page PDFs, OCR processes all pages automatically. Monitor the `Pages` field in the response. That's it! Just inject `OCRService` and extract text from any document. ================================================ FILE: go-b2b-starter/internal/platform/ocr/cmd/init.go ================================================ package cmd import ( "go.uber.org/dig" "github.com/moasq/go-b2b-starter/internal/platform/ocr/domain" "github.com/moasq/go-b2b-starter/internal/platform/ocr/infra" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) func Init(container *dig.Container) error { return container.Provide(func(logger loggerDomain.Logger) (domain.OCRService, error) { config := infra.NewOCRConfig() return infra.NewMistralOCRClient(config, logger) }) } ================================================ FILE: go-b2b-starter/internal/platform/ocr/domain/entity.go ================================================ package domain // OCRResponse represents the result of OCR text extraction type OCRResponse struct { Text string `json:"text"` // Extracted text Pages int `json:"pages"` // Number of pages processed Confidence float32 `json:"confidence"` // OCR confidence score (0.0 to 1.0) } ================================================ FILE: go-b2b-starter/internal/platform/ocr/domain/errors.go ================================================ package domain import "errors" var ( ErrInvalidInput = errors.New("invalid OCR input") ErrQuotaExceeded = errors.New("OCR quota exceeded") ErrUnsupportedFile = errors.New("unsupported file type") ErrAsyncJobFailed = errors.New("async OCR job failed") ErrJobNotFound = errors.New("OCR job not found") ErrAuthFailed = errors.New("OCR authentication failed") ErrTransientError = errors.New("OCR transient error") ErrNotFound = errors.New("OCR resource not found") ) ================================================ FILE: go-b2b-starter/internal/platform/ocr/domain/service.go ================================================ package domain import "context" // OCRService provides text extraction from files type OCRService interface { ExtractText(ctx context.Context, base64File string, mimeType string) (*OCRResponse, error) } ================================================ FILE: go-b2b-starter/internal/platform/ocr/infra/config.go ================================================ package infra import ( "fmt" "os" "strconv" ) type Config struct { MistralAPIKey string APIEndpoint string TimeoutSec int } func (c Config) Validate() error { if c.MistralAPIKey == "" { return fmt.Errorf("Mistral API key is required") } if c.APIEndpoint == "" { return fmt.Errorf("API endpoint is required") } return nil } func NewOCRConfig() Config { timeoutSec, _ := strconv.Atoi(getEnvOrDefault("OCR_TIMEOUT_SEC", "120")) return Config{ MistralAPIKey: os.Getenv("MISTRAL_API_KEY"), APIEndpoint: getEnvOrDefault("MISTRAL_OCR_ENDPOINT", "https://api.mistral.ai/v1/ocr"), TimeoutSec: timeoutSec, } } func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } ================================================ FILE: go-b2b-starter/internal/platform/ocr/infra/mistral_ocr_client.go ================================================ package infra import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/moasq/go-b2b-starter/internal/platform/ocr/domain" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) type MistralOCRClient struct { config Config client *http.Client logger loggerDomain.Logger } // Mistral API request/response structures type MistralOCRRequest struct { Model string `json:"model"` Document MistralDocument `json:"document"` IncludeImageBase64 bool `json:"include_image_base64"` } type MistralDocument struct { Type string `json:"type"` // "document_url" or "image_url" DocumentURL string `json:"document_url,omitempty"` ImageURL string `json:"image_url,omitempty"` } type MistralOCRResponse struct { Pages []MistralPage `json:"pages"` } type MistralPage struct { Index int `json:"index"` Markdown string `json:"markdown"` Images []MistralImage `json:"images,omitempty"` Bboxes []MistralBoundingBox `json:"bboxes,omitempty"` } type MistralImage struct { Base64 string `json:"base64,omitempty"` } type MistralBoundingBox struct { X float32 `json:"x"` Y float32 `json:"y"` Width float32 `json:"width"` Height float32 `json:"height"` Text string `json:"text,omitempty"` } func NewMistralOCRClient(config Config, logger loggerDomain.Logger) (domain.OCRService, error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } client := &http.Client{ Timeout: time.Duration(config.TimeoutSec) * time.Second, } return &MistralOCRClient{ config: config, client: client, logger: logger, }, nil } func (m *MistralOCRClient) ExtractText(ctx context.Context, base64File string, mimeType string) (*domain.OCRResponse, error) { m.logger.Info("Starting Mistral OCR extraction", map[string]any{ "mime_type": mimeType, }) // Validate file constraints if err := m.validateInput(base64File, mimeType); err != nil { return nil, err } // Build Mistral API request mistralRequest := m.buildMistralRequest(base64File, mimeType) // Make API call with retries mistralResponse, err := m.callMistralAPI(ctx, mistralRequest) if err != nil { return nil, err } // Convert response to domain format response := m.convertResponse(mistralResponse) m.logger.Info("Mistral OCR extraction completed", map[string]any{ "pages": response.Pages, "text_length": len(response.Text), "confidence": response.Confidence, }) return response, nil } func (m *MistralOCRClient) validateInput(base64File string, mimeType string) error { // Validate base64 file is not empty if base64File == "" { return domain.ErrInvalidInput } // Validate supported MIME types if !m.isSupportedMimeType(mimeType) { return domain.ErrUnsupportedFile } return nil } func (m *MistralOCRClient) isSupportedMimeType(mimeType string) bool { supportedTypes := []string{ "application/pdf", "image/jpeg", "image/jpg", "image/png", "image/tiff", "image/avif", "image/webp", } for _, supported := range supportedTypes { if mimeType == supported { return true } } return false } func (m *MistralOCRClient) buildMistralRequest(base64File string, mimeType string) MistralOCRRequest { mistralRequest := MistralOCRRequest{ Model: "mistral-ocr-latest", IncludeImageBase64: false, // Simplified - no layout extraction } // Determine document type based on MIME type and format as data URI if mimeType == "application/pdf" { dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64File) mistralRequest.Document = MistralDocument{ Type: "document_url", DocumentURL: dataURI, } } else if strings.HasPrefix(mimeType, "image/") { dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64File) mistralRequest.Document = MistralDocument{ Type: "image_url", ImageURL: dataURI, } } return mistralRequest } func (m *MistralOCRClient) callMistralAPI(ctx context.Context, mistralRequest MistralOCRRequest) (*MistralOCRResponse, error) { requestBody, err := json.Marshal(mistralRequest) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", m.config.APIEndpoint, bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+m.config.MistralAPIKey) resp, err := m.client.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } if resp.StatusCode == http.StatusUnauthorized { return nil, domain.ErrAuthFailed } if resp.StatusCode == http.StatusBadRequest { return nil, domain.ErrInvalidInput } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, resp.Status) } var response MistralOCRResponse if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return &response, nil } func (m *MistralOCRClient) convertResponse(mistralResponse *MistralOCRResponse) *domain.OCRResponse { // Concatenate all page markdown with form feed separators var fullText strings.Builder for i, page := range mistralResponse.Pages { if i > 0 { fullText.WriteString("\f") // Page separator } fullText.WriteString(page.Markdown) } // Calculate confidence based on content quality confidence := m.calculateConfidence(fullText.String(), len(mistralResponse.Pages)) return &domain.OCRResponse{ Text: fullText.String(), Pages: len(mistralResponse.Pages), Confidence: confidence, } } func (m *MistralOCRClient) calculateConfidence(text string, pages int) float32 { if len(text) == 0 { return 0.0 } // Base confidence for Mistral OCR confidence := float32(0.90) // Adjust based on text length (more text usually means better OCR) textLength := len(text) if textLength > 1000 { confidence += 0.05 } if textLength > 5000 { confidence += 0.03 } // Multi-page documents might have slightly lower confidence if pages > 5 { confidence -= 0.02 } // Cap at 1.0 if confidence > 1.0 { confidence = 1.0 } return confidence } ================================================ FILE: go-b2b-starter/internal/platform/ocr/infra/mock_ocr_client.go ================================================ package infra import ( "context" "strings" "time" "github.com/moasq/go-b2b-starter/internal/platform/ocr/domain" loggerDomain "github.com/moasq/go-b2b-starter/internal/platform/logger/domain" ) // MockOCRClient is a mock implementation for development/testing // In production, this would be replaced with actual Google Vision client type MockOCRClient struct { config Config logger loggerDomain.Logger } func NewMockOCRClient(config Config, logger loggerDomain.Logger) (domain.OCRService, error) { if err := config.Validate(); err != nil { return nil, err } return &MockOCRClient{ config: config, logger: logger, }, nil } func (m *MockOCRClient) ExtractText(ctx context.Context, base64File string, mimeType string) (*domain.OCRResponse, error) { m.logger.Info("Mock OCR extraction starting", map[string]any{ "mime_type": mimeType, }) // Simulate processing time time.Sleep(100 * time.Millisecond) // Mock extracted text based on file type var mockText string var pages int = 1 if mimeType == "application/pdf" { pages = 2 mockText = `INVOICE Invoice Number: INV-2024-001 Date: January 15, 2024 Bill To: ABC Company 123 Main Street City, State 12345 Description Qty Unit Price Total Professional Services 10 $150.00 $1,500.00 Consulting 5 $200.00 $1,000.00 Subtotal: $2,500.00 Tax: $250.00 Total: $2,750.00 Payment Terms: Net 30 Due Date: February 15, 2024` } else if strings.HasPrefix(mimeType, "image/") { mockText = `RECEIPT Store: Tech Solutions Inc. Date: 2024-01-10 Receipt #: R-789456 Items: - Software License $299.99 - Support Package $99.99 Subtotal: $399.98 Tax: $32.00 Total: $431.98 Thank you for your business!` } else { return nil, domain.ErrUnsupportedFile } response := &domain.OCRResponse{ Text: mockText, Pages: pages, Confidence: 0.95, } m.logger.Info("Mock OCR extraction completed", map[string]any{ "pages": pages, "text_length": len(mockText), "confidence": response.Confidence, }) return response, nil } ================================================ FILE: go-b2b-starter/internal/platform/polar/client.go ================================================ package polar import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) // Client provides a low-level HTTP client for Polar API // This is a generic HTTP wrapper - business logic should be in higher layers type Client struct { accessToken string baseURL string httpClient *http.Client debug bool } func NewClient(config *Config) (*Client, error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } return &Client{ accessToken: config.AccessToken, baseURL: config.BaseURL, httpClient: &http.Client{ Timeout: 30 * time.Second, }, debug: config.Debug, }, nil } // Get performs a GET request to the Polar API func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) { return c.doRequest(ctx, "GET", path, nil) } // Patch performs a PATCH request to the Polar API func (c *Client) Patch(ctx context.Context, path string, body interface{}) (*http.Response, error) { return c.doRequest(ctx, "PATCH", path, body) } // Post performs a POST request to the Polar API func (c *Client) Post(ctx context.Context, path string, body interface{}) (*http.Response, error) { return c.doRequest(ctx, "POST", path, body) } // doRequest performs an HTTP request to the Polar API func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { url := c.baseURL + path var bodyReader io.Reader if body != nil { bodyBytes, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } bodyReader = bytes.NewReader(bodyBytes) } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Set required headers req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if c.debug { fmt.Printf("[Polar Client] %s %s\n", method, url) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } // Check for HTTP errors if resp.StatusCode >= 400 { defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("Polar API error (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) } return resp, nil } // DecodeJSON is a helper to decode JSON response func DecodeJSON(resp *http.Response, v interface{}) error { defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(v); err != nil { return fmt.Errorf("failed to decode JSON response: %w", err) } return nil } ================================================ FILE: go-b2b-starter/internal/platform/polar/cmd/init.go ================================================ package cmd import ( "fmt" "github.com/moasq/go-b2b-starter/internal/platform/polar" "go.uber.org/dig" ) func Init(container *dig.Container) error { // Provide Polar configuration using viper if err := container.Provide(func() (*polar.Config, error) { config, err := polar.LoadConfig() if err != nil { return nil, fmt.Errorf("failed to load Polar configuration: %w", err) } return &config, nil }); err != nil { return fmt.Errorf("failed to provide Polar config: %w", err) } // Register Polar client if err := polar.Module(container); err != nil { return fmt.Errorf("failed to register Polar module: %w", err) } return nil } ================================================ FILE: go-b2b-starter/internal/platform/polar/config.go ================================================ package polar import ( "fmt" "github.com/spf13/viper" ) // Config holds configuration for the Polar client type Config struct { // AccessToken is the Polar Organization Access Token (OAT) // Required for all API requests AccessToken string `mapstructure:"POLAR_ACCESS_TOKEN"` // BaseURL is the Polar API endpoint // Use "https://api.polar.sh" for production // Use "https://sandbox-api.polar.sh" for testing BaseURL string `mapstructure:"POLAR_BASE_URL"` // WebhookSecret is the secret used to verify webhook signatures // Get this from Polar Dashboard → Settings → Webhooks WebhookSecret string `mapstructure:"WEBHOOK_SECRET"` // Debug enables debug logging Debug bool `mapstructure:"POLAR_DEBUG"` } // LoadConfig reads configuration from file or environment variables func LoadConfig() (Config, error) { var cfg Config viper.SetConfigName("app") viper.SetConfigType("env") viper.AddConfigPath(".") viper.AutomaticEnv() // Set default values viper.SetDefault("POLAR_BASE_URL", "https://api.polar.sh") viper.SetDefault("POLAR_DEBUG", false) // Best-effort: ignore missing file, allow env-only usage if err := viper.ReadInConfig(); err == nil { _ = err } if err := viper.Unmarshal(&cfg); err != nil { return cfg, fmt.Errorf("unable to decode polar config: %w", err) } // Validate required fields if err := cfg.Validate(); err != nil { return cfg, err } return cfg, nil } // Validate checks if the configuration is valid func (c *Config) Validate() error { if c.AccessToken == "" { return fmt.Errorf("polar access token is required (POLAR_ACCESS_TOKEN)") } if c.BaseURL == "" { return fmt.Errorf("polar base URL is required (POLAR_BASE_URL)") } // WebhookSecret is optional - only needed for webhook verification // If not provided, webhook signature verification will be skipped (with warning) return nil } // DefaultConfig returns a configuration with sane defaults for production func DefaultConfig() *Config { return &Config{ BaseURL: "https://api.polar.sh", Debug: false, } } // SandboxConfig returns a configuration with defaults for sandbox environment func SandboxConfig() *Config { return &Config{ BaseURL: "https://sandbox-api.polar.sh", Debug: true, } } ================================================ FILE: go-b2b-starter/internal/platform/polar/inject.go ================================================ package polar import ( "fmt" "go.uber.org/dig" ) // Module registers Polar package dependencies in the DI container func Module(container *dig.Container) error { // Register Polar client if err := container.Provide(NewClient); err != nil { return fmt.Errorf("failed to provide Polar client: %w", err) } return nil } ================================================ FILE: go-b2b-starter/internal/platform/polar/webhook.go ================================================ package polar import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "strings" ) // VerifyWebhookSignature verifies that a webhook request came from Polar // by validating the HMAC-SHA256 signature using the Standard Webhooks specification // // Polar.sh uses the Standard Webhooks format (same as Svix) where the signed content is: // {webhook-id}.{webhook-timestamp}.{body} // // Parameters: // - secret: The webhook secret from Polar Dashboard // - webhookID: The Webhook-Id header value // - timestamp: The Webhook-Timestamp header value // - payload: The raw request body (must be the exact bytes received) // - signature: The signature from the Webhook-Signature header // // Returns: // - error if verification fails, nil if successful func VerifyWebhookSignature(secret string, webhookID string, timestamp string, payload []byte, signature string) error { if secret == "" { return fmt.Errorf("webhook secret is not configured") } if signature == "" { return fmt.Errorf("webhook signature is missing from request") } if webhookID == "" { return fmt.Errorf("webhook ID is missing from request") } if timestamp == "" { return fmt.Errorf("webhook timestamp is missing from request") } // Strip version prefix (e.g., "v1,") from Polar's signature if strings.Contains(signature, ",") { parts := strings.Split(signature, ",") if len(parts) == 2 { signature = parts[1] // Get the actual signature after "v1," } } // Decode base64 signature to bytes (Polar sends base64-encoded HMAC) signatureBytes, err := base64.StdEncoding.DecodeString(signature) if err != nil { return fmt.Errorf("failed to decode signature: %w", err) } // Construct the signed content according to Standard Webhooks spec // Format: {webhook-id}.{webhook-timestamp}.{body} signedContent := webhookID + "." + timestamp + "." + string(payload) // Compute HMAC-SHA256 of the signed content mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signedContent)) expectedSignatureBytes := mac.Sum(nil) // Use constant-time comparison to prevent timing attacks if !hmac.Equal(signatureBytes, expectedSignatureBytes) { return fmt.Errorf("webhook signature verification failed: signature mismatch") } return nil } // ComputeWebhookSignature computes the HMAC-SHA256 signature for a payload // using the Standard Webhooks format // This is useful for testing webhook signature verification // Returns the signature in base64 format to match Polar's format func ComputeWebhookSignature(secret string, webhookID string, timestamp string, payload []byte) string { // Construct signed content: {webhook-id}.{webhook-timestamp}.{body} signedContent := webhookID + "." + timestamp + "." + string(payload) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signedContent)) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } ================================================ FILE: go-b2b-starter/internal/platform/redis/README.md ================================================ # Redis Module Guide Simple guide for using Redis cache in your modules. ## Setup Add to your `.env`: ```bash REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= # Optional REDIS_DB=0 # Default database ``` For local development, start Redis with Docker: ```bash make run-deps # Starts Redis and other dependencies ``` ## Usage in Your Module ### 1. Inject the Redis Client ```go import ( "github.com/moasq/go-b2b-starter/pkg/redis" ) type UserService struct { cache redis.Client } func NewUserService(cache redis.Client) *UserService { return &UserService{cache: cache} } ``` ### 2. Basic Operations **Set a value with TTL:** ```go func (s *UserService) CacheUser(ctx context.Context, userID string, data string) error { key := fmt.Sprintf("user:%s", userID) ttl := 1 * time.Hour return s.cache.Set(ctx, key, data, ttl) } ``` **Get a value:** ```go func (s *UserService) GetCachedUser(ctx context.Context, userID string) (string, error) { key := fmt.Sprintf("user:%s", userID) value, err := s.cache.Get(ctx, key) if err != nil { return "", err // redis.Nil if key doesn't exist } return value, nil } ``` **Check if key exists:** ```go exists, err := s.cache.Exists(ctx, "user:123") if exists { // Key is in cache } ``` **Delete a value:** ```go err := s.cache.Delete(ctx, "user:123") ``` ### 3. Real-World Example: Cache-Aside Pattern ```go func (s *UserService) GetUser(ctx context.Context, userID int32) (*User, error) { cacheKey := fmt.Sprintf("user:%d", userID) // 1. Try to get from cache first cached, err := s.cache.Get(ctx, cacheKey) if err == nil { // Cache hit - deserialize and return var user User json.Unmarshal([]byte(cached), &user) return &user, nil } // 2. Cache miss - get from database user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return nil, err } // 3. Store in cache for next time userJSON, _ := json.Marshal(user) s.cache.Set(ctx, cacheKey, string(userJSON), 5*time.Minute) return user, nil } ``` ### 4. Invalidation on Update ```go func (s *UserService) UpdateUser(ctx context.Context, userID int32, updates *UserUpdates) error { // 1. Update database err := s.repo.UpdateUser(ctx, userID, updates) if err != nil { return err } // 2. Invalidate cache cacheKey := fmt.Sprintf("user:%d", userID) s.cache.Delete(ctx, cacheKey) return nil } ``` ## Available Methods ```go type Client interface { Set(ctx context.Context, key string, value any, ttl time.Duration) error Get(ctx context.Context, key string) (string, error) Delete(ctx context.Context, key string) error Exists(ctx context.Context, key string) (bool, error) } ``` ## Configuration | Variable | Default | Description | |----------|---------|-------------| | `REDIS_HOST` | `localhost` | Redis server host | | `REDIS_PORT` | `6379` | Redis server port | | `REDIS_PASSWORD` | `` | Redis password (optional) | | `REDIS_DB` | `0` | Redis database number | ## Common Patterns **Session storage:** ```go sessionKey := fmt.Sprintf("session:%s", sessionID) s.cache.Set(ctx, sessionKey, userID, 24*time.Hour) ``` **Rate limiting:** ```go key := fmt.Sprintf("ratelimit:%s", userID) s.cache.Set(ctx, key, "1", 1*time.Minute) ``` **Temporary data:** ```go key := fmt.Sprintf("temp:%s", requestID) s.cache.Set(ctx, key, data, 5*time.Minute) ``` ## TTL Guidelines - **User sessions**: 24 hours - **API responses**: 5-15 minutes - **Rate limit counters**: 1 minute - **Temporary data**: 5-10 minutes That's it! Just inject `redis.Client` and start caching. ================================================ FILE: go-b2b-starter/internal/platform/redis/cmd/init.go ================================================ package cmd import ( "log" "go.uber.org/dig" ) func Init(dig *dig.Container) error { if err := provideRedisDependencies(dig); err != nil { log.Fatalf("Failed to provide Redis dependencies: %v", err) return err } return nil } ================================================ FILE: go-b2b-starter/internal/platform/redis/cmd/provider.go ================================================ package cmd import ( "fmt" "github.com/moasq/go-b2b-starter/internal/platform/redis" "go.uber.org/dig" ) func provideRedisDependencies(container *dig.Container) error { providers := []any{ redis.LoadConfig, provideRedisStore, } for _, provider := range providers { if err := container.Provide(provider); err != nil { return fmt.Errorf("failed to provide Redis dependency: %w", err) } } return nil } func provideRedisStore() (redis.Client, error) { return redis.InitRedis() } ================================================ FILE: go-b2b-starter/internal/platform/redis/config.go ================================================ package redis import ( "github.com/spf13/viper" ) type Config struct { Host string `mapstructure:"REDIS_HOST"` Port string `mapstructure:"REDIS_PORT"` Password string `mapstructure:"REDIS_PASSWORD"` DB int `mapstructure:"REDIS_DB"` } // LoadConfig reads configuration from file or environment variables. func LoadConfig() (Config, error) { var cfg Config viper.SetConfigName("app") viper.SetConfigType("env") viper.AddConfigPath(".") viper.AutomaticEnv() // Set default values viper.SetDefault("REDIS_HOST", "localhost") viper.SetDefault("REDIS_PORT", "6379") viper.SetDefault("REDIS_PASSWORD", "") viper.SetDefault("REDIS_DB", 0) if err := viper.ReadInConfig(); err == nil { _ = err } if err := viper.Unmarshal(&cfg); err != nil { return cfg, err } return cfg, nil } ================================================ FILE: go-b2b-starter/internal/platform/redis/init.go ================================================ package redis import "log" func InitRedis() (Client, error) { cfg, err := LoadConfig() if err != nil { log.Fatalf("Failed to load Redis configuration: %v", err) return nil, err } client, err := newRedisClient(cfg) if err != nil { log.Fatalf("Failed to initialize Redis connection: %v", err) return nil, err } return client, nil } ================================================ FILE: go-b2b-starter/internal/platform/redis/redis.go ================================================ package redis import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" ) type redisClient struct { rdb *redis.Client } func newRedisClient(cfg Config) (*redisClient, error) { rdb := redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), Password: cfg.Password, DB: cfg.DB, }) ctx := context.Background() if err := rdb.Ping(ctx).Err(); err != nil { return nil, fmt.Errorf("failed to connect to Redis: %w", err) } return &redisClient{rdb: rdb}, nil } func (c *redisClient) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { return c.rdb.Set(ctx, key, value, ttl).Err() } func (c *redisClient) Get(ctx context.Context, key string) (string, error) { return c.rdb.Get(ctx, key).Result() } func (c *redisClient) Delete(ctx context.Context, key string) error { return c.rdb.Del(ctx, key).Err() } func (c *redisClient) Exists(ctx context.Context, key string) (bool, error) { result, err := c.rdb.Exists(ctx, key).Result() return result > 0, err } ================================================ FILE: go-b2b-starter/internal/platform/redis/store.go ================================================ package redis import ( "context" "time" ) type Client interface { Set(ctx context.Context, key string, value any, ttl time.Duration) error Get(ctx context.Context, key string) (string, error) Delete(ctx context.Context, key string) error Exists(ctx context.Context, key string) (bool, error) } ================================================ FILE: go-b2b-starter/internal/platform/server/cmd/di.go ================================================ package cmd import ( "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/internal/modules/auth" "github.com/moasq/go-b2b-starter/internal/modules/paywall" "github.com/moasq/go-b2b-starter/internal/platform/server/config" "github.com/moasq/go-b2b-starter/internal/platform/server/domain" ginP "github.com/moasq/go-b2b-starter/internal/platform/server/gin" "github.com/moasq/go-b2b-starter/internal/platform/server/logging" "github.com/moasq/go-b2b-starter/internal/platform/server/middleware" "go.uber.org/dig" ) // serverMiddlewareAdapter adapts domain.Server to auth.ServerMiddlewareRegistrar type serverMiddlewareAdapter struct { server domain.Server } func (a *serverMiddlewareAdapter) RegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc) { // Convert func() gin.HandlerFunc to domain.MiddlewareFunc a.server.RegisterNamedMiddleware(name, domain.MiddlewareFunc(middleware)) } func SetupDependencies(container *dig.Container) { container.Provide(config.LoadConfig) container.Provide(logging.InitLogger) container.Provide(middleware.InitValidator) container.Provide(func(cfg *config.Config) *gin.Engine { return ginP.NewGinRouter(cfg).GetHandler() }) container.Provide(domain.NewHTTPServer) // Provide server as auth.ServerMiddlewareRegistrar for auth package container.Provide(func(srv domain.Server) auth.ServerMiddlewareRegistrar { return &serverMiddlewareAdapter{server: srv} }) // Provide server as paywall.ServerMiddlewareRegistrar for paywall package container.Provide(func(srv domain.Server) paywall.ServerMiddlewareRegistrar { return &serverMiddlewareAdapter{server: srv} }) } ================================================ FILE: go-b2b-starter/internal/platform/server/cmd/init.go ================================================ package cmd import "go.uber.org/dig" func Init(container *dig.Container) { SetupDependencies(container) } ================================================ FILE: go-b2b-starter/internal/platform/server/config/config.go ================================================ package config import ( "fmt" "strings" "github.com/spf13/viper" ) type Environment string const ( DEV Environment = "DEV" PROD Environment = "PROD" ) type Config struct { // Environment (cannot be disabled in production) Env Environment `mapstructure:"ENV"` // Server settings ServerAddress string `mapstructure:"SERVER_ADDRESS"` // Security settings (cannot be disabled in production) EnableTLS bool `mapstructure:"ENABLE_TLS"` // Must be true in production TLSCertPath string `mapstructure:"TLS_CERT_PATH"` // Required in production TLSKeyPath string `mapstructure:"TLS_KEY_PATH"` // Required in production // Rate limiting (cannot be disabled in production) RateLimitPerSecond int `mapstructure:"RATE_LIMIT_PER_SECOND"` // CORS settings (more restrictive in production) AllowedOrigins []string `mapstructure:"ALLOWED_ORIGINS"` // Logging (always enabled in production) LogLevel string `mapstructure:"LOG_LEVEL"` // Optional security features TrustedProxies []string `mapstructure:"TRUSTED_PROXIES"` MaxRequestSize int `mapstructure:"MAX_REQUEST_SIZE"` // IP Protection Settings IPWhitelist []string `mapstructure:"IP_WHITELIST"` IPBlacklist []string `mapstructure:"IP_BLACKLIST"` MaxFailedAttempts int `mapstructure:"MAX_FAILED_ATTEMPTS"` BlockDuration string `mapstructure:"BLOCK_DURATION"` // Request Sanitization DisableXSS bool `mapstructure:"DISABLE_XSS"` DisableSQLInjection bool `mapstructure:"DISABLE_SQL_INJECTION"` DisablePathTraversal bool `mapstructure:"DISABLE_PATH_TRAVERSAL"` // Security Logging SecurityLogPath string `mapstructure:"SECURITY_LOG_PATH"` LogRetentionDays int `mapstructure:"LOG_RETENTION_DAYS"` // Processing Settings ExtractionTimeoutSeconds int `mapstructure:"EXTRACTION_TIMEOUT_SECONDS"` // Duplicate Detection Settings DuplicateSimilarityThreshold float64 `mapstructure:"DUPLICATE_SIMILARITY_THRESHOLD"` DuplicateSearchLimit int32 `mapstructure:"DUPLICATE_SEARCH_LIMIT"` } // SanitizationConfig represents security sanitization settings type SanitizationConfig struct { DisableXSS bool DisableSQLInjection bool DisablePathTraversal bool } // GetSanitizationConfig returns sanitization configuration func (c *Config) GetSanitizationConfig() SanitizationConfig { return SanitizationConfig{ DisableXSS: c.DisableXSS, DisableSQLInjection: c.DisableSQLInjection, DisablePathTraversal: c.DisablePathTraversal, } } func (c *Config) IsProd() bool { return c.Env == PROD } // LoadConfig reads configuration from environment variables or .env files. func LoadConfig() (*Config, error) { var cfg *Config viper.SetConfigName("app") viper.SetConfigType("env") viper.AddConfigPath(".") viper.AutomaticEnv() // Set default values viper.SetDefault("ENV", "DEV") viper.SetDefault("SERVER_ADDRESS", ":8080") viper.SetDefault("RATE_LIMIT_PER_SECOND", 100) viper.SetDefault("MAX_REQUEST_SIZE", 1024*1024*10) // 10MB viper.SetDefault("LOG_LEVEL", "info") viper.SetDefault("MAX_FAILED_ATTEMPTS", 5) viper.SetDefault("BLOCK_DURATION", "15m") viper.SetDefault("DISABLE_XSS", false) viper.SetDefault("DISABLE_SQL_INJECTION", false) viper.SetDefault("DISABLE_PATH_TRAVERSAL", false) viper.SetDefault("SECURITY_LOG_PATH", "logs/security.log") viper.SetDefault("LOG_RETENTION_DAYS", 30) viper.SetDefault("EXTRACTION_TIMEOUT_SECONDS", 60) viper.SetDefault("DUPLICATE_SIMILARITY_THRESHOLD", 0.85) viper.SetDefault("DUPLICATE_SEARCH_LIMIT", 10) if err := viper.ReadInConfig(); err != nil { return nil, err } if err := viper.Unmarshal(&cfg); err != nil { return nil, err } // Validate production configuration if cfg.Env == PROD { if err := validateProductionConfig(cfg); err != nil { return nil, err } } return cfg, nil } func validateProductionConfig(cfg *Config) error { var errors []string // TLS must be enabled in production if !cfg.EnableTLS { errors = append(errors, "TLS must be enabled in production") } // TLS certificates must be provided in production if cfg.EnableTLS { if cfg.TLSCertPath == "" { errors = append(errors, "TLS certificate path must be provided in production") } if cfg.TLSKeyPath == "" { errors = append(errors, "TLS key path must be provided in production") } } // Allowed origins must be set in production if len(cfg.AllowedOrigins) == 0 { errors = append(errors, "Allowed origins must be set in production") } // Rate limiting must be reasonable in production if cfg.RateLimitPerSecond > 1000 { errors = append(errors, "Rate limit per second cannot exceed 1000 in production") } if len(errors) > 0 { return fmt.Errorf("invalid production configuration: %s", strings.Join(errors, "; ")) } // if cfg.DisableXSS || cfg.DisableSQLInjection || cfg.DisablePathTraversal { // errors = append(errors, "Security sanitization cannot be disabled in production") // } // if cfg.MaxFailedAttempts < 3 { // errors = append(errors, "MaxFailedAttempts must be at least 3 in production") // } // if cfg.SecurityLogPath == "" { // errors = append(errors, "SecurityLogPath must be set in production") // } return nil } ================================================ FILE: go-b2b-starter/internal/platform/server/domain/health.go ================================================ package domain import ( "net/http" "time" "github.com/gin-gonic/gin" ) func (s *HTTPServer) setupHealthCheck() { healthHandler := func(c *gin.Context) { if s.config.IsProd() { c.JSON(http.StatusOK, gin.H{"status": "OK"}) return } c.JSON(http.StatusOK, gin.H{ "status": "OK", "environment": s.config.Env, "version": "1.0.0", "timestamp": time.Now().UTC(), }) } // Register health endpoint at both paths s.router.GET("/health", healthHandler) s.router.GET("/api/health", healthHandler) s.logger.Info("Health check endpoints set up at /health and /api/health") } func (s *HTTPServer) setupRootEndpoint() { s.router.GET("/", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "service": "B2B SaaS Starter API", "version": "1.0.0", "status": "running", "health": "/api/health", "docs": "/api/docs", "timestamp": time.Now().UTC(), }) }) s.logger.Info("Root endpoint set up at /") } ================================================ FILE: go-b2b-starter/internal/platform/server/domain/http_server.go ================================================ package domain import ( "context" "net/http" "os" "os/signal" "syscall" "time" config "github.com/moasq/go-b2b-starter/internal/platform/server/config" "github.com/moasq/go-b2b-starter/internal/platform/server/logging" "github.com/moasq/go-b2b-starter/internal/platform/server/middleware" "github.com/gin-gonic/gin" ) type HTTPServer struct { config *config.Config router *gin.Engine logger *logging.Logger securityLogger *logging.SecurityLogger registrars map[string][]RouteRegistrar namedMiddlewares map[string]MiddlewareFunc ipProtection *middleware.IPProtection } func NewHTTPServer( config *config.Config, router *gin.Engine, logger *logging.Logger, ) Server { if config.IsProd() { gin.SetMode(gin.ReleaseMode) } ipProtection := middleware.NewIPProtection() server := &HTTPServer{ config: config, router: router, logger: logger, securityLogger: logging.NewSecurityLogger(logger.SugaredLogger), registrars: make(map[string][]RouteRegistrar), namedMiddlewares: make(map[string]MiddlewareFunc), ipProtection: ipProtection, } server.setupMiddleware() return server } // Start initializes and starts the HTTP server func (s *HTTPServer) Start() error { srv := s.createHTTPServer() s.setupHealthCheck() s.setupRootEndpoint() go s.startServer(srv) return s.handleGracefulShutdown(srv) } func (s *HTTPServer) MiddlewareResolver() MiddlewareResolver { return s } // RegisterRoutes registers route handlers with version support func (s *HTTPServer) RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) { v := "" if len(version) > 0 { v = version[0] } group := s.router.Group(prefix) if v != "" { group = group.Group("/" + v) } // Register routes immediately instead of storing for later registrar(group, s) } // RegisterNamedMiddleware registers a named middleware for later use func (s *HTTPServer) RegisterNamedMiddleware(name string, middleware MiddlewareFunc) { s.namedMiddlewares[name] = middleware s.logger.Info("Named middleware registered: " + name) } func (s *HTTPServer) createHTTPServer() *http.Server { return &http.Server{ Addr: s.config.ServerAddress, Handler: s.router, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, // Increased to accommodate auto-extraction processing IdleTimeout: 60 * time.Second, ReadHeaderTimeout: 5 * time.Second, MaxHeaderBytes: s.config.MaxRequestSize, } } func (s *HTTPServer) startServer(srv *http.Server) { s.logger.Info("Starting server on " + s.config.ServerAddress) var err error if s.config.IsProd() { err = srv.ListenAndServeTLS( s.config.TLSCertPath, s.config.TLSKeyPath, ) } else { err = srv.ListenAndServe() } if err != nil && err != http.ErrServerClosed { s.logger.Fatal("Failed to start server", err) } } func (s *HTTPServer) handleGracefulShutdown(srv *http.Server) error { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit s.logger.Info("Shutting down server...") // Stop IP Protection cleanup goroutine if s.ipProtection != nil { s.ipProtection.Stop() } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { s.logger.Fatal("Server forced to shutdown", err) } s.logger.Info("Server exited gracefully") return nil } // Get implements the MiddlewareResolver interface func (s *HTTPServer) Get(name string) gin.HandlerFunc { if middleware, exists := s.namedMiddlewares[name]; exists { return middleware() } // Return a no-op middleware if not found return func(c *gin.Context) { s.logger.Warnw("Middleware not found", "name", name) c.Next() } } // GetMiddleware returns a middleware by name (compatibility method) func (s *HTTPServer) GetMiddleware(name string) gin.HandlerFunc { return s.Get(name) } ================================================ FILE: go-b2b-starter/internal/platform/server/domain/middleware.go ================================================ package domain import ( "time" "github.com/moasq/go-b2b-starter/internal/platform/server/middleware" "github.com/gin-gonic/gin" ) func (s *HTTPServer) setupMiddleware() { ipProtection := middleware.NewIPProtection() // Calculate timeout based on extraction timeout + buffer requestTimeout := time.Duration(s.config.ExtractionTimeoutSeconds+10) * time.Second // Add 10s buffer s.router.Use( middleware.RequestID(), ipProtection.Protect(), middleware.RequestSanitization(s.config.GetSanitizationConfig()), middleware.Recovery(s.logger), middleware.RequestSizeLimit(int64(s.config.MaxRequestSize)), middleware.Timeout(requestTimeout), middleware.RateLimiter(s.config.RateLimitPerSecond), middleware.CORS(s.config.AllowedOrigins), s.requestLoggingMiddleware(), ) // production only middleware if s.config.IsProd() { s.router.Use( middleware.SecurityHeaders(), ) } if len(s.config.TrustedProxies) > 0 { s.router.SetTrustedProxies(s.config.TrustedProxies) } } func (s *HTTPServer) requestLoggingMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // Skip health check logging in production if s.config.IsProd() && c.Request.URL.Path == "/health" { c.Next() return } start := time.Now() path := c.Request.URL.Path query := c.Request.URL.RawQuery requestID := middleware.GetRequestID(c) // Get request ID c.Next() s.logger.Infow("Request completed", "request_id", requestID, "status", c.Writer.Status(), "method", c.Request.Method, "path", path, "query", query, "ip", c.ClientIP(), "latency", time.Since(start), "user-agent", c.Request.UserAgent(), "bytes-out", c.Writer.Size(), ) } } ================================================ FILE: go-b2b-starter/internal/platform/server/domain/middleware_resolver.go ================================================ package domain import "github.com/gin-gonic/gin" // MiddlewareResolver provides access to named middleware functions type MiddlewareResolver interface { Get(name string) gin.HandlerFunc } ================================================ FILE: go-b2b-starter/internal/platform/server/domain/server.go ================================================ package domain import "github.com/gin-gonic/gin" // Constants for API versioning const ( ApiPrefix = "/api" ApiVersion1 = "v1" ) // RouteRegistrar is a function type for registering routes to a router group // domain/server.go type RouteRegistrar func(*gin.RouterGroup, MiddlewareResolver) // MiddlewareFunc is a function type that returns a Gin middleware handler type MiddlewareFunc func() gin.HandlerFunc // Server defines the interface for HTTP server operations // domain/server.go - Add to the Server interface // Server defines the interface for HTTP server operations type Server interface { Start() error RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) RegisterNamedMiddleware(name string, middleware MiddlewareFunc) MiddlewareResolver() MiddlewareResolver GetMiddleware(name string) gin.HandlerFunc // Keep this method for compatibility } ================================================ FILE: go-b2b-starter/internal/platform/server/domain/server_.go ================================================ package domain // import ( // "context" // "fmt" // "net/http" // "os" // "os/signal" // "syscall" // "time" // "github.com/gin-gonic/gin" // config "github.com/moasq/go-b2b-starter/internal/platform/server/config" // "github.com/moasq/go-b2b-starter/internal/platform/server/logging" // "github.com/moasq/go-b2b-starter/internal/platform/server/middleware" // ) // // Constants // const ( // ApiPrefix = "/api" // ApiVersion1 = "v1" // ) // // Types and interfaces // type RouteRegistrar func(*gin.RouterGroup) // type MiddlewareFunc func() gin.HandlerFunc // type Server interface { // Start() error // RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) // RegisterNamedMiddleware(name string, middleware MiddlewareFunc) // } // type HTTPServer struct { // config *config.Config // router *gin.Engine // logger *logging.Logger // securityLogger *logging.SecurityLogger // registrars map[string][]RouteRegistrar // namedMiddlewares map[string]MiddlewareFunc // } // // Constructor // func NewHTTPServer(config *config.Config, router *gin.Engine, logger *logging.Logger) Server { // if config.IsProd() { // gin.SetMode(gin.ReleaseMode) // } // server := &HTTPServer{ // config: config, // router: router, // logger: logger, // securityLogger: logging.NewSecurityLogger(logger.SugaredLogger), // registrars: make(map[string][]RouteRegistrar), // namedMiddlewares: make(map[string]MiddlewareFunc), // } // server.setupMiddleware() // return server // } // // Public methods // func (s *HTTPServer) Start() error { // srv := s.createHTTPServer() // // Register all routes // s.registerAllRoutes() // // Setup health check // s.setupHealthCheck() // // Start server // go s.startServer(srv) // return s.handleGracefulShutdown(srv) // } // func (s *HTTPServer) RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) { // v := "" // if len(version) > 0 { // v = version[0] // } // group := s.router.Group(prefix) // if v != "" { // group = group.Group("/" + v) // } // s.registrars[v] = append(s.registrars[v], func(g *gin.RouterGroup) { // registrar(group) // }) // } // func (s *HTTPServer) RegisterNamedMiddleware(name string, middleware MiddlewareFunc) { // s.namedMiddlewares[name] = middleware // s.logger.Info("Named middleware registered: " + name) // } // // Private methods - Server setup and management // func (s *HTTPServer) createHTTPServer() *http.Server { // return &http.Server{ // Addr: s.config.ServerAddress, // Handler: s.router, // ReadTimeout: 15 * time.Second, // WriteTimeout: 15 * time.Second, // IdleTimeout: 60 * time.Second, // ReadHeaderTimeout: 5 * time.Second, // MaxHeaderBytes: s.config.MaxRequestSize, // } // } // func (s *HTTPServer) startServer(srv *http.Server) { // s.logger.Info("Starting server on " + s.config.ServerAddress) // var err error // if s.config.IsProd() { // err = srv.ListenAndServeTLS( // s.config.TLSCertPath, // s.config.TLSKeyPath, // ) // } else { // err = srv.ListenAndServe() // } // if err != nil && err != http.ErrServerClosed { // s.logger.Fatal("Failed to start server", err) // } // } // func (s *HTTPServer) handleGracefulShutdown(srv *http.Server) error { // quit := make(chan os.Signal, 1) // signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // <-quit // s.logger.Info("Shutting down server...") // ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // defer cancel() // if err := srv.Shutdown(ctx); err != nil { // s.logger.Fatal("Server forced to shutdown", err) // } // s.logger.Info("Server exited gracefully") // return nil // } // func (s *HTTPServer) registerAllRoutes() { // for version, registrars := range s.registrars { // group := s.router.Group("/api") // if version != "" { // group = group.Group("/" + version) // } // for _, registrar := range registrars { // registrar(group) // } // } // } // // Private methods - Middleware setup // func (s *HTTPServer) setupMiddleware() { // ipProtection := middleware.NewIPProtection() // // Add core middleware // s.router.Use( // middleware.SecurityHeaders(), // ipProtection.Protect(), // middleware.RequestSanitization(s.config.GetSanitizationConfig()), // s.recoveryMiddleware(), // middleware.RequestSizeLimit(int64(s.config.MaxRequestSize)), // middleware.Timeout(10*time.Second), // middleware.RateLimiter(s.config.RateLimitPerSecond), // middleware.CORS(s.config.AllowedOrigins), // s.requestLoggingMiddleware(), // ) // if len(s.config.TrustedProxies) > 0 { // s.router.SetTrustedProxies(s.config.TrustedProxies) // } // } // func (s *HTTPServer) recoveryMiddleware() gin.HandlerFunc { // return func(c *gin.Context) { // defer func() { // if err := recover(); err != nil { // s.securityLogger.LogSecurityEvent(logging.SecurityEvent{ // EventType: "PANIC_RECOVERED", // IP: c.ClientIP(), // Description: fmt.Sprintf("Panic recovered: %v", err), // Severity: "HIGH", // Timestamp: time.Now(), // RequestPath: c.Request.URL.Path, // RequestID: c.GetHeader("X-Request-ID"), // }) // c.AbortWithStatus(http.StatusInternalServerError) // } // }() // c.Next() // } // } // func (s *HTTPServer) requestLoggingMiddleware() gin.HandlerFunc { // return func(c *gin.Context) { // if s.config.IsProd() && c.Request.URL.Path == "/health" { // c.Next() // return // } // start := time.Now() // path := c.Request.URL.Path // query := c.Request.URL.RawQuery // c.Next() // s.logger.Infow("Request completed", // "status", c.Writer.Status(), // "method", c.Request.Method, // "path", path, // "query", query, // "ip", c.ClientIP(), // "latency", time.Since(start), // "user-agent", c.Request.UserAgent(), // "request-id", c.GetHeader("X-Request-ID"), // "bytes-out", c.Writer.Size(), // ) // } // } // // Private methods - Health check // func (s *HTTPServer) setupHealthCheck() { // s.router.GET("/health", func(c *gin.Context) { // if s.config.IsProd() { // c.JSON(http.StatusOK, gin.H{"status": "OK"}) // return // } // c.JSON(http.StatusOK, gin.H{ // "status": "OK", // "environment": s.config.Env, // "version": "1.0.0", // "timestamp": time.Now().UTC(), // }) // }) // s.logger.Info("Health check endpoint set up") // } ================================================ FILE: go-b2b-starter/internal/platform/server/errors/errors.go ================================================ package errors type APIError struct { Code int Message string } func NewAPIError(code int, message string) *APIError { return &APIError{Code: code, Message: message} } ================================================ FILE: go-b2b-starter/internal/platform/server/gin/gin.go ================================================ package gin import ( "github.com/moasq/go-b2b-starter/internal/platform/server/config" "github.com/gin-gonic/gin" ) type GinRouter struct { engine *gin.Engine v1 *gin.RouterGroup } func NewGinRouter(cfg *config.Config) *GinRouter { router := gin.New() router.Use(gin.Recovery()) return &GinRouter{ engine: router, v1: router.Group("/api/v1"), } } func (g *GinRouter) GetHandler() *gin.Engine { return g.engine } ================================================ FILE: go-b2b-starter/internal/platform/server/logging/logger.go ================================================ package logging import ( "go.uber.org/zap" ) type Logger struct { *zap.SugaredLogger } func InitLogger() (*Logger, error) { zapLogger, err := zap.NewProduction() if err != nil { return nil, err } sugar := zapLogger.Sugar() return &Logger{sugar}, nil } func (l *Logger) Error(msg string, err error) { l.SugaredLogger.Errorw(msg, "error", err) } func (l *Logger) Fatal(msg string, err error) { l.SugaredLogger.Fatalw(msg, "error", err) } // Helper function to create a zap.Field for errors func Error(err error) zap.Field { return zap.Error(err) } ================================================ FILE: go-b2b-starter/internal/platform/server/logging/security_logger.go ================================================ // logging/security_logger.go package logging import ( "time" "go.uber.org/zap" ) type SecurityLogger struct { logger *zap.SugaredLogger } type SecurityEvent struct { EventType string IP string UserID string Description string Severity string Timestamp time.Time RequestPath string RequestID string } func NewSecurityLogger(baseLogger *zap.SugaredLogger) *SecurityLogger { return &SecurityLogger{ logger: baseLogger, } } func (sl *SecurityLogger) LogSecurityEvent(event SecurityEvent) { sl.logger.Warnw("Security Event", "event_type", event.EventType, "ip", event.IP, "user_id", event.UserID, "description", event.Description, "severity", event.Severity, "timestamp", event.Timestamp, "request_path", event.RequestPath, "request_id", event.RequestID, ) } func (sl *SecurityLogger) LogFailedAuth(ip string, userID string, reason string) { sl.LogSecurityEvent(SecurityEvent{ EventType: "AUTH_FAILED", IP: ip, UserID: userID, Description: reason, Severity: "WARNING", Timestamp: time.Now(), }) } func (sl *SecurityLogger) LogSuspiciousActivity(ip string, description string) { sl.LogSecurityEvent(SecurityEvent{ EventType: "SUSPICIOUS_ACTIVITY", IP: ip, Description: description, Severity: "WARNING", Timestamp: time.Now(), }) } func (sl *SecurityLogger) LogBlacklisted(ip string) { sl.LogSecurityEvent(SecurityEvent{ EventType: "IP_BLACKLISTED", IP: ip, Description: "IP address has been blacklisted", Severity: "HIGH", Timestamp: time.Now(), }) } ================================================ FILE: go-b2b-starter/internal/platform/server/metrics/prometheus.go ================================================ package metrics import ( "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" ) func SetupPrometheus(router *gin.Engine) { router.GET("/metrics", gin.WrapH(promhttp.Handler())) } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/cors.go ================================================ package middleware import ( "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) func CORS(allowedOrigins []string) gin.HandlerFunc { return cors.New(cors.Config{ AllowOrigins: allowedOrigins, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Organization-ID", "X-Account-ID"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, MaxAge: 12 * time.Hour, AllowWildcard: false, AllowFiles: false, }) } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/ip_protection.go ================================================ // middleware/ip_protection.go package middleware import ( "sync" "time" "github.com/gin-gonic/gin" ) type IPProtection struct { whitelist map[string]bool blacklist map[string]struct{} failedAttempts map[string]*FailedAttempt mu sync.RWMutex cleanupTicker *time.Ticker done chan struct{} } type FailedAttempt struct { count int firstFail time.Time lastSeen time.Time } func NewIPProtection() *IPProtection { ip := &IPProtection{ whitelist: make(map[string]bool), blacklist: make(map[string]struct{}), failedAttempts: make(map[string]*FailedAttempt), cleanupTicker: time.NewTicker(5 * time.Minute), done: make(chan struct{}), } // Start cleanup goroutine go ip.periodicCleanup() return ip } // Stop should be called when the server is shutting down func (ip *IPProtection) Stop() { ip.cleanupTicker.Stop() close(ip.done) } // periodicCleanup removes old entries from maps to prevent memory leaks func (ip *IPProtection) periodicCleanup() { for { select { case <-ip.cleanupTicker.C: ip.cleanupFailedAttempts() case <-ip.done: return } } } // cleanupFailedAttempts removes old entries from the failedAttempts map func (ip *IPProtection) cleanupFailedAttempts() { cutoff := time.Now().Add(-15 * time.Minute) ip.mu.Lock() defer ip.mu.Unlock() for clientIP, attempt := range ip.failedAttempts { // Remove entries that haven't been seen in the last 15 minutes if attempt.lastSeen.Before(cutoff) { delete(ip.failedAttempts, clientIP) } } } func (ip *IPProtection) Protect() gin.HandlerFunc { return func(c *gin.Context) { clientIP := c.ClientIP() // Check if IP is whitelisted if ip.isWhitelisted(clientIP) { c.Next() return } // Check if IP is blacklisted if ip.isBlacklisted(clientIP) { c.AbortWithStatusJSON(403, gin.H{"error": "Access denied"}) return } // Check for suspicious activity if ip.isSuspicious(clientIP) { ip.addToBlacklist(clientIP) c.AbortWithStatusJSON(403, gin.H{"error": "Suspicious activity detected"}) return } c.Next() } } func (ip *IPProtection) isWhitelisted(clientIP string) bool { ip.mu.RLock() defer ip.mu.RUnlock() return ip.whitelist[clientIP] } func (ip *IPProtection) isBlacklisted(clientIP string) bool { ip.mu.RLock() defer ip.mu.RUnlock() _, exists := ip.blacklist[clientIP] return exists } func (ip *IPProtection) isSuspicious(clientIP string) bool { ip.mu.Lock() defer ip.mu.Unlock() attempt, exists := ip.failedAttempts[clientIP] if !exists { return false } // Check if more than 10 failed attempts in 5 minutes if attempt.count > 10 && time.Since(attempt.firstFail) < 5*time.Minute { return true } return false } func (ip *IPProtection) RecordFailedAttempt(clientIP string) { ip.mu.Lock() defer ip.mu.Unlock() now := time.Now() attempt, exists := ip.failedAttempts[clientIP] if !exists { ip.failedAttempts[clientIP] = &FailedAttempt{ count: 1, firstFail: now, lastSeen: now, } return } // Reset if last attempt was more than 5 minutes ago if time.Since(attempt.firstFail) > 5*time.Minute { attempt.count = 1 attempt.firstFail = now } else { attempt.count++ } attempt.lastSeen = now } func (ip *IPProtection) addToBlacklist(clientIP string) { ip.mu.Lock() defer ip.mu.Unlock() ip.blacklist[clientIP] = struct{}{} } // AddToWhitelist adds an IP to the whitelist func (ip *IPProtection) AddToWhitelist(clientIP string) { ip.mu.Lock() defer ip.mu.Unlock() ip.whitelist[clientIP] = true } // RemoveFromBlacklist removes an IP from the blacklist func (ip *IPProtection) RemoveFromBlacklist(clientIP string) { ip.mu.Lock() defer ip.mu.Unlock() delete(ip.blacklist, clientIP) } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/ratelimit.go ================================================ package middleware import ( "net/http" "github.com/gin-gonic/gin" "golang.org/x/time/rate" ) func RateLimiter(rateLimitPerSecond int) gin.HandlerFunc { limiter := rate.NewLimiter(rate.Limit(rateLimitPerSecond), rateLimitPerSecond) return func(c *gin.Context) { if !limiter.Allow() { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"}) return } c.Next() } } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/recovery.go ================================================ // middleware/recovery.go package middleware import ( "bytes" "io" "net/http" "runtime" "time" "github.com/moasq/go-b2b-starter/internal/platform/server/logging" "github.com/gin-gonic/gin" ) const ( // Size of the stack buffer stackSize = 4 << 10 // 4 KB ) func Recovery(logger *logging.Logger) gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { // Get stack trace stack := stack(3) // Get request details httpRequest := c.Request headers := make(map[string]string) for k, v := range httpRequest.Header { headers[k] = v[0] } // Log the error with context logger.Errorw("Panic recovered", "error", err, "stack", string(stack), "request_id", GetRequestID(c), "method", httpRequest.Method, "path", httpRequest.URL.Path, "query", httpRequest.URL.RawQuery, "ip", c.ClientIP(), "user_agent", httpRequest.UserAgent(), "time", time.Now().UTC(), ) // Return safe error to client c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ "error": "Internal Server Error", "request_id": GetRequestID(c), "code": "SERVER_ERROR", }) } }() c.Next() } } // stack returns a formatted stack trace of the goroutine that panicked func stack(_ int) []byte { buf := new(bytes.Buffer) // Get runtime stack var stackBuf [stackSize]byte n := runtime.Stack(stackBuf[:], false) // Write stack to buffer _, _ = io.WriteString(buf, "Stack Trace:\n") _, _ = buf.Write(stackBuf[:n]) return buf.Bytes() } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/request_id.go ================================================ package middleware import ( "github.com/gin-gonic/gin" "github.com/google/uuid" ) const ( // Headers RequestIDHeader = "X-Request-ID" // Context keys RequestIDKey = "request_id" ) // RequestID middleware ensures each request has a unique ID for tracing func RequestID() gin.HandlerFunc { return func(c *gin.Context) { // Check if request already has an ID requestID := c.GetHeader(RequestIDHeader) // Generate new ID if none exists if requestID == "" { requestID = generateRequestID() } // Set ID in context and response header c.Set(RequestIDKey, requestID) c.Header(RequestIDHeader, requestID) c.Next() } } // generateRequestID creates a new UUID v4 for request tracking func generateRequestID() string { return uuid.New().String() } // GetRequestID retrieves request ID from context func GetRequestID(c *gin.Context) string { if id, exists := c.Get(RequestIDKey); exists { return id.(string) } return "" } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/request_size_limit.go ================================================ // middleware/request_size.go package middleware import ( "fmt" "net/http" "github.com/gin-gonic/gin" ) func RequestSizeLimit(maxSize int64) gin.HandlerFunc { return func(c *gin.Context) { // Skip check for GET, HEAD, OPTIONS methods if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions { c.Next() return } // Check Content-Length header contentLength := c.Request.ContentLength if contentLength > maxSize { c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{ "error": fmt.Sprintf("request size limit exceeded: %d bytes > %d bytes", contentLength, maxSize), }) return } // Set body size limit for the request c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) c.Next() // Check if the request was aborted due to size limit if c.Errors.Last() != nil && c.Errors.Last().Err == http.ErrMissingFile { c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{ "error": "request size limit exceeded during processing", }) return } } } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/sanatization.go ================================================ // middleware/sanitization.go package middleware import ( "html" "net/http" "strings" "github.com/moasq/go-b2b-starter/internal/platform/server/config" "github.com/gin-gonic/gin" ) func RequestSanitization(config config.SanitizationConfig) gin.HandlerFunc { return func(c *gin.Context) { // Sanitize Path Parameters for _, param := range c.Params { if containsPathTraversal(param.Value) { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": "Invalid path parameter detected", }) return } } // Sanitize Query Parameters for _, values := range c.Request.URL.Query() { for _, value := range values { if !config.DisableXSS && containsXSS(value) { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": "Potential XSS detected in query parameter", }) return } if !config.DisableSQLInjection && containsSQLInjection(value) { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": "Potential SQL injection detected in query parameter", }) return } } } // Add a custom header indicating security checks passed c.Header("X-Content-Security", "sanitized") c.Next() } } func containsPathTraversal(path string) bool { suspicious := []string{"..", "//", "\\\\"} for _, pattern := range suspicious { if strings.Contains(path, pattern) { return true } } return false } func containsXSS(input string) bool { suspicious := []string{ "", "javascript:", "vbscript:", "onload=", "onerror=", } sanitized := html.EscapeString(input) for _, pattern := range suspicious { if strings.Contains(strings.ToLower(sanitized), strings.ToLower(pattern)) { return true } } return false } func containsSQLInjection(input string) bool { suspicious := []string{ "DROP TABLE", "DELETE FROM", "INSERT INTO", "UPDATE", "--", "UNION", "SELECT", } for _, pattern := range suspicious { if strings.Contains(strings.ToUpper(input), pattern) { return true } } return false } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/security_headers.go ================================================ package middleware import ( "github.com/gin-gonic/gin" ) func SecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { // Security headers c.Header("X-Frame-Options", "DENY") c.Header("X-Content-Type-Options", "nosniff") c.Header("X-XSS-Protection", "1; mode=block") c.Header("Referrer-Policy", "strict-origin-no-referrer") c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline';") c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") c.Header("X-Permitted-Cross-Domain-Policies", "none") // Remove sensitive headers c.Header("Server", "") c.Header("X-Powered-By", "") c.Next() } } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/timeout.go ================================================ // middleware/timeout.go package middleware import ( "context" "net/http" "sync" "time" "github.com/gin-gonic/gin" ) func Timeout(timeout time.Duration) gin.HandlerFunc { return func(c *gin.Context) { // Create a context with timeout ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() // Ensure we call cancel to prevent context leak // Create a request with the new context c.Request = c.Request.WithContext(ctx) // Create a channel to signal completion finished := make(chan struct{}, 1) // Use a WaitGroup to wait for the goroutine to finish var wg sync.WaitGroup wg.Add(1) // Create a copy of the context writer writer := c.Writer // Handle the request in a goroutine go func() { defer wg.Done() // Create a wrapped writer to capture the response status blw := &bodyLogWriter{ResponseWriter: c.Writer} c.Writer = blw // Process the handler chain c.Next() // Signal that the handler is complete finished <- struct{}{} }() // Wait for either completion or timeout select { case <-finished: // Handler completed before timeout case <-ctx.Done(): // Timeout occurred or parent context was cancelled if ctx.Err() == context.DeadlineExceeded { // Reset the original writer to avoid modifying response after timeout c.Writer = writer c.AbortWithStatusJSON(http.StatusGatewayTimeout, gin.H{"error": "request timeout"}) } } // Wait for the goroutine to finish to prevent potential leaks wg.Wait() } } // bodyLogWriter is a wrapper around ResponseWriter to capture the response status type bodyLogWriter struct { gin.ResponseWriter statusCode int } func (w *bodyLogWriter) WriteHeader(code int) { w.statusCode = code w.ResponseWriter.WriteHeader(code) } ================================================ FILE: go-b2b-starter/internal/platform/server/middleware/validator.go ================================================ package middleware import ( "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" ) var Validate *validator.Validate func InitValidator() *validator.Validate { Validate = validator.New() return Validate } func ValidateRequest(structPtr interface{}) gin.HandlerFunc { return func(c *gin.Context) { if err := c.ShouldBindJSON(structPtr); err != nil { c.JSON(400, gin.H{"error": err.Error()}) c.Abort() return } if err := Validate.Struct(structPtr); err != nil { c.JSON(400, gin.H{"error": err.Error()}) c.Abort() return } c.Next() } } ================================================ FILE: go-b2b-starter/internal/platform/stytch/client.go ================================================ package stytch import ( "fmt" "net/http" "strings" "github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi" ) // Client is a thin wrapper around the autogenerated Stytch B2B API client. type Client struct { api *b2bstytchapi.API config Config } // NewClient constructs the shared Stytch API client using the supplied configuration. func NewClient(cfg Config) (*Client, error) { httpClient := &http.Client{ Timeout: cfg.APITimeout, } var opts []b2bstytchapi.Option opts = append(opts, b2bstytchapi.WithHTTPClient(httpClient)) baseURI := strings.TrimSuffix(cfg.BaseURL, "/") if baseURI != "" { opts = append(opts, b2bstytchapi.WithBaseURI(baseURI)) } apiClient, err := b2bstytchapi.NewClient(cfg.ProjectID, cfg.Secret, opts...) if err != nil { return nil, fmt.Errorf("create stytch client: %w", err) } return &Client{ api: apiClient, config: cfg, }, nil } // API exposes the underlying autogenerated API for advanced use-cases. func (c *Client) API() *b2bstytchapi.API { return c.api } // Config returns the hydrated configuration. func (c *Client) Config() Config { return c.config } ================================================ FILE: go-b2b-starter/internal/platform/stytch/cmd/provider.go ================================================ package cmd import ( "fmt" "strings" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/moasq/go-b2b-starter/internal/platform/redis" "github.com/moasq/go-b2b-starter/internal/platform/stytch" "go.uber.org/dig" ) // ProvideStytchDependencies wires the Stytch configuration and client into the DI container. func ProvideStytchDependencies(container *dig.Container) error { providers := []any{ stytch.LoadConfig, provideStytchClient, provideRBACPolicyService, } for _, provider := range providers { if err := container.Provide(provider); err != nil { return fmt.Errorf("failed to provide Stytch dependency: %w", err) } } return nil } func provideStytchClient(cfg *stytch.Config, log logger.Logger) (*stytch.Client, error) { // Check for placeholder credentials (development mode) if isPlaceholderCredentials(cfg) { log.Warn("Stytch credentials are placeholders - Stytch client will be nil (development mode)", map[string]any{ "project_id": cfg.ProjectID, "message": "Organization/member management features will not work. Update STYTCH_PROJECT_ID and STYTCH_SECRET in app.env", }) // Return nil client for development mode - app/auth repositories should handle nil gracefully return nil, nil } return stytch.NewClient(*cfg) } // isPlaceholderCredentials checks if the Stytch credentials are placeholder values. func isPlaceholderCredentials(cfg *stytch.Config) bool { return strings.Contains(cfg.ProjectID, "REPLACE") || strings.Contains(cfg.Secret, "REPLACE") || cfg.ProjectID == "" || cfg.Secret == "" } func provideRBACPolicyService( client *stytch.Client, redisClient redis.Client, log logger.Logger, ) *stytch.RBACPolicyService { // If client is nil (development mode), return nil for RBAC service too if client == nil { return nil } return stytch.NewRBACPolicyService(client, redisClient, log) } ================================================ FILE: go-b2b-starter/internal/platform/stytch/config.go ================================================ package stytch import ( "fmt" "strings" "time" "github.com/spf13/viper" ) // Environment constants supported by the Stytch B2B API. const ( EnvTest = "test" EnvLive = "live" ) // Config captures the runtime knobs required to communicate with Stytch. type Config struct { ProjectID string `mapstructure:"STYTCH_PROJECT_ID"` Secret string `mapstructure:"STYTCH_SECRET"` Env string `mapstructure:"STYTCH_ENV"` BaseURL string `mapstructure:"STYTCH_BASE_URL"` CustomDomain string `mapstructure:"STYTCH_CUSTOM_DOMAIN"` JWKSURL string `mapstructure:"STYTCH_JWKS_URL"` SessionDurationMinutes int32 `mapstructure:"STYTCH_SESSION_DURATION_MINUTES"` DisableSessionVerification bool `mapstructure:"STYTCH_DISABLE_SESSION_VERIFICATION"` OwnerRoleSlug string `mapstructure:"STYTCH_OWNER_ROLE_SLUG"` InviteRedirectURL string `mapstructure:"STYTCH_INVITE_REDIRECT_URL"` LoginRedirectURL string `mapstructure:"STYTCH_LOGIN_REDIRECT_URL"` APITimeout time.Duration `mapstructure:"STYTCH_API_TIMEOUT"` } // LoadConfig hydrates the Stytch configuration from app.env + process environment. func LoadConfig() (*Config, error) { v := viper.New() v.SetConfigName("app") v.SetConfigType("env") v.AddConfigPath(".") v.AutomaticEnv() // Defaults mirror the Auth0 integration to minimize surprises. v.SetDefault("STYTCH_ENV", EnvTest) v.SetDefault("STYTCH_SESSION_DURATION_MINUTES", 1440) // 24 hours (previously 60 minutes) v.SetDefault("STYTCH_API_TIMEOUT", "15s") v.SetDefault("STYTCH_DISABLE_SESSION_VERIFICATION", false) // Best-effort: ignore missing file, allow env-only usage if err := v.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return nil, fmt.Errorf("failed to read config: %w", err) } } var cfg *Config if err := v.Unmarshal(&cfg); err != nil { return cfg, fmt.Errorf("unable to decode stytch config: %w", err) } cfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env)) if cfg.Env == "" { cfg.Env = EnvTest } if cfg.ProjectID == "" { return cfg, fmt.Errorf("stytch configuration invalid: STYTCH_PROJECT_ID is required") } if cfg.Secret == "" { return cfg, fmt.Errorf("stytch configuration invalid: STYTCH_SECRET is required") } // Normalize timeout (viper unmarshals duration strings automatically). if cfg.APITimeout <= 0 { cfg.APITimeout = 15 * time.Second } // Derive base URL if none supplied. if cfg.BaseURL == "" { switch cfg.Env { case EnvLive: cfg.BaseURL = "https://api.stytch.com" default: cfg.BaseURL = "https://test.stytch.com" } } // Custom domain overrides base URL for API + JWKS. if cfg.CustomDomain != "" { cfg.BaseURL = fmt.Sprintf("https://%s", strings.TrimSuffix(cfg.CustomDomain, "/")) } // Derive JWKS URL if absent. if cfg.JWKSURL == "" { if cfg.CustomDomain != "" { cfg.JWKSURL = fmt.Sprintf("https://%s/.well-known/jwks.json", strings.TrimSuffix(cfg.CustomDomain, "/")) } else { cfg.JWKSURL = fmt.Sprintf("%s/v1/b2b/sessions/jwks/%s", strings.TrimSuffix(cfg.BaseURL, "/"), cfg.ProjectID) } } return cfg, nil } ================================================ FILE: go-b2b-starter/internal/platform/stytch/errors.go ================================================ package stytch import ( "errors" "fmt" "github.com/stytchauth/stytch-go/v16/stytch/stytcherror" ) var ( // ErrUnauthorized mirrors HTTP 401 responses. ErrUnauthorized = errors.New("stytch: unauthorized") // ErrForbidden mirrors HTTP 403 responses. ErrForbidden = errors.New("stytch: forbidden") // ErrNotFound mirrors HTTP 404 responses. ErrNotFound = errors.New("stytch: resource not found") // ErrConflict mirrors HTTP 409 responses. ErrConflict = errors.New("stytch: conflict") // ErrRateLimited mirrors HTTP 429 responses. ErrRateLimited = errors.New("stytch: rate limit exceeded") // ErrBadRequest mirrors HTTP 400 responses. ErrBadRequest = errors.New("stytch: bad request") // ErrInternal mirrors HTTP 5xx responses. ErrInternal = errors.New("stytch: internal server error") // ErrInvalidConfig surfaces configuration validation issues. ErrInvalidConfig = errors.New("stytch: invalid configuration") // ErrDuplicateSlug indicates organization slug already exists. ErrDuplicateSlug = errors.New("stytch: organization slug already exists") ) // IsDuplicateSlugError checks if the error is a duplicate organization slug error func IsDuplicateSlugError(err error) bool { if err == nil { return false } var stErr *stytcherror.Error if errors.As(err, &stErr) { return string(stErr.ErrorType) == "organization_slug_already_used" } return false } // MapError inspects a returned error and maps it to one of the sentinel errors above. func MapError(err error) error { if err == nil { return nil } var stErr *stytcherror.Error if errors.As(err, &stErr) { // Check specific error types first if string(stErr.ErrorType) == "organization_slug_already_used" { return fmt.Errorf("%w: %s", ErrDuplicateSlug, stErr.Error()) } // Then check status codes switch stErr.StatusCode { case 400: return fmt.Errorf("%w: %s", ErrBadRequest, stErr.Error()) case 401: return fmt.Errorf("%w: %s", ErrUnauthorized, stErr.Error()) case 403: return fmt.Errorf("%w: %s", ErrForbidden, stErr.Error()) case 404: return fmt.Errorf("%w: %s", ErrNotFound, stErr.Error()) case 409: return fmt.Errorf("%w: %s", ErrConflict, stErr.Error()) case 429: return fmt.Errorf("%w: %s", ErrRateLimited, stErr.Error()) default: if stErr.StatusCode >= 500 { return fmt.Errorf("%w: %s", ErrInternal, stErr.Error()) } return fmt.Errorf("stytch: unexpected status %d: %w", stErr.StatusCode, stErr) } } return err } ================================================ FILE: go-b2b-starter/internal/platform/stytch/inject.go ================================================ package stytch import ( "fmt" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/moasq/go-b2b-starter/internal/platform/redis" "go.uber.org/dig" ) // ProvideDependencies registers Stytch package dependencies in the DI container func ProvideDependencies(container *dig.Container) error { // Provide Stytch client if err := container.Provide(func(cfg Config) (*Client, error) { return NewClient(cfg) }); err != nil { return fmt.Errorf("failed to provide stytch client: %w", err) } // Provide RBAC policy service if err := container.Provide(func( client *Client, redisClient redis.Client, logger logger.Logger, ) *RBACPolicyService { return NewRBACPolicyService(client, redisClient, logger) }); err != nil { return fmt.Errorf("failed to provide RBAC policy service: %w", err) } return nil } ================================================ FILE: go-b2b-starter/internal/platform/stytch/rbac_policy.go ================================================ package stytch import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/moasq/go-b2b-starter/internal/platform/logger" "github.com/moasq/go-b2b-starter/internal/platform/redis" "github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac" ) const ( // Redis cache key for RBAC policy rbacPolicyCacheKey = "stytch:rbac:policy" // Cache TTL matches Stytch SDK default (5 minutes) rbacPolicyCacheTTL = 5 * time.Minute ) // RBACPolicyService fetches and caches Stytch RBAC policy type RBACPolicyService struct { client *Client redis redis.Client logger logger.Logger } func NewRBACPolicyService( client *Client, redisClient redis.Client, logger logger.Logger, ) *RBACPolicyService { return &RBACPolicyService{ client: client, redis: redisClient, logger: logger, } } // GetRolePermissions returns all permissions for a given role from Stytch RBAC policy // Returns permissions in "resource:action" format (e.g., "invoice:create") func (s *RBACPolicyService) GetRolePermissions(ctx context.Context, roleID string) ([]string, error) { // Normalize role ID (remove stytch_ prefix) normalizedRoleID := normalizeRoleID(roleID) // Get policy from cache or Stytch policy, err := s.getPolicy(ctx) if err != nil { return nil, fmt.Errorf("failed to get RBAC policy: %w", err) } // Find role in policy for _, role := range policy.Roles { if strings.EqualFold(role.RoleID, normalizedRoleID) { return s.convertPermissions(role.Permissions, policy), nil } } // Role not found in policy return nil, nil } // getPolicy fetches policy from Redis cache or Stytch API func (s *RBACPolicyService) getPolicy(ctx context.Context) (*rbac.Policy, error) { // Try cache first cached, err := s.redis.Get(ctx, rbacPolicyCacheKey) if err == nil && cached != "" { var policy rbac.Policy if err := json.Unmarshal([]byte(cached), &policy); err == nil { s.logger.Debug("RBAC policy fetched from cache", logger.Fields{}) return &policy, nil } else { s.logger.Warn("Failed to unmarshal cached RBAC policy, fetching from Stytch", logger.Fields{ "error": err.Error(), }) } } // Cache miss or error - fetch from Stytch policy, err := s.fetchPolicyFromStytch(ctx) if err != nil { return nil, err } // Cache the policy s.cachePolicy(ctx, policy) return policy, nil } // fetchPolicyFromStytch fetches RBAC policy from Stytch API func (s *RBACPolicyService) fetchPolicyFromStytch(ctx context.Context) (*rbac.Policy, error) { s.logger.Info("Fetching RBAC policy from Stytch", logger.Fields{}) resp, err := s.client.API().RBAC.Policy(ctx, &rbac.PolicyParams{}) if err != nil { return nil, fmt.Errorf("stytch RBAC policy API call failed: %w", err) } if resp.Policy == nil { return nil, fmt.Errorf("stytch returned empty policy") } s.logger.Info("Successfully fetched RBAC policy from Stytch", logger.Fields{ "roles_count": len(resp.Policy.Roles), "resources_count": len(resp.Policy.Resources), }) return resp.Policy, nil } // cachePolicy stores policy in Redis func (s *RBACPolicyService) cachePolicy(ctx context.Context, policy *rbac.Policy) { data, err := json.Marshal(policy) if err != nil { s.logger.Warn("Failed to marshal RBAC policy for caching", logger.Fields{ "error": err.Error(), }) return } if err := s.redis.Set(ctx, rbacPolicyCacheKey, string(data), rbacPolicyCacheTTL); err != nil { s.logger.Warn("Failed to cache RBAC policy in Redis", logger.Fields{ "error": err.Error(), }) // Non-fatal error, continue without cache } else { s.logger.Debug("RBAC policy cached in Redis", logger.Fields{ "ttl": rbacPolicyCacheTTL.String(), }) } } // convertPermissions converts Stytch permission format to flat list, expanding wildcards // // Input: []PolicyRolePermission{ // {ResourceID: "Invoice", Actions: ["view", "create"]}, // {ResourceID: "approval", Actions: ["*"]}, // }, policy with Resources // // Output: ["invoice:view", "invoice:create", "approval:view", "approval:approve", "approval:assign"] // (wildcards expanded to all actions defined in policy for that resource) func (s *RBACPolicyService) convertPermissions(permissions []rbac.PolicyRolePermission, policy *rbac.Policy) []string { if len(permissions) == 0 { return nil } result := make([]string, 0, len(permissions)*5) // Estimate 5 actions per resource for _, perm := range permissions { resourceID := strings.ToLower(perm.ResourceID) // Handle empty or invalid resource if resourceID == "" { continue } // Expand wildcard actions using resource definitions from policy expandedActions := s.expandWildcardActions(perm.ResourceID, perm.Actions, policy) // Convert each action to "resource:action" format for _, action := range expandedActions { if action == "" { continue } actionLower := strings.ToLower(action) result = append(result, fmt.Sprintf("%s:%s", resourceID, actionLower)) } } return result } // expandWildcardActions expands wildcard (*) to all resource actions from Stytch policy // This ensures permissions come entirely from Stytch configuration, not local code func (s *RBACPolicyService) expandWildcardActions(resourceID string, actions []string, policy *rbac.Policy) []string { // Check if actions contain wildcard hasWildcard := false for _, action := range actions { if action == "*" { hasWildcard = true break } } // If no wildcard, return actions as-is if !hasWildcard { return actions } // Find resource definition in policy to get all possible actions for _, resource := range policy.Resources { if strings.EqualFold(resource.ResourceID, resourceID) { // Return all actions defined for this resource in Stytch if len(resource.Actions) > 0 { s.logger.Debug("Expanded wildcard permission", logger.Fields{ "resource": resourceID, "actions_count": len(resource.Actions), "actions": resource.Actions, }) return resource.Actions } } } // Resource not found in policy, keep wildcard as-is (shouldn't happen) s.logger.Warn("Resource not found in policy, keeping wildcard", logger.Fields{ "resource": resourceID, }) return actions } // normalizeRoleID removes common prefixes from role IDs // "stytch_member" -> "stytch_member" // "owner" -> "owner" // "stytch_admin" -> "stytch_admin" func normalizeRoleID(roleID string) string { roleID = strings.TrimSpace(roleID) // Keep stytch_ prefix for default roles, but remove "member" suffix roleID = strings.TrimPrefix(roleID, "member") roleID = strings.TrimSpace(roleID) return roleID } ================================================ FILE: go-b2b-starter/pkg/httperr/errors.go ================================================ package httperr import ( "net/http" ) // IsNotFoundError checks if the error is a NotFoundError func IsNotFoundError(err error) bool { if httpErr, ok := err.(*HTTPError); ok { return httpErr.StatusCode == http.StatusNotFound } return false } // IsConflictError checks if the error is a ConflictError (e.g., duplicate username) func IsConflictError(err error) bool { if httpErr, ok := err.(*HTTPError); ok { return httpErr.StatusCode == http.StatusConflict } return false } // IsBadRequestError checks if the error is a BadRequestError func IsBadRequestError(err error) bool { if httpErr, ok := err.(*HTTPError); ok { return httpErr.StatusCode == http.StatusBadRequest } return false } // IsAuthenticationError checks if the error is an authentication error func IsAuthenticationError(err error) bool { if httpErr, ok := err.(*HTTPError); ok { return httpErr.StatusCode == http.StatusUnauthorized } return false } // IsAuthorizationError checks if the error is an authorization error func IsAuthorizationError(err error) bool { if httpErr, ok := err.(*HTTPError); ok { return httpErr.StatusCode == http.StatusForbidden } return false } // IsInternalServerError checks if the error is an internal server error func IsInternalServerError(err error) bool { if httpErr, ok := err.(*HTTPError); ok { return httpErr.StatusCode == http.StatusInternalServerError } return false } // GetErrorCode extracts the error code from an HTTPError, or returns empty string func GetErrorCode(err error) string { if httpErr, ok := err.(*HTTPError); ok { return httpErr.Code } return "" } // GetErrorMessage extracts the error message from an HTTPError, or returns the error's message func GetErrorMessage(err error) string { if httpErr, ok := err.(*HTTPError); ok { return httpErr.Message } if err != nil { return err.Error() } return "" } ================================================ FILE: go-b2b-starter/pkg/httperr/http_error.go ================================================ package httperr type HTTPError struct { StatusCode int `json:"-"` Code string `json:"code"` Message string `json:"message"` } func (e HTTPError) Error() string { return e.Message } func NewHTTPError(statusCode int, code, message string) HTTPError { return HTTPError{ StatusCode: statusCode, Code: code, Message: message, } } ================================================ FILE: go-b2b-starter/pkg/pagination/pagination.go ================================================ package listingshared import "fmt" type PagePagination[T any] struct { Items []T `json:"items"` Meta Meta `json:"meta"` } type Meta struct { TotalItems int `json:"total_items"` Page int `json:"page"` PageSize int `json:"page_size"` ReturnedItemsCount int `json:"returned_items_count"` HasMore bool `json:"has_more"` FirstPageURL string `json:"first_page_url"` PreviousPageURL string `json:"previous_page_url"` NextPageURL string `json:"next_page_url"` LastPageURL string `json:"last_page_url"` TotalPages int `json:"total_pages"` } func NewPagePagination[T any](totalItems, page, pageSize int, items []T) *PagePagination[T] { // Calculate total pages properly totalPages := (totalItems + pageSize - 1) / pageSize p := &PagePagination[T]{ Meta: Meta{ TotalItems: totalItems, Page: page, PageSize: pageSize, ReturnedItemsCount: len(items), HasMore: page < totalPages, // Set HasMore TotalPages: totalPages, }, Items: items, } // First page URL only if we're not on first page if page > 1 { p.Meta.FirstPageURL = fmt.Sprintf("?page=%d&pageSize=%d", 1, pageSize) } // Last page URL only if there are multiple pages and we're not on last page if page < totalPages { p.Meta.LastPageURL = fmt.Sprintf("?page=%d&pageSize=%d", totalPages, pageSize) } // Previous page URL only if we're not on first page if page > 1 { p.Meta.PreviousPageURL = fmt.Sprintf("?page=%d&pageSize=%d", page-1, pageSize) } // Next page URL only if there are more pages if page < totalPages { p.Meta.NextPageURL = fmt.Sprintf("?page=%d&pageSize=%d", page+1, pageSize) } return p } func PageToOffset(page int, limit int) (int, error) { if page < 1 { return 0, fmt.Errorf("page must be greater than 0") } if limit < 1 { return 0, fmt.Errorf("limit must be greater than 0") } return (page - 1) * limit, nil } ================================================ FILE: go-b2b-starter/pkg/pagination/pramas.go ================================================ package listingshared type SearchableParams struct { Q string `form:"q" binding:"required,min=1,max=100"` Page int `form:"page" binding:"omitempty,min=1" default:"1"` Limit int `form:"limit" binding:"omitempty,min=1,max=100" default:"10"` Lang string `form:"lang" binding:"omitempty,oneof=en ar" default:"en"` } func (s *SearchableParams) Validate() error { // Then, set default values for empty fields if s.Page == 0 { s.Page = 1 } if s.Limit == 0 { s.Limit = 10 } if s.Lang == "" { s.Lang = "en" } return nil } type ListableParams struct { Page int `form:"page" binding:"omitempty,min=1" default:"1"` Limit int `form:"limit" binding:"omitempty,min=1,max=100" default:"10"` Lang string `form:"lang" binding:"omitempty,oneof=en ar" default:"en"` } func (s *ListableParams) Validate() error { // Then, set default values for empty fields if s.Page == 0 { s.Page = 1 } if s.Limit == 0 { s.Limit = 10 } if s.Lang == "" { s.Lang = "en" } return nil } ================================================ FILE: go-b2b-starter/pkg/pagination/util.go ================================================ package listingshared // PaginationCalc converts offset and limit to page number and page size. // Assumes default values if not specified. func PaginationCalc(offset, limit int) (page, pageSize int) { if limit <= 0 { limit = 20 // Default limit } if offset < 0 { offset = 0 // Default offset } page = (offset / limit) + 1 pageSize = limit return page, pageSize } ================================================ FILE: go-b2b-starter/pkg/response/response.go ================================================ package response import ( "github.com/gin-gonic/gin" "github.com/moasq/go-b2b-starter/pkg/httperr" ) // Success sends a successful response func Success(c *gin.Context, statusCode int, data interface{}) { c.JSON(statusCode, gin.H{ "success": true, "data": data, }) } // Error sends an error response func Error(c *gin.Context, statusCode int, message string, err error) { c.JSON(statusCode, httperr.NewHTTPError( statusCode, "error", message, )) } ================================================ FILE: go-b2b-starter/pkg/slugify/slugify.go ================================================ package slugify import ( "regexp" "strings" ) // Slugify converts a string into a URL-friendly slug // Example: "My Organization Name!" -> "my-organization-name" func Slugify(s string) string { // Convert to lowercase s = strings.ToLower(s) // Replace spaces and underscores with hyphens s = strings.ReplaceAll(s, " ", "-") s = strings.ReplaceAll(s, "_", "-") // Remove all non-alphanumeric characters except hyphens reg := regexp.MustCompile(`[^a-z0-9-]+`) s = reg.ReplaceAllString(s, "") // Replace multiple consecutive hyphens with a single hyphen reg = regexp.MustCompile(`-+`) s = reg.ReplaceAllString(s, "-") // Trim hyphens from start and end s = strings.Trim(s, "-") return s } ================================================ FILE: go-b2b-starter/scripts/migrate_down.sh ================================================ #!/bin/bash # Load environment variables from a file if it exists ENV_FILE="app.env" if [ -f "$ENV_FILE" ]; then source "$ENV_FILE" else echo "Environment file not found, ensure $ENV_FILE exists or set the variables manually." exit 1 fi # Define migration paths MIGRATION_PATHS=( "src/pkg/db/postgres/sqlc/migrations" ) # Perform migrations for path in "${MIGRATION_PATHS[@]}"; do echo "Migrating up in $path..." migrate -path $path -database "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable" -verbose down if [ $? -ne 0 ]; then echo "Migration failed for $path" exit 1 else echo "Migration completed for $path" fi done ================================================ FILE: go-b2b-starter/scripts/migrate_up.sh ================================================ #!/bin/bash # Load environment variables from a file if it exists ENV_FILE="app.env" if [ -f "$ENV_FILE" ]; then source "$ENV_FILE" else echo "Environment file not found, ensure $ENV_FILE exists or set the variables manually." exit 1 fi # Define migration paths MIGRATION_PATHS=( "src/pkg/db/postgres/sqlc/migrations" ) # Perform migrations for path in "${MIGRATION_PATHS[@]}"; do echo "Migrating up in $path..." migrate -path $path -database "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable" -verbose up if [ $? -ne 0 ]; then echo "Migration failed for $path" exit 1 else echo "Migration completed for $path" fi done ================================================ FILE: go-b2b-starter/scripts/run_tests_with_coverage.sh ================================================ #!/bin/bash # File: scripts/run_tests_with_coverage.sh echo "Running tests with coverage for all modules..." # Create coverage directory mkdir -p coverage rm -f coverage/coverage.txt # Find all go.mod files and run tests find ./src -name go.mod | while read -r mod_file; do mod_dir=$(dirname "$mod_file") mod_name=$(basename "$mod_dir") echo "Testing module: $mod_name" ( cd "$mod_dir" if go test -v -coverprofile=coverage.out ./...; then if [ -s coverage.out ]; then echo "mode: atomic" > "../../coverage/coverage.$mod_name.txt" tail -n +2 coverage.out >> "../../coverage/coverage.$mod_name.txt" else echo "No coverage data generated for $mod_name" fi else echo "Tests failed for $mod_name" fi rm -f coverage.out ) done # Combine all coverage files echo "mode: atomic" > coverage/coverage.txt find coverage -name 'coverage.*.txt' -print0 | xargs -0 tail -q -n +2 >> coverage/coverage.txt # Remove any non-coverage lines (like file headers) sed -i '/^[^[:space:]]*:/!d' coverage/coverage.txt # Generate coverage reports if [ -s coverage/coverage.txt ]; then go tool cover -func=coverage/coverage.txt go tool cover -html=coverage/coverage.txt -o coverage/coverage.html echo "Coverage report generated in coverage/coverage.html" else echo "No coverage data generated" fi ================================================ FILE: next_b2b_starter/.claude/CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview **B2B SaaS Starter** - A production-ready Next.js 16 B2B SaaS application with Stytch B2B authentication, Polar.sh billing, and comprehensive RBAC system. Built for scalability, security, and developer experience. ## Development Commands - **Development**: `pnpm dev` (uses Turbopack) - **Build**: `pnpm build` - **Production**: `pnpm start` - **Lint**: `pnpm lint` - **Package Manager**: `pnpm` only ## Tech Stack & Architecture - **Framework**: Next.js 16.0.10 + App Router - **Language**: TypeScript (strict mode) - **Styling**: Tailwind CSS - **Components**: shadcn/ui for consistent, accessible UI components - **State Management**: TanStack Query (React Query) for server state, Zustand for client state - **Authentication**: Stytch B2B with magic links, session management, and RBAC - **Billing**: Polar.sh for subscriptions, usage metering, and webhooks - **Data Fetching**: Server Actions + TanStack Query + Repository pattern - **Philosophy**: Production-ready, secure, maintainable, performant ## Key Principles - **Security First**: Authentication guards, permission checks, subscription validation on every sensitive operation - **Modern Architecture**: Server Actions for mutations, React Query for caching, Next.js 16 App Router for routing - **Type Safety**: Strict TypeScript throughout the application with comprehensive type definitions - **Performance**: Optimized bundle size, fast load times, efficient caching strategies - **Maintainability**: Clear separation of concerns, consistent patterns, comprehensive documentation ## Project Structure ``` app/ ├── layout.tsx # Root layout with providers ├── page.tsx # Landing page ├── auth/ # Authentication pages ├── authenticate/ # Magic link callback ├── signup/ # Organization signup ├── dashboard/ # Protected dashboard routes │ ├── page.tsx # Dashboard home (redirects to settings) │ ├── settings/ # Settings page with tabs │ └── knowledge/ # Knowledge/chat feature └── api/ # API routes (minimal - 2 routes) ├── auth/session/refresh/ # JWT refresh endpoint └── billing/webhook/ # Polar webhook receiver components/ ├── ui/ # shadcn/ui components ├── layout/ # Layout components (header, sidebar, user menu) ├── billing/ # Billing components (plans modal, subscription status) ├── members/ # Member management components └── cognitive/ # AI chat components lib/ ├── actions/ # Server Actions │ ├── auth/ # Auth actions (send magic link, consume, logout) │ └── billing/ # Billing actions (checkout, cancel, verify payment) ├── api/ # API client and repositories │ └── api/ │ ├── client/ # API client with auth, retry, error handling │ └── repositories/ # Repository pattern for Go backend ├── auth/ # Authentication utilities │ ├── stytch/ # Stytch B2B client setup │ ├── constants.ts # Cookie names, routes │ ├── server-permissions.ts # Permission checking │ └── token-utils.ts # JWT utilities ├── polar/ # Polar billing integration │ ├── client.ts # Polar SDK client │ ├── subscription.ts # Subscription fetching │ ├── current-subscription.ts # Subscription state resolution │ ├── plans.ts # Plan definitions │ └── usage.ts # Usage metering ├── contexts/ # React contexts │ └── auth-context.tsx # Auth state management ├── hooks/ # Custom hooks │ ├── queries/ # TanStack Query hooks │ └── mutations/ # TanStack Mutation hooks ├── models/ # TypeScript type definitions ├── providers/ # Provider components ├── stores/ # Zustand stores └── utils/ # Utility functions └── server-action-helpers.ts # ActionResult type and helpers docs/ # Comprehensive documentation ├── 01-getting-started.md ├── 02-authentication.md ├── 03-permissions-and-roles.md ├── 04-payments-and-billing.md ├── 05-making-api-requests.md ├── 06-creating-pages.md ├── 07-creating-apis.md ├── 08-using-hooks.md ├── 09-adding-a-feature.md ├── 10-server-actions.md ├── 11-feature-guards.md ├── 12-subscription-patterns.md └── API-LOGGING.md ``` ## Documentation Comprehensive guides in `docs/`: - **01-10**: Core guides (getting started, auth, permissions, billing, APIs, hooks, etc.) - **11-feature-guards.md**: Protecting features with auth, permission, and subscription guards - **12-subscription-patterns.md**: Managing subscriptions, checkout, and billing with Polar.sh ## Current State - **Production-ready** authentication with Stytch B2B (magic links, sessions, RBAC) - **Subscription billing** with Polar.sh (checkout, webhooks, usage metering) - **Server Actions** for secure mutations with auth/permission/subscription guards - **TanStack Query** for optimized data fetching and caching - **Repository pattern** for Go backend integration via API client - **Comprehensive documentation** for all major features ## Development Guidelines ### Components - Use shadcn/ui for UI components - Create custom components in appropriate directories (e.g., `components/billing/`, `components/members/`) - Always add proper TypeScript types ### State Management - **Server State**: TanStack Query (React Query) for API data - **Client State**: Zustand for global UI state, React built-ins for local component state - **Auth State**: AuthContext with sessionStorage persistence ### Data Fetching - **Queries**: Use TanStack Query hooks (e.g., `useProfileQuery()`, `useSubscriptionQuery()`) - **Mutations**: Use TanStack Mutation hooks (e.g., `useInviteMember()`, `useUpdateProfile()`) - **Server Actions**: For operations that need server-side logic (auth, billing, etc.) ### Authentication & Authorization - **Server Components**: Use `getMemberSession()` and `getServerPermissions()` - **Client Components**: Use `useAuth()` and `usePermissions()` hooks - **Server Actions**: Always check auth, permissions, and subscription status ### Styling - Tailwind CSS for all styling - Follow design system patterns from shadcn/ui - Use utility classes, avoid custom CSS ### Type Safety - Maintain strict TypeScript throughout - Define types in `lib/models/` directory - Use `ActionResult` type for Server Action return values ## Before Adding Any Package 1. Ask: "Does this solve a real problem better than existing solutions?" 2. Check: "Is this package well-maintained and widely adopted?" 3. Verify: "Does this align with our tech stack and architecture?" 4. Consider: "What's the bundle size impact and maintenance overhead?" ## Key Files - **docs/** - Comprehensive feature documentation - **STYTCH_CONFIGURATION.md** - Stytch B2B setup and configuration - **package.json** - Project dependencies and scripts - **tailwind.config.ts** - Tailwind configuration with design tokens - **components.json** - shadcn/ui configuration - **lib/utils/server-action-helpers.ts** - Server Action utilities - **lib/auth/server-permissions.ts** - Permission system - **lib/polar/current-subscription.ts** - Subscription state resolution ## Architecture Notes ### Authentication Flow 1. User enters email → `sendMagicLink()` Server Action 2. Stytch sends magic link email 3. User clicks link → `/authenticate` page 4. `consumeMagicLink()` Server Action validates token 5. Session cookies set (httpOnly, secure) 6. User redirected to dashboard ### Permission System - Roles: `owner`, `admin`, `member`, `approver` - Permissions: `org:view`, `org:manage`, `resource:view`, `resource:create`, `resource:edit`, `resource:delete` - Check via `getServerPermissions()` server-side or `usePermissions()` client-side ### Subscription Guards - Check subscription status with `resolveCurrentSubscription()` or `useSubscriptionQuery()` - Gate premium features behind active subscription checks - Handle subscription states: active, inactive, no customer, authentication required ### Server Actions Pattern ```typescript 'use server'; import { getMemberSession } from '@/lib/auth/stytch/server'; import { getServerPermissions } from '@/lib/auth/server-permissions'; import { createActionError, createActionSuccess } from '@/lib/utils/server-action-helpers'; export async function myAction() { // 1. Auth check const session = await getMemberSession(); if (!session?.session_jwt) { return createActionError('Authentication required.'); } // 2. Permission check const permissions = await getServerPermissions(session); if (!permissions.canDoSomething) { return createActionError('Insufficient permissions.'); } // 3. Business logic // ... return createActionSuccess(data); } ``` The goal is to build production-ready B2B SaaS applications with enterprise-grade authentication, billing, and permission systems using modern tools and patterns. ================================================ FILE: next_b2b_starter/.dockerignore ================================================ # ============================================================================= # Frontend - Docker Ignore Configuration # ============================================================================= # Excludes unnecessary files from Docker build context # Reduces build time, image size, and improves security # ============================================================================= # Dependencies node_modules npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Next.js build output .next/ out/ build/ dist/ # Testing coverage/ .nyc_output/ *.test.js *.test.ts *.test.tsx *.spec.js *.spec.ts *.spec.tsx __tests__/ __mocks__/ # Environment files (security) .env .env*.local .env.development .env.test .env.production.local .env.development.local # Git .git/ .gitignore .gitattributes # IDE and Editor files .vscode/ .idea/ *.swp *.swo *~ .DS_Store # Documentation and non-essential files *.md !README.md docs/ .github/ # Deployment files (keep only what's needed) deployment/ # Note: deployments/ folder is kept for .env templates # Logs logs/ *.log # Misc .cache/ .temp/ .tmp/ *.pid *.seed *.pid.lock # TypeScript *.tsbuildinfo # Linting .eslintcache # Testing .jest/ playwright-report/ test-results/ # Scripts (if not needed in production) scripts/ # Storybook .storybook/ storybook-static/ # Turbo .turbo/ ================================================ FILE: next_b2b_starter/.env.example ================================================ # Database Configuration POSTGRES_URL= POSTGRES_PRISMA_URL= POSTGRES_URL_NON_POOLING= POSTGRES_USER= POSTGRES_HOST= POSTGRES_PASSWORD= POSTGRES_DATABASE= # `openssl rand -base64 32` # Application base URL for redirects APP_BASE_URL=http://localhost:3000 NEXT_PUBLIC_APP_BASE_URL=http://localhost:3000 NOTIFICATION_EMAIL= NEXT_PUBLIC_CONTACT_EMAIL= # Stytch B2B Authentication STYTCH_PROJECT_ID= STYTCH_SECRET= STYTCH_PROJECT_ENV=test NEXT_PUBLIC_STYTCH_PROJECT_ENV=test NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN= NEXT_PUBLIC_STYTCH_LOGIN_PATH=/auth NEXT_PUBLIC_STYTCH_REDIRECT_PATH=/authenticate NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES=43200 STYTCH_ALLOWED_ORGANIZATION_IDS= # Polar Billing (sandbox defaults) POLAR_ACCESS_TOKEN= POLAR_WEBHOOK_SECRET= NEXT_PUBLIC_POLAR_PRODUCT_ID=523c6265-6eed-4ec0-b202-32e03ddd9a67 NEXT_PUBLIC_POLAR_BUSINESS_PRODUCT_ID=523c6265-6eed-4ec0-b202-32e03ddd9a67 NEXT_PUBLIC_POLAR_SCALE_PRODUCT_ID= NEXT_PUBLIC_POLAR_METER_ID=74f6f057-f061-4d20-8dc0-43ff9c8704af # Umami Analytics NEXT_PUBLIC_UMAMI_WEBSITE_ID=de101dd2-de68-4c6a-9b6a-7358e59a8cb0 NEXT_PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js # SMTP Configuration (Email Sending) # For Gmail: smtp.gmail.com, for Resend: smtp.resend.com, for SendGrid: smtp.sendgrid.net SMTP_HOST= SMTP_PORT=587 SMTP_SECURE=true SMTP_USER= SMTP_PASS= SMTP_FROM= ================================================ FILE: next_b2b_starter/.eslintrc.json ================================================ { "extends": ["next/core-web-vitals"] } ================================================ FILE: next_b2b_starter/.npmrc ================================================ # pnpm configuration # Allow trusted packages to run build scripts (required for sharp, Next.js image optimization, etc.) enable-pre-post-scripts=true # Auto-install peer dependencies auto-install-peers=true strict-peer-dependencies=false ================================================ FILE: next_b2b_starter/Dockerfile ================================================ # ============================================================================= # Frontend - Production Dockerfile # ============================================================================= # Multi-stage build for optimized Next.js 15 deployment with SSR support # Final image size: ~120-150MB (vs ~500MB+ without optimization) # Node.js 22 LTS (Jod) with Alpine Linux 3.22 # ============================================================================= # ----------------------------------------------------------------------------- # Stage 1: Dependencies Installation # ----------------------------------------------------------------------------- FROM node:22-alpine3.22 AS deps # Install necessary system dependencies RUN apk add --no-cache libc6-compat WORKDIR /app # Install pnpm globally RUN corepack enable && corepack prepare pnpm@latest --activate # Copy package management files COPY package.json pnpm-lock.yaml .npmrc* ./ # Install dependencies using pnpm # .npmrc ensures sharp is built correctly for image optimization RUN pnpm install --frozen-lockfile --prod=false # ----------------------------------------------------------------------------- # Stage 2: Application Builder # ----------------------------------------------------------------------------- FROM node:22-alpine3.22 AS builder WORKDIR /app # Install pnpm RUN corepack enable && corepack prepare pnpm@latest --activate # Copy dependencies from deps stage COPY --from=deps /app/node_modules ./node_modules # Copy application source COPY . . # Build arguments for NEXT_PUBLIC_* environment variables # These must be set at build time for client-side code ARG NEXT_PUBLIC_APP_BASE_URL ARG NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN ARG NEXT_PUBLIC_STYTCH_PROJECT_ID ARG NEXT_PUBLIC_STYTCH_PROJECT_ENV ARG NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN ARG NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL # Set build-time environment variables ENV NEXT_PUBLIC_APP_BASE_URL=$NEXT_PUBLIC_APP_BASE_URL ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ENV NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=$NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN ENV NEXT_PUBLIC_STYTCH_PROJECT_ID=$NEXT_PUBLIC_STYTCH_PROJECT_ID ENV NEXT_PUBLIC_STYTCH_PROJECT_ENV=$NEXT_PUBLIC_STYTCH_PROJECT_ENV ENV NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN=$NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN ENV NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN=$NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL # Disable Next.js telemetry ENV NEXT_TELEMETRY_DISABLED=1 # Build the application # next.config.ts already has output: "standalone" configured RUN pnpm build # ----------------------------------------------------------------------------- # Stage 3: Production Runtime # ----------------------------------------------------------------------------- FROM node:22-alpine3.22 AS runner WORKDIR /app # Set production environment ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 # Create non-root user for security RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs # Copy public assets COPY --from=builder /app/public ./public # Copy standalone build output (optimized by Next.js) # This includes only necessary dependencies and files COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static # Switch to non-root user USER nextjs # Expose application port EXPOSE 3000 # Set runtime environment variables ENV PORT=3000 ENV HOSTNAME="0.0.0.0" # Health check for container orchestration HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" # Start the application # Use node directly (not npm/pnpm) for proper signal handling and graceful shutdown CMD ["node", "server.js"] ================================================ FILE: next_b2b_starter/README.md ================================================ # B2B SaaS Starter A modern B2B SaaS starter kit with Authentication, Billing, and RBAC. ## 🚀 Quick Start ```bash ./setup.sh cd next_b2b_starter pnpm dev ``` Visit `http://localhost:3000`. ## 📚 Documentation - **[Getting Started](./docs/01-getting-started.md)** - Setup and installation - **[Authentication](./docs/02-authentication.md)** - How auth works - **[Permissions](./docs/03-permissions-and-roles.md)** - RBAC system - **[Payments](./docs/04-payments-and-billing.md)** - Subscriptions - **[Full Documentation](./docs/README.md)** - Complete guide index ## 🔧 Advanced - **[Stytch Configuration](./STYTCH_CONFIGURATION.md)** - Advanced auth security settings - **[Claude Guide](./CLAUDE.md)** - Development rules ## Features - **Stack**: Next.js 16, TypeScript, Tailwind, shadcn/ui - **Auth**: Stytch B2B (passwordless) - **Billing**: Polar.sh integration - **State**: React Query + Zustand ## Project Structure - `app/`: Next.js App Router - `components/`: UI Components - `lib/`: Business logic, hooks, API clients - `middleware.ts`: Auth protection ## Support Open an issue on GitHub for support. License: MIT. ================================================ FILE: next_b2b_starter/STYTCH_CONFIGURATION.md ================================================ # Stytch Configuration Guide This document explains how to configure Stytch B2B to prevent unknown users from receiving magic link emails and creating accounts. ## Overview We've implemented a custom solution to address two critical security requirements: 1. **Prevent emails being sent to non-existent users** 2. **Block unknown email addresses from creating accounts** ## How It Works ### Custom Backend Validation Instead of using Stytch's UI component directly (which always sends emails), we've created a custom flow: 1. **Frontend**: Custom email form in `app/auth/page.tsx` 2. **Backend API**: `/api/auth/magic-link` validates membership before sending 3. **Stytch API**: Only called if user is an existing member ### Security Features ✅ **Email validation**: Backend checks if email exists in any organization before sending magic link ✅ **No user enumeration**: Returns same message for existing and non-existing users ✅ **JIT provisioning blocked**: Organization settings prevent auto-creation of new members ✅ **Discovery flow restricted**: Users can only join organizations they're invited to ## Required Stytch Dashboard Configuration ### Step 1: Disable Self-Service Organization Creation 1. Log into your [Stytch Dashboard](https://stytch.com/dashboard) 2. Navigate to **Frontend SDK** settings 3. Find **"Create Organizations"** toggle under **Enabled methods** 4. **Disable** this toggle **Result**: Users cannot create new organizations via the discovery flow ### Step 2: Configure Organization Settings (Per Organization) For each organization in your Stytch project: 1. Navigate to **Organizations** in the dashboard 2. Select your organization 3. Go to **Settings** → **Authentication** 4. Configure the following: ```json { "email_jit_provisioning": "NOT_ALLOWED", "email_invites": "RESTRICTED", "email_allowed_domains": ["your-company.com"] // Optional: restrict by domain } ``` **What each setting does:** - `email_jit_provisioning: "NOT_ALLOWED"` - Prevents new members from being auto-created via magic link - `email_invites: "RESTRICTED"` - Requires explicit invitation to join - `email_allowed_domains` - (Optional) Only allows specific email domains ### Step 2a: (Optional) Configure Allowed Organization IDs The backend validates emails by searching members across a specific list of organizations. To avoid an extra API call during login, you can provide a comma-separated allowlist: ```bash STYTCH_ALLOWED_ORGANIZATION_IDS=org-test-123,org-test-456 ``` If this variable is not set, we automatically fetch all organizations in the workspace and cache the IDs in memory. ### Step 3: Verify API Permissions Ensure your Stytch API credentials have permission to: - Search members (`organizations.members.search`) - Send magic links (`magicLinks.email.discovery.send`) ## Testing the Implementation ### Test Case 1: Unknown Email 1. Enter an email that doesn't exist in any organization 2. Click "Send magic link" 3. **Expected**: Message says "If an account exists with that email, a magic link has been sent." 4. **Verify**: No email is actually sent 5. **Check backend logs**: Should see "No members found" for the email ### Test Case 2: Existing Member 1. Enter an email of an existing organization member 2. Click "Send magic link" 3. **Expected**: Same message as above 4. **Verify**: Email IS sent with magic link 5. **Check inbox**: Magic link email received ### Test Case 3: Magic Link Authentication 1. Click the magic link from Test Case 2 2. **Expected**: User is authenticated and redirected to dashboard 3. **Verify**: Session is created with correct organization ### Test Case 4: Unknown User Clicks Link (if they somehow got one) 1. If someone gets a magic link URL (e.g., from a legitimate user) 2. **Expected**: Authentication fails with error 3. **Verify**: No session is created, user cannot access dashboard ## API Endpoint Documentation ### POST `/api/auth/magic-link` Validates email and sends magic link to existing members only. **Request:** ```json { "email": "user@company.com" } ``` **Response (Success):** ```json { "success": true, "message": "If an account exists with that email, a magic link has been sent." } ``` **Response (Error):** ```json { "error": "Unable to process request. Please try again later." } ``` **Note**: Response is the same whether user exists or not (prevents enumeration) ## How to Add New Members Since self-service signup is disabled, use one of these methods: ### Method 1: Invite via Stytch Dashboard 1. Go to **Organizations** → Select org → **Members** 2. Click **Invite Member** 3. Enter email and assign roles 4. User receives invitation email ### Method 2: Programmatic Invite ```typescript import { getStytchB2BClient } from "@/lib/auth/stytch/server"; const client = getStytchB2BClient(); await client.magicLinks.email.invite.send({ organization_id: "org-test-...", email_address: "newuser@company.com", invited_by_member_id: "member-test-...", }); ``` ### Method 3: Create Member via API ```typescript await client.organizations.members.create({ organization_id: "org-test-...", email_address: "newuser@company.com", name: "New User", roles: ["member"], }); ``` ## Troubleshooting ### Issue: Existing users not receiving emails **Check:** 1. Email is verified in Stytch 2. Member status is "active" (not "pending" or "invited") 3. Backend logs for member search results 4. Stytch API credentials are correct ### Issue: Unknown users still getting emails **Check:** 1. Using `/api/auth/magic-link` endpoint (not direct Stytch SDK call) 2. Backend search is working correctly 3. No caching issues in API route ### Issue: Users can't create organizations **This is expected!** Self-service organization creation is disabled. **Solution:** Create organizations manually via: - Stytch Dashboard - Stytch API programmatically ## Environment Variables Required in `.env.local`: ```bash # Stytch B2B Authentication STYTCH_PROJECT_ID=project-test-... STYTCH_SECRET=secret-test-... NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=public-token-test-... # Session configuration NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES=43200 # 30 days # App URLs NEXT_PUBLIC_APP_BASE_URL=http://localhost:3000 NEXT_PUBLIC_STYTCH_REDIRECT_PATH=/authenticate ``` ## Additional Security Recommendations 1. **Enable MFA**: Require multi-factor authentication for sensitive organizations 2. **Monitor failed attempts**: Track authentication failures in your logs 3. **Rate limiting**: Add rate limiting to `/api/auth/magic-link` endpoint 4. **Email verification**: Ensure all members have verified emails 5. **Session duration**: Keep session duration appropriate for your security requirements ## Migration from Discovery Flow If you were previously using the Discovery flow with self-service signup: 1. **Export existing members**: Get list of all current members 2. **Notify users**: Inform them that signup is now invite-only 3. **Update documentation**: Update user docs about the new auth flow 4. **Monitor support requests**: Users may try to sign up and fail ## Questions? For Stytch-specific configuration questions: - [Stytch B2B Documentation](https://stytch.com/docs/b2b) - [Stytch Support](https://stytch.com/contact) For implementation questions related to this codebase: - Review `app/api/auth/magic-link/route.ts` for backend logic - Review `app/auth/page.tsx` for frontend implementation ================================================ FILE: next_b2b_starter/app/api/auth/session/refresh/route.ts ================================================ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { getStytchB2BClient } from "@/lib/auth/stytch/server"; import { SESSION_COOKIE_NAME, SESSION_JWT_COOKIE_NAME, } from "@/lib/auth/constants"; import { getSessionDurationMinutes, getCookieConfig, } from "@/lib/auth/server-constants"; import { isTokenExpired } from "@/lib/auth/token-utils"; export async function POST() { try { const cookieStore = await cookies(); // First, check if we already have a valid JWT const existingJwt = cookieStore.get(SESSION_JWT_COOKIE_NAME)?.value ?? null; if (existingJwt && !isTokenExpired(existingJwt)) { return NextResponse.json({ sessionJwt: existingJwt }); } // Try to get session token to exchange for JWT const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; if (!sessionToken) { return NextResponse.json( { sessionJwt: null, error: "session_not_found" }, { status: 401 } ); } const client = getStytchB2BClient(); try { const response = await client.sessions.authenticate({ session_token: sessionToken, session_duration_minutes: getSessionDurationMinutes(), }); const sessionJwt = (response as any)?.session_jwt ?? null; if (!sessionJwt) { return NextResponse.json( { sessionJwt: null, error: "session_missing_jwt" }, { status: 401 } ); } // Validate the new JWT before returning it if (isTokenExpired(sessionJwt)) { return NextResponse.json( { sessionJwt: null, error: "session_jwt_expired" }, { status: 401 } ); } const res = NextResponse.json({ sessionJwt }); const maxAgeSeconds = getSessionDurationMinutes() * 60; res.cookies.set(SESSION_JWT_COOKIE_NAME, sessionJwt, { ...getCookieConfig(), maxAge: maxAgeSeconds, }); return res; } catch { // Clear invalid session cookies const response = NextResponse.json( { sessionJwt: null, error: "session_invalid" }, { status: 401 } ); // Clear the invalid cookies response.cookies.delete(SESSION_COOKIE_NAME); response.cookies.delete(SESSION_JWT_COOKIE_NAME); return response; } } catch { return NextResponse.json( { sessionJwt: null, error: "refresh_failed" }, { status: 500 } ); } } ================================================ FILE: next_b2b_starter/app/api/billing/webhook/route.ts ================================================ import { NextResponse } from "next/server"; import { Webhooks } from "@polar-sh/nextjs"; const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; async function handleSubscriptionEvent(_eventType: string, _payload: unknown) { // TODO: Forward webhook events to Go backend for persistence. // Options: // 1. Call backend API: POST /api/webhooks/polar with { eventType, payload } // 2. Configure Polar.sh to send webhooks directly to Go backend // // Backend already has ProcessWebhookEvent service ready in: // src/app/billing/app/services/process_webhook_event_service.go } export const POST = webhookSecret ? Webhooks({ webhookSecret, onSubscriptionCreated: async (subscription) => { await handleSubscriptionEvent("subscription.created", subscription); }, onSubscriptionUpdated: async (subscription) => { await handleSubscriptionEvent("subscription.updated", subscription); }, onSubscriptionCanceled: async (subscription) => { await handleSubscriptionEvent("subscription.canceled", subscription); }, onOrderPaid: async (order) => { await handleSubscriptionEvent("order.paid", order); }, }) : async () => NextResponse.json( { error: "Polar webhook secret not configured." }, { status: 503 } ); ================================================ FILE: next_b2b_starter/app/auth/page.tsx ================================================ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { useStytchMember } from "@stytch/nextjs/b2b"; import { ArrowRight, CheckCircle2, Home, Inbox, Mail } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { sendMagicLink } from "@/lib/actions/auth/send-magic-link"; const highlights = [ "Single workspace to review invoices, approvals, and exports.", "Ready-made controls that plug into your existing banking stack.", "Sessions scoped to your organization with role-aware access.", ]; const emailProviders = [ { label: "Open Gmail", href: "https://mail.google.com/", }, { label: "Open Outlook", href: "https://outlook.office.com/mail/", }, { label: "Open iCloud Mail", href: "https://www.icloud.com/mail", }, { label: "Open Yahoo Mail", href: "https://mail.yahoo.com/", }, ]; export default function AuthPage() { const router = useRouter(); const searchParams = useSearchParams(); const { member, isInitialized } = useStytchMember(); const [email, setEmail] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [status, setStatus] = useState<{ type: "info" | "error" | "success"; message: string; } | null>(null); const [isRedirecting, setIsRedirecting] = useState(false); const [view, setView] = useState<"form" | "success">("form"); const [lastSubmittedEmail, setLastSubmittedEmail] = useState(""); const hasRedirectedRef = useRef(false); const redirectTimeoutRef = useRef(null); const targetAfterLogin = useMemo(() => { const returnTo = searchParams.get("returnTo") || "/dashboard"; // Validate returnTo is a safe relative path: // - Must start with single / // - Cannot be protocol-relative (//), contain backslash (\), or have : before first / const isSafeRelativePath = returnTo.startsWith("/") && !returnTo.startsWith("//") && !returnTo.includes("\\") && !returnTo.slice(1).includes(":"); return isSafeRelativePath ? returnTo : "/dashboard"; }, [searchParams]); const handleAuthSuccess = useCallback(() => { if (hasRedirectedRef.current) return; hasRedirectedRef.current = true; setIsRedirecting(true); setStatus({ type: "info", message: "You’re signed in. Redirecting to your workspace…", }); router.replace(targetAfterLogin); setTimeout(() => { router.refresh(); }, 150); if (typeof window !== "undefined") { if (redirectTimeoutRef.current !== null) { window.clearTimeout(redirectTimeoutRef.current); } redirectTimeoutRef.current = window.setTimeout(() => { window.location.assign(targetAfterLogin); }, 1500); } }, [router, targetAfterLogin]); useEffect(() => { if (!isInitialized) return; if (member) { handleAuthSuccess(); } }, [isInitialized, member, handleAuthSuccess]); useEffect(() => { const hasMagicLinkParams = searchParams.has("stytch_token") || searchParams.has("token") || searchParams.has("stytch_token_type"); if (hasMagicLinkParams) { setStatus({ type: "info", message: "We’re verifying your sign-in link. This usually takes just a moment.", }); } }, [searchParams]); const submitEmail = useCallback( async ( rawEmail: string, options: { resetField?: boolean; stayOnSuccessView?: boolean } = {} ) => { const { resetField = true, stayOnSuccessView = false } = options; const trimmedEmail = rawEmail.trim().toLowerCase(); if (!trimmedEmail) { setStatus({ type: "error", message: "Please enter a valid email address.", }); return; } setIsSubmitting(true); setStatus({ type: "info", message: "Checking your workspace access…", }); if (!stayOnSuccessView) { setView("form"); } try { const query = new URLSearchParams({ email: trimmedEmail }).toString(); const apiBaseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api").replace(/\/$/, ""); const emailCheckResponse = await fetch(`${apiBaseUrl}/auth/check-email?${query}`); if (emailCheckResponse.status === 404) { const errorBody = await emailCheckResponse.json().catch(() => null); setStatus({ type: "error", message: (errorBody && errorBody.message) || "We couldn't find an account with that email. Try a different email or ask your admin to invite you.", }); return; } if (!emailCheckResponse.ok) { const errorBody = await emailCheckResponse.json().catch(() => null); throw new Error( (errorBody && errorBody.message) || "We couldn't verify that email right now. Please try again in a moment.", ); } setStatus({ type: "info", message: "Sending your secure sign-in link…", }); // Call Server Action instead of API route const result = await sendMagicLink(trimmedEmail); if (!result.success) { throw new Error(result.error || "Failed to send sign-in link."); } setLastSubmittedEmail(trimmedEmail); setStatus({ type: "success", message: "Check your email for a secure link to sign in.", }); setView("success"); if (resetField) { setEmail(""); } } catch (error: any) { setStatus({ type: "error", message: error?.message || "Something went wrong while sending your sign-in link. Please try again.", }); } finally { setIsSubmitting(false); } }, [] ); const handleSendMagicLink = async (e: React.FormEvent) => { e.preventDefault(); await submitEmail(email, { resetField: true }); }; const handleResend = async () => { if (!lastSubmittedEmail) return; await submitEmail(lastSubmittedEmail, { resetField: false, stayOnSuccessView: true, }); }; useEffect(() => { router.prefetch(targetAfterLogin); }, [router, targetAfterLogin]); useEffect(() => { return () => { if (typeof window !== "undefined" && redirectTimeoutRef.current !== null) { window.clearTimeout(redirectTimeoutRef.current); redirectTimeoutRef.current = null; } }; }, []); if (!isInitialized) { return (

Checking your workspace session…

); } if (isRedirecting || member) { return (

Redirecting to your dashboard

{(status && status.message) || "We’re setting up your workspace now."}

Taking longer than expected?{" "} Open your workspace .

); } return (
Secure email sign-in Home

Welcome back to Your App

Use your work email to receive a one-time, organization-aware sign-in link. We’ll land you back where you left off as soon as you’re authenticated.

{highlights.map((item) => (

{item}

))}

Need a hand?

Reach out at{" "} support@yourapp.com {" "} for support, or check the documentation in your workspace.

); } ================================================ FILE: next_b2b_starter/app/authenticate/page.tsx ================================================ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { consumeMagicLink } from "@/lib/actions/auth/consume-magic-link"; const SESSION_DURATION_MINUTES = Number( process.env.NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES ?? "60" ) || 60; const DEFAULT_DESTINATION = "/dashboard"; type StatusState = { state: "verifying" | "success" | "error"; headline: string; message: string; }; const INITIAL_STATUS: StatusState = { state: "verifying", headline: "We're verifying your magic link", message: "Hang tight—this usually takes just a moment.", }; function extractErrorMessage(error: unknown): string { if (error && typeof error === "object") { const typed = error as any; if (typed.error_message) { return typed.error_message; } if (typed.message) { return typed.message; } } return "We couldn't verify that link. Please request a new magic link from the login page."; } export default function AuthenticateRedirectPage() { const router = useRouter(); const searchParams = useSearchParams(); const [status, setStatus] = useState(INITIAL_STATUS); const hasAttemptedAuthRef = useRef(false); const magicLinkToken = searchParams.get("stytch_token") || searchParams.get("token"); const tokenType = searchParams.get("stytch_token_type") || searchParams.get("token_type"); const returnTo = searchParams.get("returnTo")?.trim() || DEFAULT_DESTINATION; const redirectToDestination = useCallback(() => { router.push(returnTo); router.refresh(); }, [returnTo, router]); const exchangeMagicLink = useCallback(async () => { if (!magicLinkToken) { setStatus({ state: "error", headline: "Magic link is missing or invalid", message: "This sign-in link is missing its token. Please request a new magic link.", }); return; } hasAttemptedAuthRef.current = true; setStatus(INITIAL_STATUS); try { const result = await consumeMagicLink( magicLinkToken, SESSION_DURATION_MINUTES ); if (!result.success) { throw new Error(result.error || "Failed to verify magic link."); } if (!result.data.memberAuthenticated) { setStatus({ state: "error", headline: "Additional verification required", message: "We need a bit more information to finish signing you in. Please continue from the login page.", }); return; } setStatus({ state: "success", headline: "Magic link verified", message: "You're all set. Redirecting you to your workspace…", }); redirectToDestination(); } catch (error) { setStatus({ state: "error", headline: "We couldn't verify your link", message: extractErrorMessage(error), }); } }, [magicLinkToken, redirectToDestination]); useEffect(() => { if (hasAttemptedAuthRef.current) return; void exchangeMagicLink(); }, [exchangeMagicLink]); const icon = status.state === "success" ? (