diff --git a/docs/getting-started/billing-manager/README.md b/docs/getting-started/billing-manager/README.md index dfdc055..0f09aef 100644 --- a/docs/getting-started/billing-manager/README.md +++ b/docs/getting-started/billing-manager/README.md @@ -1,36 +1,36 @@ -# Getting Started With Billing Manager +# Billing Manager -The recommended billing functionality comes in the form of three independent, composable packages: [**paymentprovider**](../../../external/holder/paymentprovider/), [**billing**](../../../external/holder/billing/), and [**billingmanager**](../../../external/holder/billingmanager/). For most application features, you should use the high-level `billingmanager` package, which handles webhook processing, subscription management, and billing event tracking with integrated audit logging. +The recommended billing functionality comes in three independent, composable packages: `paymentprovider`, `billing`, and `billingmanager`. For most application features, you should use the high-level `billingmanager` package, which handles webhook processing, subscription management, and billing event tracking with integrated audit logging. ## Core Packages Overview -See below an overview of the core packages mentioned above: +Here's an overview of the core packages: -| Package | Purpose | Use Case (Recommended) | Examples Location | -|---------|---------|------------------------|-------------------| -| **`paymentprovider`** | Abstracts payment provider webhook verification and payload normalisation (e.g., Stripe, Lemon Squeezy, Ko-fi). | Building custom webhook handlers or testing provider integrations. | [external/holder/paymentprovider/examples](../../../external/holder/paymentprovider/examples/examples.go) | -| **`billing`** | Manages subscription and billing event data persistence with repository pattern. | Direct database operations or building custom billing workflows. | [external/holder/billing/examples](../../../external/holder/billing/examples/examples.go) | -| **`billingmanager`** | Orchestrates paymentprovider and billing with high-level API methods for webhook processing. | Building application features (Standard)—provides the full workflow and audit logging. | [external/holder/billingmanager/examples](../../../external/holder/billingmanager/examples/examples.go) | +| Package | Purpose | Recommended Use Case | Examples | +|---|---|---|---| +| `paymentprovider` | Abstracts payment provider webhook verification and payload normalisation (e.g., Stripe, Lemon Squeezy). | Building custom webhook handlers or testing provider integrations. | [`paymentprovider/examples`](../../../external/paymentprovider/examples/examples.go) | +| `billing` | Manages subscription and billing event data persistence with a repository pattern. | Direct database operations or building custom billing workflows. | [`billing/examples`](../../../external/billing/examples/examples.go) | +| `billingmanager` | Orchestrates `paymentprovider` and `billing` with high-level API methods for webhook processing. | Building application features (Standard)—provides the full workflow and audit logging. | [`billingmanager/examples`](../../../external/billingmanager/examples/examples.go) | ### Usage Overview -For a high-level overview of how this might fit into your GHAT(D) project, please [**visit this section**](#high-level-overview). +For a high-level overview of how this might fit into your project, please [**visit this section**](#high-level-overview). ## Quick Start: Setup and Processing Webhooks -In the following section we'll demonstrate how to set up the `billingmanager` and process payment provider webhooks, which is the recommended way to use the system for standard application operations. For more examples [please check out the reference examples above](#core-packages-overview). +This section shows how to set up the `billingmanager` and process payment provider webhooks. This is the recommended way to use the system for standard operations. For more examples, [check out the reference examples above](#core-packages-overview). ### 1. Import Packages and Configure -You'll need configuration for the `paymentprovider`, a `billing` service instance, and an [`audit` service](../../../external/audit/). +You'll need configuration for the `paymentprovider`, a `billing` service instance, and an `audit` service. ```go import ( "context" "net/http" - "github.com/ooaklee/ghatd/external/holder/paymentprovider" - "github.com/ooaklee/ghatd/external/holder/billing" - "github.com/ooaklee/ghatd/external/holder/billingmanager" + "github.com/ooaklee/ghatd/external/paymentprovider" + "github.com/ooaklee/ghatd/external/billing" + "github.com/ooaklee/ghatd/external/billingmanager" ) // Assume auditService and userService are initialised dependencies @@ -76,7 +76,7 @@ manager.WithUserService(userService) // Optional: Enables email->user ID reso ### 2. Process Webhook -You'll be able to use the high-level methods on the `billingmanager` to process incoming webhooks. +You can use the high-level methods on the `billingmanager` to process incoming webhooks. ```go // 6. Process a webhook from a payment provider @@ -148,7 +148,7 @@ for _, event := range eventsResp.Events { ### 4. Development Environment Setup -If you don't want to use your payment provider's webhook endpoints when running locally, you can leverage the `MockProvider` to simulate webhook payloads for testing. +If you don't want to use your payment provider's webhook endpoints when running locally, you can use the `MockProvider` to simulate webhook payloads for testing. ```go // Use mock provider for local development @@ -175,7 +175,7 @@ manager := billingmanager.NewService( ) ``` -> **Note on Environments:** You can also use the `LoggingProvider` wrapper to log webhook payloads for debugging without actually processing them in your billing system. +> **Note on Environments:** You can also use the `LoggingProvider` wrapper to log webhook payloads for debugging without processing them in your billing system. ## Advanced Use Cases @@ -210,7 +210,7 @@ subsResp, err := billingService.GetSubscriptions(ctx, &billing.GetSubscriptionsR Order: "created_at_desc", }) -// Create billing events for audit trail +// Create billing events for an audit trail eventResp, err := billingService.CreateBillingEvent(ctx, &billing.CreateBillingEventRequest{ SubscriptionID: "sub_123", IntegratorEventID: "evt_stripe_456", @@ -242,7 +242,7 @@ func (p *MyCustomProvider) VerifyWebhook(ctx context.Context, req *http.Request) } func (p *MyCustomProvider) ParsePayload(ctx context.Context, req *http.Request) (*paymentprovider.WebhookPayload, error) { - // Parse provider-specific payload into normalised format + // Parse provider-specific payload into a normalised format return &paymentprovider.WebhookPayload{ EventType: paymentprovider.EventTypePaymentSucceeded, SubscriptionID: "sub_from_provider", @@ -266,7 +266,7 @@ manager := billingmanager.NewService(registry, billingService) ### Custom Repository Implementation -You can implement custom repositories for different databases while maintaining the same service layer. +You can implement custom repositories for different databases while keeping the same service layer. ```go // Implement the repository interfaces for your database @@ -295,7 +295,7 @@ billingService := billing.NewService(postgresRepo, postgresRepo) ## High-level Overview -See below high-level overviews of this billing solution (and its component packages) and a few examples of how it can be used in your GHAT(D) application for different use-cases. +Here are some high-level overviews of this billing solution and its packages, with examples of how it can be used in your application for different use-cases. ### Usage Patterns @@ -422,11 +422,11 @@ func SetupBillingRoutes(r *mux.Router, manager *billingmanager.Service) { #### Using AttachRoutes -For a more comprehensive setup that includes all billing manager endpoints with proper middleware, use the `AttachRoutes` function: +For a more comprehensive setup that includes all billing manager endpoints with the correct middleware, use the `AttachRoutes` function: ```go import ( - "github.com/ooaklee/ghatd/external/holder/billingmanager" + "github.com/ooaklee/ghatd/external/billingmanager" "github.com/ooaklee/ghatd/external/router" "github.com/gorilla/mux" ) @@ -452,14 +452,14 @@ This sets up the following routes automatically: - `POST /api/v1/bms/billings/{providerName}/webhooks` - Process payment provider webhooks **Authenticated User Routes:** -- `GET /api/v1/bms/billings/users/{userId}/events` - Get user's billing events -- `GET /api/v1/bms/users/{userId}/details/subscription` - Get user's subscription status -- `GET /api/v1/bms/users/{userId}/details/billing` - Get user's billing details +- `GET /api/v1/bms/billings/users/{userId}/events` - Get a user's billing events. +- `GET /api/v1/bms/users/{userId}/details/subscription` - Get a user's subscription status. +- `GET /api/v1/bms/users/{userId}/details/billing` - Get a user's billing details. **Admin Only Routes:** -- `GET /api/v1/bms/admin/billings/users/{userId}/events` - Get any user's billing events -- `GET /api/v1/bms/admin/users/{userId}/details/subscription` - Get any user's subscription status -- `GET /api/v1/bms/admin/users/{userId}/details/billing` - Get any user's billing details +- `GET /api/v1/bms/admin/billings/users/{userId}/events` - Get any user's billing events. +- `GET /api/v1/bms/admin/users/{userId}/details/subscription` - Get any user's subscription status. +- `GET /api/v1/bms/admin/users/{userId}/details/billing` - Get any user's billing details. ## Subscription Lifecycle @@ -484,30 +484,30 @@ Understanding the subscription lifecycle helps you work effectively with the bil │ 4. Subscription Management │ - ├──► Create new subscription (if first event) - ├──► Update existing subscription (status, dates, etc.) + ├──► Create a new subscription (if it's the first event) + ├──► Update an existing subscription (status, dates, etc.) │ 5. Event Recording │ - ├──► Create billing event for audit trail + ├──► Create a billing event for the audit trail │ 6. Audit Logging (Optional) │ - └──► Log to audit service + └──► Log to the audit service ``` ### Subscription States The billing system tracks various subscription states: -- **`active`** - Subscription is active and in good standing -- **`trialing`** - Subscription is in trial period -- **`past_due`** - Payment failed but subscription still active -- **`cancelled`** - Subscription has been cancelled -- **`paused`** - Subscription is temporarily paused -- **`expired`** - Subscription has expired -- **`incomplete`** - Subscription setup incomplete -- **`unpaid`** - Subscription unpaid +- **`active`** - The subscription is active and in good standing. +- **`trialing`** - The subscription is in a trial period. +- **`past_due`** - Payment has failed, but the subscription is still active. +- **`cancelled`** - The subscription has been cancelled. +- **`paused`** - The subscription is temporarily paused. +- **`expired`** - The subscription has expired. +- **`incomplete`** - The subscription setup is incomplete. +- **`unpaid`** - The subscription is unpaid. ## Authorisation & Security @@ -525,7 +525,7 @@ statusResp, err := manager.GetUserSubscriptionStatus(ctx, &billingmanager.GetUse // Admin querying another user's subscription (requires admin role via UserService) statusResp, err := manager.GetUserSubscriptionStatus(ctx, &billingmanager.GetUserSubscriptionStatusRequest{ UserID: "user-456", - RequestingUserID: "admin-user-123", // Must be admin + RequestingUserID: "admin-user-123", // Must be an admin }) ``` @@ -533,9 +533,9 @@ statusResp, err := manager.GetUserSubscriptionStatus(ctx, &billingmanager.GetUse Each provider has its own webhook verification mechanism: -- **Stripe**: HMAC-SHA256 signature verification -- **Lemon Squeezy**: HMAC-SHA256 signature verification -- **Ko-fi**: Verification token validation +- **Stripe**: HMAC-SHA256 signature verification. +- **Lemon Squeezy**: HMAC-SHA256 signature verification. +- **Ko-fi**: Verification token validation. The `paymentprovider` package handles all verification automatically before processing webhooks. @@ -546,8 +546,8 @@ The `paymentprovider` package handles all verification automatically before proc ```go import ( "testing" - "github.com/ooaklee/ghatd/external/holder/paymentprovider" - "github.com/ooaklee/ghatd/external/holder/billing" + "github.com/ooaklee/ghatd/external/paymentprovider" + "github.com/ooaklee/ghatd/external/billing" ) func TestWebhookProcessing(t *testing.T) { @@ -597,18 +597,18 @@ func TestSubscriptionLifecycle(t *testing.T) { provider, _ := paymentprovider.NewStripeProvider(stripeConfig) - // Use MongoDB test database + // Use a MongoDB test database mongoRepo := billing.NewRepository(mongoStore) billingService := billing.NewService(mongoRepo, mongoRepo) - // Test full webhook flow + // Test the full webhook flow // ... } ``` ## Potential Future Improvements -Below is a list of areas for improvement in future iterations of `billingmanager`, `billing`, and `paymentprovider`. Please note that these suggestions are not prioritised. +Here's a list of areas for improvement in future iterations of `billingmanager`, `billing`, and `paymentprovider`. Please note that these suggestions are not prioritised. ### Additional Providers - [ ] Paddle provider diff --git a/docs/getting-started/billing/README.md b/docs/getting-started/billing/README.md index a3d29d9..772866d 100644 --- a/docs/getting-started/billing/README.md +++ b/docs/getting-started/billing/README.md @@ -1,8 +1,6 @@ -# Billing Package Documentation +# Billing -## Overview - -The `billing` package (`external/billing`) provides core subscription and billing event management with MongoDB persistence. It forms the data layer of the billing system, handling storage, retrieval, and management of subscriptions and billing events with optimised indexes for performance. +The `billing` package (`external/billing`) gives you the tools for core subscription and billing event management, with MongoDB persistence. It acts as the data layer for your billing system, handling how you store, retrieve, and manage subscriptions and billing events, using optimised indexes for better performance. ## Table of Contents @@ -17,35 +15,35 @@ The `billing` package (`external/billing`) provides core subscription and billin ## Key Features ### 1. **Email-Based Subscriptions** -Subscriptions can be created with just an email address, enabling pre-registration purchases: -- Users can purchase before signing up -- Automatic association when user creates account -- Orphan subscription management tools +Create subscriptions with just an email address, allowing for pre-registration purchases: +- Users can purchase before signing up. +- Automatic association when a user creates an account. +- Tools for managing orphan subscriptions. ### 2. **Optimised MongoDB Indexes** -Purpose-built indexes for efficient queries: -- Standard user/email lookups -- Partial indexes for orphaned subscriptions -- Unique constraints preventing duplicates -- Time-based sorting and filtering +Comes with purpose-built indexes for efficient queries: +- Standard user/email lookups. +- Partial indexes for orphaned subscriptions. +- Unique constraints to prevent duplicates. +- Time-based sorting and filtering. ### 3. **Dual Repository Pattern** -Flexible data access with two implementations: -- **MongoDbStore**: Production-ready MongoDB persistence -- **InMemoryRepositoryStore**: Testing and development +A flexible data access layer with two implementations: +- **MongoDbStore**: Production-ready MongoDB persistence. +- **InMemoryRepositoryStore**: For testing and development. ### 4. **Comprehensive Event Tracking** -Every billing action is recorded: -- Payment events -- Subscription changes -- Status updates -- Full audit trail +Records every billing action: +- Payment events. +- Subscription changes. +- Status updates. +- A full audit trail. ## Architecture ### Service Layer -The billing service provides business logic and orchestration: +The billing service holds the business logic and orchestration: ``` Application @@ -83,16 +81,16 @@ Application The billing package uses two MongoDB collections: -1. **`billing_subscriptions`** - Subscription records -2. **`billing_events`** - Billing event history +1. **`billing_subscriptions`** - Stores subscription records. +2. **`billing_events`** - Holds the billing event history. ## MongoDB Setup ### Prerequisites -- MongoDB 4.2 or higher -- mongo-migrate library: `github.com/xakep666/mongo-migrate` -- Go mongo driver: `go.mongodb.org/mongo-driver/mongo` +- MongoDB 4.2 or higher. +- `mongo-migrate` library: `github.com/xakep666/mongo-migrate`. +- Go mongo driver: `go.mongodb.org/mongo-driver/mongo`. ### Installing Dependencies @@ -103,7 +101,7 @@ go get go.mongodb.org/mongo-driver/mongo ### Running Migrations -Create a migration runner to set up indexes: +Create a migration runner to set up the indexes: ```go package main @@ -185,11 +183,11 @@ Five indexes are created for the `billing_subscriptions` collection: | Index Name | Fields | Type | Purpose | Example Query | |------------|--------|------|---------|---------------| -| `idx_subscriptions_user_id` | `user_id` | Standard | Fetch all subscriptions for a user | `db.billing_subscriptions.find({user_id: "user-123"})` | -| `idx_subscriptions_email` | `email` | Standard | Query subscriptions by email | `db.billing_subscriptions.find({email: "user@example.com"})` | -| `idx_subscriptions_email_no_user` | `email` | Partial | Find orphaned subscriptions (no user ID) | `db.billing_subscriptions.find({email: "user@example.com", user_id: {$in: ["", null]}})` | -| `idx_subscriptions_integrator` | `integrator`, `integrator_subscription_id` | Unique Compound | Prevent duplicate subscriptions from same provider | Used internally by MongoDB for uniqueness | -| `idx_subscriptions_created_at` | `created_at` | Standard (Descending) | Sort/filter by date | `db.billing_subscriptions.find().sort({created_at: -1})` | +| `idx_subscriptions_user_id` | `user_id` | Standard | Fetch all subscriptions for a user. | `db.billing_subscriptions.find({user_id: "user-123"})` | +| `idx_subscriptions_email` | `email` | Standard | Query subscriptions by email. | `db.billing_subscriptions.find({email: "user@example.com"})` | +| `idx_subscriptions_email_no_user` | `email` | Partial | Find orphaned subscriptions (no user ID). | `db.billing_subscriptions.find({email: "user@example.com", user_id: {$in: ["", null]}})` | +| `idx_subscriptions_integrator` | `integrator`, `integrator_subscription_id` | Unique Compound | Prevent duplicate subscriptions from the same provider. | Used internally by MongoDB for uniqueness. | +| `idx_subscriptions_created_at` | `created_at` | Standard (Descending) | Sort/filter by date. | `db.billing_subscriptions.find().sort({created_at: -1})` | **Partial Index Details:** @@ -213,10 +211,10 @@ Four indexes are created for the `billing_events` collection: | Index Name | Fields | Type | Purpose | Example Query | |------------|--------|------|---------|---------------| -| `idx_billing_events_user_id` | `user_id` | Standard | Fetch event history for a user | `db.billing_events.find({user_id: "user-123"})` | -| `idx_billing_events_email` | `email` | Standard | Query events by email | `db.billing_events.find({email: "user@example.com"})` | -| `idx_billing_events_subscription_id` | `integrator_subscription_id` | Standard | Get all events for a subscription | `db.billing_events.find({integrator_subscription_id: "sub-123"})` | -| `idx_billing_events_created_at` | `created_at` | Standard (Descending) | Sort/filter by date | `db.billing_events.find().sort({created_at: -1})` | +| `idx_billing_events_user_id` | `user_id` | Standard | Fetch event history for a user. | `db.billing_events.find({user_id: "user-123"})` | +| `idx_billing_events_email` | `email` | Standard | Query events by email. | `db.billing_events.find({email: "user@example.com"})` | +| `idx_billing_events_subscription_id` | `integrator_subscription_id` | Standard | Get all events for a subscription. | `db.billing_events.find({integrator_subscription_id: "sub-123"})` | +| `idx_billing_events_created_at` | `created_at` | Standard (Descending) | Sort/filter by date. | `db.billing_events.find().sort({created_at: -1})` | ### Verifying Indexes @@ -265,22 +263,22 @@ go run cmd/mongo-migrator/migrator.go down 1 ### Overview -The billing package supports subscriptions without a user ID, enabling pre-registration purchases. This allows users to purchase subscriptions before creating an account. +The billing package supports subscriptions without a user ID, enabling pre-registration purchases. This allows users to buy subscriptions before creating an account. ### Core Concept **Subscription States:** -1. **Orphaned**: `UserID=""`, `Email="user@example.com"` (pre-registration) -2. **Associated**: `UserID="user-123"`, `Email="user@example.com"` (after signup) +1. **Orphaned**: `UserID=""`, `Email="user@example.com"` (pre-registration). +2. **Associated**: `UserID="user-123"`, `Email="user@example.com"` (after signup). ### Flow Diagram ``` ┌─────────────────────────────────────────────────────────────┐ │ 1. Pre-Registration Purchase │ -│ User purchases without account │ -│ Webhook creates subscription with email only │ +│ User purchases without an account │ +│ Webhook creates a subscription with email only │ └────────────────────────┬────────────────────────────────────┘ │ ▼ @@ -298,8 +296,8 @@ The billing package supports subscriptions without a user ID, enabling pre-regis ▼ ┌─────────────────────────────────────────────────────────────┐ │ 2. User Signs Up │ -│ User creates account with same email │ -│ accessmanager calls AssociateSubscriptionsWithUser │ +│ User creates an account with the same email │ +│ `accessmanager` calls `AssociateSubscriptionsWithUser` │ └────────────────────────┬────────────────────────────────────┘ │ ▼ @@ -320,26 +318,26 @@ The billing package supports subscriptions without a user ID, enabling pre-regis **1. Pre-Launch Sales** ``` Marketing campaign before platform launch -→ Users purchase early bird subscriptions +→ Users purchase early-bird subscriptions → Platform launches → Users sign up -→ Subscriptions automatically associated +→ Subscriptions are automatically associated ``` **2. Gift Subscriptions** ``` -Alice buys subscription for bob@example.com -→ Bob doesn't have account yet -→ Subscription stored with email +Alice buys a subscription for bob@example.com +→ Bob doesn't have an account yet +→ Subscription is stored with the email → Bob signs up weeks later -→ Gets subscription automatically +→ Gets the subscription automatically ``` **3. Corporate Bulk Purchases** ``` -Company admin buys 10 licenses -→ Provides list of employee emails -→ Subscriptions created for each email +A company admin buys 10 licenses +→ Provides a list of employee emails +→ Subscriptions are created for each email → Employees sign up at their convenience → Each gets their subscription ``` @@ -348,7 +346,7 @@ Company admin buys 10 licenses #### Query by Email -Retrieve subscriptions before user account exists: +Retrieve subscriptions before a user account exists: ```go // Get all subscriptions for an email (regardless of user_id) @@ -401,7 +399,7 @@ for _, event := range response.Events { Manually link email-based subscriptions to a user: ```go -// When user signs up or when you want to manually associate +// When a user signs up or when you want to manually associate result, err := billingService.AssociateSubscriptionsWithUser(ctx, &billing.AssociateSubscriptionsWithUserRequest{ UserID: "user-123", @@ -525,24 +523,24 @@ db.billing_subscriptions.aggregate([ ### Best Practices **1. Email Normalisation** -- Emails are automatically converted to lowercase -- Ensures consistent matching between purchase and signup -- No manual normalisation required +- Emails are automatically converted to lowercase. +- This ensures consistent matching between purchase and signup. +- No manual normalisation is required. **2. Monitoring** -- Set up alerts for subscriptions orphaned > 30 days -- Track conversion rate from orphaned to associated -- Monitor by provider and date range +- Set up alerts for subscriptions orphaned for more than 30 days. +- Track the conversion rate from orphaned to associated. +- Monitor by provider and date range. **3. User Experience** -- Show "You have a pending subscription" message on signup page if email matches orphaned subscription -- Send reminder emails to users with orphaned subscriptions -- Provide clear instructions for claiming subscriptions +- Show a "You have a pending subscription" message on the signup page if the email matches an orphaned subscription. +- Send reminder emails to users with orphaned subscriptions. +- Provide clear instructions for claiming subscriptions. **4. Administrative Tools** -- Build admin dashboard showing orphaned subscriptions -- Allow manual association via UI -- Export orphaned subscriptions for analysis +- Build an admin dashboard showing orphaned subscriptions. +- Allow manual association via the UI. +- Export orphaned subscriptions for analysis. ## Service Methods @@ -559,7 +557,7 @@ response, err := billingService.CreateSubscription(ctx, &billing.CreateSubscriptionRequest{ IntegratorSubscriptionID: "stripe_sub_abc123", Integrator: "stripe", - UserID: "user-123", // Can be empty for pre-reg + UserID: "user-123", // Can be empty for pre-registration Email: "user@example.com", PlanName: "Pro Plan", Status: billing.StatusActive, diff --git a/docs/getting-started/email-manager/README.md b/docs/getting-started/email-manager/README.md index 080f62c..693d84b 100644 --- a/docs/getting-started/email-manager/README.md +++ b/docs/getting-started/email-manager/README.md @@ -1,33 +1,33 @@ -# Getting Started With Email Manager +# Email Manager -The recommended email functionality comes in the form of three independent, composable packages: [**emailtemplater**](../../../external/emailtemplater/), [**emailprovider**](../../../external/emailprovider/), and [**emailmanager](../../../external/emailmanager/). For most application features, you should use the high-level `emailmanager` package, which handles both templating and sending with integrated audit logging. +The recommended email functionality comes in three independent, composable packages: `emailtemplater`, `emailprovider`, and `emailmanager`. For most application features, you should use the high-level `emailmanager` package, which handles both templating and sending with integrated audit logging. ## Core Packages Overview -See below an overview of the core packages mentioned above +Here's an overview of the core packages: - -|Package | Purpose Use Case (Recommended) | Examples Location | -|**`emailtemplater`** | Generates HTML email templates (e.g., login, verification, and custom) with variable substitution. Generating email previews or testing template rendering. | [external/emailtemplater/examples](../../../external/emailtemplater/examples/examples.go) | -|**`emailprovider`** | Abstracts the logic for sending an email through a service (e.g., SparkPost). Sending pre-rendered HTML or custom email workflows. | [external/emailprovider/examples](../../../external/emailprovider/examples/examples.go) | -|**`emailmanager`** | Orchestrates the templater and emailprovider with high-level API methods. Building application features (Standard)—provides the full workflow and audit logging. | [external/emailmanager/examples](../../../external/emailmanager/examples/examples.go) | +| Package | Purpose | Recommended Use Case | Examples | +|---|---|---|---| +| `emailtemplater` | Generates HTML email templates (e.g., login, verification) with variable substitution. | Generating email previews or testing template rendering. | [`emailtemplater/examples`](../../../external/emailtemplater/examples/examples.go) | +| `emailprovider` | Abstracts the logic for sending an email through a service (e.g., SparkPost). | Sending pre-rendered HTML or custom email workflows. | [`emailprovider/examples`](../../../external/emailprovider/examples/examples.go) | +| `emailmanager` | Orchestrates the templater and email provider with high-level API methods. | Building application features (Standard)—provides the full workflow and audit logging. | [`emailmanager/examples`](../../../external/emailmanager/examples/examples.go) | ### Usage Overview -For a high-level overview of how this might fit into your GHAT(D) project, please [**visit this section**.](#high-level-overview). +For a high-level overview of how this might fit into your project, please [**visit this section**](#high-level-overview). ## Quick Start: Setup and Sending -In the following section we'll demonstrate how to set up the `emailmanager` and send a verification email, which is the recommended way to use the system for standard application operations, for more examples [please check out the reference examples above](#core-packages-overview). +This section shows how to set up the `emailmanager` and send a verification email. This is the recommended way to use the system for standard operations. For more examples, [check out the reference examples above](#core-packages-overview). ### 1. Import Packages and Configure -You'll need configuration for the `emailtemplater`, an `emailprovider` instance, and an [`audit` service](../../../external/audit/). +You'll need configuration for the `emailtemplater`, an `emailprovider` instance, and an [`audit` service](../../../external/audit). ```go import ( "context" - "github.com/ooaklee/ghatd/external/templater" + "github.com/ooaklee/ghatd/external/emailtemplater" "github.com/ooaklee/ghatd/external/emailprovider" "github.com/ooaklee/ghatd/external/emailmanager" ) @@ -56,7 +56,7 @@ templaterConfig := &emailtemplater.Config{ emailtemplater.EmailTemplateTypeBase: templates.NewBaseHtmlEmailTemplate, }, } -tmpltr := templater.NewEmailTemplater(templaterConfig) +tmpltr := emailtemplater.NewEmailTemplater(templaterConfig) // 2. Create email provider (using SparkPost for Production) provider := emailprovider.NewSparkPostEmailProvider(sparkpostClient) @@ -70,7 +70,7 @@ manager := emailmanager.NewEmailManager(tmpltr, provider, auditService, &emailma ### 2. Send an Email -You'll be able to use the high-level methods on the `emailmanager`. +You can use the high-level methods on the `emailmanager`. ```go // 4. Send a verification email @@ -92,17 +92,16 @@ if err != nil { ### 3. Development Environment Setup -If you don't want to use your email providers allowance when running your code locally, you can also leverage the `LoggingEmailProvider` to log the email content instead of actually sending it via an external API like `SparkPost`. - +If you don't want to use your email provider's allowance when running your code locally, you can use the `LoggingEmailProvider` to log the email content instead of sending it. ```go // Use logging provider to see emails in logs instead of sending provider := emailprovider.NewLoggingEmailProvider(&emailprovider.LoggingEmailProviderConfig{ - DisableFullHtmlBodyPreview: false, // The login and verification token by default contain the token/ magic link url for you to sign in + DisableFullHtmlBodyPreview: false, // The login and verification token by default contain the token/magic link URL for you to sign in }) manager := emailmanager.NewEmailManager( - templater.NewEmailTemplater(templaterConfig), + emailtemplater.NewEmailTemplater(templaterConfig), provider, auditService, &emailmanager.Config{ @@ -114,7 +113,6 @@ manager := emailmanager.NewEmailManager( > **Note on Environments:** The `emailtemplater` is also **environment-aware**; for example, setting the `Environment` config to `"staging"` will add `[staging]` to the email subject line. - ## Advanced Use Cases While `emailmanager` is recommended, the packages can be used independently for specialised needs. @@ -123,10 +121,9 @@ While `emailmanager` is recommended, the packages can be used independently for You can generate the HTML body without sending an email. - ```go // Use templater alone -rendered, err := tmpltr.GenerateVerificationEmail(&templater.GenerateVerificationEmailRequest{ +rendered, err := tmpltr.GenerateVerificationEmail(&emailtemplater.GenerateVerificationEmailRequest{ FirstName: "Test", // ... }) @@ -137,7 +134,6 @@ rendered, err := tmpltr.GenerateVerificationEmail(&templater.GenerateVerificatio Adding a new provider (e.g., SendGrid, AWS SES) only requires implementing the `emailprovider.EmailProvider` interface and integrating it with the `emailmanager`. - ```go type MyCustomProvider struct{} @@ -163,7 +159,7 @@ manager := emailmanager.NewEmailManager(tmpl, provider, audit, config) ## High-level Overview -See below high-level overviews of this email solution (and its component packages) and a few examples of how it can be used in your GHATD application or different use-cases. +Here are some high-level overviews of this email solution and its packages, with examples of how it can be used in your application for different use-cases. ### Usage Patterns @@ -207,7 +203,6 @@ Application Code └──► emailprovider ──► Send when ready ``` - ### Environment Usage & Outputs Flow ``` @@ -259,7 +254,7 @@ Application Code ## Potential Future Improvements -Below is a list of areas for improvement in future iterations of `emailmanager`, `emailtemplater`, and `emailprovider`. Please note that these suggestions are not prioritised. +Here's a list of areas for improvement in future iterations of `emailmanager`, `emailtemplater`, and `emailprovider`. Please note that these suggestions are not prioritised. ### Additional Providers - [ ] SendGrid provider @@ -272,7 +267,7 @@ Below is a list of areas for improvement in future iterations of `emailmanager`, - [ ] Email templating with layouts - [ ] Multi-language support - [ ] Email preview generation -- [ ] Batch sending optimization +- [ ] Batch sending optimisation - [ ] Rate limiting - [ ] Retry mechanisms - [ ] Email queueing diff --git a/docs/getting-started/group/README.md b/docs/getting-started/group/README.md new file mode 100644 index 0000000..15ac89e --- /dev/null +++ b/docs/getting-started/group/README.md @@ -0,0 +1,157 @@ +# Group + +The `group` package provides a robust and flexible system for managing user groups, teams, organisations, and other collections. It is built with a "universal model" approach, allowing a single data structure to represent various types of groups with support for hierarchical nesting. + +This guide will walk you through the architecture, setup, and common use cases of the `group` package. + +## Architecture + +The package follows a standard layered architecture common throughout this project: + +1. **Routes (`routes.go`)**: Defines the HTTP API endpoints (e.g., `POST /v1/groups`, `GET /v1/groups/{id}`). It maps URLs to specific handlers. +2. **Handler (`handler.go`)**: Receives HTTP requests, parses them, and validates input. It acts as the bridge between the transport layer (HTTP) and the business logic. +3. **Fender (`fender.go`)**: An authorisation layer that checks if the authenticated user has the necessary permissions to perform an action on a group. +4. **Service (`service.go`)**: Contains the core business logic. It orchestrates operations like creating, updating, and retrieving groups, and it interacts with the repository. +5. **Repository (`repository.go`)**: The data access layer. It is responsible for all database operations (Create, Read, Update, Delete) for the `groups` collection in MongoDB. +6. **Model (`model.go`)**: Defines the `UniversalGroup` data structure and its associated helper methods. This is the core entity of the package. + +## MongoDB Setup & Migrations + +The `group` package relies on a MongoDB collection named `groups`. To ensure optimal query performance, a set of database indexes must be created. + +### Initial Indexes + +The initial migration script, located at `external/group/migrations/indexes_groups.go`, creates 19 indexes to support the various query patterns used by the repository. These include: + +- **Unique Indexes**: To enforce uniqueness on `_id` and `_nano_id`. +- **Standard Indexes**: On individual fields like `name`, `type`, `status`, and `metadata.created_at` for efficient filtering and sorting. +- **Compound Indexes**: For common multi-field queries, such as filtering by type and status simultaneously. +- **Member & Leadership Indexes**: To quickly find groups based on their members (`members.id`), owner (`leadership.owner_id`), or other leadership roles. +- **Text Index**: A text index on `name` and `display_info.name_aliases` to enable full-text search capabilities. + +### Running Migrations + +To apply these indexes, you must integrate the provided migration functions into your application's startup process using a migration tool like `mongo-migrate`. + +The migration provides two key functions: + +- `InitGroupsIndexesUp(db *mongo.Database) error`: Creates all 19 indexes on the `groups` collection. +- `InitGroupsIndexesDown(db *mongo.Database) error`: Drops all indexes created by the `Up` function. + +**Example Integration:** + +```go +// In your main application setup +import ( + "github.com/xakep666/mongo-migrate" + groupMigrations "github.com/ooaklee/ghatd/external/group/migrations" +) + +func main() { + // ... database connection setup ... + db := client.Database("your_db_name") + migrate.SetDatabase(db) + + // Add the group migrations + migrate.Add(migrate.NewMigration( + "initial_groups_indexes", + groupMigrations.InitGroupsIndexesUp, + groupMigrations.InitGroupsIndexesDown, + )) + + // Apply migrations + if err := migrate.Up(migrate.AllAvailable); err != nil { + log.Fatalf("Migration failed: %v", err) + } +} +``` + +## API Endpoints + +The following are the primary API endpoints provided by the `group` service. + +- `POST /v1/groups`: Create a new group. +- `GET /v1/groups`: Retrieve a paginated list of groups with filtering and sorting options. +- `GET /v1/groups/{id}`: Get a single group by its ID. +- `PUT /v1/groups/{id}`: Update a group's details. +- `DELETE /v1/groups/{id}`: Delete a group. +- `POST /v1/groups/{id}/members`: Add a member to a group. +- `DELETE /v1/groups/{id}/members/{memberId}`: Remove a member from a group. + +## Using the Model (`UniversalGroup`) + +The `UniversalGroup` model is the heart of the package. It is designed to be used both within the service and potentially in other parts of your application. + +### Creating a New Group + +Always use the `NewUniversalGroup` constructor to ensure all dependencies are injected correctly. + +```go +import ( + "github.com/ooaklee/ghatd/external/group" + "github.com/ooaklee/ghatd/external/toolbox" +) + +// Setup dependencies (typically done once) +config := group.DefaultGroupConfig() +idGen := toolbox.NewIDGenerator() +timeProvider := toolbox.NewTimeProvider() +stringUtils := toolbox.NewStringUtils() + +// Create a new group +g := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) +g.Name = "My Awesome Team" +g.Type = group.GroupTypeTeam +g.SetInitialState() // Sets ID, NanoID, default status, and timestamps + +// The group is now ready to be saved to the database. +if err := g.Validate(); err != nil { + // Handle validation error +} +``` + +### Modifying an Existing Group + +When you retrieve a group from the database, you must re-inject its dependencies to use its methods safely. + +```go +// Assume 'existingGroup' is loaded from MongoDB +var existingGroup group.UniversalGroup +// ... unmarshal from BSON ... + +// Set dependencies before using methods +existingGroup.SetDependencies(config, idGen, timeProvider, stringUtils) + +// Now you can safely use methods +existingGroup.AddMember("user-123", group.MemberTypeUser, group.MemberRoleAdmin) +existingGroup.SetUpdatedAtNow() +``` + +### Key Model Features + +- **Hierarchical Nesting**: Add a group as a member of another group by using `MemberTypeGroup`. The model prevents circular references. + +- **Flexible Membership**: Members can be users (`MemberTypeUser`) or other groups (`MemberTypeGroup`). Each member is assigned a role, such as: + - `MemberRoleOwner` + - `MemberRoleAdmin` + - `MemberRoleMember` + - `MemberRoleModerator` + - `MemberRoleGuest` + +- **Custom Data**: Use `SetExtension(key, value)` to store any project-specific data on a group. + +- **Status Control**: Use `UpdateStatus(newStatus)` to safely transition between states. The model supports several built-in statuses: + - `GroupStatusActive` + - `GroupStatusInactive` + - `GroupStatusArchived` + - `GroupStatusSuspended` + - `GroupStatusProvisioned` + +- **Configuration**: Create a `CustomGroupConfig` to define your own valid group types, status transitions, and required fields. The package includes many default types: + - `GroupTypeTeam` + - `GroupTypeDepartment` + - `GroupTypeOrganization` + - `GroupTypeProject` + - `GroupTypeCommunity` + +- **Rich Metadata**: The model includes dedicated structures for `DisplayInfo` (description, avatar), `Leadership` (owner, head), `Settings` (visibility, max members), and `Integrations` (e.g., Slack). diff --git a/docs/getting-started/router/README.md b/docs/getting-started/router/README.md new file mode 100644 index 0000000..3ba3842 --- /dev/null +++ b/docs/getting-started/router/README.md @@ -0,0 +1,102 @@ +# Router + +The `router` package provides a standardised, project-specific wrapper around `gorilla/mux`. It's designed to simplify setting up common application-level routing concerns, like default handlers, middleware, and authentication endpoints. + +## Architecture + +- **`router.go`**: Contains the `Router` struct and the `NewRouter` constructor. It initialises a `mux.Router` and applies any provided default handlers or global middleware. +- **`handler.go`**: Provides handlers for common, cross-cutting concerns. A key example is `NewAuthVerifyHandler`, which manages the redirection flow for email and login verification links. +- **`const.go`**: Defines constant URI paths for shared endpoints like health checks (`/v0/health/check`) and authentication verification (`/v0/auth/verify`). + +## Getting Started + +The following example shows how to initialise the `ghatdRouter`, configure it with default handlers and middleware, and attach a verification endpoint. + +### Example Initialisation + +This setup is typically done once in your application's `main` function or wherever you configure your HTTP server. + +```go +package main + +import ( + "fmt" + "net/http" + + "github.com/gorilla/mux" + "github.com/ooaklee/ghatd/external/router" + // ... other imports +) + +func main() { + // ... initialise logger and other dependencies + + // Define base URLs for the application (this will be the same if packaging your UI with the ghatd backend) + backendBaseURL := "http://localhost:8080" + frontendBaseURL := "http://localhost:3000" + + // 1. Define Default Handlers + // These handlers are used by the router for unhandled routes or health checks. + default404Handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "Not Found") + }) + defaultHealthcheckHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + }) + + // 2. Define Global Middleware (e.g., CORS) + // This is a mock CORS middleware for demonstration purposes. + // In a real project, you would use a proper CORS library. + corsMiddleware := func(allowedOrigins []string) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") // Be more restrictive in production + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) + } + } + + // 3. Create a New Router + // Initialise the router with the default handlers and global middleware. + ghatdRouter := router.NewRouter( + default404Handler, + defaultHealthcheckHandler, + corsMiddleware([]string{frontendBaseURL}), + ) + + // 4. Add Application-Specific Handlers + // The AuthVerifyEndpoint is a special handler for processing verification links from emails. + ghatdRouter.GetRouter().HandleFunc( + router.AuthVerifyEndpoint, + router.NewAuthVerifyHandler( + backendBaseURL+"/api/v1/ams/verify/email?t=%s", // URL to verify an email + backendBaseURL+"/api/v1/ams/login?t=%s", // URL to process a magic login link + frontendBaseURL+"/auth/login", // Fallback URL for failed attempts + frontendBaseURL, // Success URL after verification + ), + ) + + // 5. Attach Service-Specific Routes + // At this point, you would attach the routes for each of your services. + // For example: + // + // usermanager.AttachRoutes(&usermanager.AttachRoutesRequest{ + // Router: ghatdRouter, + // Handler: umsHandler, + // // ... middleware + // }) + + // 6. Start the HTTP Server + // http.ListenAndServe(":8080", ghatdRouter.GetRouter()) +} +``` + +By following this pattern, you establish a consistent foundation for routing across your entire application, which can then be referenced by other "Getting Started" guides. diff --git a/docs/getting-started/user-manager/README.md b/docs/getting-started/user-manager/README.md new file mode 100644 index 0000000..a0229c8 --- /dev/null +++ b/docs/getting-started/user-manager/README.md @@ -0,0 +1,109 @@ +# User Manager + +The `usermanager` package is a high-level, full-stack service designed to simplify managing users and their associated data. It acts as an orchestrator, integrating with various other packages like `user`, `group`, and `contacter` to provide a unified API for common user-centric operations. + +This guide gives an overview of the `usermanager` architecture, its key features, and how to interact with its API. + +## Architecture + +The package follows a standard layered architecture, consistent with other services in this project. Its primary role is to orchestrate calls to other services rather than managing its own data directly. + +1. **Routes (`routes.go`)**: Defines the HTTP API endpoints for user management, like `/api/v1/ums/users/{userId}/profile`, mapping incoming requests to the appropriate handlers. +2. **Handler (`handler.go`)**: Acts as the intermediary between the HTTP transport layer and the business logic. It's responsible for parsing requests, calling the service layer, and formatting responses. +3. **Service (`service.go`, `service.group.go`)**: Contains the core business logic. The service layer makes calls to other downstream services (e.g., `UserService`, `GroupService`, `ContacterService`) to gather and assemble the data needed to fulfil a request. +4. **Request/Response (`request.go`, `response.go`)**: Defines the data structures for API communication, ensuring a clear and consistent contract for clients. +5. **Fender (`fender.go`)**: An authorisation layer that can be used to secure endpoints, ensuring the authenticated user has the correct permissions for the requested action. + +Unlike other packages, the `usermanager` doesn't have its own repository or database collection, as it exclusively deals with orchestrating data from other services. + +## Key Features + +The `usermanager` is designed to streamline complex user-related workflows into single API calls. + +- **Expanded User Profiles**: Fetch a complete user profile, expanded with data from multiple sources. For example, a single request can return a user's core details alongside a list of all the groups they are a member of. +- **Simplified Group Management**: Provides intuitive endpoints for managing a user's membership in groups. This includes adding a user to a group, removing them, and listing their current groups, without needing to interact directly with the `group` service. +- **Communication Management**: Integrates with the `contacter` service to manage a user's communication preferences and history. +- **Administrative Functions**: Offers secure, admin-only endpoints for performing privileged actions, such as creating a new group. + +## API Endpoints + +The following are the primary API endpoints provided by the `usermanager` service: + +- `GET /api/v1/ums/users/{userId}/profile`: Retrieves a user's expanded profile, including their group memberships. +- `GET /api/v1/ums/users/{userId}/groups`: Lists all groups that the specified user is a member of. +- `POST /api/v1/ums/users/{userId}/groups/{groupId}`: Adds a user to a specified group. +- `DELETE /api/v1/ums/users/{userId}/groups/{groupId}`: Removes a user from a specified group. +- `POST /api/v1/ums/admin/groups`: An admin-only endpoint to create a new group. + +## Configuration and Initialisation + +To use the `usermanager` service, you must initialise it and attach its routes to a configured `ghatdRouter`. This is typically done during your application's startup process. + +For a detailed guide on setting up the main application router, please see the [Router Package Getting Started documentation](../router/README.md). + +**Example Initialisation:** + +```go +import ( + "net/http" + + "github.com/ooaklee/ghatd/external/router" + "github.com/ooaklee/ghatd/external/usermanager" + "github.com/ooaklee/ghatd/external/validator" + // ... import other required services (user, group, etc.) +) + +func main() { + // ... assume ghatdRouter is already initialised as per the router documentation + var ghatdRouter *router.Router + + // Initialise a validator + validator := validator.New() + + // Initialise downstream services + userService := user.NewService(...) + groupService := group.NewService(...) + contacterService := contacter.NewService(...) + auditService := audit.NewService(...) + apiTokenService := apitoken.NewService(...) + + // Create the usermanager service + umsService := usermanager.NewService(&usermanager.NewServiceRequest{ + UserService: userService, + ApiTokenService: apiTokenService, + AuditService: auditService, + ContacterService: contacterService, + }) + + // Add optional services + umsService.WithGroupService(groupService) + + // Create the usermanager handler + umsHandler := usermanager.NewHandler(&usermanager.NewHandlerRequest{ + Service: umsService, + Validator: validator, + // ... other options like ErrorMaps, Environment, etc. + }) + + // Mock middleware for the example + mockMiddleware := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) + } + + // Attach the usermanager routes + usermanager.AttachRoutes(&usermanager.AttachRoutesRequest{ + Router: ghatdRouter, + Handler: umsHandler, + AuthenticatedMiddleware: mockMiddleware, + ActiveOnlyMiddleware: mockMiddleware, + AdminOnlyMiddleware: mockMiddleware, + ActiveValidApiTokenOrJWTMiddleware: mockMiddleware, + ValidApiTokenOrJWTMiddleware: mockMiddleware, + RateLimitOrActiveMiddleware: mockMiddleware, + }) + + // ... set up and start the HTTP server with ghatdRouter +} +``` diff --git a/external/accessmanager/const.go b/external/accessmanager/const.go index f8bb520..a8753d1 100644 --- a/external/accessmanager/const.go +++ b/external/accessmanager/const.go @@ -87,6 +87,9 @@ const ( // ErrKeyInvalidResultQueryParam error when a user makes a request with invalid query param ErrKeyInvalidResultQueryParam string = "InvalidResultQueryParam" + + // ErrKeyEmptyRefreshToken error when a user makes a request with empty refresh token + ErrKeyEmptyRefreshToken string = "EmptyRefreshToken" ) const ( diff --git a/external/accessmanager/errormap.go b/external/accessmanager/errormap.go index 67b0be2..7ef0b10 100644 --- a/external/accessmanager/errormap.go +++ b/external/accessmanager/errormap.go @@ -34,4 +34,5 @@ var AccessmanagerErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestIt ErrKeyInvalidLogOutUserOthersRequest: {Title: "Bad request to log off other devices", StatusCode: 400, Code: "AM00-024"}, ErrKeyInvalidAuthToken: {Title: "Invalid authorization", StatusCode: 401, Code: "AM00-025"}, ErrKeyInvalidResultQueryParam: {Title: "Invalid result query param", StatusCode: 400, Code: "AM00-026"}, + ErrKeyEmptyRefreshToken: {Title: "Unauthorized", StatusCode: 401, Code: "AM00-027"}, } diff --git a/external/accessmanager/helpers/context.go b/external/accessmanager/helpers/context.go index c189597..02decfa 100644 --- a/external/accessmanager/helpers/context.go +++ b/external/accessmanager/helpers/context.go @@ -1,27 +1,50 @@ package accessmanagerhelpers -import "context" +import ( + "context" -// contextKey represents the key to reference the requestor in context + userv2 "github.com/ooaklee/ghatd/external/user/v2" +) + +// contextKey defines a distinct type used as keys when storing values in a +// request context. It reduces the likelihood of key collisions with other +// context values that may use plain strings. type contextKey string -// RequestorKey is the key used to hold userID is context +// RequestorKey stores the authenticated user's ID in the request context. const RequestorKey contextKey = "ContextRequestor" -// TransitWith packages both passed context and requestorID to move -// across processes. +// RequestorUserKey stores the full authenticated user object in the request context. +const RequestorUserKey contextKey = "ContextRequestorUser" + +// TransitWith returns a new context derived from ctx that carries the +// authenticated user's ID. func TransitWith(ctx context.Context, userID string) context.Context { return context.WithValue(ctx, RequestorKey, userID) } -// AcquireFrom pulls requestor ID (userID) from context if exists or returns empty string +// AcquireFrom extracts the authenticated user's ID from ctx or returns an +// empty string if no ID is present. func AcquireFrom(ctx context.Context) string { - userID, ok := ctx.Value(RequestorKey).(string) if ok && userID != "" { return userID } - return "" +} + +// TransitUserWith returns a new context derived from ctx that carries the +// full authenticated user object. +func TransitUserWith(ctx context.Context, user *userv2.UniversalUser) context.Context { + return context.WithValue(ctx, RequestorUserKey, user) +} +// AcquireUserFrom retrieves the authenticated user object from ctx or nil if +// it is not present. +func AcquireUserFrom(ctx context.Context) *userv2.UniversalUser { + user, ok := ctx.Value(RequestorUserKey).(*userv2.UniversalUser) + if ok && user != nil { + return user + } + return nil } diff --git a/external/accessmanager/helpers/newrelictransaction.go b/external/accessmanager/helpers/newrelictransaction.go deleted file mode 100644 index a2096a4..0000000 --- a/external/accessmanager/helpers/newrelictransaction.go +++ /dev/null @@ -1,28 +0,0 @@ -package accessmanagerhelpers - -import ( - "context" - - "github.com/newrelic/go-agent/v3/newrelic" -) - -// TransactionKey is the key used to hold the transaction is context -const TransactionKey contextKey = "NewRelicTransaction" - -// TransitTransactionWith packages both passed context and new relic transaction to move -// across processes. -func TransitTransactionWith(ctx context.Context, newRelicTransaction *newrelic.Transaction) context.Context { - return context.WithValue(ctx, TransactionKey, newRelicTransaction) -} - -// AcquireTransactionFrom pulls new relic transaction from context if exists or returns nil -func AcquireTransactionFrom(ctx context.Context) *newrelic.Transaction { - - newRelicTransaction, ok := ctx.Value(TransactionKey).(*newrelic.Transaction) - if ok && newRelicTransaction != nil { - return newRelicTransaction - } - - return nil - -} diff --git a/external/accessmanager/middleware/middleware.go b/external/accessmanager/middleware/middleware.go index 73500b9..0d86a60 100644 --- a/external/accessmanager/middleware/middleware.go +++ b/external/accessmanager/middleware/middleware.go @@ -3,10 +3,8 @@ package middleware import ( "context" "errors" - "fmt" "net/http" - "github.com/newrelic/go-agent/v3/newrelic" "github.com/ooaklee/ghatd/external/accessmanager" accessmanagerhelpers "github.com/ooaklee/ghatd/external/accessmanager/helpers" "github.com/ooaklee/ghatd/external/common" @@ -14,20 +12,33 @@ import ( "github.com/ooaklee/reply" ) +// jwtValidationType defines the type of JWT validation to perform +type jwtValidationType int + +const ( + // jwtValidationStandard is the JWT validation for a user in any state + jwtValidationStandard jwtValidationType = iota + + // jwtValidationActive is the JWT validation for a user in the active state + jwtValidationActive + + // jwtValidationAdmin is the JWT validation for a user with the admin role + jwtValidationAdmin +) + // accessManagerService holds method of valid access manaer service type accessManagerService interface { - MiddlewareAdminJWTRequired(r *http.Request) (string, error) - MiddlewareAdminAPITokenRequired(r *http.Request) (string, error) - MiddlewareActiveJWTRequired(r *http.Request) (string, error) - MiddlewareJWTRequired(r *http.Request) (string, error) - MiddlewareValidAPITokenRequired(r *http.Request) (string, error) - MiddlewareRateLimitOrActiveJWTRequired(r *http.Request) (string, error) + MiddlewareAdminJWTRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + MiddlewareAdminAPITokenRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + MiddlewareActiveJWTRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + MiddlewareJWTRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + MiddlewareValidAPITokenRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + MiddlewareRateLimitOrActiveJWTRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) RefreshToken(ctx context.Context, r *accessmanager.RefreshTokenRequest) (*accessmanager.RefreshTokenResponse, error) } // Middleware manages accessmanager middleware logic type Middleware struct { - newRelicApplication *newrelic.Application service accessManagerService errorMaps []reply.ErrorManifest cookiePrefixAuthToken string @@ -38,7 +49,6 @@ type Middleware struct { // NewMiddlewareRequest holds expected dependencies for an accessmanager middleware type NewMiddlewareRequest struct { - NewRelicConf *newrelic.Application Service accessManagerService ErrorMaps []reply.ErrorManifest Environment string @@ -51,7 +61,6 @@ type NewMiddlewareRequest struct { func NewMiddleware(r *NewMiddlewareRequest) *Middleware { return &Middleware{ - newRelicApplication: r.NewRelicConf, service: r.Service, errorMaps: r.ErrorMaps, cookiePrefixAuthToken: r.CookiePrefixAuthToken, @@ -65,34 +74,14 @@ func NewMiddleware(r *NewMiddlewareRequest) *Middleware { // valid token or an authenticated user, API tokens will take precedence func (m *Middleware) ActiveValidApiTokenOrAuthenticated(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - - // Add newrelic transaction - if m.newRelicApplication != nil { - newRelicTransaction := m.newRelicApplication.StartTransaction(fmt.Sprintf("%s %s", req.Method, req.URL.Path)) - // req is a *http.Request, this marks the transaction as a web transaction - newRelicTransaction.SetWebRequestHTTP(req) - - // Add to context - req = req.WithContext(accessmanagerhelpers.TransitTransactionWith(req.Context(), newRelicTransaction)) - } - // check for API header - userFullToken := req.Header.Get(common.SystemWideXApiToken) - - // if present, run API middleware logic - if userFullToken != "" { + if req.Header.Get(common.SystemWideXApiToken) != "" { m.handleValidAPITokenRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) - return } - // Otherwise, Run authenticated token check - m.handleJWTRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) - + // Otherwise, run JWT validation + m.handleJWTRequest(w, req, handler, jwtValidationStandard) }) } @@ -100,32 +89,14 @@ func (m *Middleware) ActiveValidApiTokenOrAuthenticated(handler http.Handler) ht // valid token or an active JWT token, API tokens will take precedence func (m *Middleware) ActiveValidApiTokenOrJWTRequired(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - - // Add newrelic transaction - if m.newRelicApplication != nil { - newRelicTransaction := m.newRelicApplication.StartTransaction(fmt.Sprintf("%s %s", req.Method, req.URL.Path)) - // req is a *http.Request, this marks the transaction as a web transaction - newRelicTransaction.SetWebRequestHTTP(req) - - // Add to context - req = req.WithContext(accessmanagerhelpers.TransitTransactionWith(req.Context(), newRelicTransaction)) - } - // check for API header - userFullToken := req.Header.Get(common.SystemWideXApiToken) - - // if present, run API middleware logic - if userFullToken != "" { + if req.Header.Get(common.SystemWideXApiToken) != "" { m.handleValidAPITokenRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) return } - // Otherwise, Run active token check - m.handleActiveJWTRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) + // Otherwise, run active JWT validation + m.handleJWTRequest(w, req, handler, jwtValidationActive) }) } @@ -135,20 +106,7 @@ func (m *Middleware) ActiveValidApiTokenOrJWTRequired(handler http.Handler) http // `NOTE` - Status of user account should always trump token status func (m *Middleware) ValidAPITokenRequired(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - - // Add newrelic transaction - if m.newRelicApplication != nil { - newRelicTransaction := m.newRelicApplication.StartTransaction(fmt.Sprintf("%s %s", req.Method, req.URL.Path)) - // req is a *http.Request, this marks the transaction as a web transaction - newRelicTransaction.SetWebRequestHTTP(req) - - // Add to context - req = req.WithContext(accessmanagerhelpers.TransitTransactionWith(req.Context(), newRelicTransaction)) - } - m.handleValidAPITokenRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) }) } @@ -157,22 +115,7 @@ func (m *Middleware) ValidAPITokenRequired(handler http.Handler) http.Handler { // in an `ACTIVE` user state. func (m *Middleware) AdminJWTRequired(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - - // Add newrelic transaction - if m.newRelicApplication != nil { - newRelicTransaction := m.newRelicApplication.StartTransaction(fmt.Sprintf("%s %s", req.Method, req.URL.Path)) - // req is a *http.Request, this marks the transaction as a web transaction - newRelicTransaction.SetWebRequestHTTP(req) - - // Add to context - req = req.WithContext(accessmanagerhelpers.TransitTransactionWith(req.Context(), newRelicTransaction)) - } - - // Otherwise, Run active token check - m.handleAdminJWTRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) - + m.handleJWTRequest(w, req, handler, jwtValidationAdmin) }) } @@ -180,33 +123,14 @@ func (m *Middleware) AdminJWTRequired(handler http.Handler) http.Handler { // valid token or an active JWT token, for an admin account API tokens will take precedence func (m *Middleware) AdminApiTokenOrJWTRequired(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - - // Add newrelic transaction - if m.newRelicApplication != nil { - newRelicTransaction := m.newRelicApplication.StartTransaction(fmt.Sprintf("%s %s", req.Method, req.URL.Path)) - // req is a *http.Request, this marks the transaction as a web transaction - newRelicTransaction.SetWebRequestHTTP(req) - - // Add to context - req = req.WithContext(accessmanagerhelpers.TransitTransactionWith(req.Context(), newRelicTransaction)) - } - // check for API header - userFullToken := req.Header.Get(common.SystemWideXApiToken) - - // if present, run API middleware logic - if userFullToken != "" { + if req.Header.Get(common.SystemWideXApiToken) != "" { m.handleAdminAPITokenRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) - return } - // Otherwise, Run active token check - m.handleAdminJWTRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) + // Otherwise, run admin JWT validation + m.handleJWTRequest(w, req, handler, jwtValidationAdmin) }) } @@ -214,21 +138,7 @@ func (m *Middleware) AdminApiTokenOrJWTRequired(handler http.Handler) http.Handl // valid token, and the user is in an `ACTIVE` state (status) func (m *Middleware) ActiveJWTRequired(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - - // Add newrelic transaction - if m.newRelicApplication != nil { - newRelicTransaction := m.newRelicApplication.StartTransaction(fmt.Sprintf("%s %s", req.Method, req.URL.Path)) - // req is a *http.Request, this marks the transaction as a web transaction - newRelicTransaction.SetWebRequestHTTP(req) - - // Add to context - req = req.WithContext(accessmanagerhelpers.TransitTransactionWith(req.Context(), newRelicTransaction)) - } - - m.handleActiveJWTRequiredRequest(w, req, handler) - - m.endNewrelicTransaction(req) - + m.handleJWTRequest(w, req, handler, jwtValidationActive) }) } @@ -236,88 +146,122 @@ func (m *Middleware) ActiveJWTRequired(handler http.Handler) http.Handler { // valid token, non expired token func (m *Middleware) JWTRequired(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + m.handleJWTRequest(w, req, handler, jwtValidationStandard) + }) +} - // Add newrelic transaction - if m.newRelicApplication != nil { - newRelicTransaction := m.newRelicApplication.StartTransaction(fmt.Sprintf("%s %s", req.Method, req.URL.Path)) - // req is a *http.Request, this marks the transaction as a web transaction - newRelicTransaction.SetWebRequestHTTP(req) +// validationFunc returns the appropriate service validation function based on validation type +func (m *Middleware) validationFunc(validationType jwtValidationType) func(*http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + switch validationType { + case jwtValidationAdmin: + return m.service.MiddlewareAdminJWTRequired + case jwtValidationActive: + return m.service.MiddlewareActiveJWTRequired + default: + return m.service.MiddlewareJWTRequired + } +} - // Add to context - req = req.WithContext(accessmanagerhelpers.TransitTransactionWith(req.Context(), newRelicTransaction)) - } +// getCookies retrieves and validates auth and refresh token cookies from the request +func (m *Middleware) getCookies(req *http.Request) (authCookie, refreshCookie *http.Cookie, err error) { + authCookie, _ = req.Cookie(m.cookiePrefixAuthToken) + refreshCookie, refreshErr := req.Cookie(m.cookiePrefixRefreshToken) - m.handleJWTRequiredRequest(w, req, handler) + if refreshErr != nil && refreshErr != http.ErrNoCookie { + return nil, nil, refreshErr + } - m.endNewrelicTransaction(req) + if refreshCookie == nil { + return nil, nil, errors.New(accessmanager.ErrKeyUnauthorizedUnableToAttainRequestorID) + } - }) + return authCookie, refreshCookie, nil } -// handleJWTRequiredRequest is checking to make sure the request -// coming in has a valid JWT -func (m *Middleware) handleJWTRequiredRequest(w http.ResponseWriter, req *http.Request, handler http.Handler) { +// attemptTokenRefresh attempts to refresh tokens and retry validation +func (m *Middleware) attemptTokenRefresh( + w http.ResponseWriter, + req *http.Request, + refreshCookie *http.Cookie, + validateFunc func(*http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error), +) (*accessmanager.MiddlewareAuthedUserResponse, error) { + if refreshCookie.Value == "" { + return nil, errors.New(accessmanager.ErrKeyEmptyRefreshToken) + } - var ( - userId string - err error + // Refresh the tokens + tokenResp, err := m.service.RefreshToken(req.Context(), &accessmanager.RefreshTokenRequest{ + RefreshToken: refreshCookie.Value, + }) + if err != nil { + return nil, err + } + + // Set new tokens in cookies + toolbox.AddAuthCookies( + w, + m.environment, + m.cookieDomain, + m.cookiePrefixAuthToken, + tokenResp.AccessToken, + tokenResp.AccessTokenExpiresAt, + m.cookiePrefixRefreshToken, + tokenResp.RefreshToken, + tokenResp.RefreshTokenExpiresAt, ) - // check to see if request is coming with cookies - cookie, aTokenErr := req.Cookie(m.cookiePrefixAuthToken) - refreshTokenCookie, _ := req.Cookie(m.cookiePrefixRefreshToken) - if aTokenErr != nil && aTokenErr != http.ErrNoCookie && refreshTokenCookie == nil { - m.endNewrelicTransaction(req) + // Update request header with new access token + req.Header["Authorization"] = []string{"Bearer " + tokenResp.AccessToken} - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, aTokenErr) - return - } + // Retry validation with new token + return validateFunc(req) +} - if refreshTokenCookie == nil { +// handleJWTRequest is a unified handler for all JWT validation types +func (m *Middleware) handleJWTRequest( + w http.ResponseWriter, + req *http.Request, + handler http.Handler, + validationType jwtValidationType, +) { + // Get cookies + authCookie, refreshCookie, err := m.getCookies(req) + if err != nil { toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, errors.New(accessmanager.ErrKeyUnauthorizedUnableToAttainRequestorID)) + m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) return } - if cookie != nil { - req.Header["Authorization"] = []string{"Bearer " + cookie.Value} + // Set authorization header if auth cookie exists + if authCookie != nil { + req.Header["Authorization"] = []string{"Bearer " + authCookie.Value} } - userId, err = m.service.MiddlewareJWTRequired(req) - if err != nil { - // handle the case where the access token is expired - if refreshTokenCookie.Value != "" { - - m.refreshTokenAndUpdateRequest(w, req, refreshTokenCookie.Value) + // Get validation function + validateFunc := m.validationFunc(validationType) - // retry the request with the new access token - userId, err = m.service.MiddlewareJWTRequired(req) - if err != nil { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) - return - } - } else { + // Attempt validation + authedUserResp, err := validateFunc(req) + if err != nil { + // Try token refresh + authedUserResp, refreshErr := m.attemptTokenRefresh(w, req, refreshCookie, validateFunc) + if refreshErr != nil { toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) return } + + // Refresh succeeded, use the new authedUser + req = handleTransmittingAuthenticatedUserDetails(req, authedUserResp) + + handler.ServeHTTP(w, req) + return } - request := req.WithContext(accessmanagerhelpers.TransitWith(req.Context(), userId)) + // Validation succeeded + req = handleTransmittingAuthenticatedUserDetails(req, authedUserResp) - responseWriter := middlewareResponseWriter(w, accessmanagerhelpers.AcquireTransactionFrom(req.Context())) - handler.ServeHTTP(responseWriter, request) + handler.ServeHTTP(w, req) } // RateLimitOrActiveJWTRequired creates a middleware ensuring that the request is rate limited if @@ -326,320 +270,92 @@ func (m *Middleware) handleJWTRequiredRequest(w http.ResponseWriter, req *http.R // or passed with a valid token, and the user is in an `ACTIVE` state (status) func (m *Middleware) RateLimitOrActiveJWTRequired(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + authCookie, _ := req.Cookie(m.cookiePrefixAuthToken) + refreshCookie, _ := req.Cookie(m.cookiePrefixRefreshToken) - var ( - userId string - err error - ) - - // Add newrelic transaction - if m.newRelicApplication != nil { - newRelicTransaction := m.newRelicApplication.StartTransaction(fmt.Sprintf("%s %s", req.Method, req.URL.Path)) - // req is a *http.Request, this marks the transaction as a web transaction - newRelicTransaction.SetWebRequestHTTP(req) - - // Add to context - req = req.WithContext(accessmanagerhelpers.TransitTransactionWith(req.Context(), newRelicTransaction)) - } - - // check to see if request is coming with cookies - cookie, aTokenErr := req.Cookie(m.cookiePrefixAuthToken) - refreshTokenCookie, rAuthErr := req.Cookie(m.cookiePrefixRefreshToken) - if (aTokenErr != nil && aTokenErr != http.ErrNoCookie) && (rAuthErr != nil && rAuthErr != http.ErrNoCookie) { - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, aTokenErr) - return - } - - // if both cookies are empty, then we need to - // carry on with rate limiting flow - if cookie == nil && refreshTokenCookie == nil { - userId, err = m.service.MiddlewareRateLimitOrActiveJWTRequired(req) + // If both cookies are absent, use rate limiting flow + if authCookie == nil && refreshCookie == nil { + authedUserResp, err := m.service.MiddlewareRateLimitOrActiveJWTRequired(req) if err != nil { - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) return } - } - // if there is a cookie, the we need refresh logic - if cookie != nil || refreshTokenCookie != nil { + req = handleTransmittingAuthenticatedUserDetails(req, authedUserResp) - if refreshTokenCookie == nil { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, errors.New(accessmanager.ErrKeyUnauthorizedUnableToAttainRequestorID)) - return - } - - if cookie != nil { - req.Header["Authorization"] = []string{"Bearer " + cookie.Value} - } - - userId, err = m.service.MiddlewareRateLimitOrActiveJWTRequired(req) - if err != nil { - // handle the case where the access token is expired - if refreshTokenCookie.Value != "" { - - m.refreshTokenAndUpdateRequest(w, req, refreshTokenCookie.Value) - - // retry the request with the new access token - userId, err = m.service.MiddlewareRateLimitOrActiveJWTRequired(req) - if err != nil { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) - return - } - } else { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) - return - } - - } + handler.ServeHTTP(w, req) + return } - request := req.WithContext(accessmanagerhelpers.TransitWith(req.Context(), userId)) - - responseWriter := middlewareResponseWriter(w, accessmanagerhelpers.AcquireTransactionFrom(req.Context())) - handler.ServeHTTP(responseWriter, request) - - m.endNewrelicTransaction(req) - }) -} - -// handleAdminJWTRequiredRequest is checking to make sure the request -// coming in has a valid admin JWT which is in active state associated to it -func (m *Middleware) handleAdminJWTRequiredRequest(w http.ResponseWriter, req *http.Request, handler http.Handler) { - - var ( - userId string - err error - ) - - // check to see if request is coming with cookies - cookie, aTokenErr := req.Cookie(m.cookiePrefixAuthToken) - refreshTokenCookie, _ := req.Cookie(m.cookiePrefixRefreshToken) - if aTokenErr != nil && aTokenErr != http.ErrNoCookie && refreshTokenCookie == nil { - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, aTokenErr) - return - } - - if refreshTokenCookie == nil { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, errors.New(accessmanager.ErrKeyUnauthorizedUnableToAttainRequestorID)) - return - } - - if cookie != nil { - req.Header["Authorization"] = []string{"Bearer " + cookie.Value} - } - - userId, err = m.service.MiddlewareAdminJWTRequired(req) - if err != nil { - // handle the case where the access token is expired - if refreshTokenCookie.Value != "" { - - m.refreshTokenAndUpdateRequest(w, req, refreshTokenCookie.Value) - - // retry the request with the new access token - userId, err = m.service.MiddlewareAdminJWTRequired(req) - if err != nil { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) - return - } - } else { + // Otherwise handle JWT authentication with refresh capability + if refreshCookie == nil { toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + m.getBaseResponseHandler().NewHTTPErrorResponse(w, errors.New(accessmanager.ErrKeyUnauthorizedUnableToAttainRequestorID)) return } - } - - request := req.WithContext(accessmanagerhelpers.TransitWith(req.Context(), userId)) - - responseWriter := middlewareResponseWriter(w, accessmanagerhelpers.AcquireTransactionFrom(req.Context())) - handler.ServeHTTP(responseWriter, request) - -} -// handleActiveJWTRequiredRequest is checking to make sure the request -// coming in has a valid JWT which is in active state associated to it -func (m *Middleware) handleActiveJWTRequiredRequest(w http.ResponseWriter, req *http.Request, handler http.Handler) { - - var ( - userId string - err error - ) - - // check to see if request is coming with cookies - cookie, aTokenErr := req.Cookie(m.cookiePrefixAuthToken) - refreshTokenCookie, _ := req.Cookie(m.cookiePrefixRefreshToken) - if aTokenErr != nil && aTokenErr != http.ErrNoCookie && refreshTokenCookie == nil { - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, aTokenErr) - return - } - - if refreshTokenCookie == nil { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, errors.New(accessmanager.ErrKeyUnauthorizedUnableToAttainRequestorID)) - return - } - - if cookie != nil { - req.Header["Authorization"] = []string{"Bearer " + cookie.Value} - } - - userId, err = m.service.MiddlewareActiveJWTRequired(req) - if err != nil { - // handle the case where the access token is expired - if refreshTokenCookie.Value != "" { - - m.refreshTokenAndUpdateRequest(w, req, refreshTokenCookie.Value) + if authCookie != nil { + req.Header["Authorization"] = []string{"Bearer " + authCookie.Value} + } - // retry the request with the new access token - userId, err = m.service.MiddlewareActiveJWTRequired(req) - if err != nil { + authedUserResp, err := m.service.MiddlewareRateLimitOrActiveJWTRequired(req) + if err != nil { + // Try token refresh + authedUserResp, refreshErr := m.attemptTokenRefresh(w, req, refreshCookie, m.service.MiddlewareRateLimitOrActiveJWTRequired) + if refreshErr != nil { toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) return } - } else { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + req = handleTransmittingAuthenticatedUserDetails(req, authedUserResp) + + handler.ServeHTTP(w, req) return } - } - request := req.WithContext(accessmanagerhelpers.TransitWith(req.Context(), userId)) + req = handleTransmittingAuthenticatedUserDetails(req, authedUserResp) - responseWriter := middlewareResponseWriter(w, accessmanagerhelpers.AcquireTransactionFrom(req.Context())) - handler.ServeHTTP(responseWriter, request) + handler.ServeHTTP(w, req) + }) } // handleAdminAPITokenRequiredRequest is checking to make sure the request // coming in has a valid admin Api token associated to it func (m *Middleware) handleAdminAPITokenRequiredRequest(w http.ResponseWriter, req *http.Request, handler http.Handler) { - userID, err := m.service.MiddlewareAdminAPITokenRequired(req) + authedUserResp, err := m.service.MiddlewareAdminAPITokenRequired(req) if err != nil { - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) return } - request := req.WithContext(accessmanagerhelpers.TransitWith(req.Context(), userID)) + req = handleTransmittingAuthenticatedUserDetails(req, authedUserResp) - responseWriter := middlewareResponseWriter(w, accessmanagerhelpers.AcquireTransactionFrom(req.Context())) - handler.ServeHTTP(responseWriter, request) + handler.ServeHTTP(w, req) } // handleValidAPITokenRequiredRequest is checking to make sure the request // coming in has a valid token associated to it func (m *Middleware) handleValidAPITokenRequiredRequest(w http.ResponseWriter, req *http.Request, handler http.Handler) { - userID, err := m.service.MiddlewareValidAPITokenRequired(req) + authedUserResp, err := m.service.MiddlewareValidAPITokenRequired(req) if err != nil { - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later m.getBaseResponseHandler().NewHTTPErrorResponse(w, err) return } - request := req.WithContext(accessmanagerhelpers.TransitWith(req.Context(), userID)) - - responseWriter := middlewareResponseWriter(w, accessmanagerhelpers.AcquireTransactionFrom(req.Context())) - handler.ServeHTTP(responseWriter, request) -} - -// endNewrelicTransaction is a helper function to end the newrelic transaction -// if the newrelic application is not nil -func (m *Middleware) endNewrelicTransaction(req *http.Request) { - if m.newRelicApplication != nil { - newRelicTransaction := accessmanagerhelpers.AcquireTransactionFrom(req.Context()) - newRelicTransaction.End() - } -} - -// refreshTokenAndUpdateRequest is a helper function to refresh the token and update the request -// with the new tokens and headers -func (m *Middleware) refreshTokenAndUpdateRequest(w http.ResponseWriter, req *http.Request, refreshToken string) { - - // refresh the tokens - tokenResp, refreshErr := m.service.RefreshToken(req.Context(), &accessmanager.RefreshTokenRequest{ - RefreshToken: refreshToken, - }) - if refreshErr != nil { - toolbox.RemoveAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, m.cookiePrefixRefreshToken) - m.endNewrelicTransaction(req) - - //nolint will set up default fallback later - m.getBaseResponseHandler().NewHTTPErrorResponse(w, refreshErr) - return - } - - // set the new tokens in the cookies - toolbox.AddAuthCookies(w, m.environment, m.cookieDomain, m.cookiePrefixAuthToken, tokenResp.AccessToken, tokenResp.AccessTokenExpiresAt, m.cookiePrefixRefreshToken, tokenResp.RefreshToken, tokenResp.RefreshTokenExpiresAt) + req = handleTransmittingAuthenticatedUserDetails(req, authedUserResp) - // set the new access token in the header - req.Header["Authorization"] = []string{"Bearer " + tokenResp.AccessToken} -} - -type httpResponseWriter struct { - http.ResponseWriter - statusCode int + handler.ServeHTTP(w, req) } -// middlewareResponseWriter handles events when responses that implicitly returns 200 OK do -// no call WriteHeader(int). -func middlewareResponseWriter(w http.ResponseWriter, txn *newrelic.Transaction) *httpResponseWriter { +// handleTransmittingAuthenticatedUserDetails extends the request context with +// the authenticated user's details including user ID and/or the user object +func handleTransmittingAuthenticatedUserDetails(req *http.Request, authedUserResp *accessmanager.MiddlewareAuthedUserResponse) *http.Request { - // writer is a http.ResponseWriter, use the returned writer in place of the original - w = txn.SetWebResponse(w) - - return &httpResponseWriter{w, http.StatusOK} -} + req = req.WithContext(accessmanagerhelpers.TransitUserWith(req.Context(), authedUserResp.User)) + req = req.WithContext(accessmanagerhelpers.TransitWith(req.Context(), authedUserResp.User.GetUserId())) -func (lrw *httpResponseWriter) WriteHeader(code int) { - lrw.statusCode = code - lrw.ResponseWriter.WriteHeader(code) + return req } // getBaseResponseHandler returns response handler configured with auth error map diff --git a/external/accessmanager/middleware/middleware_test.go b/external/accessmanager/middleware/middleware_test.go new file mode 100644 index 0000000..b7832f7 --- /dev/null +++ b/external/accessmanager/middleware/middleware_test.go @@ -0,0 +1,589 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ooaklee/ghatd/external/accessmanager" + accessmanagerhelpers "github.com/ooaklee/ghatd/external/accessmanager/helpers" + "github.com/ooaklee/ghatd/external/common" + userv2 "github.com/ooaklee/ghatd/external/user/v2" + "github.com/ooaklee/reply" +) + +// mockAccessManagerService is a mock implementation of accessManagerService for testing +type mockAccessManagerService struct { + middlewareAdminJWTRequiredFunc func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + middlewareAdminAPITokenRequiredFunc func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + middlewareActiveJWTRequiredFunc func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + middlewareJWTRequiredFunc func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + middlewareValidAPITokenRequiredFunc func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + middlewareRateLimitOrActiveJWTRequiredFunc func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) + refreshTokenFunc func(ctx context.Context, r *accessmanager.RefreshTokenRequest) (*accessmanager.RefreshTokenResponse, error) +} + +func (m *mockAccessManagerService) MiddlewareAdminJWTRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + if m.middlewareAdminJWTRequiredFunc != nil { + return m.middlewareAdminJWTRequiredFunc(r) + } + return nil, nil +} + +func (m *mockAccessManagerService) MiddlewareAdminAPITokenRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + if m.middlewareAdminAPITokenRequiredFunc != nil { + return m.middlewareAdminAPITokenRequiredFunc(r) + } + return nil, nil +} + +func (m *mockAccessManagerService) MiddlewareActiveJWTRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + if m.middlewareActiveJWTRequiredFunc != nil { + return m.middlewareActiveJWTRequiredFunc(r) + } + return nil, nil +} + +func (m *mockAccessManagerService) MiddlewareJWTRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + if m.middlewareJWTRequiredFunc != nil { + return m.middlewareJWTRequiredFunc(r) + } + return nil, nil +} + +func (m *mockAccessManagerService) MiddlewareValidAPITokenRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + if m.middlewareValidAPITokenRequiredFunc != nil { + return m.middlewareValidAPITokenRequiredFunc(r) + } + return nil, nil +} + +func (m *mockAccessManagerService) MiddlewareRateLimitOrActiveJWTRequired(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + if m.middlewareRateLimitOrActiveJWTRequiredFunc != nil { + return m.middlewareRateLimitOrActiveJWTRequiredFunc(r) + } + return nil, nil +} + +func (m *mockAccessManagerService) RefreshToken(ctx context.Context, r *accessmanager.RefreshTokenRequest) (*accessmanager.RefreshTokenResponse, error) { + if m.refreshTokenFunc != nil { + return m.refreshTokenFunc(ctx, r) + } + return nil, nil +} + +// helper to build a mock authenticated user response with a minimal user object +func mockAuthedResp(userID, status string, roles []string) *accessmanager.MiddlewareAuthedUserResponse { + return &accessmanager.MiddlewareAuthedUserResponse{ + UserID: userID, + User: &userv2.UniversalUser{ + ID: userID, + Email: userID + "@example.com", + Status: status, + Roles: roles, + }, + } +} + +// createTestMiddleware creates a middleware instance for testing +func createTestMiddleware(service accessManagerService) *Middleware { + return NewMiddleware(&NewMiddlewareRequest{ + Service: service, + ErrorMaps: []reply.ErrorManifest{}, + Environment: "test", + CookiePrefixAuthToken: "test_auth", + CookiePrefixRefreshToken: "test_refresh", + CookieDomain: "test.com", + }) +} + +// createTestHandler creates a simple test handler that writes success response +func createTestHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract user ID from context if it exists + userID := accessmanagerhelpers.AcquireFrom(r.Context()) + w.WriteHeader(http.StatusOK) + w.Write([]byte("success:" + userID)) + }) +} + +func TestNewMiddleware(t *testing.T) { + service := &mockAccessManagerService{} + + middleware := NewMiddleware(&NewMiddlewareRequest{ + Service: service, + ErrorMaps: []reply.ErrorManifest{}, + Environment: "production", + CookiePrefixAuthToken: "auth_token", + CookiePrefixRefreshToken: "refresh_token", + CookieDomain: "example.com", + }) + + if middleware == nil { + t.Fatal("Expected middleware to be created, got nil") + } + + if middleware.service != service { + t.Error("Service not properly initialized") + } + + if middleware.environment != "production" { + t.Errorf("Expected environment to be 'production', got '%s'", middleware.environment) + } + + if middleware.cookiePrefixAuthToken != "auth_token" { + t.Errorf("Expected cookiePrefixAuthToken to be 'auth_token', got '%s'", middleware.cookiePrefixAuthToken) + } + + if middleware.cookiePrefixRefreshToken != "refresh_token" { + t.Errorf("Expected cookiePrefixRefreshToken to be 'refresh_token', got '%s'", middleware.cookiePrefixRefreshToken) + } + + if middleware.cookieDomain != "example.com" { + t.Errorf("Expected cookieDomain to be 'example.com', got '%s'", middleware.cookieDomain) + } +} + +func TestValidAPITokenRequired_Success(t *testing.T) { + userID := "test-user-123" + mockService := &mockAccessManagerService{ + middlewareValidAPITokenRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.ValidAPITokenRequired(handler) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set(common.SystemWideXApiToken, "test-token") + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + expected := "success:" + userID + if w.Body.String() != expected { + t.Errorf("Expected body '%s', got '%s'", expected, w.Body.String()) + } +} + +func TestValidAPITokenRequired_Failure(t *testing.T) { + mockService := &mockAccessManagerService{ + middlewareValidAPITokenRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return nil, errors.New("invalid token") + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.ValidAPITokenRequired(handler) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set(common.SystemWideXApiToken, "invalid-token") + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code == http.StatusOK { + t.Error("Expected non-200 status code for invalid token") + } +} + +func TestAdminJWTRequired_Success(t *testing.T) { + userID := "admin-user-123" + mockService := &mockAccessManagerService{ + middlewareAdminJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleAdmin}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.AdminJWTRequired(handler) + + req := httptest.NewRequest("GET", "/admin", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "valid-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestAdminJWTRequired_MissingRefreshToken(t *testing.T) { + mockService := &mockAccessManagerService{ + middlewareAdminJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return nil, errors.New("unauthorized") + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.AdminJWTRequired(handler) + + req := httptest.NewRequest("GET", "/admin", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "valid-jwt-token"}) + // Missing refresh token + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code == http.StatusOK { + t.Error("Expected non-200 status code when refresh token is missing") + } +} + +func TestActiveJWTRequired_Success(t *testing.T) { + userID := "active-user-123" + mockService := &mockAccessManagerService{ + middlewareActiveJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.ActiveJWTRequired(handler) + + req := httptest.NewRequest("GET", "/active", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "valid-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestJWTRequired_Success(t *testing.T) { + userID := "user-123" + mockService := &mockAccessManagerService{ + middlewareJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.JWTRequired(handler) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "valid-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestJWTRequired_TokenRefresh(t *testing.T) { + userID := "user-123" + callCount := 0 + + mockService := &mockAccessManagerService{ + middlewareJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + callCount++ + // First call fails (expired token), second call succeeds + if callCount == 1 { + return nil, errors.New("token expired") + } + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + refreshTokenFunc: func(ctx context.Context, r *accessmanager.RefreshTokenRequest) (*accessmanager.RefreshTokenResponse, error) { + return &accessmanager.RefreshTokenResponse{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + AccessTokenExpiresAt: 1700000000, + RefreshTokenExpiresAt: 1700000000, + }, nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.JWTRequired(handler) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "expired-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d after refresh, got %d", http.StatusOK, w.Code) + } + + if callCount != 2 { + t.Errorf("Expected middleware function to be called twice (initial + retry), got %d", callCount) + } +} + +func TestJWTRequired_RefreshTokenFailure(t *testing.T) { + mockService := &mockAccessManagerService{ + middlewareJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return nil, errors.New("token expired") + }, + refreshTokenFunc: func(ctx context.Context, r *accessmanager.RefreshTokenRequest) (*accessmanager.RefreshTokenResponse, error) { + return nil, errors.New("refresh token invalid") + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.JWTRequired(handler) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "expired-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "invalid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code == http.StatusOK { + t.Error("Expected non-200 status code when refresh token is invalid") + } +} + +func TestActiveValidApiTokenOrJWTRequired_APITokenPresent(t *testing.T) { + userID := "api-user-123" + mockService := &mockAccessManagerService{ + middlewareValidAPITokenRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.ActiveValidApiTokenOrJWTRequired(handler) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set(common.SystemWideXApiToken, "valid-api-token") + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestActiveValidApiTokenOrJWTRequired_JWTPresent(t *testing.T) { + userID := "jwt-user-123" + mockService := &mockAccessManagerService{ + middlewareActiveJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.ActiveValidApiTokenOrJWTRequired(handler) + + req := httptest.NewRequest("GET", "/test", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "valid-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestActiveValidApiTokenOrAuthenticated_APITokenPrecedence(t *testing.T) { + apiTokenUserID := "api-user-123" + jwtUserID := "jwt-user-123" + + mockService := &mockAccessManagerService{ + middlewareValidAPITokenRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(apiTokenUserID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + middlewareJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(jwtUserID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.ActiveValidApiTokenOrAuthenticated(handler) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set(common.SystemWideXApiToken, "valid-api-token") + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "valid-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + // Verify API token was used (not JWT) + expected := "success:" + apiTokenUserID + if w.Body.String() != expected { + t.Errorf("Expected API token to take precedence. Got body '%s', expected '%s'", w.Body.String(), expected) + } +} + +func TestAdminApiTokenOrJWTRequired_APITokenPresent(t *testing.T) { + userID := "admin-api-user-123" + mockService := &mockAccessManagerService{ + middlewareAdminAPITokenRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleAdmin}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.AdminApiTokenOrJWTRequired(handler) + + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set(common.SystemWideXApiToken, "valid-admin-api-token") + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestAdminApiTokenOrJWTRequired_JWTPresent(t *testing.T) { + userID := "admin-jwt-user-123" + mockService := &mockAccessManagerService{ + middlewareAdminJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleAdmin}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.AdminApiTokenOrJWTRequired(handler) + + req := httptest.NewRequest("GET", "/admin", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "valid-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestRateLimitOrActiveJWTRequired_NoCookies(t *testing.T) { + userID := "rate-limited-user" + mockService := &mockAccessManagerService{ + middlewareRateLimitOrActiveJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.RateLimitOrActiveJWTRequired(handler) + + req := httptest.NewRequest("GET", "/public", nil) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestRateLimitOrActiveJWTRequired_WithValidJWT(t *testing.T) { + userID := "authenticated-user-123" + mockService := &mockAccessManagerService{ + middlewareRateLimitOrActiveJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + return mockAuthedResp(userID, userv2.AccountStatusKeyActive, []string{userv2.UserRoleUser}), nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.RateLimitOrActiveJWTRequired(handler) + + req := httptest.NewRequest("GET", "/public", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "valid-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } +} + +func TestRateLimitOrActiveJWTRequired_TokenRefresh(t *testing.T) { + userID := "user-123" + callCount := 0 + + mockService := &mockAccessManagerService{ + middlewareRateLimitOrActiveJWTRequiredFunc: func(r *http.Request) (*accessmanager.MiddlewareAuthedUserResponse, error) { + callCount++ + if callCount == 1 { + return nil, errors.New("token expired") + } + return mockAuthedResp(userID, userv2.AccountStatusKeyProvisioned, []string{userv2.UserRoleUser}), nil + }, + refreshTokenFunc: func(ctx context.Context, r *accessmanager.RefreshTokenRequest) (*accessmanager.RefreshTokenResponse, error) { + return &accessmanager.RefreshTokenResponse{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + AccessTokenExpiresAt: 1700000000, + RefreshTokenExpiresAt: 1700000000, + }, nil + }, + } + + middleware := createTestMiddleware(mockService) + handler := createTestHandler() + wrappedHandler := middleware.RateLimitOrActiveJWTRequired(handler) + + req := httptest.NewRequest("GET", "/public", nil) + req.AddCookie(&http.Cookie{Name: "test_auth", Value: "expired-jwt-token"}) + req.AddCookie(&http.Cookie{Name: "test_refresh", Value: "valid-refresh-token"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d after refresh, got %d", http.StatusOK, w.Code) + } + + if callCount != 2 { + t.Errorf("Expected middleware function to be called twice, got %d", callCount) + } +} + +func TestGetBaseResponseHandler(t *testing.T) { + mockService := &mockAccessManagerService{} + middleware := createTestMiddleware(mockService) + + handler := middleware.getBaseResponseHandler() + if handler == nil { + t.Fatal("Expected non-nil response handler") + } +} diff --git a/external/accessmanager/request.go b/external/accessmanager/request.go index d210267..dd628e5 100644 --- a/external/accessmanager/request.go +++ b/external/accessmanager/request.go @@ -5,7 +5,7 @@ import ( "net/url" "github.com/ooaklee/ghatd/external/apitoken" - "github.com/ooaklee/ghatd/external/user" + userv2 "github.com/ooaklee/ghatd/external/user/v2" ) // RefreshTokenRequest holds refresh token which will be used to @@ -47,7 +47,7 @@ type CreateUserRequest struct { // CreateEmailVerificationTokenRequest holds the data required for a user request type CreateEmailVerificationTokenRequest struct { // User to create and send a verification token to - User user.User + User *userv2.UniversalUser // IsDashboardRequest whether the request originates from // our dashboard portal diff --git a/external/accessmanager/response.go b/external/accessmanager/response.go index 13e9e9d..3c68d64 100644 --- a/external/accessmanager/response.go +++ b/external/accessmanager/response.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/ooaklee/ghatd/external/apitoken" - "github.com/ooaklee/ghatd/external/user" + userv2 "github.com/ooaklee/ghatd/external/user/v2" ) // CreateUserAPITokenResponse holds response data for CreateUserAPIToken request @@ -16,7 +16,7 @@ type CreateUserAPITokenResponse struct { // CreateUserResponse holds response data for CreateUserResponse request type CreateUserResponse struct { // User represents the user created on the platform - User user.User + User *userv2.UniversalUser } // TokenAsStringValidatorResponse holds the response for TokenAsStringValidator request @@ -148,3 +148,13 @@ type OauthCallbackResponse struct { // signed in RequestUrl string } + +// MiddlewareAuthedUserResponse holds the data returned for authenticated user +type MiddlewareAuthedUserResponse struct { + + // UserID is the authenticated user's ID + UserID string + + // User is the authenticated user + User *userv2.UniversalUser +} diff --git a/external/accessmanager/service.go b/external/accessmanager/service.go index 69ff8c4..63ce4c0 100644 --- a/external/accessmanager/service.go +++ b/external/accessmanager/service.go @@ -11,6 +11,8 @@ import ( "time" "github.com/dgrijalva/jwt-go" + "go.uber.org/zap" + "github.com/ooaklee/ghatd/external/apitoken" "github.com/ooaklee/ghatd/external/audit" "github.com/ooaklee/ghatd/external/auth" @@ -21,17 +23,9 @@ import ( "github.com/ooaklee/ghatd/external/logger" "github.com/ooaklee/ghatd/external/oauth" "github.com/ooaklee/ghatd/external/toolbox" - "github.com/ooaklee/ghatd/external/user" - "go.uber.org/zap" + userv2 "github.com/ooaklee/ghatd/external/user/v2" ) -// User expected methods of a valid user -type User interface { - GetAttributeByJsonPath(jsonPath string) (any, error) - IsAdmin() bool - UpdateStatus(desiredStatus string) (*user.User, error) -} - // AuditService expected methods of a valid audit service type AuditService interface { LogAuditEvent(ctx context.Context, r *audit.LogAuditEventRequest) error @@ -80,11 +74,11 @@ type AuthService interface { // UserService expected methods of a valid user service type UserService interface { - GetUserByNanoId(ctx context.Context, id string) (*user.GetUserByIDResponse, error) - GetUserByID(ctx context.Context, r *user.GetUserByIdRequest) (*user.GetUserByIDResponse, error) - GetUserByEmail(ctx context.Context, r *user.GetUserByEmailRequest) (*user.GetUserByEmailResponse, error) - UpdateUser(ctx context.Context, user *user.UpdateUserRequest) (*user.UpdateUserResponse, error) - CreateUser(ctx context.Context, r *user.CreateUserRequest) (*user.CreateUserResponse, error) + GetUserByNanoID(ctx context.Context, r *userv2.GetUserByNanoIDRequest) (*userv2.GetUserByNanoIDResponse, error) + GetUserByID(ctx context.Context, r *userv2.GetUserByIDRequest) (*userv2.GetUserByIDResponse, error) + GetUserByEmail(ctx context.Context, r *userv2.GetUserByEmailRequest) (*userv2.GetUserByEmailResponse, error) + UpdateUser(ctx context.Context, r *userv2.UpdateUserRequest) (*userv2.UpdateUserResponse, error) + CreateUser(ctx context.Context, r *userv2.CreateUserRequest) (*userv2.CreateUserResponse, error) } // ApitokenService expected methods of a valid apitoken service @@ -189,21 +183,21 @@ func (s *Service) UpdateUserEmail(ctx context.Context, r *UpdateUserEmailRequest // signed off of the client they are currently using to make the request signUserOutOfPlatform bool = false - requestingUser User + requestingUser *userv2.UniversalUser ) // check that the user id the same as the target user id or the user is an admin if r.UserId != r.TargetUserId { // check if the user is an admin - userByIdResponse, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{ - Id: r.UserId, + userByIdResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: r.UserId, }) if err != nil { log.Error("ams/failed-to-get-requesting-user-by-id", zap.Error(err)) return signUserOutOfPlatform, err } - requestingUser = &userByIdResponse.User + requestingUser = userByIdResponse.User if !requestingUser.IsAdmin() { log.Warn("ams/non-admin-user-attempted-to-update-another-user-email", zap.String("user-id", r.UserId), zap.String("target-user-id", r.TargetUserId)) @@ -212,30 +206,19 @@ func (s *Service) UpdateUserEmail(ctx context.Context, r *UpdateUserEmailRequest } // check if the user's old email is the same as the new email (error with no neeed to signout) - userByIdResponse, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{ - Id: r.TargetUserId, + userByIdResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: r.TargetUserId, }) if err != nil { log.Error("ams/failed-to-get-target-user-by-id", zap.Error(err)) return signUserOutOfPlatform, err } - targetUser := &userByIdResponse.User + targetUser := userByIdResponse.User // if above ok, take copy the user's old email - emailPathValue, err := targetUser.GetAttributeByJsonPath("$.email") - if err != nil { - log.Error("ams/failed-to-get-target-user-email-using-json", zap.String("target-user-id", r.TargetUserId), zap.Error(err)) - return signUserOutOfPlatform, err - } - - emailPathValueString, ok := emailPathValue.(string) - if !ok || emailPathValueString == "" { - log.Error("ams/failed-to-get-target-user-email-as-string", zap.String("target-user-id", r.TargetUserId)) - return signUserOutOfPlatform, errors.New(ErrKeyConflictingUserState) - } - standardiseExistingEmail := toolbox.StringStandardisedToLower(emailPathValueString) + standardiseExistingEmail := toolbox.StringStandardisedToLower(targetUser.GetUserEmail()) standardiseNewEmail := toolbox.StringStandardisedToLower(r.Email) if standardiseExistingEmail == standardiseNewEmail { log.Warn("ams/user-attempted-to-update-email-to-same-email", zap.String("user-id", r.UserId), zap.String("target-user-id", r.TargetUserId)) @@ -243,22 +226,22 @@ func (s *Service) UpdateUserEmail(ctx context.Context, r *UpdateUserEmailRequest } // check if the new email is already in use - userByEmailResponse, newEmailInUseErr := s.UserService.GetUserByEmail(ctx, &user.GetUserByEmailRequest{ + userByEmailResponse, newEmailInUseErr := s.UserService.GetUserByEmail(ctx, &userv2.GetUserByEmailRequest{ Email: r.Email, }) - if newEmailInUseErr != nil && newEmailInUseErr.Error() != user.ErrKeyResourceNotFound { + if newEmailInUseErr != nil && newEmailInUseErr.Error() != userv2.ErrKeyUserNotFound { log.Error("ams/failed-to-verify-whether-new-email-already-in-use", zap.String("user-id", r.UserId), zap.String("target-user-id", r.TargetUserId), zap.Error(err)) return signUserOutOfPlatform, newEmailInUseErr } if newEmailInUseErr == nil { - log.Warn("ams/new-email-already-in-use", zap.String("existing-user-id", userByEmailResponse.User.GetUserId()), zap.String("target-user-id", r.TargetUserId), zap.String("user-id", r.UserId)) + log.Warn("ams/new-email-already-in-use", zap.String("existing-user-id", userByEmailResponse.User.ID), zap.String("target-user-id", r.TargetUserId), zap.String("user-id", r.UserId)) return signUserOutOfPlatform, errors.New(ErrKeyConflictingUserState) } // if here, then the new email is not in use // send email to old email - emailBodyToNotifyExistingEmail := fmt.Sprintf(UpdateUserEmailOldEmailNotificationBodyTmpl, standardiseExistingEmail, standardiseNewEmail, targetUser.GetUserId()) + emailBodyToNotifyExistingEmail := fmt.Sprintf(UpdateUserEmailOldEmailNotificationBodyTmpl, standardiseExistingEmail, standardiseNewEmail, targetUser.ID) err = s.EmailManager.SendCustomEmail(ctx, &emailmanager.SendCustomEmailRequest{ EmailSubject: "Email Change Request Received", @@ -266,7 +249,7 @@ func (s *Service) UpdateUserEmail(ctx context.Context, r *UpdateUserEmailRequest EmailTo: standardiseExistingEmail, EmailBody: emailBodyToNotifyExistingEmail, WithFooter: true, - UserId: targetUser.GetUserId(), + UserId: targetUser.ID, RecipientType: string(audit.User), }) if err != nil { @@ -276,7 +259,7 @@ func (s *Service) UpdateUserEmail(ctx context.Context, r *UpdateUserEmailRequest // update the target users' account with the new email, make sure the email is unique // and unverify the email on the account - _, err = targetUser.UpdateStatus(user.AccountStatusValidOriginKeyEmailChange) + _, err = targetUser.UpdateStatus(userv2.AccountStatusValidOriginKeyEmailChange) if err != nil { log.Warn("ams/unable-to-update-status-of-user-to-provisioned-after-email-change", zap.String("user-id", r.UserId), zap.String("target-user-id", r.TargetUserId), zap.Error(err)) return signUserOutOfPlatform, errors.New(ErrKeyConflictingUserState) @@ -284,10 +267,10 @@ func (s *Service) UpdateUserEmail(ctx context.Context, r *UpdateUserEmailRequest // set the new email targetUser.Email = toolbox.StringStandardisedToLower(r.Email) - targetUser.SetUpdatedAtTimeToNow() + targetUser.SetUpdatedAtNow() // update the user - _, err = s.UserService.UpdateUser(ctx, &user.UpdateUserRequest{ + _, err = s.UserService.UpdateUser(ctx, &userv2.UpdateUserRequest{ User: targetUser, }) if err != nil { @@ -322,13 +305,13 @@ func (s *Service) UpdateUserEmail(ctx context.Context, r *UpdateUserEmailRequest } // send a verification email to the new email address - log.Info(fmt.Sprintf("ams/initiate-verification-email-for-user-with-changed-email: %s", targetUser.GetUserId())) + log.Info(fmt.Sprintf("ams/initiate-verification-email-for-user-with-changed-email: %s", targetUser.ID)) _, err = s.CreateEmailVerificationToken(ctx, &CreateEmailVerificationTokenRequest{ - User: *targetUser, + User: targetUser, RequestUrl: "", }) if err != nil { - log.Error(fmt.Sprintf("ams/error-failed-to-initiate-verification-email-for-user-with-changed-emai: %s", targetUser.GetUserId())) + log.Error(fmt.Sprintf("ams/error-failed-to-initiate-verification-email-for-user-with-changed-emai: %s", targetUser.ID)) return signUserOutOfPlatform, err } @@ -336,7 +319,7 @@ func (s *Service) UpdateUserEmail(ctx context.Context, r *UpdateUserEmailRequest auditErr := s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ ActorId: audit.AuditActorIdSystem, Action: auditEvent, - TargetId: targetUser.GetUserId(), + TargetId: targetUser.ID, TargetType: audit.User, Domain: "accessmanager", Details: map[string]interface{}{ @@ -359,8 +342,8 @@ func (s *Service) LogoutUserOthers(ctx context.Context, r *LogoutUserOthersReque var refreshTokenId string // Check if ID returns valid user - requestingUser, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{ - Id: r.UserId, + requestingUser, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: r.UserId, }) if err != nil { return err @@ -457,9 +440,9 @@ func (s *Service) OauthCallback(ctx context.Context, r *OauthCallbackRequest) (* } // Manage flow with user information - persistentUserResponse, err := s.UserService.GetUserByEmail(ctx, &user.GetUserByEmailRequest{Email: providerUserInfo.GetUserEmail()}) + persistentUserResponse, err := s.UserService.GetUserByEmail(ctx, &userv2.GetUserByEmailRequest{Email: providerUserInfo.GetUserEmail()}) // Check if there is an error outside of user not being found - if persistentUserResponse == nil && err.Error() != user.ErrKeyResourceNotFound { + if persistentUserResponse == nil && err.Error() != userv2.ErrKeyUserNotFound { return &OauthCallbackResponse{ ProviderStateCookieKey: providerCookieKey, }, err @@ -469,7 +452,7 @@ func (s *Service) OauthCallback(ctx context.Context, r *OauthCallbackRequest) (* if err == nil { persistentUser := persistentUserResponse.User - tokenDetails, err := s.AuthService.CreateToken(ctx, &persistentUser) + tokenDetails, err := s.AuthService.CreateToken(ctx, persistentUser) if err != nil { return &OauthCallbackResponse{ ProviderStateCookieKey: providerCookieKey, @@ -477,17 +460,19 @@ func (s *Service) OauthCallback(ctx context.Context, r *OauthCallbackRequest) (* } // update users logged in time - persistentUser.SetLastLoginAtTimeToNow().SetLastFreshLoginAtTimeToNow() + persistentUser.SetLastLoginAtNow() + persistentUser.Metadata.LastFreshLoginAt = persistentUser.Metadata.LastLoginAt // If user is verified by provider but not our platform, we should trust provider - if !persistentUser.Verified.EmailVerified && providerUserInfo.IsUserEmailVerifiedByProvider() { + if !persistentUser.Verification.EmailVerified && providerUserInfo.IsUserEmailVerifiedByProvider() { log.Info("provider-login-user-email-verified-based-on-provider-records", zap.String("user-id", persistentUser.ID)) - persistentUser.Verified.EmailVerified = providerUserInfo.IsUserEmailVerifiedByProvider() - persistentUser.Verified.EmailVerifiedAt = toolbox.TimeNowUTC() + persistentUser.VerifyEmail() } - UpdateUserResponse, err := s.UserService.UpdateUser(ctx, &user.UpdateUserRequest{User: &persistentUser}) + UpdateUserResponse, err := s.UserService.UpdateUser(ctx, &userv2.UpdateUserRequest{ + User: persistentUser, + }) if err != nil { log.Error("provider-login-user-update-failed-after-successful-login-initiation", zap.String("user-id:", persistentUser.ID)) return &OauthCallbackResponse{ @@ -544,15 +529,14 @@ func (s *Service) OauthCallback(ctx context.Context, r *OauthCallbackRequest) (* } // If user is verified by provider but not our platform, we should trust provider - if !newUserResp.User.Verified.EmailVerified && providerUserInfo.IsUserEmailVerifiedByProvider() { + if !newUserResp.User.Verification.EmailVerified && providerUserInfo.IsUserEmailVerifiedByProvider() { log.Info("provider-signup-user-email-verified-based-on-provider-records", zap.String("user-id", newUserResp.User.ID)) - newUserResp.User.Verified.EmailVerified = providerUserInfo.IsUserEmailVerifiedByProvider() - newUserResp.User.Verified.EmailVerifiedAt = toolbox.TimeNowUTC() + newUserResp.User.VerifyEmail() // Update user with verification information - updatedUser, err := s.UserService.UpdateUser(ctx, &user.UpdateUserRequest{ - User: &newUserResp.User, + updatedUser, err := s.UserService.UpdateUser(ctx, &userv2.UpdateUserRequest{ + User: newUserResp.User, }) if err != nil { log.Error(fmt.Sprintf("ams/error-failed-to-save-new-user-verficaiton-by-provider: %s", newUserResp.User.ID)) @@ -699,8 +683,8 @@ func (s *Service) GetSpecificUserAPITokens(ctx context.Context, r *GetSpecificUs func (s *Service) GetUserAPITokenThreshold(ctx context.Context, r *GetUserAPITokenThresholdRequest) (*GetUserAPITokenThresholdResponse, error) { // Check if user exist - userResponse, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{ - Id: r.UserId, + userResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: r.UserId, }) if err != nil { return nil, err @@ -740,8 +724,8 @@ func (s *Service) UpdateUserAPITokenStatus(ctx context.Context, r *UserAPITokenS // TODO: Create tests func (s *Service) DeleteUserAPIToken(ctx context.Context, r *DeleteUserAPITokenRequest) error { // Check if user exist - _, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{ - Id: r.UserID, + _, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: r.UserID, }) if err != nil { return err @@ -784,8 +768,8 @@ func (s *Service) CreateUserAPIToken(ctx context.Context, r *CreateUserAPITokenR ) // Check if user exist - userResponse, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{ - Id: r.UserID, + userResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: r.UserID, }) if err != nil { return nil, err @@ -824,7 +808,7 @@ func (s *Service) CreateUserAPIToken(ctx context.Context, r *CreateUserAPITokenR // Generate token apiTokenResponse, err := s.ApitokenService.CreateAPIToken(ctx, &apitoken.CreateAPITokenRequest{ UserID: persistentUser.ID, - UserNanoId: persistentUser.NanoId, + UserNanoId: persistentUser.NanoID, TokenTtl: r.Ttl, }) if err != nil { @@ -905,206 +889,182 @@ func (s *Service) getUserApiTokensCountByType(ctx context.Context, userId string return userPermanentToken, userEphemeralToken, nil } -// MiddlewareAdminAPITokenRequired handles the business/ cross logic of ensuring -// that the request is passed with a valid admin client ID and secret -func (s *Service) MiddlewareAdminAPITokenRequired(r *http.Request) (string, error) { - - var log *zap.Logger = logger.AcquireFrom(r.Context()).WithOptions( - zap.AddStacktrace(zap.DPanicLevel), - ) - +// MiddlewareAdminAPITokenRequired validates that the request contains a valid API +// token belonging to an active admin user. Returns the user ID if valid. +func (s *Service) MiddlewareAdminAPITokenRequired(r *http.Request) (*MiddlewareAuthedUserResponse, error) { tokenRequester, err := s.ApitokenService.ExtractValidateUserAPITokenMetadata(r.Context(), r) if err != nil { - return "", err + return nil, err } - // If we made it here, it means we've found a matching token - // get user for matching token - // could probably also check liveness here one time - persistentUserResponse, err := s.UserService.GetUserByNanoId(r.Context(), tokenRequester.NanoId) + persistentUserResponse, err := s.UserService.GetUserByNanoID(r.Context(), &userv2.GetUserByNanoIDRequest{ + NanoID: tokenRequester.NanoId, + }) if err != nil { - return "", err + return nil, err } - // Set token requester user Id to make backwards compatible tokenRequester.UserID = persistentUserResponse.User.ID if !persistentUserResponse.User.IsAdmin() { - log.Warn("unauthorized-admin-access-attempted", zap.String("user-id", persistentUserResponse.User.GetUserId())) - return "", errors.New(ErrKeyUnauthorizedAdminAccessAttempted) + return nil, errors.New(ErrKeyUnauthorizedAdminAccessAttempted) } - // Check if user it active - if persistentUserResponse.User.Status != user.AccountStatusKeyActive { - log.Warn("unauthorized-non-active-status", zap.String("user-id", tokenRequester.UserID), zap.String("token-id", tokenRequester.UserAPIToken)) - return "", errors.New(ErrKeyUnauthorizedNonActiveStatus) + if persistentUserResponse.User.Status != userv2.AccountStatusKeyActive { + return nil, errors.New(ErrKeyUnauthorizedNonActiveStatus) } - // Update last used time on token - err = s.ApitokenService.UpdateAPITokenLastUsedAt(r.Context(), &apitoken.UpdateAPITokenLastUsedAtRequest{ + _ = s.ApitokenService.UpdateAPITokenLastUsedAt(r.Context(), &apitoken.UpdateAPITokenLastUsedAtRequest{ APITokenEncoded: tokenRequester.UserAPITokenEncoded, ClientID: tokenRequester.UserID, }) - if err != nil { - log.Warn("failed-updating-token-last-used-at", zap.String("user-id", tokenRequester.UserID), zap.String("token-id", tokenRequester.UserAPIToken)) - } - log.Info("validated-token-request", zap.String("user-id", tokenRequester.UserID), zap.String("token-id", tokenRequester.UserAPIToken)) - - return tokenRequester.UserID, nil + return &MiddlewareAuthedUserResponse{ + UserID: persistentUserResponse.User.GetUserId(), + User: persistentUserResponse.User, + }, nil } -// MiddlewareValidAPITokenRequired handles the business/ cross logic of ensuring -// that the request is passed with a valid client ID and secret -// TODO: Create tests -func (s *Service) MiddlewareValidAPITokenRequired(r *http.Request) (string, error) { +// MiddlewareValidAPITokenRequired validates that the request contains a valid API +// token belonging to an active user. Returns the user ID if valid. +func (s *Service) MiddlewareValidAPITokenRequired(r *http.Request) (*MiddlewareAuthedUserResponse, error) { + ctx := r.Context() - var log *zap.Logger = logger.AcquireFrom(r.Context()).WithOptions( - zap.AddStacktrace(zap.DPanicLevel), - ) - - tokenRequester, err := s.ApitokenService.ExtractValidateUserAPITokenMetadata(r.Context(), r) + tokenRequester, err := s.ApitokenService.ExtractValidateUserAPITokenMetadata(ctx, r) if err != nil { - return "", err + return nil, err } - // If we made it here, it means we've found a matching token - // get user for matching token - // could probably also check liveness here one time - persistentUserResponse, err := s.UserService.GetUserByNanoId(r.Context(), tokenRequester.NanoId) + persistentUserResponse, err := s.UserService.GetUserByNanoID(ctx, &userv2.GetUserByNanoIDRequest{ + NanoID: tokenRequester.NanoId, + }) if err != nil { - return "", err + return nil, err } - // Set token requester user Id to make backwards compatible tokenRequester.UserID = persistentUserResponse.User.ID - // Check if user it active - if persistentUserResponse.User.Status != user.AccountStatusKeyActive { - log.Warn("unauthorized-non-active-status", zap.String("user-id", tokenRequester.UserID), zap.String("token-id", tokenRequester.UserAPIToken)) - return "", errors.New(ErrKeyUnauthorizedNonActiveStatus) + if persistentUserResponse.User.Status != userv2.AccountStatusKeyActive { + return nil, errors.New(ErrKeyUnauthorizedNonActiveStatus) } - // Update last used time on token - err = s.ApitokenService.UpdateAPITokenLastUsedAt(r.Context(), &apitoken.UpdateAPITokenLastUsedAtRequest{ + _ = s.ApitokenService.UpdateAPITokenLastUsedAt(ctx, &apitoken.UpdateAPITokenLastUsedAtRequest{ APITokenEncoded: tokenRequester.UserAPITokenEncoded, ClientID: tokenRequester.UserID, }) - if err != nil { - log.Warn("failed-updating-token-last-used-at", zap.String("user-id", tokenRequester.UserID), zap.String("token-id", tokenRequester.UserAPIToken)) - } - - log.Info("validated-token-request", zap.String("user-id", tokenRequester.UserID), zap.String("token-id", tokenRequester.UserAPIToken)) - return tokenRequester.UserID, nil + return &MiddlewareAuthedUserResponse{ + UserID: persistentUserResponse.User.GetUserId(), + User: persistentUserResponse.User, + }, nil } -// MiddlewareJWTRequired handles the business/ cross logic of ensuring -// that the request is passed with a valid, non-expired token -// TODO: Create tests -func (s *Service) MiddlewareJWTRequired(r *http.Request) (string, error) { - var log *zap.Logger = logger.AcquireFrom(r.Context()).WithOptions( - zap.AddStacktrace(zap.DPanicLevel), - ) +// MiddlewareJWTRequired validates that the request contains a valid, non-expired +// JWT token. Returns the user ID if the token is valid and active in the store. +func (s *Service) MiddlewareJWTRequired(r *http.Request) (*MiddlewareAuthedUserResponse, error) { + ctx := r.Context() - tokenAuth, err := s.AuthService.ExtractTokenMetadata(r.Context(), r) + tokenAuth, err := s.AuthService.ExtractTokenMetadata(ctx, r) if err != nil { - return "", err + return nil, err } - _, err = s.EphemeralStore.FetchAuth(r.Context(), tokenAuth) + if _, err = s.EphemeralStore.FetchAuth(ctx, tokenAuth); err != nil { + return nil, errors.New(ErrKeyUnauthorizedTokenNotFoundInStore) + } + + persistentUserResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ID: tokenAuth.UserID}) if err != nil { - log.Warn("unauthorized-token-not-found", zap.String("user-id", tokenAuth.UserID)) - return "", errors.New(ErrKeyUnauthorizedTokenNotFoundInStore) + return nil, err } - return tokenAuth.UserID, nil + return &MiddlewareAuthedUserResponse{ + UserID: persistentUserResponse.User.GetUserId(), + User: persistentUserResponse.User, + }, nil } -// MiddlewareActiveJWTRequired handles the business/ cross logic of ensuring that the request is passed with a -// valid token, and the user is in an `ACTIVE` state (status) -// TODO: Create tests -func (s *Service) MiddlewareActiveJWTRequired(r *http.Request) (string, error) { +// MiddlewareActiveJWTRequired validates that the request contains a valid JWT token +// and the associated user account is in an ACTIVE status. Returns the user ID if valid. +func (s *Service) MiddlewareActiveJWTRequired(r *http.Request) (*MiddlewareAuthedUserResponse, error) { tokenAuth, err := s.AuthService.ExtractTokenMetadata(r.Context(), r) if err != nil { - return "", err + return nil, err } return s.checkActivenessOfUser(r.Context(), tokenAuth) } -// MiddlewareAdminJWTRequired handles the business/ cross logic of making sure the token passed is -// that of a platform admin, for middleware -// TODO: Create tests -func (s *Service) MiddlewareAdminJWTRequired(r *http.Request) (string, error) { - var log *zap.Logger = logger.AcquireFrom(r.Context()).WithOptions( - zap.AddStacktrace(zap.DPanicLevel), - ) +// MiddlewareAdminJWTRequired validates that the request contains a valid JWT token +// belonging to an active admin user. Returns the user ID if valid. +func (s *Service) MiddlewareAdminJWTRequired(r *http.Request) (*MiddlewareAuthedUserResponse, error) { + ctx := r.Context() - tokenAuth, err := s.AuthService.ExtractTokenMetadata(r.Context(), r) + tokenAuth, err := s.AuthService.ExtractTokenMetadata(ctx, r) if err != nil { - return "", err + return nil, err } if !tokenAuth.IsAdmin { - log.Warn("unauthorized-admin-access-attempted", zap.String("user-id", tokenAuth.UserID)) - return "", errors.New(ErrKeyUnauthorizedAdminAccessAttempted) + return nil, errors.New(ErrKeyUnauthorizedAdminAccessAttempted) } - // Check when user was `ACTIVE` when access token was generated if !tokenAuth.IsAuthorized { - log.Warn("unauthorized-non-active-status", zap.String("user-id", tokenAuth.UserID)) - return "", errors.New(ErrKeyUnauthorizedNonActiveStatus) + return nil, errors.New(ErrKeyUnauthorizedNonActiveStatus) + } + + if _, err = s.EphemeralStore.FetchAuth(ctx, tokenAuth); err != nil { + return nil, errors.New(ErrKeyUnauthorizedTokenNotFoundInStore) } - _, err = s.EphemeralStore.FetchAuth(r.Context(), tokenAuth) + persistentUserResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ID: tokenAuth.UserID}) if err != nil { - log.Warn("unauthorized-token-not-found", zap.String("user-id", tokenAuth.UserID)) - return "", errors.New(ErrKeyUnauthorizedTokenNotFoundInStore) + return nil, err } - return tokenAuth.UserID, nil + return &MiddlewareAuthedUserResponse{ + UserID: persistentUserResponse.User.GetUserId(), + User: persistentUserResponse.User, + }, nil } -// MiddlewareRateLimitOrActiveJWTRequired handles the business/ cross logic of ensuring -// that the request has not exceeded its rate limit, and any unathed request is given the -// default annoymous user ID. -// Otherwise, if a bearer token is detected, typical check is carried out to ensure that the -// passed token is valid, non-expired token -// TODO: Create tests -func (s *Service) MiddlewareRateLimitOrActiveJWTRequired(r *http.Request) (string, error) { - +// MiddlewareRateLimitOrActiveJWTRequired validates authenticated requests via JWT or +// applies rate limiting to unauthenticated requests. Unauthenticated requests are assigned +// a placeholder user ID and tracked by IP address. Returns the user ID or placeholder. +func (s *Service) MiddlewareRateLimitOrActiveJWTRequired(r *http.Request) (*MiddlewareAuthedUserResponse, error) { tokenAuth, err := s.AuthService.ExtractTokenMetadata(r.Context(), r) if err != nil && err.Error() == auth.ErrKeyNoBearerHeaderFound { - // Register request count for unauth user (note IP used) if ephErr := s.EphemeralStore.AddRequestCountEntry(r.Context(), getValidRequestorIP(r)); ephErr != nil { - return "", ephErr + return nil, ephErr } - return s.StaticPlaceholderUuid, nil + return &MiddlewareAuthedUserResponse{ + UserID: s.StaticPlaceholderUuid, + User: &userv2.UniversalUser{ + ID: s.StaticPlaceholderUuid, + }, + }, nil } if err != nil { - return "", err + return nil, err } return s.checkActivenessOfUser(r.Context(), tokenAuth) } -// checkActivenessOfUser validates whether the user's account was in an active state at time of -// token creation -func (s *Service) checkActivenessOfUser(ctx context.Context, tokenAuth *auth.TokenAccessDetails) (string, error) { - var log *zap.Logger = logger.AcquireFrom(ctx).WithOptions( - zap.AddStacktrace(zap.DPanicLevel), - ) - - // Check when user was `ACTIVE` when access token was generated - if !s.isUserLiveStatusActive(ctx, tokenAuth.UserID) { - log.Warn("unauthorized-non-active-status", zap.String("user-id", tokenAuth.UserID)) - return "", errors.New(ErrKeyUnauthorizedNonActiveStatus) +// checkActivenessOfUser verifies that the user account is currently in an ACTIVE status. +// Returns the user ID if active, otherwise returns an error. +func (s *Service) checkActivenessOfUser(ctx context.Context, tokenAuth *auth.TokenAccessDetails) (*MiddlewareAuthedUserResponse, error) { + user, isActiveUser := s.isUserLiveStatusActive(ctx, tokenAuth.UserID) + if !isActiveUser { + return nil, errors.New(ErrKeyUnauthorizedNonActiveStatus) } - return tokenAuth.UserID, nil + return &MiddlewareAuthedUserResponse{ + UserID: user.GetUserId(), + User: user, + }, nil } // LogoutUser handles the logic of signing user off of platform. Delete token(s) from ephemeral store @@ -1259,8 +1219,8 @@ func (s *Service) RemoveRefreshTokenWithCookieValue(ctx context.Context, refresh refreshTokenUuid = refreshTokenDetails.RefreshUUID // Get user details - persistentUserResponse, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{ - Id: refreshTokenDetails.UserID}) + persistentUserResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: refreshTokenDetails.UserID}) if err != nil { log.Error("unable-to-find-user-for-refresh-token-by-its-provided-user-uuid", zap.Error(err)) return nil, refreshTokenUuid, err @@ -1276,7 +1236,7 @@ func (s *Service) RemoveRefreshTokenWithCookieValue(ctx context.Context, refresh } log.Info("refresh-token-successfully-removed", zap.String("user-id", userId), zap.String("refresh-token", refreshTokenDetails.RefreshUUID)) - return &persistentUserResponse.User, refreshTokenUuid, nil + return persistentUserResponse.User, refreshTokenUuid, nil } // LoginUser handies verifying initial login token token, and actioning all surrounding steps in @@ -1295,8 +1255,8 @@ func (s *Service) LoginUser(ctx context.Context, r *LoginUserRequest) (*LoginUse } // Check if ID returns valid user - gIDResponse, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{ - Id: initiateLoginTokenDetails.UserID, + gIDResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: initiateLoginTokenDetails.UserID, }) if err != nil { return nil, err @@ -1304,15 +1264,18 @@ func (s *Service) LoginUser(ctx context.Context, r *LoginUserRequest) (*LoginUse persistentUser := gIDResponse.User - tokenDetails, err := s.AuthService.CreateToken(ctx, &persistentUser) + tokenDetails, err := s.AuthService.CreateToken(ctx, persistentUser) if err != nil { return nil, err } // update users logged in time - persistentUser.SetLastLoginAtTimeToNow().SetLastFreshLoginAtTimeToNow() + persistentUser.SetLastLoginAtNow() + persistentUser.Metadata.LastFreshLoginAt = persistentUser.Metadata.LastLoginAt - UpdateUserResponse, err := s.UserService.UpdateUser(ctx, &user.UpdateUserRequest{User: &persistentUser}) + UpdateUserResponse, err := s.UserService.UpdateUser(ctx, &userv2.UpdateUserRequest{ + User: persistentUser, + }) if err != nil { log.Error("system-update-failed-after-successful-login-initiation", zap.String("user-id:", persistentUser.ID)) return nil, err @@ -1365,18 +1328,18 @@ func (s *Service) CreateInitalLoginOrVerificationTokenEmail(ctx context.Context, zap.AddStacktrace(zap.DPanicLevel), ) - persistentUserResponse, err := s.UserService.GetUserByEmail(ctx, &user.GetUserByEmailRequest{Email: r.Email}) + persistentUserResponse, err := s.UserService.GetUserByEmail(ctx, &userv2.GetUserByEmailRequest{Email: r.Email}) if err != nil { return err } switch persistentUserResponse.User.Status { - case user.AccountStatusKeyActive: - _, err = s.CreateInitalLoginToken(ctx, &persistentUserResponse.User, r.Dashboard, r.RequestUrl) + case userv2.AccountStatusKeyActive: + _, err = s.CreateInitalLoginToken(ctx, persistentUserResponse.User, r.Dashboard, r.RequestUrl) if err != nil { return err } - case user.AccountStatusKeyProvisioned: + case userv2.AccountStatusKeyProvisioned: _, err = s.CreateEmailVerificationToken(ctx, &CreateEmailVerificationTokenRequest{ User: persistentUserResponse.User, IsDashboardRequest: r.Dashboard, @@ -1386,8 +1349,7 @@ func (s *Service) CreateInitalLoginOrVerificationTokenEmail(ctx context.Context, return err } default: - // TODO: Send custom email with actions - log.Error("requested-user-in-unexpected-state", zap.String("user-id", persistentUserResponse.User.ID)) + log.Error("requested-user-in-unexpected-state", zap.String("user-id", persistentUserResponse.User.ID), zap.String("user-status", persistentUserResponse.User.Status)) return errors.New(ErrKeyUserStatusUncaught) } @@ -1430,7 +1392,7 @@ func (s *Service) UserEmailVerificationRevisions(ctx context.Context, r *UserEma zap.AddStacktrace(zap.DPanicLevel), ) - persistentUserResponse, err := s.UserService.GetUserByID(ctx, &user.GetUserByIdRequest{Id: r.UserID}) + persistentUserResponse, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ID: r.UserID}) if err != nil { return "", 0, "", 0, err } @@ -1438,19 +1400,23 @@ func (s *Service) UserEmailVerificationRevisions(ctx context.Context, r *UserEma persistentUser := persistentUserResponse.User // Update user's verificaiton data, metadata and state - revisionedUser, err := persistentUser.SetLastLoginAtTimeToNow().VerifyEmailNow().UpdateStatus(user.AccountStatusKeyActive) + persistentUser.SetLastLoginAtNow() + persistentUser.VerifyEmail() + revisionedUser, err := persistentUser.UpdateStatus(userv2.AccountStatusKeyActive) if err != nil { log.Error("user-status-update-failed-after-successful-email-verification", zap.String("user-id:", r.UserID)) return "", 0, "", 0, err } - UpdateUserResponse, err := s.UserService.UpdateUser(ctx, &user.UpdateUserRequest{User: revisionedUser}) + UpdateUserResponse, err := s.UserService.UpdateUser(ctx, &userv2.UpdateUserRequest{ + User: revisionedUser, + }) if err != nil { log.Error("system-update-failed-after-successful-email-verification", zap.String("user-id:", r.UserID)) return "", 0, "", 0, err } - newTokenDetails, err := s.AuthService.CreateToken(ctx, &UpdateUserResponse.User) + newTokenDetails, err := s.AuthService.CreateToken(ctx, UpdateUserResponse.User) if err != nil { log.Error("token-creation-failed-after-successful-email-verification", zap.String("user-id:", r.UserID)) return "", 0, "", 0, err @@ -1509,10 +1475,12 @@ func (s *Service) CreateUser(ctx context.Context, r *CreateUserRequest) (*Create response := &CreateUserResponse{} - newUser, err := s.UserService.CreateUser(ctx, &user.CreateUserRequest{ - FirstName: r.FirstName, - LastName: r.LastName, - Email: r.Email, + newUser, err := s.UserService.CreateUser(ctx, &userv2.CreateUserRequest{ + FirstName: r.FirstName, + LastName: r.LastName, + Email: r.Email, + GenerateUUID: true, + GenerateNanoID: true, }) if err != nil { return nil, err @@ -1606,7 +1574,7 @@ func (s *Service) CreateUser(ctx context.Context, r *CreateUserRequest) (*Create // CreateInitalLoginToken creates token used to initiate login flow for user passed // TODO: Create tests -func (s *Service) CreateInitalLoginToken(ctx context.Context, user *user.User, isDashboardRequest bool, requestUrl string) (string, error) { +func (s *Service) CreateInitalLoginToken(ctx context.Context, user *userv2.UniversalUser, isDashboardRequest bool, requestUrl string) (string, error) { var log *zap.Logger = logger.AcquireFrom(ctx).WithOptions( zap.AddStacktrace(zap.DPanicLevel), @@ -1646,7 +1614,7 @@ func (s *Service) CreateEmailVerificationToken(ctx context.Context, r *CreateEma var log *zap.Logger = logger.AcquireFrom(ctx).WithOptions( zap.AddStacktrace(zap.DPanicLevel), ) - user := &r.User + user := r.User tokenDetails, err := s.AuthService.CreateEmailVerificationToken(ctx, user) if err != nil { @@ -1662,8 +1630,8 @@ func (s *Service) CreateEmailVerificationToken(ctx context.Context, r *CreateEma // Beging email sending process err = s.EmailManager.SendVerificationEmail(ctx, &emailmanager.SendVerificationEmailRequest{ - FirstName: user.FirstName, - LastName: user.LastName, + FirstName: user.PersonalInfo.FirstName, + LastName: user.PersonalInfo.LastName, Email: user.Email, Token: tokenDetails.EmailVerificationToken, IsDashboardRequest: r.IsDashboardRequest, @@ -1693,24 +1661,19 @@ func getValidRequestorIP(r *http.Request) string { return r.RemoteAddr } -// isUserLiveStatusActive returns whether user matching passed user ID -// has an `ACTIVE` user status -func (s *Service) isUserLiveStatusActive(ctx context.Context, userID string) bool { - var log *zap.Logger = logger.AcquireFrom(ctx).WithOptions( - zap.AddStacktrace(zap.DPanicLevel), - ) - +// isUserLiveStatusActive checks if the user account with the given ID has an ACTIVE status. +// Returns the user object and true if active, otherwise nil and false. +func (s *Service) isUserLiveStatusActive(ctx context.Context, userID string) (*userv2.UniversalUser, bool) { persistentUserResponse, err := s.UserService.GetUserByID(ctx, - &user.GetUserByIdRequest{Id: userID}, + &userv2.GetUserByIDRequest{ID: userID}, ) if err != nil { - log.Warn("live-status-check-failure", zap.String("user-id", userID), zap.Error(err)) - return false + return nil, false } - if persistentUserResponse.User.Status == user.AccountStatusKeyActive { - return true + if persistentUserResponse.User.Status == userv2.AccountStatusKeyActive { + return persistentUserResponse.User, true } - return false + return nil, false } diff --git a/external/apitoken/errormap.go b/external/apitoken/errormap.go index 7367cc8..144e2de 100644 --- a/external/apitoken/errormap.go +++ b/external/apitoken/errormap.go @@ -6,12 +6,12 @@ import ( // ApitokenErrorMap holds Error keys, their corresponding human-friendly message, and response status code var ApitokenErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ - ErrKeyPageOutOfRange: {Title: "Bad Request", Detail: "Page out of range", StatusCode: 400}, - ErrKeyTokenStatusInvalid: {Title: "Bad Request", Detail: "Please verify token status", StatusCode: 400}, - ErrKeyNoMatchingUserAPITokenFound: {Title: "Unauthorized", Code: "200", StatusCode: 401}, - ErrKeyUnableToValidateUserAPIToken: {Title: "Unauthorized", Code: "201", StatusCode: 401}, - ErrKeyUnableToFindRequiredHeaders: {Title: "Unauthorized", Code: "202", StatusCode: 401}, - ErrKeyRequiredUserIDMissing: {Title: "Bad Request", Detail: "Requirements unsatisfied", StatusCode: 400}, - ErrKeyInvalidAPIFormatDetected: {Title: "Bad Request", Code: "203", Detail: "Malformed API token provided", StatusCode: 400}, - ErrKeyResourceNotFound: {Title: "Not Found", Code: "204", StatusCode: 404}, + ErrKeyPageOutOfRange: {Title: "Bad Request", Detail: "Page out of range", StatusCode: 400, Code: "APT0-001"}, + ErrKeyTokenStatusInvalid: {Title: "Bad Request", Detail: "Please verify token status", StatusCode: 400, Code: "APT0-002"}, + ErrKeyNoMatchingUserAPITokenFound: {Title: "Unauthorized", Detail: "Invalid credentials provided", StatusCode: 401, Code: "APT0-003"}, + ErrKeyUnableToValidateUserAPIToken: {Title: "Unauthorized", Detail: "Unable to validate credentials", StatusCode: 401, Code: "APT0-004"}, + ErrKeyUnableToFindRequiredHeaders: {Title: "Unauthorized", Detail: "Missing required authentication headers", StatusCode: 401, Code: "APT0-005"}, + ErrKeyRequiredUserIDMissing: {Title: "Bad Request", Detail: "Requirements unsatisfied", StatusCode: 400, Code: "APT0-006"}, + ErrKeyInvalidAPIFormatDetected: {Title: "Bad Request", Detail: "Malformed API token provided", StatusCode: 400, Code: "APT0-007"}, + ErrKeyResourceNotFound: {Title: "Not Found", Detail: "API token not found", StatusCode: 404, Code: "APT0-008"}, } diff --git a/external/auth/errormap.go b/external/auth/errormap.go index 58f5a86..7f7916c 100644 --- a/external/auth/errormap.go +++ b/external/auth/errormap.go @@ -7,15 +7,15 @@ import ( // AuthErrorMap holds Error keys, their corresponding human-friendly message, and response status code // Use https://docs.microsoft.com/en-us/troubleshoot/iis/http-status-code to expand messages i.e. AccessDenied1 var AuthErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ - ErrKeyUnauthorized: {Title: "Unauthorized", StatusCode: 401}, - ErrKeyUnauthorizedNoTokenUUID: {Title: "Unauthorized", Code: "1", StatusCode: 401}, - ErrKeyUnauthorizedNoUserIDFound: {Title: "Unauthorized", Code: "2", StatusCode: 401}, - ErrKeyUnauthorizedNoAdminInfoFound: {Title: "Unauthorized", Code: "3", StatusCode: 401}, - ErrKeyUnauthorizedNoAuthorizationInfoFound: {Title: "Unauthorized", Code: "4", StatusCode: 401}, - ErrKeyUnauthorizedRefreshTokenExpired: {Title: "Unauthorized", Code: "5", StatusCode: 401}, - ErrKeyUnauthorizedParsedStringTokenExpired: {Title: "Unauthorized", Code: "6", StatusCode: 401}, - ErrKeyUnauthorizedTokenUnexpectedSigningMethod: {Title: "Unauthorized", Code: "7", StatusCode: 401}, - ErrKeyUnauthorizedParsedStringUnknown: {Title: "Unauthorized", Code: "8", StatusCode: 401}, - ErrKeyUnauthorizedMalformattedToken: {Title: "Unauthorized", Code: "9", StatusCode: 401}, - ErrKeyNoBearerHeaderFound: {Title: "Unauthorized", Code: "10", StatusCode: 401}, + ErrKeyUnauthorized: {Title: "Unauthorized", Detail: "Invalid or expired credentials", StatusCode: 401, Code: "AUTH0-001"}, + ErrKeyUnauthorizedNoTokenUUID: {Title: "Unauthorized", Detail: "Invalid authentication token", StatusCode: 401, Code: "AUTH0-002"}, + ErrKeyUnauthorizedNoUserIDFound: {Title: "Unauthorized", Detail: "Invalid authentication token", StatusCode: 401, Code: "AUTH0-003"}, + ErrKeyUnauthorizedNoAdminInfoFound: {Title: "Unauthorized", Detail: "Invalid authentication token", StatusCode: 401, Code: "AUTH0-004"}, + ErrKeyUnauthorizedNoAuthorizationInfoFound: {Title: "Unauthorized", Detail: "Invalid authentication token", StatusCode: 401, Code: "AUTH0-005"}, + ErrKeyUnauthorizedRefreshTokenExpired: {Title: "Unauthorized", Detail: "Session has expired, please log in again", StatusCode: 401, Code: "AUTH0-006"}, + ErrKeyUnauthorizedParsedStringTokenExpired: {Title: "Unauthorized", Detail: "Session has expired, please log in again", StatusCode: 401, Code: "AUTH0-007"}, + ErrKeyUnauthorizedTokenUnexpectedSigningMethod: {Title: "Unauthorized", Detail: "Invalid authentication token", StatusCode: 401, Code: "AUTH0-008"}, + ErrKeyUnauthorizedParsedStringUnknown: {Title: "Unauthorized", Detail: "Authentication failed", StatusCode: 401, Code: "AUTH0-009"}, + ErrKeyUnauthorizedMalformattedToken: {Title: "Unauthorized", Detail: "Invalid authentication token", StatusCode: 401, Code: "AUTH0-010"}, + ErrKeyNoBearerHeaderFound: {Title: "Unauthorized", Detail: "Missing authentication credentials", StatusCode: 401, Code: "AUTH0-011"}, } diff --git a/external/billingmanager/routes.go b/external/billingmanager/routes.go index 0323e99..2ec25c3 100644 --- a/external/billingmanager/routes.go +++ b/external/billingmanager/routes.go @@ -16,8 +16,8 @@ type billingmanagerHandler interface { } const ( - // ApiBillingManagerPrefix base URI prefix for all billing manager v1 routes - ApiV1BillingManagerPrefix = "/api/v1/bms" + // APIBillingManagerV1Prefix base URI prefix for all billing manager v1 routes + APIBillingManagerV1Prefix = "/api/v1/bms" ) // AttachRoutesRequest holds everything needed to attach billingmanager @@ -42,16 +42,16 @@ type AttachRoutesRequest struct { func AttachRoutes(request *AttachRoutesRequest) { httpRouter := request.Router.GetRouter() - billingmanagerOpenRoutes := httpRouter.PathPrefix(ApiV1BillingManagerPrefix).Subrouter() + billingmanagerOpenRoutes := httpRouter.PathPrefix(APIBillingManagerV1Prefix).Subrouter() billingmanagerOpenRoutes.HandleFunc("/billings/{providerName}/webhooks", request.Handler.ProcessBillingProviderWebhooks).Methods(http.MethodPost, http.MethodOptions) - billingmanagerActiveOnlyRoutes := httpRouter.PathPrefix(ApiV1BillingManagerPrefix).Subrouter() + billingmanagerActiveOnlyRoutes := httpRouter.PathPrefix(APIBillingManagerV1Prefix).Subrouter() billingmanagerActiveOnlyRoutes.HandleFunc("/billings/users/{userId}/events", request.Handler.GetUserBillingEvents).Methods(http.MethodGet, http.MethodOptions) billingmanagerActiveOnlyRoutes.HandleFunc("/users/{userId}/details/subscription", request.Handler.GetUserSubscriptionStatus).Methods(http.MethodGet, http.MethodOptions) billingmanagerActiveOnlyRoutes.HandleFunc("/users/{userId}/details/billing", request.Handler.GetUserBillingDetail).Methods(http.MethodGet, http.MethodOptions) billingmanagerActiveOnlyRoutes.Use(request.MiddlewareActiveValidApiTokenOrJWTMiddleware) - billingmanagerAdminRoutes := httpRouter.PathPrefix(ApiV1BillingManagerPrefix + "/admin").Subrouter() + billingmanagerAdminRoutes := httpRouter.PathPrefix(APIBillingManagerV1Prefix + "/admin").Subrouter() billingmanagerAdminRoutes.HandleFunc("/billings/users/{userId}/events", request.Handler.GetUserBillingEvents).Methods(http.MethodGet, http.MethodOptions) billingmanagerAdminRoutes.HandleFunc("/users/{userId}/details/subscription", request.Handler.GetUserSubscriptionStatus).Methods(http.MethodGet, http.MethodOptions) billingmanagerAdminRoutes.HandleFunc("/users/{userId}/details/billing", request.Handler.GetUserBillingDetail).Methods(http.MethodGet, http.MethodOptions) diff --git a/external/contacter/const.go b/external/contacter/const.go index 88b8db1..b6cd0c5 100644 --- a/external/contacter/const.go +++ b/external/contacter/const.go @@ -5,4 +5,10 @@ const ( // ErrKeyInvalidCommsPayload is the error key for when the comms payload is invalid ErrKeyInvalidCommsPayload = "InvalidCommsPayload" + + // ErrKeyFullNameRequired is the error key for when full name is required but not provided + ErrKeyFullNameRequired = "ContacterFullNameRequired" + + // ErrKeyEmailRequired is the error key for when email is required but not provided + ErrKeyEmailRequired = "ContacterEmailRequired" ) diff --git a/external/contacter/errormap.go b/external/contacter/errormap.go index d28ef12..de6bbdf 100644 --- a/external/contacter/errormap.go +++ b/external/contacter/errormap.go @@ -5,5 +5,7 @@ import "github.com/ooaklee/reply" // ContacterErrorMap holds Error keys, their corresponding human-friendly message, and response status code // nolint will be used later var ContacterErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ - ErrKeyInvalidCommsPayload: {Title: "Bad Request", Detail: "Invalid comms payload", StatusCode: 400, Code: "CT00-01"}, + ErrKeyInvalidCommsPayload: {Title: "Bad Request", Detail: "Invalid communication payload provided", StatusCode: 400, Code: "CT00-01"}, + ErrKeyFullNameRequired: {Title: "Bad Request", Detail: "Full name is required when user ID is not provided", StatusCode: 400, Code: "CT00-02"}, + ErrKeyEmailRequired: {Title: "Bad Request", Detail: "Email is required when user ID is not provided", StatusCode: 400, Code: "CT00-03"}, } diff --git a/external/contacter/request.go b/external/contacter/request.go index 84759e7..0b3ab39 100644 --- a/external/contacter/request.go +++ b/external/contacter/request.go @@ -126,13 +126,15 @@ type CreateCommsRequest struct { UserId string // FullName is the full name of the person who made the comms - FullName string `json:"full_name" validate:"required"` + // Required if UserId is not provided + FullName string `json:"full_name"` // Email is the email of the person who made the comms - Email string `json:"email" validate:"required,email"` + // Required if UserId is not provided + Email string `json:"email"` // Type is the type of the comms - Type CommsType `json:"type" validate:"required"` + Type CommsType `json:"type"` // Message is the body of the comms Message string `json:"message"` diff --git a/external/contacter/service.go b/external/contacter/service.go index a16c425..ad6c266 100644 --- a/external/contacter/service.go +++ b/external/contacter/service.go @@ -2,6 +2,7 @@ package contacter import ( "context" + "errors" "github.com/ooaklee/ghatd/external/logger" "github.com/ooaklee/ghatd/external/toolbox" @@ -47,6 +48,20 @@ func (s *Service) CreateComms(ctx context.Context, req *CreateCommsRequest) (*Cr newComms = newComms.SetCommsType(string(req.Type)).SetStandardisedEmail(req.Email).SetStandardisedFullName(req.FullName) + // If UserId is not provided, FullName and Email are required + if req.UserId == "" { + var err error = nil + if req.FullName == "" { + err = errors.Join(err, errors.New(ErrKeyFullNameRequired)) + } + if req.Email == "" { + err = errors.Join(err, errors.New(ErrKeyEmailRequired)) + } + if err != nil { + return nil, err + } + } + if req.UserId != "" { newComms.UserLoggedIn = true } diff --git a/external/emailtemplater/errormap.go b/external/emailtemplater/errormap.go index 3530eec..75f9b0d 100644 --- a/external/emailtemplater/errormap.go +++ b/external/emailtemplater/errormap.go @@ -5,17 +5,18 @@ import "github.com/ooaklee/reply" // EmailTemplaterErrorMap holds Error keys, their corresponding human-friendly message, and response status code // nolint will be used later var EmailTemplaterErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ - ErrKeyEmailTemplaterNoConfigProvided: {Title: "Internal Server Error", Detail: "No configuration provided for email templater", StatusCode: 500, Code: "ET0-001"}, - ErrKeyEmailTemplaterTemplateNotFound: {Title: "Internal Server Error", Detail: "Email template not found", StatusCode: 500, Code: "ET0-002"}, + ErrKeyEmailTemplaterNoConfigProvided: {Title: "Internal Server Error", Detail: "Unable to process email request", StatusCode: 500, Code: "ET0-001"}, + ErrKeyEmailTemplaterTemplateNotFound: {Title: "Internal Server Error", Detail: "Unable to process email request", StatusCode: 500, Code: "ET0-002"}, ErrKeyEmailTemplaterDynamicTemplateNotFound: { Title: "Internal Server Error", - Detail: "Dynamic email template function not found", + Detail: "Unable to process email request", StatusCode: 500, Code: "ET0-003", }, - ErrKeyEmailTemplaterMissingRecipient: {Title: "Bad Request", Detail: "Recipient email is required", StatusCode: 400, Code: "ET0-004"}, - ErrKeyEmailTemplaterMissingSubject: {Title: "Bad Request", Detail: "Email subject is required", StatusCode: 400, Code: "ET0-005"}, - ErrKeyEmailTemplaterMissingBody: {Title: "Bad Request", Detail: "Email body is required", StatusCode: 400, Code: "ET0-006"}, - ErrKeyEmailTemplaterMissingToken: {Title: "Bad Request", Detail: "Authentication token is required", StatusCode: 400, Code: "ET0-007"}, - ErrKeyEmailTemplaterMissingPersonalInfo: {Title: "Bad Request", Detail: "First name and last name are required", StatusCode: 400, Code: "ET0-008"}, + ErrKeyEmailTemplaterMissingRecipient: {Title: "Bad Request", Detail: "Recipient email is required", StatusCode: 400, Code: "ET0-004"}, + ErrKeyEmailTemplaterMissingSubject: {Title: "Bad Request", Detail: "Email subject is required", StatusCode: 400, Code: "ET0-005"}, + ErrKeyEmailTemplaterMissingBody: {Title: "Bad Request", Detail: "Email body is required", StatusCode: 400, Code: "ET0-006"}, + ErrKeyEmailTemplaterMissingToken: {Title: "Bad Request", Detail: "Authentication token is required", StatusCode: 400, Code: "ET0-007"}, + ErrKeyEmailTemplaterMissingPersonalInfo: {Title: "Bad Request", Detail: "First name and last name are required", StatusCode: 400, Code: "ET0-008"}, + ErrKeyEmailTemplaterTemplateRenderingFailed: {Title: "Internal Server Error", Detail: "Unable to process email request", StatusCode: 500, Code: "ET0-009"}, } diff --git a/external/ephemeral/errormap.go b/external/ephemeral/errormap.go index 227db04..86ea851 100644 --- a/external/ephemeral/errormap.go +++ b/external/ephemeral/errormap.go @@ -8,5 +8,5 @@ import ( // TODO: remove nolint // nolint will be used later var EphemeralStoreErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ - ErrKeyRequestorLimitExceeded: {Title: "Rate Limited", Detail: "You have used up allocated requests allowance; please try again later or verify you have authenticated yourself.", StatusCode: 429}, + ErrKeyRequestorLimitExceeded: {Title: "Rate Limited", Detail: "You have used up allocated requests allowance; please try again later or verify you have authenticated yourself.", StatusCode: 429, Code: "EPH0-001"}, } diff --git a/external/group/config.go b/external/group/config.go new file mode 100644 index 0000000..60a46ba --- /dev/null +++ b/external/group/config.go @@ -0,0 +1,105 @@ +package group + +// DefaultGroupConfig returns a default configuration +func DefaultGroupConfig() *GroupConfig { + return &GroupConfig{ + DefaultStatus: GroupStatusActive, + StatusTransitions: map[string][]string{ + GroupStatusActive: { + GroupStatusProvisioned, + GroupStatusInactive, + }, + GroupStatusInactive: { + GroupStatusActive, + GroupStatusArchived, + }, + GroupStatusArchived: { + GroupStatusInactive, + GroupStatusActive, + }, + GroupStatusSuspended: { + GroupStatusActive, + GroupStatusInactive, + }, + }, + RequiredFields: []string{"name", "type"}, + ValidTypes: []string{ + GroupTypeTeam, + GroupTypeTribe, + GroupTypeSquad, + GroupTypeDepartment, + GroupTypeOrganisation, + GroupTypeCompany, + GroupTypeProject, + GroupTypeCommunity, + GroupTypeFamily, + GroupTypeFriends, + GroupTypeCustom, + }, + ValidMemberTypes: []string{ + MemberTypeUser, + MemberTypeGroup, + }, + AllowNestedGroups: true, + MaxNestingDepth: 5, + MultipleIdentifiers: true, + } +} + +// NewCustomGroupConfig creates a custom group config builder +func NewCustomGroupConfig() *GroupConfig { + return &GroupConfig{ + StatusTransitions: make(map[string][]string), + RequiredFields: []string{}, + ValidTypes: []string{}, + ValidMemberTypes: []string{}, + } +} + +// WithDefaultStatus sets the default status +func (c *GroupConfig) WithDefaultStatus(status string) *GroupConfig { + c.DefaultStatus = status + return c +} + +// WithStatusTransition adds a status transition +func (c *GroupConfig) WithStatusTransition(toStatus string, fromStatuses []string) *GroupConfig { + c.StatusTransitions[toStatus] = fromStatuses + return c +} + +// WithRequiredFields sets required fields +func (c *GroupConfig) WithRequiredFields(fields ...string) *GroupConfig { + c.RequiredFields = append(c.RequiredFields, fields...) + return c +} + +// WithValidTypes sets valid group types +func (c *GroupConfig) WithValidTypes(types ...string) *GroupConfig { + c.ValidTypes = append(c.ValidTypes, types...) + return c +} + +// WithValidMemberTypes sets valid member types +func (c *GroupConfig) WithValidMemberTypes(types ...string) *GroupConfig { + c.ValidMemberTypes = append(c.ValidMemberTypes, types...) + return c +} + +// WithNestedGroups enables/disables nested groups +func (c *GroupConfig) WithNestedGroups(allow bool) *GroupConfig { + c.AllowNestedGroups = allow + return c +} + +// WithMaxNestingDepth sets maximum nesting depth +func (c *GroupConfig) WithMaxNestingDepth(depth int) *GroupConfig { + c.MaxNestingDepth = depth + return c +} + +// WithMultipleIdentifiers enables/disables multiple identifier types +func (c *GroupConfig) WithMultipleIdentifiers(allow bool) *GroupConfig { + c.MultipleIdentifiers = allow + return c +} diff --git a/external/group/const.go b/external/group/const.go new file mode 100644 index 0000000..9f3d21d --- /dev/null +++ b/external/group/const.go @@ -0,0 +1,82 @@ +package group + +const ( + // Group Type Keys + GroupTypeTeam = "TEAM" + GroupTypeTribe = "TRIBE" + GroupTypeSquad = "SQUAD" + GroupTypeDepartment = "DEPARTMENT" + GroupTypeOrganisation = "ORGANISATION" + GroupTypeCompany = "COMPANY" + GroupTypeProject = "PROJECT" + GroupTypeCommunity = "COMMUNITY" + GroupTypeFamily = "FAMILY" + GroupTypeFriends = "FRIENDS" + GroupTypeCustom = "CUSTOM" + + // Member Type Keys + MemberTypeUser = "USER" + MemberTypeGroup = "GROUP" + + // Group Status Keys + GroupStatusActive = "ACTIVE" + GroupStatusInactive = "INACTIVE" + GroupStatusArchived = "ARCHIVED" + GroupStatusSuspended = "SUSPENDED" + GroupStatusProvisioned = "PROVISIONED" + + // Membership Role Keys + MemberRoleOwner = "OWNER" + MemberRoleAdmin = "ADMIN" + MemberRoleMember = "MEMBER" + MemberRoleModerator = "MODERATOR" + MemberRoleGuest = "GUEST" + MemberRoleHead = "HEAD" + MemberRoleLead = "LEAD" + MemberRoleCoordinator = "COORDINATOR" + + // Visibility Keys + VisibilityPublic = "PUBLIC" + VisibilityPrivate = "PRIVATE" + VisibilityInternal = "INTERNAL" +) + +const ( + // Error Keys + ErrKeyGroupConfigNotSet = "GroupConfigNotSet" + ErrKeyInvalidGroupType = "InvalidGroupType" + ErrKeyInvalidGroupStatus = "InvalidGroupStatus" + ErrKeyInvalidStatusTransition = "InvalidStatusTransition" + ErrKeyRequiredFieldMissingName = "RequiredFieldMissingName" + ErrKeyRequiredFieldMissingType = "RequiredFieldMissingType" + ErrKeyValidationFailed = "GroupValidationFailed" + ErrKeyResourceNotFound = "GroupResourceNotFound" + ErrKeyResourceConflict = "GroupResourceConflict" + ErrKeyDatabaseError = "GroupDatabaseError" + ErrKeyInvalidQueryParam = "GroupInvalidQueryParam" + ErrKeyNoChangesDetected = "GroupNoChangesDetected" + ErrKeyInvalidMemberType = "InvalidMemberType" + ErrKeyMemberNotFound = "MemberNotFound" + ErrKeyMemberAlreadyExists = "MemberAlreadyExists" + ErrKeyInsufficientPermissions = "InsufficientPermissions" + ErrKeyCircularReferenceDetected = "CircularReferenceDetected" + ErrKeyMaxDepthExceeded = "MaxDepthExceeded" + ErrKeyInvalidNanoID = "GroupInvalidNanoID" + ErrKeyNameAlreadyExists = "GroupNameAlreadyExists" + ErrKeyUnableToFindGroupWithName = "UnableToFindGroupWithGivenName" + ErrKeyInvalidGroupID = "GroupInvalidID" + ErrKeyInvalidGroupBody = "GroupInvalidBody" + ErrKeyInvalidMemberID = "GroupInvalidMemberID" +) + +const ( + // Group Order Constants + GetGroupOrderCreatedAtDesc = "created_at_desc" + GetGroupOrderCreatedAtAsc = "created_at_asc" + GetGroupOrderUpdatedAtDesc = "updated_at_desc" + GetGroupOrderUpdatedAtAsc = "updated_at_asc" + GetGroupOrderNameAsc = "name_asc" + GetGroupOrderNameDesc = "name_desc" + GetGroupOrderMemberCountDesc = "member_count_desc" + GetGroupOrderMemberCountAsc = "member_count_asc" +) diff --git a/external/group/errormap.go b/external/group/errormap.go new file mode 100644 index 0000000..a854c49 --- /dev/null +++ b/external/group/errormap.go @@ -0,0 +1,144 @@ +package group + +import ( + "net/http" + + "github.com/ooaklee/reply" +) + +// GroupErrorResponseMap holds Error keys, their corresponding human-friendly message, and response status code +// nolint will be used later +var GroupErrorResponseMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ + ErrKeyGroupConfigNotSet: { + StatusCode: http.StatusInternalServerError, + Code: "GRP0-001", + Detail: "An error occurred while processing your request", + }, + + // Validation errors + ErrKeyInvalidGroupType: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-002", + Detail: "The specified group type is not supported", + }, + ErrKeyInvalidGroupStatus: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-003", + Detail: "The specified group status is not valid", + }, + ErrKeyInvalidStatusTransition: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-004", + Detail: "The requested status change is not allowed", + }, + ErrKeyRequiredFieldMissingName: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-005", + Detail: "Please provide a name for the group", + }, + ErrKeyRequiredFieldMissingType: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-006", + Detail: "Please specify a type for the group", + }, + ErrKeyValidationFailed: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-007", + Detail: "The provided group data failed validation", + }, + ErrKeyInvalidNanoID: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-008", + Detail: "The provided ID format is invalid", + }, + ErrKeyInvalidGroupID: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-009", + Detail: "The provided group ID is invalid", + }, + ErrKeyInvalidGroupBody: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-010", + Detail: "The request body contains invalid data", + }, + + // Resource errors + ErrKeyResourceNotFound: { + StatusCode: http.StatusNotFound, + Code: "GRP0-011", + Detail: "The requested group could not be found", + }, + ErrKeyResourceConflict: { + StatusCode: http.StatusConflict, + Code: "GRP0-012", + Detail: "A group with this identifier already exists", + }, + ErrKeyNameAlreadyExists: { + StatusCode: http.StatusConflict, + Code: "GRP0-013", + Detail: "A group with this name is already registered", + }, + ErrKeyUnableToFindGroupWithName: { + StatusCode: http.StatusNotFound, + Code: "GRP0-014", + Detail: "No group found matching the provided name", + }, + + // Member errors + ErrKeyInvalidMemberType: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-015", + Detail: "The specified member type is not valid", + }, + ErrKeyMemberNotFound: { + StatusCode: http.StatusNotFound, + Code: "GRP0-016", + Detail: "The specified member is not part of this group", + }, + ErrKeyMemberAlreadyExists: { + StatusCode: http.StatusConflict, + Code: "GRP0-017", + Detail: "This member is already part of the group", + }, + ErrKeyInsufficientPermissions: { + StatusCode: http.StatusForbidden, + Code: "GRP0-018", + Detail: "You do not have permission to perform this action", + }, + ErrKeyInvalidMemberID: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-019", + Detail: "The provided member ID is invalid", + }, + + // Hierarchy errors + ErrKeyCircularReferenceDetected: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-020", + Detail: "The operation would create a circular reference in the group structure", + }, + ErrKeyMaxDepthExceeded: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-021", + Detail: "The maximum allowed nesting depth has been exceeded", + }, + + // Database errors + ErrKeyDatabaseError: { + StatusCode: http.StatusInternalServerError, + Code: "GRP0-022", + Detail: "Unable to complete the operation at this time", + }, + ErrKeyNoChangesDetected: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-023", + Detail: "No modifications were detected in the request", + }, + + // Query errors + ErrKeyInvalidQueryParam: { + StatusCode: http.StatusBadRequest, + Code: "GRP0-024", + Detail: "One or more query parameters are invalid", + }, +} diff --git a/external/group/examples/basic_usage.go b/external/group/examples/basic_usage.go new file mode 100644 index 0000000..ddc07a3 --- /dev/null +++ b/external/group/examples/basic_usage.go @@ -0,0 +1,285 @@ +package main + +import ( + "fmt" + "log" + + group "github.com/ooaklee/ghatd/external/group" +) + +// Example 1: Creating a Simple Team +func CreateSimpleTeam() { + // Set up dependencies + config := group.DefaultGroupConfig() + idGen := group.NewDefaultIDGenerator() + timeProvider := group.NewDefaultTimeProvider() + stringUtils := group.NewDefaultStringUtils() + + // Create a new team + team := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + team.Name = "Frontend Engineering Team" + team.Type = group.GroupTypeTeam + team.DisplayInfo.Description = "Responsible for all user-facing web applications" + team.DisplayInfo.Email = "frontend@company.com" + team.DisplayInfo.Icon = "🎨" + + // Set initial state (generates IDs, timestamps, default status) + team.SetInitialState() + + // Add members + team.AddMember("user-alice", group.MemberTypeUser, group.MemberRoleHead) + team.AddMember("user-bob", group.MemberTypeUser, group.MemberRoleAdmin) + team.AddMember("user-charlie", group.MemberTypeUser, group.MemberRoleMember) + team.AddMember("user-diana", group.MemberTypeUser, group.MemberRoleMember) + + // Set leadership + team.Leadership.HeadID = "user-alice" + team.Leadership.AdminIDs = []string{"user-bob"} + + // Add custom extensions + team.SetExtension("github_team", "frontend-eng") + team.SetExtension("jira_project", "FE") + team.SetExtension("cost_center", "ENG-001") + + // Validate + if err := team.Validate(); err != nil { + log.Fatalf("Validation failed: %v", err) + } + + fmt.Printf("Created team: %s (ID: %s)\n", team.Name, team.ID) + fmt.Printf("Members: %d users\n", len(team.GetUserMemberIDs())) + fmt.Printf("Status: %s\n", team.Status) +} + +// Example 2: Creating a Hierarchical Organization +func CreateHierarchicalOrganization() { + config := group.DefaultGroupConfig() + idGen := group.NewDefaultIDGenerator() + timeProvider := group.NewDefaultTimeProvider() + stringUtils := group.NewDefaultStringUtils() + + // Create company (top level) + company := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + company.Name = "Acme Corporation" + company.Type = group.GroupTypeCompany + company.DisplayInfo.Description = "A leading technology company" + company.DisplayInfo.Website = "https://acme.com" + company.SetInitialState() + + // Create engineering department + engineering := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + engineering.Name = "Engineering" + engineering.Type = group.GroupTypeDepartment + engineering.DisplayInfo.Description = "Engineering department" + engineering.SetInitialState() + + // Create teams under engineering + frontendTeam := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + frontendTeam.Name = "Frontend Team" + frontendTeam.Type = group.GroupTypeTeam + frontendTeam.SetInitialState() + + backendTeam := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + backendTeam.Name = "Backend Team" + backendTeam.Type = group.GroupTypeTeam + backendTeam.SetInitialState() + + // Add users to teams + frontendTeam.AddMember("user-1", group.MemberTypeUser, group.MemberRoleMember) + frontendTeam.AddMember("user-2", group.MemberTypeUser, group.MemberRoleMember) + backendTeam.AddMember("user-3", group.MemberTypeUser, group.MemberRoleMember) + backendTeam.AddMember("user-4", group.MemberTypeUser, group.MemberRoleMember) + + // Build hierarchy: Company -> Engineering -> Teams + company.AddMember(engineering.ID, group.MemberTypeGroup, group.MemberRoleMember) + engineering.AddMember(frontendTeam.ID, group.MemberTypeGroup, group.MemberRoleMember) + engineering.AddMember(backendTeam.ID, group.MemberTypeGroup, group.MemberRoleMember) + + fmt.Printf("Created organization hierarchy:\n") + fmt.Printf(" %s\n", company.Name) + fmt.Printf(" └─ %s\n", engineering.Name) + fmt.Printf(" ├─ %s (%d members)\n", frontendTeam.Name, frontendTeam.GetMemberCount()) + fmt.Printf(" └─ %s (%d members)\n", backendTeam.Name, backendTeam.GetMemberCount()) +} + +// Example 3: Managing Group Status +func ManageGroupStatus() { + config := group.DefaultGroupConfig() + idGen := group.NewDefaultIDGenerator() + timeProvider := group.NewDefaultTimeProvider() + stringUtils := group.NewDefaultStringUtils() + + uniGroup := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + uniGroup.Name = "Project Alpha" + uniGroup.Type = group.GroupTypeProject + uniGroup.SetInitialState() + + fmt.Printf("Initial status: %s\n", uniGroup.Status) + + // Update status to inactive + if _, err := uniGroup.UpdateStatus(group.GroupStatusInactive); err != nil { + log.Printf("Failed to set inactive: %v", err) + } else { + fmt.Printf("Status after inactive: %s\n", uniGroup.Status) + } + + // Archive the group + if _, err := uniGroup.UpdateStatus(group.GroupStatusArchived); err != nil { + log.Printf("Failed to archive: %v", err) + } else { + fmt.Printf("Status after archive: %s\n", uniGroup.Status) + fmt.Printf("Archived at: %s\n", uniGroup.Metadata.ArchivedAt) + } + + // Try invalid transition (should fail) + if _, err := uniGroup.UpdateStatus(group.GroupStatusProvisioned); err != nil { + fmt.Printf("Invalid transition blocked: %v\n", err) + } +} + +// Example 4: Custom Group Type (Family Group) +func CreateFamilyGroup() { + // Custom config for family groups + config := group.NewCustomGroupConfig(). + WithDefaultStatus(group.GroupStatusActive). + WithValidTypes(group.GroupTypeFamily). + WithValidMemberTypes(group.MemberTypeUser). // Only users, no nested groups + WithNestedGroups(false). + WithRequiredFields("name") + + idGen := group.NewDefaultIDGenerator() + timeProvider := group.NewDefaultTimeProvider() + stringUtils := group.NewDefaultStringUtils() + + family := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + family.Name = "The Smith Family" + family.Type = group.GroupTypeFamily + family.DisplayInfo.Description = "Our wonderful family" + family.SetInitialState() + + // Add family members + family.AddMember("user-dad", group.MemberTypeUser, "PARENT") + family.AddMember("user-mom", group.MemberTypeUser, "PARENT") + family.AddMember("user-son", group.MemberTypeUser, "CHILD") + family.AddMember("user-daughter", group.MemberTypeUser, "CHILD") + + // Add custom extensions for family-specific data + family.SetExtension("home_address", "123 Main St") + family.SetExtension("family_motto", "Together we are stronger") + family.SetExtension("established_year", 2010) + + fmt.Printf("Created family: %s with %d members\n", family.Name, family.GetMemberCount()) +} + +// Example 5: Using Extensions for Integration +func IntegrateWithSlack() { + config := group.DefaultGroupConfig() + idGen := group.NewDefaultIDGenerator() + timeProvider := group.NewDefaultTimeProvider() + stringUtils := group.NewDefaultStringUtils() + + team := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + team.Name = "DevOps Team" + team.Type = group.GroupTypeTeam + team.SetInitialState() + + // Set Slack integration + team.Integrations.Slack = &group.SlackIntegration{ + DisplayID: "SLACK-CHANNEL-ID-123", + OnDutyDisplayID: "SLACK-USER-ONCALL", + OnDutyDisplayName: "John Doe", + Emoji: ":rocket:", + AdditionalRecipients: []string{ + "SLACK-USER-1", + "SLACK-USER-2", + }, + } + + // Add other custom integrations + if team.Integrations.Custom == nil { + team.Integrations.Custom = make(map[string]interface{}) + } + team.Integrations.Custom["pagerduty"] = map[string]interface{}{ + "service_id": "PD-SERVICE-123", + "escalation_policy": "POL-456", + } + + team.SetExtension("monitoring_dashboard", "https://grafana.company.com/d/team-devops") + + fmt.Printf("Team %s integrated with:\n", team.Name) + fmt.Printf(" - Slack: %s\n", team.Integrations.Slack.DisplayID) + fmt.Printf(" - PagerDuty: %v\n", team.Integrations.Custom["pagerduty"]) +} + +// Example 6: Member Management +func ManageMembers() { + var err error + + config := group.DefaultGroupConfig() + idGen := group.NewDefaultIDGenerator() + timeProvider := group.NewDefaultTimeProvider() + stringUtils := group.NewDefaultStringUtils() + + team := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + team.Name = "Backend Team" + team.Type = group.GroupTypeTeam + team.SetInitialState() + + // Add members + team.AddMember("user-1", group.MemberTypeUser, group.MemberRoleMember) + team.AddMember("user-2", group.MemberTypeUser, group.MemberRoleMember) + team.AddMember("user-3", group.MemberTypeUser, group.MemberRoleMember) + + fmt.Printf("Total members: %d\n", team.GetMemberCount()) + fmt.Printf("User members: %v\n", team.GetUserMemberIDs()) + + // Update member role + if team, err = team.UpdateMemberRole("user-2", group.MemberRoleAdmin); err != nil { + log.Printf("Failed to update role: %v", err) + } else { + fmt.Println("Updated user-2 to admin role") + } + + // Check if member exists + if team.HasMember("user-1") { + fmt.Println("user-1 is a member") + } + + // Get specific member + if member, err := team.GetMemberByID("user-1"); err == nil { + fmt.Printf("Member details: ID=%s, Type=%s, Role=%s\n", + member.ID, member.Type, member.Role) + } + + // Remove member + if team, err = team.RemoveMember("user-3"); err != nil { + log.Printf("Failed to remove member: %v", err) + } else { + fmt.Printf("Removed user-3. New count: %d\n", team.GetMemberCount()) + } +} + +func main() { + fmt.Println("=== Example 1: Simple Team ===") + CreateSimpleTeam() + fmt.Println() + + fmt.Println("=== Example 2: Hierarchical Organization ===") + CreateHierarchicalOrganization() + fmt.Println() + + fmt.Println("=== Example 3: Status Management ===") + ManageGroupStatus() + fmt.Println() + + fmt.Println("=== Example 4: Family Group ===") + CreateFamilyGroup() + fmt.Println() + + fmt.Println("=== Example 5: Slack Integration ===") + IntegrateWithSlack() + fmt.Println() + + fmt.Println("=== Example 6: Member Management ===") + ManageMembers() +} diff --git a/external/group/fender.go b/external/group/fender.go new file mode 100644 index 0000000..ad32094 --- /dev/null +++ b/external/group/fender.go @@ -0,0 +1,338 @@ +package group + +import ( + "errors" + "net/http" + + "github.com/ooaklee/ghatd/external/toolbox" + "github.com/ritwickdey/querydecoder" +) + +// MapRequestToCreateGroupRequest maps incoming CreateGroup request to correct struct +func MapRequestToCreateGroupRequest(request *http.Request, validator GroupValidator) (*CreateGroupRequest, error) { + parsedRequest := &CreateGroupRequest{} + + err := toolbox.DecodeRequestBody(request, parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupBody) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyValidationFailed) + } + + return parsedRequest, nil +} + +// MapRequestToGetGroupByIDRequest maps incoming GetGroupByID request to correct struct +func MapRequestToGetGroupByIDRequest(request *http.Request, validator GroupValidator) (*GetGroupByIDRequest, error) { + var err error + parsedRequest := &GetGroupByIDRequest{} + + // get group id from uri + parsedRequest.ID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + return parsedRequest, nil +} + +// MapRequestToGetGroupByNanoIDRequest maps incoming GetGroupByNanoID request to correct struct +func MapRequestToGetGroupByNanoIDRequest(request *http.Request, validator GroupValidator) (*GetGroupByNanoIDRequest, error) { + var err error + parsedRequest := &GetGroupByNanoIDRequest{} + + // get nano id from uri + parsedRequest.NanoID, err = toolbox.GetVariableValueFromUri(request, "groupNanoID") + if err != nil { + return nil, errors.New(ErrKeyInvalidNanoID) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidNanoID) + } + + return parsedRequest, nil +} + +// MapRequestToGetGroupByNameRequest maps incoming GetGroupByName request to correct struct +func MapRequestToGetGroupByNameRequest(request *http.Request, validator GroupValidator) (*GetGroupByNameRequest, error) { + parsedRequest := &GetGroupByNameRequest{} + + // get query parameters + query := request.URL.Query() + err := querydecoder.New(query).Decode(parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidQueryParam) + } + + if parsedRequest.Name == "" { + return nil, errors.New(ErrKeyInvalidQueryParam) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidQueryParam) + } + + return parsedRequest, nil +} + +// MapRequestToUpdateGroupRequest maps incoming UpdateGroup request to correct struct +func MapRequestToUpdateGroupRequest(request *http.Request, validator GroupValidator) (*UpdateGroupRequest, error) { + var err error + parsedRequest := &UpdateGroupRequest{} + + // get group id from uri + parsedRequest.ID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + err = toolbox.DecodeRequestBody(request, parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupBody) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyValidationFailed) + } + + return parsedRequest, nil +} + +// MapRequestToDeleteGroupRequest maps incoming DeleteGroup request to correct struct +func MapRequestToDeleteGroupRequest(request *http.Request, validator GroupValidator) (*DeleteGroupRequest, error) { + var err error + parsedRequest := &DeleteGroupRequest{} + + // get group id from uri + parsedRequest.ID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + // get query parameters + query := request.URL.Query() + err = querydecoder.New(query).Decode(parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidQueryParam) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + return parsedRequest, nil +} + +// MapRequestToGetGroupsRequest maps incoming GetGroups request to correct struct +func MapRequestToGetGroupsRequest(request *http.Request, validator GroupValidator) (*GetGroupsRequest, error) { + var err error + parsedRequest := &GetGroupsRequest{} + + // get request queries + query := request.URL.Query() + err = querydecoder.New(query).Decode(parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidQueryParam) + } + + err = validator.Validate(parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidQueryParam) + } + + return parsedRequest, nil +} + +// MapRequestToAddMemberRequest maps incoming AddMember request to correct struct +func MapRequestToAddMemberRequest(request *http.Request, validator GroupValidator) (*AddMemberRequest, error) { + var err error + parsedRequest := &AddMemberRequest{} + + // get group id from uri + parsedRequest.GroupID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + err = toolbox.DecodeRequestBody(request, parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupBody) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyValidationFailed) + } + + return parsedRequest, nil +} + +// MapRequestToRemoveMemberRequest maps incoming RemoveMember request to correct struct +func MapRequestToRemoveMemberRequest(request *http.Request, validator GroupValidator) (*RemoveMemberRequest, error) { + var err error + parsedRequest := &RemoveMemberRequest{} + + // get group id from uri + parsedRequest.GroupID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + // get member id from uri + parsedRequest.MemberID, err = toolbox.GetVariableValueFromUri(request, "memberID") + if err != nil { + return nil, errors.New(ErrKeyInvalidMemberID) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidMemberID) + } + + return parsedRequest, nil +} + +// MapRequestToUpdateMemberRoleRequest maps incoming UpdateMemberRole request to correct struct +func MapRequestToUpdateMemberRoleRequest(request *http.Request, validator GroupValidator) (*UpdateMemberRoleRequest, error) { + var err error + parsedRequest := &UpdateMemberRoleRequest{} + + // get group id from uri + parsedRequest.GroupID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + // get member id from uri + parsedRequest.MemberID, err = toolbox.GetVariableValueFromUri(request, "memberID") + if err != nil { + return nil, errors.New(ErrKeyInvalidMemberID) + } + + err = toolbox.DecodeRequestBody(request, parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupBody) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyValidationFailed) + } + + return parsedRequest, nil +} + +// MapRequestToGetGroupMembersRequest maps incoming GetGroupMembers request to correct struct +func MapRequestToGetGroupMembersRequest(request *http.Request, validator GroupValidator) (*GetGroupMembersRequest, error) { + var err error + parsedRequest := &GetGroupMembersRequest{} + + // get group id from uri + parsedRequest.GroupID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + // get query parameters + query := request.URL.Query() + err = querydecoder.New(query).Decode(parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidQueryParam) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidQueryParam) + } + + return parsedRequest, nil +} + +// MapRequestToUpdateLeadershipRequest maps incoming UpdateLeadership request to correct struct +func MapRequestToUpdateLeadershipRequest(request *http.Request, validator GroupValidator) (*UpdateLeadershipRequest, error) { + var err error + parsedRequest := &UpdateLeadershipRequest{} + + // get group id from uri + parsedRequest.GroupID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + err = toolbox.DecodeRequestBody(request, parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupBody) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyValidationFailed) + } + + return parsedRequest, nil +} + +// MapRequestToArchiveGroupRequest maps incoming ArchiveGroup request to correct struct +func MapRequestToArchiveGroupRequest(request *http.Request, validator GroupValidator) (*ArchiveGroupRequest, error) { + var err error + parsedRequest := &ArchiveGroupRequest{} + + // get group id from uri + parsedRequest.ID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + return parsedRequest, nil +} + +// MapRequestToRestoreGroupRequest maps incoming RestoreGroup request to correct struct +func MapRequestToRestoreGroupRequest(request *http.Request, validator GroupValidator) (*RestoreGroupRequest, error) { + var err error + parsedRequest := &RestoreGroupRequest{} + + // get group id from uri + parsedRequest.ID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + return parsedRequest, nil +} + +// MapRequestToGetGroupStatsRequest maps incoming GetGroupStats request to correct struct +func MapRequestToGetGroupStatsRequest(request *http.Request, validator GroupValidator) (*GetGroupStatsRequest, error) { + var err error + parsedRequest := &GetGroupStatsRequest{} + + // get group id from uri + parsedRequest.ID, err = toolbox.GetVariableValueFromUri(request, "groupID") + if err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyInvalidGroupID) + } + + return parsedRequest, nil +} + +// validateParsedRequest validates the parsed request using the validator +func validateParsedRequest(request interface{}, validator GroupValidator) error { + if validator == nil { + return nil + } + + return validator.Validate(request) +} diff --git a/external/group/handler.go b/external/group/handler.go new file mode 100644 index 0000000..051b459 --- /dev/null +++ b/external/group/handler.go @@ -0,0 +1,386 @@ +package group + +import ( + "context" + "net/http" + + "github.com/ooaklee/reply" +) + +// GroupService interface defines expected methods of a valid group service +type GroupService interface { + CreateGroup(ctx context.Context, r *CreateGroupRequest) (*CreateGroupResponse, error) + GetGroupByID(ctx context.Context, r *GetGroupByIDRequest) (*GetGroupByIDResponse, error) + GetGroupByNanoID(ctx context.Context, r *GetGroupByNanoIDRequest) (*GetGroupByNanoIDResponse, error) + GetGroupByName(ctx context.Context, r *GetGroupByNameRequest) (*GetGroupByNameResponse, error) + UpdateGroup(ctx context.Context, r *UpdateGroupRequest) (*UpdateGroupResponse, error) + DeleteGroup(ctx context.Context, r *DeleteGroupRequest) error + GetGroups(ctx context.Context, r *GetGroupsRequest) (*GetGroupsResponse, error) + GetGroupsByMemberID(ctx context.Context, r *GetGroupsRequest) (*GetGroupsResponse, error) + GetGroupsByLeaderID(ctx context.Context, r *GetGroupsRequest) (*GetGroupsResponse, error) + SearchGroupsByExtension(ctx context.Context, r *GetGroupsRequest) (*GetGroupsResponse, error) + AddMember(ctx context.Context, r *AddMemberRequest) (*AddMemberResponse, error) + RemoveMember(ctx context.Context, r *RemoveMemberRequest) (*RemoveMemberResponse, error) + UpdateMemberRole(ctx context.Context, r *UpdateMemberRoleRequest) (*UpdateMemberRoleResponse, error) + GetGroupMembers(ctx context.Context, r *GetGroupMembersRequest) (*GetGroupMembersResponse, error) + UpdateLeadership(ctx context.Context, r *UpdateLeadershipRequest) (*UpdateLeadershipResponse, error) + ArchiveGroup(ctx context.Context, r *ArchiveGroupRequest) (*ArchiveGroupResponse, error) + RestoreGroup(ctx context.Context, r *RestoreGroupRequest) (*RestoreGroupResponse, error) + GetGroupStats(ctx context.Context, groupID string) (*GetGroupStatsResponse, error) +} + +// GroupValidator interface defines expected methods of a valid validator +type GroupValidator interface { + Validate(s interface{}) error +} + +// Handler manages group requests +type Handler struct { + Service GroupService + Validator GroupValidator + ErrorMaps []reply.ErrorManifest +} + +// NewHandler returns a new group handler +func NewHandler(service GroupService, validator GroupValidator, errorMaps ...reply.ErrorManifest) *Handler { + return &Handler{ + Service: service, + Validator: validator, + ErrorMaps: errorMaps, + } +} + +// CreateGroup handles group creation +func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToCreateGroupRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.CreateGroup(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusCreated, response.Group) +} + +// GetGroupByID handles getting a group by ID +func (h *Handler) GetGroupByID(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupByIDRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroupByID(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// GetGroupByNanoID handles getting a group by nano ID +func (h *Handler) GetGroupByNanoID(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupByNanoIDRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroupByNanoID(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// GetGroupByName handles getting a group by name +func (h *Handler) GetGroupByName(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupByNameRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroupByName(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// GetGroups handles getting groups with filters and pagination +func (h *Handler) GetGroups(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupsRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroups(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + // Return with pagination metadata if requested + if request.Meta { + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups, reply.WithMeta(response.GetMetaData())) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups) +} + +// GetGroupsByMemberID handles getting groups by member ID with pagination +func (h *Handler) GetGroupsByMemberID(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupsRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroupsByMemberID(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + // Return with pagination metadata if requested + if request.Meta { + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups, reply.WithMeta(response.GetMetaData())) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups) +} + +// GetGroupsByLeaderID handles getting groups by leader ID with pagination +func (h *Handler) GetGroupsByLeaderID(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupsRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroupsByLeaderID(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + // Return with pagination metadata if requested + if request.Meta { + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups, reply.WithMeta(response.GetMetaData())) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups) +} + +// SearchGroupsByExtension handles searching groups by extension field with pagination +func (h *Handler) SearchGroupsByExtension(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupsRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.SearchGroupsByExtension(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + // Return with pagination metadata if requested + if request.Meta { + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups, reply.WithMeta(response.GetMetaData())) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups) +} + +// UpdateGroup handles group updates +func (h *Handler) UpdateGroup(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToUpdateGroupRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.UpdateGroup(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// DeleteGroup handles group deletion +func (h *Handler) DeleteGroup(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToDeleteGroupRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + err = h.Service.DeleteGroup(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusNoContent, nil) +} + +// AddMember handles adding a member to a group +func (h *Handler) AddMember(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToAddMemberRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.AddMember(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// RemoveMember handles removing a member from a group +func (h *Handler) RemoveMember(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToRemoveMemberRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.RemoveMember(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// UpdateMemberRole handles updating a member's role +func (h *Handler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToUpdateMemberRoleRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.UpdateMemberRole(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// GetGroupMembers handles getting group members +func (h *Handler) GetGroupMembers(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupMembersRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroupMembers(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Members) +} + +// UpdateLeadership handles updating group leadership +func (h *Handler) UpdateLeadership(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToUpdateLeadershipRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.UpdateLeadership(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// ArchiveGroup handles archiving a group +func (h *Handler) ArchiveGroup(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToArchiveGroupRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.ArchiveGroup(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// RestoreGroup handles restoring an archived group +func (h *Handler) RestoreGroup(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToRestoreGroupRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.RestoreGroup(r.Context(), request) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Group) +} + +// GetGroupStats handles getting group statistics +func (h *Handler) GetGroupStats(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupStatsRequest(r, h.Validator) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroupStats(r.Context(), request.ID) + if err != nil { + h.getBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + h.getBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response) +} + +// getBaseResponseHandler returns response handler configured with group error maps +func (h *Handler) getBaseResponseHandler() *reply.Replier { + return reply.NewReplier(h.ErrorMaps) +} diff --git a/external/group/migrations/indexes_groups.go b/external/group/migrations/indexes_groups.go new file mode 100644 index 0000000..10f0299 --- /dev/null +++ b/external/group/migrations/indexes_groups.go @@ -0,0 +1,233 @@ +package migrations + +import ( + "context" + "log" + + "github.com/ooaklee/ghatd/external/group" + "github.com/ooaklee/ghatd/external/toolbox" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// InitGroupsIndexesUp initializes indexes for the groups collection +func InitGroupsIndexesUp(db *mongo.Database) error { //Up + log.SetFlags(0) + const mongoCollectionName = group.GroupCollection + + log.Default().Println(toolbox.OutputBasicLogString("info", "starting-task-to-add-groups-indexes")) + + // Unique compound index on name and type for fast lookups and uniqueness + nameTypeIndexModel := mongo.IndexModel{ + Keys: bson.D{ + {Key: "name", Value: 1}, + {Key: "type", Value: 1}, + }, + Options: options.Index(). + SetName("idx_groups_name_type"). + SetUnique(true), + } + + // Unique index on nano_id for alternative identifier lookups + nanoIDIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "_nano_id", Value: 1}}, + Options: options.Index(). + SetName("idx_groups_nano_id"). + SetUnique(true). + SetSparse(true), // Sparse because nano_id is optional + } + + // Index on type for filtering groups by type + typeIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "type", Value: 1}}, + Options: options.Index().SetName("idx_groups_type"), + } + + // Index on status for filtering groups by status + statusIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "status", Value: 1}}, + Options: options.Index().SetName("idx_groups_status"), + } + + // Compound index on status and created_at for efficient filtered sorting + statusCreatedAtIndexModel := mongo.IndexModel{ + Keys: bson.D{ + {Key: "status", Value: 1}, + {Key: "metadata.created_at", Value: -1}, + }, + Options: options.Index().SetName("idx_groups_status_created_at"), + } + + // Index on members.id for finding groups by member (supports $elemMatch) + membersIdIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "members.id", Value: 1}}, + Options: options.Index().SetName("idx_groups_members_id"), + } + + // Compound index on members.id and members.type for filtered member queries + membersIdTypeIndexModel := mongo.IndexModel{ + Keys: bson.D{ + {Key: "members.id", Value: 1}, + {Key: "members.type", Value: 1}, + }, + Options: options.Index().SetName("idx_groups_members_id_type"), + } + + // Index on leadership.owner_id for finding groups by owner + ownerIdIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "leadership.owner_id", Value: 1}}, + Options: options.Index().SetName("idx_groups_owner_id"), + } + + // Index on leadership.head_id for finding groups by head + headIdIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "leadership.head_id", Value: 1}}, + Options: options.Index().SetName("idx_groups_head_id"), + } + + // Index on leadership.lead_id for finding groups by lead + leadIdIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "leadership.lead_id", Value: 1}}, + Options: options.Index().SetName("idx_groups_lead_id"), + } + + // Index on leadership.admin_ids for finding groups by admin + adminIdsIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "leadership.admin_ids", Value: 1}}, + Options: options.Index().SetName("idx_groups_admin_ids"), + } + + // Index on settings.visibility for filtering by visibility + visibilityIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "settings.visibility", Value: 1}}, + Options: options.Index().SetName("idx_groups_visibility"), + } + + // Text index on name for text search capabilities + nameTextIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "name", Value: "text"}}, + Options: options.Index().SetName("idx_groups_name_text"), + } + + // Index on created_at for sorting/filtering by creation date + createdAtIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "metadata.created_at", Value: -1}}, + Options: options.Index().SetName("idx_groups_created_at"), + } + + // Index on updated_at for sorting by last update + updatedAtIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "metadata.updated_at", Value: -1}}, + Options: options.Index().SetName("idx_groups_updated_at"), + } + + // Index on archived_at for filtering archived groups + archivedAtIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "metadata.archived_at", Value: -1}}, + Options: options.Index(). + SetName("idx_groups_archived_at"). + SetSparse(true), // Sparse since most groups won't be archived + } + + // Index on deleted_at for soft-delete filtering + deletedAtIndexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "metadata.deleted_at", Value: -1}}, + Options: options.Index(). + SetName("idx_groups_deleted_at"). + SetSparse(true), // Sparse since most groups won't be deleted + } + + // Compound index on type and status for common filtered queries + typeStatusIndexModel := mongo.IndexModel{ + Keys: bson.D{ + {Key: "type", Value: 1}, + {Key: "status", Value: 1}, + }, + Options: options.Index().SetName("idx_groups_type_status"), + } + + // Compound index on visibility and status for access control queries + visibilityStatusIndexModel := mongo.IndexModel{ + Keys: bson.D{ + {Key: "settings.visibility", Value: 1}, + {Key: "status", Value: 1}, + }, + Options: options.Index().SetName("idx_groups_visibility_status"), + } + + // Create all indexes + _, err := db.Collection(mongoCollectionName).Indexes().CreateMany( + context.Background(), + []mongo.IndexModel{ + nameTypeIndexModel, + nanoIDIndexModel, + typeIndexModel, + statusIndexModel, + statusCreatedAtIndexModel, + membersIdIndexModel, + membersIdTypeIndexModel, + ownerIdIndexModel, + headIdIndexModel, + leadIdIndexModel, + adminIdsIndexModel, + visibilityIndexModel, + nameTextIndexModel, + createdAtIndexModel, + updatedAtIndexModel, + archivedAtIndexModel, + deletedAtIndexModel, + typeStatusIndexModel, + visibilityStatusIndexModel, + }, + ) + if err != nil { + log.Default().Println(toolbox.OutputBasicLogString("error", "failed-task-to-add-groups-indexes")) + return err + } + + log.Default().Println(toolbox.OutputBasicLogString("info", "completed-task-to-add-groups-indexes")) + return nil +} + +// InitGroupsIndexesDown rolls back the groups indexes +func InitGroupsIndexesDown(db *mongo.Database) error { //Down + log.SetFlags(0) + const mongoCollectionName = group.GroupCollection + + log.Default().Println(toolbox.OutputBasicLogString("info", "rolling-back-task-to-add-groups-indexes")) + + // Drop all indexes by name + indexNames := []string{ + "idx_groups_name_type", + "idx_groups_nano_id", + "idx_groups_type", + "idx_groups_status", + "idx_groups_status_created_at", + "idx_groups_members_id", + "idx_groups_members_id_type", + "idx_groups_owner_id", + "idx_groups_head_id", + "idx_groups_lead_id", + "idx_groups_admin_ids", + "idx_groups_visibility", + "idx_groups_name_text", + "idx_groups_created_at", + "idx_groups_updated_at", + "idx_groups_archived_at", + "idx_groups_deleted_at", + "idx_groups_type_status", + "idx_groups_visibility_status", + } + + for _, indexName := range indexNames { + _, err := db.Collection(mongoCollectionName).Indexes().DropOne(context.TODO(), indexName) + if err != nil { + log.Default().Println(toolbox.OutputBasicLogString("error", "failed-rolling-back-index: "+indexName)) + return err + } + } + + log.Default().Println(toolbox.OutputBasicLogString("info", "completed-rolling-back-task-to-add-groups-indexes")) + return nil +} diff --git a/external/group/model.go b/external/group/model.go new file mode 100644 index 0000000..2e83ac5 --- /dev/null +++ b/external/group/model.go @@ -0,0 +1,620 @@ +package group + +import ( + "encoding/json" + "errors" + "time" + + "github.com/PaesslerAG/jsonpath" + "github.com/ooaklee/ghatd/external/toolbox" +) + +// IDGenerator generates unique identifiers +type IDGenerator interface { + GenerateUUID() string + GenerateNanoID() string +} + +// TimeProvider provides current time (useful for testing) +type TimeProvider interface { + Now() time.Time + NowUTC() string +} + +// StringUtils provides string manipulation utilities +type StringUtils interface { + ToTitleCase(s string) string + ToLowerCase(s string) string + ToUpperCase(s string) string + InSlice(item string, slice []string) bool +} + +// GroupConfig holds configuration for group behavior +type GroupConfig struct { + DefaultStatus string + StatusTransitions map[string][]string + RequiredFields []string + ValidTypes []string + ValidMemberTypes []string + AllowNestedGroups bool + MaxNestingDepth int + MultipleIdentifiers bool // Support both UUID and NanoID +} + +// UniversalGroup represents a flexible group/collection model +type UniversalGroup struct { + // Core required fields + ID string `json:"id" bson:"_id" db:"id"` + Name string `json:"name" bson:"name" db:"name"` + Type string `json:"type" bson:"type" db:"type"` + Status string `json:"status" bson:"status" db:"status"` + + // Version field for tracking model version + Version int `json:"-" bson:"version" db:"version"` + + // Optional identifier (for systems that need multiple ID types) + NanoID string `json:"nano_id,omitempty" bson:"_nano_id,omitempty" db:"nano_id"` + + // Display information + DisplayInfo *DisplayInfo `json:"display_info,omitempty" bson:"display_info,omitempty" db:"display_info"` + + // Members collection - supports both users and nested groups + Members []Member `json:"members,omitempty" bson:"members,omitempty" db:"members"` + + // Leadership/hierarchy + Leadership *Leadership `json:"leadership,omitempty" bson:"leadership,omitempty" db:"leadership"` + + // Settings and permissions + Settings *GroupSettings `json:"settings,omitempty" bson:"settings,omitempty" db:"settings"` + + // Integration points + Integrations *Integrations `json:"integrations,omitempty" bson:"integrations,omitempty" db:"integrations"` + + // Metadata with flexible timestamps + Metadata *GroupMetadata `json:"metadata" bson:"metadata" db:"metadata"` + + // Extension point for project-specific fields + Extensions map[string]interface{} `json:"extensions,omitempty" bson:"extensions,omitempty" db:"extensions"` + + // Injected dependencies + config *GroupConfig `json:"-" bson:"-" db:"-"` + idGenerator IDGenerator `json:"-" bson:"-" db:"-"` + timeProvider TimeProvider `json:"-" bson:"-" db:"-"` + stringUtils StringUtils `json:"-" bson:"-" db:"-"` +} + +// DisplayInfo holds display-related information +type DisplayInfo struct { + Description string `json:"description,omitempty" bson:"description,omitempty" db:"description"` + NameAliases []string `json:"name_aliases,omitempty" bson:"name_aliases,omitempty" db:"name_aliases"` + Icon string `json:"icon,omitempty" bson:"icon,omitempty" db:"icon"` + Avatar string `json:"avatar,omitempty" bson:"avatar,omitempty" db:"avatar"` + Color string `json:"color,omitempty" bson:"color,omitempty" db:"color"` + Email string `json:"email,omitempty" bson:"email,omitempty" db:"email"` + Website string `json:"website,omitempty" bson:"website,omitempty" db:"website"` +} + +// Member represents a member (user or group) of the group +type Member struct { + ID string `json:"id" bson:"id" db:"id"` + Type string `json:"type" bson:"type" db:"type"` // USER or GROUP + Role string `json:"role,omitempty" bson:"role,omitempty" db:"role"` + JoinedAt string `json:"joined_at,omitempty" bson:"joined_at,omitempty" db:"joined_at"` + Metadata map[string]interface{} `json:"metadata,omitempty" bson:"metadata,omitempty" db:"metadata"` +} + +// Leadership holds leadership structure +type Leadership struct { + OwnerID string `json:"owner_id,omitempty" bson:"owner_id,omitempty" db:"owner_id"` + HeadID string `json:"head_id,omitempty" bson:"head_id,omitempty" db:"head_id"` + LeadID string `json:"lead_id,omitempty" bson:"lead_id,omitempty" db:"lead_id"` + AdminIDs []string `json:"admin_ids,omitempty" bson:"admin_ids,omitempty" db:"admin_ids"` + ModeratorIDs []string `json:"moderator_ids,omitempty" bson:"moderator_ids,omitempty" db:"moderator_ids"` +} + +// GroupSettings holds group configuration +type GroupSettings struct { + Visibility string `json:"visibility,omitempty" bson:"visibility,omitempty" db:"visibility"` // PUBLIC, PRIVATE, INTERNAL + AllowMemberInvites bool `json:"allow_member_invites,omitempty" bson:"allow_member_invites,omitempty" db:"allow_member_invites"` + RequireApproval bool `json:"require_approval,omitempty" bson:"require_approval,omitempty" db:"require_approval"` + MaxMembers int `json:"max_members,omitempty" bson:"max_members,omitempty" db:"max_members"` + AllowedMemberTypes []string `json:"allowed_member_types,omitempty" bson:"allowed_member_types,omitempty" db:"allowed_member_types"` +} + +// Integrations holds external integration data +type Integrations struct { + Slack *SlackIntegration `json:"slack,omitempty" bson:"slack,omitempty" db:"slack"` + Custom map[string]interface{} `json:"custom,omitempty" bson:"custom,omitempty" db:"custom"` +} + +// SlackIntegration holds Slack-specific data +type SlackIntegration struct { + DisplayID string `json:"display_id,omitempty" bson:"display_id,omitempty" db:"display_id"` + OnDutyDisplayID string `json:"on_duty_display_id,omitempty" bson:"on_duty_display_id,omitempty" db:"on_duty_display_id"` + OnDutyDisplayName string `json:"on_duty_display_name,omitempty" bson:"on_duty_display_name,omitempty" db:"on_duty_display_name"` + Emoji string `json:"emoji,omitempty" bson:"emoji,omitempty" db:"emoji"` + AdditionalRecipients []string `json:"additional_recipients,omitempty" bson:"additional_recipients,omitempty" db:"additional_recipients"` +} + +// GroupMetadata holds timestamp and audit information +type GroupMetadata struct { + CreatedAt string `json:"created_at" bson:"created_at" db:"created_at"` + UpdatedAt string `json:"updated_at,omitempty" bson:"updated_at,omitempty" db:"updated_at"` + ArchivedAt string `json:"archived_at,omitempty" bson:"archived_at,omitempty" db:"archived_at"` + DeletedAt string `json:"deleted_at,omitempty" bson:"deleted_at,omitempty" db:"deleted_at"` + DeletedByID string `json:"deleted_by_id,omitempty" bson:"deleted_by_id,omitempty" db:"deleted_by_id"` + + // Extensible metadata + CustomTimestamps map[string]string `json:"custom_timestamps,omitempty" bson:"custom_timestamps,omitempty" db:"custom_timestamps"` +} + +// NewUniversalGroup creates a new group with injected dependencies +func NewUniversalGroup( + config *GroupConfig, + idGenerator IDGenerator, + timeProvider TimeProvider, + stringUtils StringUtils, +) *UniversalGroup { + if config == nil { + config = DefaultGroupConfig() + } + + return &UniversalGroup{ + Members: []Member{}, + Extensions: make(map[string]interface{}), + DisplayInfo: &DisplayInfo{}, + Leadership: &Leadership{}, + Settings: &GroupSettings{}, + Integrations: &Integrations{}, + Metadata: &GroupMetadata{ + CustomTimestamps: make(map[string]string), + }, + config: config, + idGenerator: idGenerator, + timeProvider: timeProvider, + stringUtils: stringUtils, + } +} + +// SetDependencies allows setting dependencies after creation (useful for existing records) +func (g *UniversalGroup) SetDependencies( + config *GroupConfig, + idGenerator IDGenerator, + timeProvider TimeProvider, + stringUtils StringUtils, +) *UniversalGroup { + if config == nil { + config = DefaultGroupConfig() + } + + g.config = config + g.idGenerator = idGenerator + g.timeProvider = timeProvider + g.stringUtils = stringUtils + + // Initialize nil fields if needed + if g.Extensions == nil { + g.Extensions = make(map[string]interface{}) + } + if g.DisplayInfo == nil { + g.DisplayInfo = &DisplayInfo{} + } + if g.Leadership == nil { + g.Leadership = &Leadership{} + } + if g.Settings == nil { + g.Settings = &GroupSettings{} + } + if g.Integrations == nil { + g.Integrations = &Integrations{} + } + if g.Metadata == nil { + g.Metadata = &GroupMetadata{ + CustomTimestamps: make(map[string]string), + } + } + if g.Metadata.CustomTimestamps == nil { + g.Metadata.CustomTimestamps = make(map[string]string) + } + + return g +} + +// Core ID Methods + +// GenerateNewUUID creates a new UUID for the group +func (g *UniversalGroup) GenerateNewUUID() *UniversalGroup { + if g.idGenerator != nil { + g.ID = g.idGenerator.GenerateUUID() + } + return g +} + +// GenerateNewNanoID creates a new nano ID for the group +func (g *UniversalGroup) GenerateNewNanoID() *UniversalGroup { + if g.idGenerator != nil && g.config.MultipleIdentifiers { + g.NanoID = g.idGenerator.GenerateNanoID() + } + return g +} + +// SetInitialState sets up a new group with default values +func (g *UniversalGroup) SetInitialState() *UniversalGroup { + g.Status = g.config.DefaultStatus + g.Version = 1 // Mark as v1 model + g.SetCreatedAtNow() + return g +} + +// SetVersion sets the model version +func (g *UniversalGroup) SetVersion(version int) *UniversalGroup { + g.Version = version + return g +} + +// EnsureVersion ensures the group has version 1 set +func (g *UniversalGroup) EnsureVersion() *UniversalGroup { + if g.Version != 1 { + g.Version = 1 + } + return g +} + +// Timestamp Management + +// SetCreatedAtNow sets created timestamp to current time +func (g *UniversalGroup) SetCreatedAtNow() *UniversalGroup { + if g.timeProvider != nil { + g.Metadata.CreatedAt = g.timeProvider.NowUTC() + } + return g +} + +// SetUpdatedAtNow sets updated timestamp to current time +func (g *UniversalGroup) SetUpdatedAtNow() *UniversalGroup { + if g.timeProvider != nil { + g.Metadata.UpdatedAt = g.timeProvider.NowUTC() + } + return g +} + +// SetArchivedAtNow sets archived timestamp to current time +func (g *UniversalGroup) SetArchivedAtNow() *UniversalGroup { + if g.timeProvider != nil { + g.Metadata.ArchivedAt = g.timeProvider.NowUTC() + } + return g +} + +// SetDeletedAtNow sets deleted timestamp to current time +func (g *UniversalGroup) SetDeletedAtNow(deletedByID string) *UniversalGroup { + if g.timeProvider != nil { + g.Metadata.DeletedAt = g.timeProvider.NowUTC() + g.Metadata.DeletedByID = deletedByID + } + return g +} + +// SetCustomTimestamp sets a custom timestamp field +func (g *UniversalGroup) SetCustomTimestamp(key string) *UniversalGroup { + if g.timeProvider != nil { + g.Metadata.CustomTimestamps[key] = g.timeProvider.NowUTC() + } + return g +} + +// Status Management + +// UpdateStatus updates group status with validation +func (g *UniversalGroup) UpdateStatus(desiredStatus string) (*UniversalGroup, error) { + if g.config == nil { + return g, errors.New(ErrKeyGroupConfigNotSet) + } + + // Check if transition is valid + validSources, exists := g.config.StatusTransitions[desiredStatus] + if !exists { + return g, errors.New(ErrKeyInvalidGroupStatus) + } + + // Check if current status allows transition + if g.stringUtils != nil && !g.stringUtils.InSlice(g.Status, validSources) { + return g, errors.New(ErrKeyInvalidStatusTransition) + } + + // Update status + g.Status = desiredStatus + g.SetUpdatedAtNow() + + // Handle special status logic + if desiredStatus == GroupStatusArchived { + g.SetArchivedAtNow() + } + + return g, nil +} + +// IsValidStatus checks if a status is valid +func (g *UniversalGroup) IsValidStatus(status string) bool { + if g.config == nil { + return false + } + _, exists := g.config.StatusTransitions[status] + return exists || status == g.config.DefaultStatus +} + +// Member Management + +// AddMember adds a member to the group +func (g *UniversalGroup) AddMember(memberID, memberType, role string) (*UniversalGroup, error) { + // Validate member type + if !g.isValidMemberType(memberType) { + return g, errors.New(ErrKeyInvalidMemberType) + } + + // Check if member already exists + if g.HasMember(memberID) { + return g, errors.New(ErrKeyMemberAlreadyExists) + } + + // Check max members limit + if g.Settings != nil && g.Settings.MaxMembers > 0 && len(g.Members) >= g.Settings.MaxMembers { + return g, errors.New("MaxMembersReached") + } + + member := Member{ + ID: memberID, + Type: memberType, + Role: role, + } + + if g.timeProvider != nil { + member.JoinedAt = g.timeProvider.NowUTC() + } + + g.Members = append(g.Members, member) + g.SetUpdatedAtNow() + + return g, nil +} + +// RemoveMember removes a member from the group +func (g *UniversalGroup) RemoveMember(memberID string) (*UniversalGroup, error) { + for i, member := range g.Members { + if member.ID == memberID { + g.Members = append(g.Members[:i], g.Members[i+1:]...) + g.SetUpdatedAtNow() + return g, nil + } + } + return g, errors.New(ErrKeyMemberNotFound) +} + +// HasMember checks if a member exists in the group +func (g *UniversalGroup) HasMember(memberID string) bool { + for _, member := range g.Members { + if member.ID == memberID { + return true + } + } + return false +} + +// GetMemberByID retrieves a member by ID +func (g *UniversalGroup) GetMemberByID(memberID string) (*Member, error) { + for _, member := range g.Members { + if member.ID == memberID { + return &member, nil + } + } + return nil, errors.New(ErrKeyMemberNotFound) +} + +// GetMembersByType retrieves all members of a specific type +func (g *UniversalGroup) GetMembersByType(memberType string) []Member { + var members []Member + for _, member := range g.Members { + if member.Type == memberType { + members = append(members, member) + } + } + return members +} + +// GetMemberIDsByType retrieves all member IDs of a specific type +func (g *UniversalGroup) GetMemberIDsByType(memberType string) []string { + var ids []string + for _, member := range g.Members { + if member.Type == memberType { + ids = append(ids, member.ID) + } + } + return ids +} + +// GetUserMemberIDs retrieves all user member IDs +func (g *UniversalGroup) GetUserMemberIDs() []string { + return g.GetMemberIDsByType(MemberTypeUser) +} + +// GetGroupMemberIDs retrieves all group member IDs (nested groups) +func (g *UniversalGroup) GetGroupMemberIDs() []string { + return g.GetMemberIDsByType(MemberTypeGroup) +} + +// UpdateMemberRole updates a member's role +func (g *UniversalGroup) UpdateMemberRole(memberID, newRole string) (*UniversalGroup, error) { + for i, member := range g.Members { + if member.ID == memberID { + g.Members[i].Role = newRole + g.SetUpdatedAtNow() + return g, nil + } + } + return g, errors.New(ErrKeyMemberNotFound) +} + +// GetMemberCount returns the total number of members +func (g *UniversalGroup) GetMemberCount() int { + return len(g.Members) +} + +// isValidMemberType checks if member type is valid +func (g *UniversalGroup) isValidMemberType(memberType string) bool { + if g.config == nil || len(g.config.ValidMemberTypes) == 0 { + return true // Allow any if not configured + } + + if g.stringUtils != nil { + return g.stringUtils.InSlice(memberType, g.config.ValidMemberTypes) + } + + for _, validType := range g.config.ValidMemberTypes { + if validType == memberType { + return true + } + } + return false +} + +// Extension Management + +// SetExtension sets a custom extension field +func (g *UniversalGroup) SetExtension(key string, value interface{}) *UniversalGroup { + g.Extensions[key] = value + g.SetUpdatedAtNow() + return g +} + +// GetExtension retrieves a custom extension field +func (g *UniversalGroup) GetExtension(key string) (interface{}, bool) { + value, exists := g.Extensions[key] + return value, exists +} + +// Validation + +// Validate checks if group meets configured requirements +func (g *UniversalGroup) Validate() error { + if g.config == nil { + return errors.New(ErrKeyGroupConfigNotSet) + } + + // Check required fields + for _, field := range g.config.RequiredFields { + switch field { + case "name": + if g.Name == "" { + return errors.New(ErrKeyRequiredFieldMissingName) + } + case "type": + if g.Type == "" { + return errors.New(ErrKeyRequiredFieldMissingType) + } + } + } + + // Validate type + if !g.isValidType(g.Type) { + return errors.New(ErrKeyInvalidGroupType) + } + + // Validate status + if !g.IsValidStatus(g.Status) { + return errors.New(ErrKeyInvalidGroupStatus) + } + + return nil +} + +// isValidType checks if group type is valid +func (g *UniversalGroup) isValidType(groupType string) bool { + if g.config == nil || len(g.config.ValidTypes) == 0 { + return true // Allow any if not configured + } + + if g.stringUtils != nil { + return g.stringUtils.InSlice(groupType, g.config.ValidTypes) + } + + for _, validType := range g.config.ValidTypes { + if validType == groupType { + return true + } + } + return false +} + +// Utility Methods + +// GetAttributeByJSONPath retrieves nested field values using JSON path +func (g *UniversalGroup) GetAttributeByJSONPath(jsonPath string) (interface{}, error) { + jsonData, err := json.Marshal(g) + if err != nil { + return nil, err + } + + var dataMap map[string]interface{} + if err := json.Unmarshal(jsonData, &dataMap); err != nil { + return nil, err + } + + result, err := jsonpath.Get(jsonPath, dataMap) + if err != nil { + return nil, err + } + + return result, nil +} + +// Legacy method aliases for backward compatibility + +func (g *UniversalGroup) GetId() string { + return g.ID +} + +func (g *UniversalGroup) GetFullName() string { + return g.Name +} + +func (g *UniversalGroup) GetType() string { + return g.Type +} + +func (g *UniversalGroup) GetHeadId() string { + if g.Leadership != nil { + return g.Leadership.HeadID + } + return "" +} + +func (g *UniversalGroup) GetLeadId() string { + if g.Leadership != nil { + return g.Leadership.LeadID + } + return "" +} + +func (g *UniversalGroup) GetMemberIdsByKind(kind string) []string { + return g.GetMemberIDsByType(toolbox.StringStandardisedToUpper(kind)) +} + +func (g *UniversalGroup) GetUserMembersIds() []string { + return g.GetUserMemberIDs() +} + +func (g *UniversalGroup) SetCreatedAtTimeToNow() *UniversalGroup { + return g.SetCreatedAtNow() +} + +func (g *UniversalGroup) SetUpdatedAtTimeToNow() *UniversalGroup { + return g.SetUpdatedAtNow() +} + +func (g *UniversalGroup) GenerateNewUuid() *UniversalGroup { + return g.GenerateNewUUID() +} + +func (g *UniversalGroup) GetAttributeByJsonPath(jsonPath string) (interface{}, error) { + return g.GetAttributeByJSONPath(jsonPath) +} diff --git a/external/group/repository.go b/external/group/repository.go new file mode 100644 index 0000000..a973ab0 --- /dev/null +++ b/external/group/repository.go @@ -0,0 +1,618 @@ +package group + +import ( + "context" + "errors" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GroupCollection collection name for groups +const GroupCollection string = "groups" + +// MongoDbStore represents the datastore to hold group data +type MongoDbStore interface { + ExecuteCountDocuments(ctx context.Context, collection *mongo.Collection, filter interface{}, opts ...*options.CountOptions) (int64, error) + ExecuteDeleteOneCommand(ctx context.Context, collection *mongo.Collection, filter interface{}, targetObjectName string) error + ExecuteFindCommand(ctx context.Context, collection *mongo.Collection, filter interface{}, opts ...*options.FindOptions) (*mongo.Cursor, error) + ExecuteInsertOneCommand(ctx context.Context, collection *mongo.Collection, document interface{}, resultObjectName string) (*mongo.InsertOneResult, error) + ExecuteUpdateOneCommand(ctx context.Context, collection *mongo.Collection, filter interface{}, updateFilter interface{}, resultObjectName string) error + ExecuteDeleteManyCommand(ctx context.Context, collection *mongo.Collection, filter interface{}, targetObjectName string) error + ExecuteFindOneCommandDecodeResult(ctx context.Context, collection *mongo.Collection, filter interface{}, result interface{}, resultObjectName string, logError bool, onFailureErr error) error + ExecuteAggregateCommand(ctx context.Context, collection *mongo.Collection, mongoPipeline []bson.D) (*mongo.Cursor, error) + ExecuteReplaceOneCommand(ctx context.Context, collection *mongo.Collection, filter interface{}, replacementObject interface{}, resultObjectName string) error + ExecuteUpdateManyCommand(ctx context.Context, collection *mongo.Collection, filter interface{}, updateFilter interface{}, resultObjectName string) error + ExecuteInsertManyCommand(ctx context.Context, collection *mongo.Collection, documents []interface{}, resultObjectName string) (*mongo.InsertManyResult, error) + + GetDatabase(ctx context.Context, dbName string) (*mongo.Database, error) + InitialiseClient(ctx context.Context) (*mongo.Client, error) + MapAllInCursorToResult(ctx context.Context, cursor *mongo.Cursor, result interface{}, resultObjectName string) error + MapOneInCursorToResult(ctx context.Context, cursor *mongo.Cursor, result interface{}, resultObjectName string) error +} + +// Repository handles group data persistence +type Repository struct { + Store MongoDbStore +} + +// NewRepository creates a new group repository +func NewRepository(store MongoDbStore) *Repository { + return &Repository{ + Store: store, + } +} + +// GetGroupCollection returns collection used for groups domain +func (r *Repository) GetGroupCollection(ctx context.Context) (*mongo.Collection, error) { + _, err := r.Store.InitialiseClient(ctx) + if err != nil { + return nil, err + } + + db, err := r.Store.GetDatabase(ctx, "") + if err != nil { + return nil, err + } + collection := db.Collection(GroupCollection) + + return collection, nil +} + +// CreateGroup creates a new group in the repository +func (r *Repository) CreateGroup(ctx context.Context, group *UniversalGroup) (*UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + _, err = r.Store.ExecuteInsertOneCommand(ctx, collection, group, "group") + if err != nil { + return nil, err + } + + return group, nil +} + +// GetGroupByID retrieves a group by ID +func (r *Repository) GetGroupByID(ctx context.Context, id string) (*UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "_id": id, + } + + var result UniversalGroup + err = r.Store.ExecuteFindOneCommandDecodeResult(ctx, collection, queryFilter, &result, "group", true, errors.New(ErrKeyResourceNotFound)) + if err != nil { + return nil, err + } + + return &result, nil +} + +// GetGroupByNanoID retrieves a group by nano ID +func (r *Repository) GetGroupByNanoID(ctx context.Context, nanoID string) (*UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "_nano_id": nanoID, + } + + var result UniversalGroup + err = r.Store.ExecuteFindOneCommandDecodeResult(ctx, collection, queryFilter, &result, "group", true, errors.New(ErrKeyResourceNotFound)) + if err != nil { + return nil, err + } + + return &result, nil +} + +// GetGroupByName retrieves a group by name and optional type +func (r *Repository) GetGroupByName(ctx context.Context, name, groupType string, logError bool) (*UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "name": normaliseGroupName(name), + } + + if groupType != "" { + queryFilter["type"] = groupType + } + + var result UniversalGroup + err = r.Store.ExecuteFindOneCommandDecodeResult(ctx, collection, queryFilter, &result, "group", logError, errors.New(ErrKeyUnableToFindGroupWithName)) + if err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateGroup updates an existing group +func (r *Repository) UpdateGroup(ctx context.Context, group *UniversalGroup) (*UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "_id": group.ID, + } + + update := bson.M{ + "$set": group, + } + + err = r.Store.ExecuteUpdateOneCommand(ctx, collection, queryFilter, update, "group") + if err != nil { + return nil, err + } + + return group, nil +} + +// DeleteGroupByID deletes a group by ID +func (r *Repository) DeleteGroupByID(ctx context.Context, id string) error { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return err + } + + queryFilter := bson.M{ + "_id": id, + } + + err = r.Store.ExecuteDeleteOneCommand(ctx, collection, queryFilter, "group") + return err +} + +// SoftDeleteGroup soft deletes a group by setting deleted timestamp +func (r *Repository) SoftDeleteGroup(ctx context.Context, id, deletedByID string, deletedAt string) error { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return err + } + + queryFilter := bson.M{ + "_id": id, + } + + update := bson.M{ + "$set": bson.M{ + "metadata.deleted_at": deletedAt, + "metadata.deleted_by_id": deletedByID, + "status": GroupStatusArchived, + }, + } + + err = r.Store.ExecuteUpdateOneCommand(ctx, collection, queryFilter, update, "group") + return err +} + +// GetGroups retrieves groups with filters and pagination +func (r *Repository) GetGroups(ctx context.Context, req *GetGroupsRequest) ([]UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + // Build query filter + queryFilter := r.buildGroupQueryFilter(req) + + // Build sort options + sortOptions := r.buildSortOptions(req.OrderBy) + + // Calculate skip + skip := int64((req.Page - 1) * req.PageSize) + limit := int64(req.PageSize) + + options := options.Find(). + SetSort(sortOptions). + SetSkip(skip). + SetLimit(limit) + + cursor, err := r.Store.ExecuteFindCommand(ctx, collection, queryFilter, options) + if err != nil { + return nil, err + } + + var results []UniversalGroup + err = r.Store.MapAllInCursorToResult(ctx, cursor, &results, "group") + if err != nil { + return nil, err + } + + return results, nil +} + +// GetTotalGroups retrieves the total count of groups matching filters +func (r *Repository) GetTotalGroups(ctx context.Context, req *GetGroupsRequest) (int64, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return 0, err + } + + queryFilter := r.buildGroupQueryFilter(req) + + count, err := r.Store.ExecuteCountDocuments(ctx, collection, queryFilter) + if err != nil { + return 0, err + } + + return count, nil +} + +// GetGroupsByType retrieves groups by type with pagination +func (r *Repository) GetGroupsByType(ctx context.Context, groupType string, page, pageSize int, order string) ([]UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "type": groupType, + } + + sortOptions := r.buildSortOptions(order) + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + options := options.Find(). + SetSort(sortOptions). + SetSkip(skip). + SetLimit(limit) + + cursor, err := r.Store.ExecuteFindCommand(ctx, collection, queryFilter, options) + if err != nil { + return nil, err + } + + var results []UniversalGroup + err = r.Store.MapAllInCursorToResult(ctx, cursor, &results, "group") + if err != nil { + return nil, err + } + + return results, nil +} + +// GetGroupsByStatus retrieves groups by status with pagination +func (r *Repository) GetGroupsByStatus(ctx context.Context, status string, page, pageSize int, order string) ([]UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "status": status, + } + + sortOptions := r.buildSortOptions(order) + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + options := options.Find(). + SetSort(sortOptions). + SetSkip(skip). + SetLimit(limit) + + cursor, err := r.Store.ExecuteFindCommand(ctx, collection, queryFilter, options) + if err != nil { + return nil, err + } + + var results []UniversalGroup + err = r.Store.MapAllInCursorToResult(ctx, cursor, &results, "group") + if err != nil { + return nil, err + } + + return results, nil +} + +// GetGroupsByMemberID retrieves groups that contain a specific member +func (r *Repository) GetGroupsByMemberID(ctx context.Context, memberID string, memberType string, page, pageSize int) ([]UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "members": bson.M{ + "$elemMatch": bson.M{ + "id": memberID, + }, + }, + } + + if memberType != "" { + queryFilter["members"].(bson.M)["$elemMatch"].(bson.M)["type"] = memberType + } + + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + options := options.Find(). + SetSkip(skip). + SetLimit(limit). + SetSort(bson.D{{Key: "metadata.created_at", Value: -1}}) + + cursor, err := r.Store.ExecuteFindCommand(ctx, collection, queryFilter, options) + if err != nil { + return nil, err + } + + var results []UniversalGroup + err = r.Store.MapAllInCursorToResult(ctx, cursor, &results, "group") + if err != nil { + return nil, err + } + + return results, nil +} + +// GetGroupsByLeaderID retrieves groups where user is a leader (owner, head, or lead) +func (r *Repository) GetGroupsByLeaderID(ctx context.Context, leaderID string, page, pageSize int) ([]UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "$or": []bson.M{ + {"leadership.owner_id": leaderID}, + {"leadership.head_id": leaderID}, + {"leadership.lead_id": leaderID}, + {"leadership.admin_ids": leaderID}, + }, + } + + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + options := options.Find(). + SetSkip(skip). + SetLimit(limit). + SetSort(bson.D{{Key: "metadata.created_at", Value: -1}}) + + cursor, err := r.Store.ExecuteFindCommand(ctx, collection, queryFilter, options) + if err != nil { + return nil, err + } + + var results []UniversalGroup + err = r.Store.MapAllInCursorToResult(ctx, cursor, &results, "group") + if err != nil { + return nil, err + } + + return results, nil +} + +// SearchGroupsByExtension searches groups by extension field +func (r *Repository) SearchGroupsByExtension(ctx context.Context, key string, value interface{}, page, pageSize int) ([]UniversalGroup, error) { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return nil, err + } + + queryFilter := bson.M{ + "extensions." + key: value, + } + + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + options := options.Find(). + SetSkip(skip). + SetLimit(limit) + + cursor, err := r.Store.ExecuteFindCommand(ctx, collection, queryFilter, options) + if err != nil { + return nil, err + } + + var results []UniversalGroup + err = r.Store.MapAllInCursorToResult(ctx, cursor, &results, "group") + if err != nil { + return nil, err + } + + return results, nil +} + +// AddMemberToGroup adds a member to a group +func (r *Repository) AddMemberToGroup(ctx context.Context, groupID string, member Member) error { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return err + } + + queryFilter := bson.M{ + "_id": groupID, + } + + update := bson.M{ + "$push": bson.M{ + "members": member, + }, + } + + err = r.Store.ExecuteUpdateOneCommand(ctx, collection, queryFilter, update, "group") + return err +} + +// RemoveMemberFromGroup removes a member from a group +func (r *Repository) RemoveMemberFromGroup(ctx context.Context, groupID, memberID string) error { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return err + } + + queryFilter := bson.M{ + "_id": groupID, + } + + update := bson.M{ + "$pull": bson.M{ + "members": bson.M{ + "id": memberID, + }, + }, + } + + err = r.Store.ExecuteUpdateOneCommand(ctx, collection, queryFilter, update, "group") + return err +} + +// BulkUpdateGroupsStatus updates status for multiple groups +func (r *Repository) BulkUpdateGroupsStatus(ctx context.Context, groupIDs []string, status string) error { + collection, err := r.GetGroupCollection(ctx) + if err != nil { + return err + } + + queryFilter := bson.M{ + "_id": bson.M{"$in": groupIDs}, + } + + update := bson.M{ + "$set": bson.M{ + "status": status, + }, + } + + err = r.Store.ExecuteUpdateManyCommand(ctx, collection, queryFilter, update, "groups") + return err +} + +// Helper methods + +// buildGroupQueryFilter builds a query filter for group searches +func (r *Repository) buildGroupQueryFilter(req *GetGroupsRequest) bson.M { + queryFilter := bson.M{} + + // Type filters + if len(req.Types) > 0 { + queryFilter["type"] = bson.M{"$in": req.Types} + } + + // Status filters + if len(req.Statuses) > 0 { + queryFilter["status"] = bson.M{"$in": req.Statuses} + } + + // Member filters + if req.MemberID != "" { + memberFilter := bson.M{ + "members": bson.M{ + "$elemMatch": bson.M{ + "id": req.MemberID, + }, + }, + } + if req.MemberType != "" { + memberFilter["members"].(bson.M)["$elemMatch"].(bson.M)["type"] = req.MemberType + } + for key, value := range memberFilter { + queryFilter[key] = value + } + } + + // Members with specific IDs and types + if len(req.MembersWithIDs) > 0 { + memberConditions := []bson.M{} + for memberType, ids := range req.MembersWithIDs { + for _, id := range ids { + memberConditions = append(memberConditions, bson.M{ + "members": bson.M{ + "$elemMatch": bson.M{ + "id": id, + "type": memberType, + }, + }, + }) + } + } + if len(memberConditions) > 0 { + queryFilter["$and"] = memberConditions + } + } + + // Leadership filters + if req.OwnerID != "" { + queryFilter["leadership.owner_id"] = req.OwnerID + } + if req.HeadID != "" { + queryFilter["leadership.head_id"] = req.HeadID + } + if req.LeadID != "" { + queryFilter["leadership.lead_id"] = req.LeadID + } + + // Settings filters + if req.Visibility != "" { + queryFilter["settings.visibility"] = req.Visibility + } + + // Name search (case-insensitive regex) + if req.NameSearch != "" { + queryFilter["name"] = bson.M{ + "$regex": req.NameSearch, + "$options": "i", + } + } + + // Extension filters + for key, value := range req.ExtensionFilters { + queryFilter["extensions."+key] = value + } + + // Exclude soft-deleted groups by default + if queryFilter["metadata.deleted_at"] == nil { + queryFilter["metadata.deleted_at"] = bson.M{"$exists": false} + } + + return queryFilter +} + +// buildSortOptions builds sort options based on order string +func (r *Repository) buildSortOptions(order string) bson.D { + switch order { + case GetGroupOrderCreatedAtDesc: + return bson.D{{Key: "metadata.created_at", Value: -1}} + case GetGroupOrderCreatedAtAsc: + return bson.D{{Key: "metadata.created_at", Value: 1}} + case GetGroupOrderUpdatedAtDesc: + return bson.D{{Key: "metadata.updated_at", Value: -1}} + case GetGroupOrderUpdatedAtAsc: + return bson.D{{Key: "metadata.updated_at", Value: 1}} + case GetGroupOrderNameAsc: + return bson.D{{Key: "name", Value: 1}} + case GetGroupOrderNameDesc: + return bson.D{{Key: "name", Value: -1}} + case GetGroupOrderMemberCountDesc: + return bson.D{{Key: "members", Value: -1}} + case GetGroupOrderMemberCountAsc: + return bson.D{{Key: "members", Value: 1}} + default: + return bson.D{{Key: "metadata.created_at", Value: -1}} + } +} + +// normaliseGroupName standardises group name +func normaliseGroupName(name string) string { + return strings.TrimSpace(name) +} diff --git a/external/group/request.go b/external/group/request.go new file mode 100644 index 0000000..2177bb2 --- /dev/null +++ b/external/group/request.go @@ -0,0 +1,151 @@ +package group + +// GetGroupsRequest defines the request structure for getting groups +type GetGroupsRequest struct { + // Type filters (flexible - any type string) + Types []string `json:"types,omitempty" query:"types"` + + // Status filters + Statuses []string `query:"statuses"` + + // Member filters + MemberID string ` query:"member_id"` + MemberType string ` query:"member_type"` + MembersWithIDs map[string][]string ` query:"members_with_ids"` + + // Leadership filters + OwnerID string ` query:"owner_id"` + HeadID string ` query:"head_id"` + LeadID string ` query:"lead_id"` + + // Settings filters + Visibility string `query:"visibility"` + + // Search + NameSearch string `query:"name_search"` + + // Pagination + Page int ` query:"page"` + PageSize int ` query:"page_size"` + PerPage int ` query:"per_page"` // Alias for PageSize + OrderBy string ` query:"order_by"` + Meta bool ` query:"meta"` + TotalCount int ` query:"total_count"` + + // Extension filters (for custom queries) + ExtensionFilters map[string]interface{} `json:"extension_filters,omitempty" query:"extension_filters"` +} + +// GetGroupByIDRequest defines the request for getting a single group +type GetGroupByIDRequest struct { + ID string `path:"groupID"` +} + +// GetGroupByNanoIDRequest defines the request for getting a single group by nano ID +type GetGroupByNanoIDRequest struct { + NanoID string `path:"groupNanoID"` +} + +// GetGroupByNameRequest defines the request for getting a group by name +type GetGroupByNameRequest struct { + Name string `query:"name"` + Type string `query:"type"` // Optional type filter +} + +// CreateGroupRequest defines the request for creating a new group +type CreateGroupRequest struct { + Name string `json:"name" validate:"required"` + Type string `json:"type" validate:"required"` + Description string `json:"description,omitempty"` + Email string `json:"email,omitempty"` + Icon string `json:"icon,omitempty"` + Visibility string `json:"visibility,omitempty"` + Extensions map[string]interface{} `json:"extensions,omitempty"` + + // Initial members + InitialMembers []CreateMemberRequest `json:"initial_members,omitempty"` + + // Initial leadership + OwnerID string `json:"owner_id,omitempty"` + HeadID string `json:"head_id,omitempty"` + LeadID string `json:"lead_id,omitempty"` +} + +// CreateMemberRequest defines member data for creation +type CreateMemberRequest struct { + ID string `json:"id" validate:"required"` + Type string `json:"type" validate:"required"` + Role string `json:"role,omitempty"` +} + +// UpdateGroupRequest defines the request for updating a group +type UpdateGroupRequest struct { + ID string `path:"groupID"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Email *string `json:"email,omitempty"` + Icon *string `json:"icon,omitempty"` + Visibility *string `json:"visibility,omitempty"` + Status *string `json:"status,omitempty"` + Extensions map[string]interface{} `json:"extensions,omitempty"` + // Group if specified updates entire group object + Group *UniversalGroup `json:"group,omitempty"` +} + +// AddMemberRequest defines the request for adding a member +type AddMemberRequest struct { + GroupID string `path:"groupID"` + MemberID string `json:"member_id" validate:"required"` + Type string `json:"type" validate:"required"` + Role string `json:"role,omitempty"` +} + +// RemoveMemberRequest defines the request for removing a member +type RemoveMemberRequest struct { + GroupID string `path:"groupID"` + MemberID string `path:"memberID"` +} + +// UpdateMemberRoleRequest defines the request for updating a member's role +type UpdateMemberRoleRequest struct { + GroupID string `path:"groupID"` + MemberID string `path:"memberID"` + NewRole string `json:"new_role" validate:"required"` +} + +// GetGroupMembersRequest defines the request for getting group members +type GetGroupMembersRequest struct { + GroupID string `path:"groupID"` + MemberType string `query:"member_type"` + Role string `query:"role"` +} + +// UpdateLeadershipRequest defines the request for updating leadership +type UpdateLeadershipRequest struct { + GroupID string `path:"groupID"` + OwnerID *string `json:"owner_id,omitempty"` + HeadID *string `json:"head_id,omitempty"` + LeadID *string `json:"lead_id,omitempty"` +} + +// DeleteGroupRequest defines the request for deleting a group +type DeleteGroupRequest struct { + ID string `path:"groupID"` + DeletedByID string `query:"deleted_by_id"` + HardDelete bool `query:"hard_delete"` +} + +// ArchiveGroupRequest defines the request for archiving a group +type ArchiveGroupRequest struct { + ID string `path:"groupID"` +} + +// RestoreGroupRequest defines the request for restoring a group +type RestoreGroupRequest struct { + ID string `path:"groupID"` +} + +// GetGroupStatsRequest defines the request for getting group statistics +type GetGroupStatsRequest struct { + ID string `path:"groupID"` +} diff --git a/external/group/response.go b/external/group/response.go new file mode 100644 index 0000000..d48717c --- /dev/null +++ b/external/group/response.go @@ -0,0 +1,96 @@ +package group + +// GetGroupsResponse defines the response structure for getting groups +type GetGroupsResponse struct { + Total int `json:"total"` + TotalPages int `json:"total_pages"` + Groups []*UniversalGroup `json:"groups"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +// GetMetaData returns metadata in reply.WithMeta format +func (r *GetGroupsResponse) GetMetaData() map[string]interface{} { + return map[string]interface{}{ + "page": r.Page, + "per_page": r.PerPage, + "total_resources": r.Total, + "total_pages": r.TotalPages, + } +} + +// GetGroupByIDResponse defines the response for getting a single group +type GetGroupByIDResponse struct { + Group *UniversalGroup `json:"group"` +} + +// GetGroupByNanoIDResponse defines the response for getting a single group by nano ID +type GetGroupByNanoIDResponse struct { + Group *UniversalGroup `json:"group"` +} + +// GetGroupByNameResponse defines the response for getting a group by name +type GetGroupByNameResponse struct { + Group *UniversalGroup `json:"group"` +} + +// CreateGroupResponse defines the response for creating a group +type CreateGroupResponse struct { + Group *UniversalGroup `json:"group"` +} + +// UpdateGroupResponse defines the response for updating a group +type UpdateGroupResponse struct { + Group *UniversalGroup `json:"group"` +} + +// DeleteGroupResponse defines the response for deleting a group +type DeleteGroupResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +// GetGroupMembersResponse defines the response for getting group members +type GetGroupMembersResponse struct { + Members []Member `json:"members"` + Count int `json:"count"` +} + +// AddMemberResponse defines the response for adding a member +type AddMemberResponse struct { + Group *UniversalGroup `json:"group"` +} + +// RemoveMemberResponse defines the response for removing a member +type RemoveMemberResponse struct { + Group *UniversalGroup `json:"group"` +} + +// UpdateMemberRoleResponse defines the response for updating a member role +type UpdateMemberRoleResponse struct { + Group *UniversalGroup `json:"group"` +} + +// UpdateLeadershipResponse defines the response for updating leadership +type UpdateLeadershipResponse struct { + Group *UniversalGroup `json:"group"` +} + +// ArchiveGroupResponse defines the response for archiving a group +type ArchiveGroupResponse struct { + Group *UniversalGroup `json:"group"` +} + +// RestoreGroupResponse defines the response for restoring a group +type RestoreGroupResponse struct { + Group *UniversalGroup `json:"group"` +} + +// GetGroupStatsResponse defines the response for group statistics +type GetGroupStatsResponse struct { + GroupID string `json:"group_id"` + MemberCount int `json:"member_count"` + UserMemberCount int `json:"user_member_count"` + GroupMemberCount int `json:"group_member_count"` + SubgroupCount int `json:"subgroup_count,omitempty"` +} diff --git a/external/group/routes.go b/external/group/routes.go new file mode 100644 index 0000000..906d4ef --- /dev/null +++ b/external/group/routes.go @@ -0,0 +1,84 @@ +package group + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/ooaklee/ghatd/external/router" +) + +// GroupHandler interface defines expected methods for valid group handler +type GroupHandler interface { + CreateGroup(w http.ResponseWriter, r *http.Request) + GetGroupByID(w http.ResponseWriter, r *http.Request) + GetGroupByNanoID(w http.ResponseWriter, r *http.Request) + GetGroupByName(w http.ResponseWriter, r *http.Request) + UpdateGroup(w http.ResponseWriter, r *http.Request) + DeleteGroup(w http.ResponseWriter, r *http.Request) + GetGroups(w http.ResponseWriter, r *http.Request) + AddMember(w http.ResponseWriter, r *http.Request) + RemoveMember(w http.ResponseWriter, r *http.Request) + UpdateMemberRole(w http.ResponseWriter, r *http.Request) + GetGroupMembers(w http.ResponseWriter, r *http.Request) + UpdateLeadership(w http.ResponseWriter, r *http.Request) + ArchiveGroup(w http.ResponseWriter, r *http.Request) + RestoreGroup(w http.ResponseWriter, r *http.Request) + GetGroupStats(w http.ResponseWriter, r *http.Request) +} + +// APIGroupsV1Prefix base URI prefix for all v1 groups routes +const APIGroupsV1Prefix = "/api/v1/groups" + +// AttachRoutesRequest holds everything needed to attach group routes to router +type AttachRoutesRequest struct { + // Router main router being served by API + Router *router.Router + + // Handler valid group handler + Handler GroupHandler + + // AdminOnlyMiddleware middleware used to lock endpoints down to admin only + AdminOnlyMiddleware mux.MiddlewareFunc + + // AuthenticatedMiddleware middleware used for authenticated users + AuthenticatedMiddleware mux.MiddlewareFunc +} + +// AttachRoutes attaches group handler to corresponding routes on router +func AttachRoutes(request *AttachRoutesRequest) { + httpRouter := request.Router.GetRouter() + + // Admin-only routes for full group management + groupsAdminOnlyRoutes := httpRouter.PathPrefix(APIGroupsV1Prefix).Subrouter() + groupsAdminOnlyRoutes.HandleFunc("", request.Handler.CreateGroup).Methods(http.MethodPost, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("", request.Handler.GetGroups).Methods(http.MethodGet, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/{groupID}", request.Handler.GetGroupByID).Methods(http.MethodGet, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/{groupID}", request.Handler.UpdateGroup).Methods(http.MethodPatch, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/{groupID}", request.Handler.DeleteGroup).Methods(http.MethodDelete, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/nano/{groupNanoID}", request.Handler.GetGroupByNanoID).Methods(http.MethodGet, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/search", request.Handler.GetGroupByName).Methods(http.MethodGet, http.MethodOptions) + + // Group status operations + groupsAdminOnlyRoutes.HandleFunc("/{groupID}/archive", request.Handler.ArchiveGroup).Methods(http.MethodPost, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/{groupID}/restore", request.Handler.RestoreGroup).Methods(http.MethodPost, http.MethodOptions) + + // Member management + groupsAdminOnlyRoutes.HandleFunc("/{groupID}/members", request.Handler.GetGroupMembers).Methods(http.MethodGet, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/{groupID}/members", request.Handler.AddMember).Methods(http.MethodPost, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/{groupID}/members/{memberID}", request.Handler.RemoveMember).Methods(http.MethodDelete, http.MethodOptions) + groupsAdminOnlyRoutes.HandleFunc("/{groupID}/members/{memberID}/role", request.Handler.UpdateMemberRole).Methods(http.MethodPut, http.MethodOptions) + + // Leadership management + groupsAdminOnlyRoutes.HandleFunc("/{groupID}/leadership", request.Handler.UpdateLeadership).Methods(http.MethodPut, http.MethodOptions) + + // Statistics + groupsAdminOnlyRoutes.HandleFunc("/{groupID}/stats", request.Handler.GetGroupStats).Methods(http.MethodGet, http.MethodOptions) + + groupsAdminOnlyRoutes.Use(request.AdminOnlyMiddleware) + + // Authenticated routes (if needed for self-service operations) + // Uncomment and customise as needed: + // groupsAuthenticatedRoutes := httpRouter.PathPrefix(APIGroupsV1Prefix).Subrouter() + // groupsAuthenticatedRoutes.HandleFunc("/my-groups", request.Handler.GetMyGroups).Methods(http.MethodGet, http.MethodOptions) + // groupsAuthenticatedRoutes.Use(request.AuthenticatedMiddleware) +} diff --git a/external/group/service.go b/external/group/service.go new file mode 100644 index 0000000..20bd396 --- /dev/null +++ b/external/group/service.go @@ -0,0 +1,1027 @@ +package group + +import ( + "context" + "errors" + + "github.com/ooaklee/ghatd/external/audit" + "github.com/ooaklee/ghatd/external/logger" + "github.com/ooaklee/ghatd/external/toolbox" + "go.uber.org/zap" +) + +// AuditService expected methods of a valid audit service +type AuditService interface { + LogAuditEvent(ctx context.Context, r *audit.LogAuditEventRequest) error +} + +// GroupRepository expected methods of a valid group repository +type GroupRepository interface { + CreateGroup(ctx context.Context, group *UniversalGroup) (*UniversalGroup, error) + GetGroupByID(ctx context.Context, id string) (*UniversalGroup, error) + GetGroupByNanoID(ctx context.Context, nanoID string) (*UniversalGroup, error) + GetGroupByName(ctx context.Context, name, groupType string, logError bool) (*UniversalGroup, error) + UpdateGroup(ctx context.Context, group *UniversalGroup) (*UniversalGroup, error) + DeleteGroupByID(ctx context.Context, id string) error + SoftDeleteGroup(ctx context.Context, id, deletedByID string, deletedAt string) error + GetGroups(ctx context.Context, req *GetGroupsRequest) ([]UniversalGroup, error) + GetTotalGroups(ctx context.Context, req *GetGroupsRequest) (int64, error) + GetGroupsByType(ctx context.Context, groupType string, page, pageSize int, order string) ([]UniversalGroup, error) + GetGroupsByStatus(ctx context.Context, status string, page, pageSize int, order string) ([]UniversalGroup, error) + GetGroupsByMemberID(ctx context.Context, memberID string, memberType string, page, pageSize int) ([]UniversalGroup, error) + GetGroupsByLeaderID(ctx context.Context, leaderID string, page, pageSize int) ([]UniversalGroup, error) + SearchGroupsByExtension(ctx context.Context, key string, value interface{}, page, pageSize int) ([]UniversalGroup, error) + AddMemberToGroup(ctx context.Context, groupID string, member Member) error + RemoveMemberFromGroup(ctx context.Context, groupID, memberID string) error + BulkUpdateGroupsStatus(ctx context.Context, groupIDs []string, status string) error +} + +// Service holds and manages group business logic +type Service struct { + GroupRepository GroupRepository + AuditService AuditService + Config *GroupConfig + IDGenerator IDGenerator + TimeProvider TimeProvider + StringUtils StringUtils +} + +// NewService creates a new group service +func NewService( + groupRepository GroupRepository, + auditService AuditService, + config *GroupConfig, + idGenerator IDGenerator, + timeProvider TimeProvider, + stringUtils StringUtils, +) *Service { + if config == nil { + config = DefaultGroupConfig() + } + + return &Service{ + GroupRepository: groupRepository, + AuditService: auditService, + Config: config, + IDGenerator: idGenerator, + TimeProvider: timeProvider, + StringUtils: stringUtils, + } +} + +// CreateGroup creates a new group +func (s *Service) CreateGroup(ctx context.Context, req *CreateGroupRequest) (*CreateGroupResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "create-group")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("creating-group", zap.String("name", req.Name), zap.String("type", req.Type)) + + // Check if group with same name already exists + existingGroup, err := s.GroupRepository.GetGroupByName(ctx, req.Name, req.Type, false) + if err == nil && existingGroup != nil { + log.Debug("group-with-name-already-exists", zap.String("name", req.Name)) + return nil, errors.New(ErrKeyNameAlreadyExists) + } + + // Create new group with dependencies + group := NewUniversalGroup(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Set basic fields + group.Name = req.Name + group.Type = req.Type + + // Set display info if provided + if req.Description != "" || req.Email != "" || req.Icon != "" { + group.DisplayInfo.Description = req.Description + group.DisplayInfo.Email = req.Email + group.DisplayInfo.Icon = req.Icon + } + + // Set visibility + if req.Visibility != "" { + group.Settings.Visibility = req.Visibility + } else { + group.Settings.Visibility = VisibilityPrivate // Default + } + + // Set extensions + if len(req.Extensions) > 0 { + group.Extensions = req.Extensions + } + + // Set leadership + if req.OwnerID != "" || req.HeadID != "" || req.LeadID != "" { + group.Leadership.OwnerID = req.OwnerID + group.Leadership.HeadID = req.HeadID + group.Leadership.LeadID = req.LeadID + } + + // Generate IDs + group.GenerateNewUUID() + if s.Config.MultipleIdentifiers { + group.GenerateNewNanoID() + } + + // Set initial timestamps and state + group.SetInitialState() + + // Add initial members if provided + for _, memberReq := range req.InitialMembers { + if _, err := group.AddMember(memberReq.ID, memberReq.Type, memberReq.Role); err != nil { + log.Error("failed-to-add-initial-member", zap.Error(err), zap.String("member_id", memberReq.ID)) + return nil, err + } + } + + // Validate group + if err := group.Validate(); err != nil { + log.Error("group-validation-failed", zap.Error(err)) + return nil, err + } + + // Create group in repository + createdGroup, err := s.GroupRepository.CreateGroup(ctx, group) + if err != nil { + log.Error("failed-to-create-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.created", + TargetId: createdGroup.ID, + Details: map[string]interface{}{ + "group_name": createdGroup.Name, + "group_type": createdGroup.Type, + }, + }) + } + + return &CreateGroupResponse{Group: createdGroup}, nil +} + +// GetGroupByID retrieves a group by ID +func (s *Service) GetGroupByID(ctx context.Context, req *GetGroupByIDRequest) (*GetGroupByIDResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "get-group-by-id")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("getting-group-by-id", zap.String("id", req.ID)) + + group, err := s.GroupRepository.GetGroupByID(ctx, req.ID) + if err != nil { + log.Error("failed-to-get-group-by-id", zap.Error(err), zap.String("id", req.ID)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + return &GetGroupByIDResponse{Group: group}, nil +} + +// GetGroupByName retrieves a group by its name +func (s *Service) GetGroupByNanoID(ctx context.Context, req *GetGroupByNanoIDRequest) (*GetGroupByNanoIDResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "get-group-by-nano-id")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("getting-group-by-nano-id", zap.String("nano_id", req.NanoID)) + + group, err := s.GroupRepository.GetGroupByNanoID(ctx, req.NanoID) + if err != nil { + log.Error("failed-to-get-group-by-nano-id", zap.Error(err), zap.String("nano_id", req.NanoID)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + return &GetGroupByNanoIDResponse{Group: group}, nil +} + +// GetGroupByName retrieves a group by name and optional type +func (s *Service) GetGroupByName(ctx context.Context, req *GetGroupByNameRequest) (*GetGroupByNameResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "get-group-by-name")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("getting-group-by-name", zap.String("name", req.Name), zap.String("type", req.Type)) + + group, err := s.GroupRepository.GetGroupByName(ctx, req.Name, req.Type, true) + if err != nil { + log.Error("failed-to-get-group-by-name", zap.Error(err), zap.String("name", req.Name)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + return &GetGroupByNameResponse{Group: group}, nil +} + +// UpdateGroup updates an existing group +func (s *Service) UpdateGroup(ctx context.Context, req *UpdateGroupRequest) (*UpdateGroupResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "update-group")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("updating-group", zap.String("id", req.ID)) + + targetGroupID := req.ID + if req.Group != nil && req.Group.ID != "" { + targetGroupID = req.Group.ID + } + + // Get existing group + group, err := s.GroupRepository.GetGroupByID(ctx, targetGroupID) + if err != nil { + log.Error("failed-to-get-group-for-update", zap.Error(err), zap.String("id", targetGroupID)) + return nil, err + } + + if req.Group != nil { + groupWithProvidedData := req.Group + + groupWithProvidedData.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + if groupWithProvidedData.Name != "" && groupWithProvidedData.Name != group.Name { + // Check if new name already exists + existingGroup, _ := s.GroupRepository.GetGroupByName(ctx, groupWithProvidedData.Name, groupWithProvidedData.Type, false) + if existingGroup != nil && existingGroup.ID != group.ID { + return nil, errors.New(ErrKeyNameAlreadyExists) + } + group.Name = groupWithProvidedData.Name + } + + group = groupWithProvidedData + } + + if req.Group == nil { + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Track if any changes were made + hasChanges := false + + // Update fields if provided + if req.Name != nil && *req.Name != group.Name { + group.Name = *req.Name + hasChanges = true + } + + if req.Description != nil && *req.Description != group.DisplayInfo.Description { + group.DisplayInfo.Description = *req.Description + hasChanges = true + } + + if req.Email != nil && *req.Email != group.DisplayInfo.Email { + group.DisplayInfo.Email = *req.Email + hasChanges = true + } + + if req.Icon != nil && *req.Icon != group.DisplayInfo.Icon { + group.DisplayInfo.Icon = *req.Icon + hasChanges = true + } + + if req.Visibility != nil && *req.Visibility != group.Settings.Visibility { + group.Settings.Visibility = *req.Visibility + hasChanges = true + } + + if req.Status != nil && *req.Status != group.Status { + _, err := group.UpdateStatus(*req.Status) + if err != nil { + log.Error("failed-to-update-group-status", zap.Error(err)) + return nil, err + } + hasChanges = true + } + + // Update extensions + if len(req.Extensions) > 0 { + for key, value := range req.Extensions { + group.SetExtension(key, value) + } + hasChanges = true + } + + if !hasChanges { + log.Debug("no-changes-detected-for-group-update", zap.String("id", req.ID)) + return &UpdateGroupResponse{Group: group}, nil + } + } + + // Update timestamps + group.SetUpdatedAtNow() + + // Validate group + if err := group.Validate(); err != nil { + log.Error("group-validation-failed", zap.Error(err)) + return nil, err + } + + // Ensure version is set to 1 + group.EnsureVersion() + + // Update in repository + updatedGroup, err := s.GroupRepository.UpdateGroup(ctx, group) + if err != nil { + log.Error("failed-to-update-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.updated", + TargetId: updatedGroup.ID, + Details: map[string]interface{}{ + "group_name": updatedGroup.Name, + }, + }) + } + + log.Info("group-updated-successfully", zap.String("group-id", updatedGroup.ID)) + + return &UpdateGroupResponse{Group: updatedGroup}, nil +} + +// DeleteGroup deletes a group +func (s *Service) DeleteGroup(ctx context.Context, req *DeleteGroupRequest) (*DeleteGroupResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "delete-group")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("deleting-group", zap.String("id", req.ID), zap.Bool("hard_delete", req.HardDelete)) + + // Verify group exists + _, err := s.GroupRepository.GetGroupByID(ctx, req.ID) + if err != nil { + log.Error("group-not-found-for-deletion", zap.Error(err), zap.String("id", req.ID)) + return nil, err + } + + if req.HardDelete { + // Hard delete - permanently remove + err = s.GroupRepository.DeleteGroupByID(ctx, req.ID) + } else { + // Soft delete - mark as deleted + deletedAt := s.TimeProvider.NowUTC() + err = s.GroupRepository.SoftDeleteGroup(ctx, req.ID, req.DeletedByID, deletedAt) + } + + if err != nil { + log.Error("failed-to-delete-group", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.deleted", + TargetId: req.ID, + Details: map[string]interface{}{ + "hard_delete": req.HardDelete, + "deleted_by_id": req.DeletedByID, + }, + }) + } + + return &DeleteGroupResponse{ + Success: true, + Message: "Group deleted successfully", + }, nil +} + +// GetGroups retrieves groups with filters and pagination +func (s *Service) GetGroups(ctx context.Context, req *GetGroupsRequest) (*GetGroupsResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "get-groups")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("getting-groups", zap.Int("page", req.Page), zap.Int("page_size", req.PageSize)) + + // Normalize PerPage to PageSize + if req.PerPage > 0 { + req.PageSize = req.PerPage + } + + // Set defaults + if req.PageSize == 0 { + req.PageSize = 20 + } + if req.Page == 0 { + req.Page = 1 + } + + // Get total count + totalGroups, err := s.GroupRepository.GetTotalGroups(ctx, req) + if err != nil { + log.Error("failed-to-get-total-groups-count", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + req.TotalCount = int(totalGroups) + log.Debug("handling-get-groups-request-total-groups-found", zap.Int64("total", totalGroups), zap.Any("request", req)) + + // Get groups + groups, err := s.GroupRepository.GetGroups(ctx, req) + if err != nil { + log.Error("failed-to-get-groups", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Reinject dependencies for all groups + groupPointers := make([]*UniversalGroup, len(groups)) + for i := range groups { + groups[i].SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + groupPointers[i] = &groups[i] + } + + paginatedResponse, err := toolbox.Paginate(ctx, &toolbox.PaginationRequest{ + PerPage: req.PageSize, + Page: req.Page, + }, groupPointers, req.TotalCount) + + if err != nil { + return nil, err + } + + return &GetGroupsResponse{ + Total: paginatedResponse.Total, + TotalPages: paginatedResponse.TotalPages, + Groups: paginatedResponse.Resources, + Page: paginatedResponse.Page, + PerPage: paginatedResponse.ResourcePerPage, + }, nil +} + +// AddMember adds a member to a group +func (s *Service) AddMember(ctx context.Context, req *AddMemberRequest) (*AddMemberResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "add-member")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("adding-member-to-group", zap.String("group_id", req.GroupID), zap.String("member_id", req.MemberID)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, req.GroupID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err), zap.String("group_id", req.GroupID)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Add member + if _, err := group.AddMember(req.MemberID, req.Type, req.Role); err != nil { + log.Error("failed-to-add-member", zap.Error(err)) + return nil, err + } + + // Save to repository + updatedGroup, err := s.GroupRepository.UpdateGroup(ctx, group) + if err != nil { + log.Error("failed-to-update-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Get the added member + addedMember, _ := group.GetMemberByID(req.MemberID) + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.member.added", + TargetId: req.GroupID, + Details: map[string]interface{}{ + "member": addedMember, + }, + }) + } + + return &AddMemberResponse{ + Group: updatedGroup, + }, nil +} + +// RemoveMember removes a member from a group +func (s *Service) RemoveMember(ctx context.Context, req *RemoveMemberRequest) (*RemoveMemberResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "remove-member")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("removing-member-from-group", zap.String("group_id", req.GroupID), zap.String("member_id", req.MemberID)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, req.GroupID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err), zap.String("group_id", req.GroupID)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Remove member + if group, err = group.RemoveMember(req.MemberID); err != nil { + log.Error("failed-to-remove-member", zap.Error(err)) + return nil, err + } + + // Save to repository + updatedGroup, err := s.GroupRepository.UpdateGroup(ctx, group) + if err != nil { + log.Error("failed-to-update-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.member.removed", + TargetId: req.GroupID, + Details: map[string]interface{}{ + "member": map[string]string{ + "id": req.MemberID, + }, + }, + }) + } + + return &RemoveMemberResponse{ + Group: updatedGroup, + }, nil +} + +// UpdateMemberRole updates a member's role in a group +func (s *Service) UpdateMemberRole(ctx context.Context, req *UpdateMemberRoleRequest) (*UpdateGroupResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "update-member-role")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("updating-member-role", zap.String("group_id", req.GroupID), zap.String("member_id", req.MemberID)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, req.GroupID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Update role + if group, err = group.UpdateMemberRole(req.MemberID, req.NewRole); err != nil { + log.Error("failed-to-update-member-role", zap.Error(err)) + return nil, err + } + + // Save to repository + updatedGroup, err := s.GroupRepository.UpdateGroup(ctx, group) + if err != nil { + log.Error("failed-to-update-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + memberInfo, _ := group.GetMemberByID(req.MemberID) + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.member.role_updated", + TargetId: req.GroupID, + Details: map[string]interface{}{ + "member": memberInfo, + }, + }) + } + + return &UpdateGroupResponse{Group: updatedGroup}, nil +} + +// GetGroupMembers retrieves members of a group with optional filters +func (s *Service) GetGroupMembers(ctx context.Context, req *GetGroupMembersRequest) (*GetGroupMembersResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "get-group-members")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("getting-group-members", zap.String("group_id", req.GroupID)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, req.GroupID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Filter members + var members []Member + if req.MemberType != "" { + members = group.GetMembersByType(req.MemberType) + } else { + members = group.Members + } + + // Further filter by role if specified + if req.Role != "" { + filteredMembers := []Member{} + for _, member := range members { + if member.Role == req.Role { + filteredMembers = append(filteredMembers, member) + } + } + members = filteredMembers + } + + return &GetGroupMembersResponse{ + Members: members, + Count: len(members), + }, nil +} + +// UpdateLeadership updates group leadership +func (s *Service) UpdateLeadership(ctx context.Context, req *UpdateLeadershipRequest) (*UpdateGroupResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "update-leadership")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("updating-group-leadership", zap.String("group_id", req.GroupID)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, req.GroupID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Update leadership fields + hasChanges := false + if req.OwnerID != nil && *req.OwnerID != group.Leadership.OwnerID { + group.Leadership.OwnerID = *req.OwnerID + hasChanges = true + } + if req.HeadID != nil && *req.HeadID != group.Leadership.HeadID { + group.Leadership.HeadID = *req.HeadID + hasChanges = true + } + if req.LeadID != nil && *req.LeadID != group.Leadership.LeadID { + group.Leadership.LeadID = *req.LeadID + hasChanges = true + } + + if !hasChanges { + return nil, errors.New(ErrKeyNoChangesDetected) + } + + // Update timestamps + group.SetUpdatedAtNow() + + // Save to repository + updatedGroup, err := s.GroupRepository.UpdateGroup(ctx, group) + if err != nil { + log.Error("failed-to-update-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.leadership.updated", + TargetId: req.GroupID, + }) + } + + return &UpdateGroupResponse{Group: updatedGroup}, nil +} + +// ArchiveGroup archives a group +func (s *Service) ArchiveGroup(ctx context.Context, req *ArchiveGroupRequest) (*UpdateGroupResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "archive-group")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("archiving-group", zap.String("id", req.ID)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, req.ID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Update status to archived + _, err = group.UpdateStatus(GroupStatusArchived) + if err != nil { + log.Error("failed-to-archive-group", zap.Error(err)) + return nil, err + } + + // Save to repository + updatedGroup, err := s.GroupRepository.UpdateGroup(ctx, group) + if err != nil { + log.Error("failed-to-update-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.archived", + TargetId: req.ID, + }) + } + + return &UpdateGroupResponse{Group: updatedGroup}, nil +} + +// RestoreGroup restores an archived group +func (s *Service) RestoreGroup(ctx context.Context, req *RestoreGroupRequest) (*UpdateGroupResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "restore-group")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("restoring-group", zap.String("id", req.ID)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, req.ID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Update status to active + _, err = group.UpdateStatus(GroupStatusActive) + if err != nil { + log.Error("failed-to-restore-group", zap.Error(err)) + return nil, err + } + + // Save to repository + updatedGroup, err := s.GroupRepository.UpdateGroup(ctx, group) + if err != nil { + log.Error("failed-to-update-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "group.restored", + TargetId: req.ID, + }) + } + + return &UpdateGroupResponse{Group: updatedGroup}, nil +} + +// GetGroupsByMemberID retrieves groups that contain a specific member +func (s *Service) GetGroupsByMemberID(ctx context.Context, req *GetGroupsRequest) (*GetGroupsResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "get-groups-by-member-id")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("getting-groups-by-member-id", zap.String("member_id", req.MemberID)) + + // Normalize PerPage to PageSize + if req.PerPage > 0 { + req.PageSize = req.PerPage + } + + // Set defaults + if req.PageSize == 0 { + req.PageSize = 20 + } + if req.Page == 0 { + req.Page = 1 + } + + // Get total count using GetTotalGroups + totalGroups, err := s.GroupRepository.GetTotalGroups(ctx, req) + if err != nil { + log.Error("failed-to-get-total-groups-count-by-member-id", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + req.TotalCount = int(totalGroups) + log.Debug("handling-get-groups-by-member-id-total-groups-found", zap.Int64("total", totalGroups), zap.Any("request", req)) + + groups, err := s.GroupRepository.GetGroupsByMemberID(ctx, req.MemberID, req.MemberType, req.Page, req.PageSize) + if err != nil { + log.Error("failed-to-get-groups-by-member-id", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Reinject dependencies + groupPointers := make([]*UniversalGroup, len(groups)) + for i := range groups { + groups[i].SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + groupPointers[i] = &groups[i] + } + + paginatedResponse, err := toolbox.Paginate(ctx, &toolbox.PaginationRequest{ + PerPage: req.PageSize, + Page: req.Page, + }, groupPointers, req.TotalCount) + + if err != nil { + return nil, err + } + + return &GetGroupsResponse{ + Total: paginatedResponse.Total, + TotalPages: paginatedResponse.TotalPages, + Groups: paginatedResponse.Resources, + Page: paginatedResponse.Page, + PerPage: paginatedResponse.ResourcePerPage, + }, nil +} + +// GetGroupsByLeaderID retrieves groups where user is a leader +func (s *Service) GetGroupsByLeaderID(ctx context.Context, req *GetGroupsRequest) (*GetGroupsResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "get-groups-by-leader-id")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("getting-groups-by-leader-id", zap.String("owner_id", req.OwnerID), zap.String("head_id", req.HeadID), zap.String("lead_id", req.LeadID)) + + // Normalize PerPage to PageSize + if req.PerPage > 0 { + req.PageSize = req.PerPage + } + + // Set defaults + if req.PageSize == 0 { + req.PageSize = 20 + } + if req.Page == 0 { + req.Page = 1 + } + + // Get total count using GetTotalGroups + totalGroups, err := s.GroupRepository.GetTotalGroups(ctx, req) + if err != nil { + log.Error("failed-to-get-total-groups-count-by-leader-id", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + req.TotalCount = int(totalGroups) + log.Debug("handling-get-groups-by-leader-id-total-groups-found", zap.Int64("total", totalGroups), zap.Any("request", req)) + + // Determine which leader field to use + leaderID := req.OwnerID + if leaderID == "" { + leaderID = req.HeadID + } + if leaderID == "" { + leaderID = req.LeadID + } + + groups, err := s.GroupRepository.GetGroupsByLeaderID(ctx, leaderID, req.Page, req.PageSize) + if err != nil { + log.Error("failed-to-get-groups-by-leader-id", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Reinject dependencies + groupPointers := make([]*UniversalGroup, len(groups)) + for i := range groups { + groups[i].SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + groupPointers[i] = &groups[i] + } + + paginatedResponse, err := toolbox.Paginate(ctx, &toolbox.PaginationRequest{ + PerPage: req.PageSize, + Page: req.Page, + }, groupPointers, req.TotalCount) + + if err != nil { + return nil, err + } + + return &GetGroupsResponse{ + Total: paginatedResponse.Total, + TotalPages: paginatedResponse.TotalPages, + Groups: paginatedResponse.Resources, + Page: paginatedResponse.Page, + PerPage: paginatedResponse.ResourcePerPage, + }, nil +} + +// GetGroupStats retrieves statistics for a group +func (s *Service) GetGroupStats(ctx context.Context, groupID string) (*GetGroupStatsResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "get-group-stats")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("getting-group-stats", zap.String("group_id", groupID)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, groupID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Calculate stats + userCount := len(group.GetUserMemberIDs()) + groupCount := len(group.GetGroupMemberIDs()) + + return &GetGroupStatsResponse{ + GroupID: group.ID, + MemberCount: group.GetMemberCount(), + UserMemberCount: userCount, + GroupMemberCount: groupCount, + SubgroupCount: groupCount, + }, nil +} + +// SetGroupExtension sets an extension field value +func (s *Service) SetGroupExtension(ctx context.Context, groupID, key string, value interface{}) (*UpdateGroupResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "set-group-extension")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("setting-group-extension", zap.String("group_id", groupID), zap.String("key", key)) + + // Get group + group, err := s.GroupRepository.GetGroupByID(ctx, groupID) + if err != nil { + log.Error("failed-to-get-group", zap.Error(err)) + return nil, err + } + + // Reinject dependencies + group.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + + // Set extension + group.SetExtension(key, value) + + // Save to repository + updatedGroup, err := s.GroupRepository.UpdateGroup(ctx, group) + if err != nil { + log.Error("failed-to-update-group-in-repository", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + return &UpdateGroupResponse{Group: updatedGroup}, nil +} + +// SearchGroupsByExtension searches for groups by extension field value +func (s *Service) SearchGroupsByExtension(ctx context.Context, req *GetGroupsRequest) (*GetGroupsResponse, error) { + log := logger.AcquireFrom(ctx).With(zap.String("method", "search-groups-by-extension")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("searching-groups-by-extension", zap.Any("extension_filters", req.ExtensionFilters)) + + // Normalize PerPage to PageSize + if req.PerPage > 0 { + req.PageSize = req.PerPage + } + + // Set defaults + if req.PageSize == 0 { + req.PageSize = 20 + } + if req.Page == 0 { + req.Page = 1 + } + + // Get total count using GetTotalGroups + totalGroups, err := s.GroupRepository.GetTotalGroups(ctx, req) + if err != nil { + log.Error("failed-to-get-total-groups-count-by-extension", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + req.TotalCount = int(totalGroups) + log.Debug("handling-search-groups-by-extension-total-groups-found", zap.Int64("total", totalGroups), zap.Any("request", req)) + + // Extract first extension filter for backward compatibility with repository method + var key string + var value interface{} + for k, v := range req.ExtensionFilters { + key = k + value = v + break + } + + groups, err := s.GroupRepository.SearchGroupsByExtension(ctx, key, value, req.Page, req.PageSize) + if err != nil { + log.Error("failed-to-search-groups-by-extension", zap.Error(err)) + return nil, errors.New(ErrKeyDatabaseError) + } + + // Reinject dependencies + groupPointers := make([]*UniversalGroup, len(groups)) + for i := range groups { + groups[i].SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + groupPointers[i] = &groups[i] + } + + paginatedResponse, err := toolbox.Paginate(ctx, &toolbox.PaginationRequest{ + PerPage: req.PageSize, + Page: req.Page, + }, groupPointers, req.TotalCount) + + if err != nil { + return nil, err + } + + return &GetGroupsResponse{ + Total: paginatedResponse.Total, + TotalPages: paginatedResponse.TotalPages, + Groups: paginatedResponse.Resources, + Page: paginatedResponse.Page, + PerPage: paginatedResponse.ResourcePerPage, + }, nil +} + +// BulkUpdateGroupsStatus updates status for multiple groups +func (s *Service) BulkUpdateGroupsStatus(ctx context.Context, groupIDs []string, status string) error { + log := logger.AcquireFrom(ctx).With(zap.String("method", "bulk-update-groups-status")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + log.Debug("bulk-updating-group-status", zap.Int("count", len(groupIDs)), zap.String("status", status)) + + err := s.GroupRepository.BulkUpdateGroupsStatus(ctx, groupIDs, status) + if err != nil { + log.Error("failed-to-bulk-update-groups-status", zap.Error(err)) + return errors.New(ErrKeyDatabaseError) + } + + // Audit log + if s.AuditService != nil { + s.AuditService.LogAuditEvent(ctx, &audit.LogAuditEventRequest{ + Action: "groups.bulk_status_updated", + Details: map[string]interface{}{ + "group_ids": groupIDs, + "status": status, + }, + }) + } + + return nil +} diff --git a/external/group/utils.groupfactory.go b/external/group/utils.groupfactory.go new file mode 100644 index 0000000..b114c59 --- /dev/null +++ b/external/group/utils.groupfactory.go @@ -0,0 +1,109 @@ +package group + +// GroupFactory provides convenient methods for creating groups +type GroupFactory struct { + Config *GroupConfig + IDGenerator IDGenerator + TimeProvider TimeProvider + StringUtils StringUtils +} + +// NewGroupFactory creates a new group factory +func NewGroupFactory( + config *GroupConfig, + idGenerator IDGenerator, + timeProvider TimeProvider, + stringUtils StringUtils, +) *GroupFactory { + if config == nil { + config = DefaultGroupConfig() + } + if idGenerator == nil { + idGenerator = NewDefaultIDGenerator() + } + if timeProvider == nil { + timeProvider = NewDefaultTimeProvider() + } + if stringUtils == nil { + stringUtils = NewDefaultStringUtils() + } + + return &GroupFactory{ + Config: config, + IDGenerator: idGenerator, + TimeProvider: timeProvider, + StringUtils: stringUtils, + } +} + +// CreateGroup creates a new group with basic information +func (f *GroupFactory) CreateGroup(name, groupType string) *UniversalGroup { + group := NewUniversalGroup(f.Config, f.IDGenerator, f.TimeProvider, f.StringUtils) + group.Name = name + group.Type = groupType + group.SetInitialState() + return group +} + +// CreateTeam creates a new team group +func (f *GroupFactory) CreateTeam(name string) *UniversalGroup { + return f.CreateGroup(name, GroupTypeTeam) +} + +// CreateDepartment creates a new department group +func (f *GroupFactory) CreateDepartment(name string) *UniversalGroup { + return f.CreateGroup(name, GroupTypeDepartment) +} + +// CreateOrganization creates a new organization group +func (f *GroupFactory) CreateOrganization(name string) *UniversalGroup { + return f.CreateGroup(name, GroupTypeOrganisation) +} + +// CreateProject creates a new project group +func (f *GroupFactory) CreateProject(name string) *UniversalGroup { + return f.CreateGroup(name, GroupTypeProject) +} + +// CreateCommunity creates a new community group +func (f *GroupFactory) CreateCommunity(name string) *UniversalGroup { + return f.CreateGroup(name, GroupTypeCommunity) +} + +// CreateGroupWithMembers creates a group and adds initial members +func (f *GroupFactory) CreateGroupWithMembers(name, groupType string, memberIDs []string, memberType string) *UniversalGroup { + group := f.CreateGroup(name, groupType) + for _, memberID := range memberIDs { + group.AddMember(memberID, memberType, MemberRoleMember) + } + return group +} + +// CreateGroupWithOwner creates a group with an owner +func (f *GroupFactory) CreateGroupWithOwner(name, groupType, ownerID string) *UniversalGroup { + group := f.CreateGroup(name, groupType) + group.Leadership.OwnerID = ownerID + group.AddMember(ownerID, MemberTypeUser, MemberRoleOwner) + return group +} + +// CreateHierarchicalGroup creates a parent-child group relationship +func (f *GroupFactory) CreateHierarchicalGroup(parentName, parentType, childName, childType string) (*UniversalGroup, *UniversalGroup) { + parent := f.CreateGroup(parentName, parentType) + child := f.CreateGroup(childName, childType) + parent.AddMember(child.ID, MemberTypeGroup, MemberRoleMember) + return parent, child +} + +// ReinjectDependencies reinjects dependencies into an existing group +func (f *GroupFactory) ReinjectDependencies(group *UniversalGroup) *UniversalGroup { + return group.SetDependencies(f.Config, f.IDGenerator, f.TimeProvider, f.StringUtils) +} + +// ReinjectDependenciesMultiple reinjects dependencies into multiple groups +func (f *GroupFactory) ReinjectDependenciesMultiple(groups []*UniversalGroup) []*UniversalGroup { + for _, group := range groups { + group.SetDependencies(f.Config, f.IDGenerator, f.TimeProvider, f.StringUtils) + } + return groups +} diff --git a/external/group/utils.toolbox.go b/external/group/utils.toolbox.go new file mode 100644 index 0000000..299e345 --- /dev/null +++ b/external/group/utils.toolbox.go @@ -0,0 +1,68 @@ +package group + +import ( + "time" + + "github.com/ooaklee/ghatd/external/toolbox" +) + +// DefaultIDGenerator implementation using toolbox +type DefaultIDGenerator struct{} + +func (d *DefaultIDGenerator) GenerateUUID() string { + return toolbox.GenerateUuidV4() +} + +func (d *DefaultIDGenerator) GenerateNanoID() string { + return toolbox.GenerateNanoId() +} + +// NewDefaultIDGenerator creates a new default ID generator +func NewDefaultIDGenerator() IDGenerator { + return &DefaultIDGenerator{} +} + +// DefaultTimeProvider implementation +type DefaultTimeProvider struct{} + +func (d *DefaultTimeProvider) Now() time.Time { + return time.Now() +} + +func (d *DefaultTimeProvider) NowUTC() string { + return time.Now().UTC().Format(time.RFC3339) +} + +// NewDefaultTimeProvider creates a new default time provider +func NewDefaultTimeProvider() TimeProvider { + return &DefaultTimeProvider{} +} + +// DefaultStringUtils implementation using toolbox +type DefaultStringUtils struct{} + +func (d *DefaultStringUtils) ToTitleCase(s string) string { + return toolbox.StringConvertToTitleCase(s) +} + +func (d *DefaultStringUtils) ToLowerCase(s string) string { + return toolbox.StringStandardisedToLower(s) +} + +func (d *DefaultStringUtils) ToUpperCase(s string) string { + return toolbox.StringStandardisedToUpper(s) +} + +func (d *DefaultStringUtils) InSlice(item string, slice []string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// NewDefaultStringUtils creates a new default string utils +func NewDefaultStringUtils() StringUtils { + return &DefaultStringUtils{} +} diff --git a/external/policy/errormap.go b/external/policy/errormap.go index 1435247..6acb7a6 100644 --- a/external/policy/errormap.go +++ b/external/policy/errormap.go @@ -7,7 +7,7 @@ import ( // PolicyErrorMap holds Error keys, their corresponding human-friendly message, and response status code // nolint will be used later var PolicyErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ - ErrKeyPolicyError: {Title: "Bad Request", Detail: "Some policy error", StatusCode: 400, Code: "P0-001"}, + ErrKeyPolicyError: {Title: "Bad Request", Detail: "An error occurred while processing policy", StatusCode: 400, Code: "P0-001"}, ErrKeyPolicyNotFound: {Title: "Not Found", Detail: "Policy not found", StatusCode: 404, Code: "P0-002"}, ErrKeyInvalidpolicyName: {Title: "Bad Request", Detail: "Invalid policy name", StatusCode: 400, Code: "P0-003"}, } diff --git a/external/policy/service.go b/external/policy/service.go index 37f490f..e63869b 100644 --- a/external/policy/service.go +++ b/external/policy/service.go @@ -43,12 +43,12 @@ func (s *Service) GetPolicyByName(ctx context.Context, r *GetPolicyByNameRequest zap.AddStacktrace(zap.DPanicLevel), ) + // standardise requested policy name + r.PolicyName = standardisePolicyName(r.PolicyName) + for _, policy := range s.Store.GetPolicies() { - policyName := strings.ReplaceAll( - toolbox.StringStandardisedToLower(policy.Name), - " ", - "-") + policyName := standardisePolicyName(policy.Name) logger.Info("policy-names-to-compare", zap.String("policy-name", policyName), zap.String("requested-policy-name", r.PolicyName)) @@ -59,3 +59,12 @@ func (s *Service) GetPolicyByName(ctx context.Context, r *GetPolicyByNameRequest return nil, errors.New(ErrKeyPolicyNotFound) } + +// standardisePolicyName handles converting a policy name to lowercase +// and replacing spaces with hyphens. +func standardisePolicyName(name string) string { + return strings.ReplaceAll( + toolbox.StringStandardisedToLower(name), + " ", + "-") +} diff --git a/external/user/errormap.go b/external/user/errormap.go index b2e6a28..701a5f9 100644 --- a/external/user/errormap.go +++ b/external/user/errormap.go @@ -7,12 +7,13 @@ import ( // UserErrorMap holds Error keys, their corresponding human-friendly message, and response status code // Use https://docs.microsoft.com/en-us/troubleshoot/iis/http-status-code to expand messages i.e. AccessDenied1 var UserErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ - ErrKeyInvalidUserBody: {Title: "Bad Request", Detail: "Check submitted user information.", StatusCode: 400}, - ErrKeyInvalidUserID: {Title: "Bad Request", Detail: "User ID missing or malformatted.", StatusCode: 400}, - ErrKeyUserNeverActivated: {Title: "Invalid User State", Detail: "User resource state conflicts with request.", StatusCode: 409}, - ErrKeyInvalidUserOriginStatus: {Title: "Invalid User State", Detail: "User resource state conflicts with request.", StatusCode: 409}, - ErrKeyInvalidQueryParam: {Title: "Bad Request.", Detail: "Invalid query param(s) passed.", StatusCode: 400}, - ErrKeyPageOutOfRange: {Title: "Bad Request.", Detail: "Page out of range.", StatusCode: 400}, - ErrKeyResourceConflict: {Title: "User registered on system.", StatusCode: 409}, - ErrKeyResourceNotFound: {Title: "User resource not found.", StatusCode: 404}, + ErrKeyInvalidUserBody: {Title: "Bad Request", Detail: "Check submitted user information.", StatusCode: 400, Code: "U0-001"}, + ErrKeyInvalidUserID: {Title: "Bad Request", Detail: "User ID missing or malformatted.", StatusCode: 400, Code: "U0-002"}, + ErrKeyUserNeverActivated: {Title: "Invalid User State", Detail: "User resource state conflicts with request.", StatusCode: 409, Code: "U0-003"}, + ErrKeyInvalidUserOriginStatus: {Title: "Invalid User State", Detail: "User resource state conflicts with request.", StatusCode: 409, Code: "U0-004"}, + ErrKeyInvalidQueryParam: {Title: "Bad Request.", Detail: "Invalid query param(s) passed.", StatusCode: 400, Code: "U0-005"}, + ErrKeyPageOutOfRange: {Title: "Bad Request.", Detail: "Page out of range.", StatusCode: 400, Code: "U0-006"}, + ErrKeyResourceConflict: {Title: "Conflict", Detail: "User already exists on system.", StatusCode: 409, Code: "U0-007"}, + ErrKeyResourceNotFound: {Title: "Not Found", Detail: "User resource not found.", StatusCode: 404, Code: "U0-008"}, + ErrKeyNoChangesDetected: {Title: "Bad Request", Detail: "No changes detected.", StatusCode: 400, Code: "U0-009"}, } diff --git a/external/user/fender.go b/external/user/fender.go index 157d70a..bef688e 100644 --- a/external/user/fender.go +++ b/external/user/fender.go @@ -4,7 +4,6 @@ import ( "errors" "net/http" - accessmanagerhelpers "github.com/ooaklee/ghatd/external/accessmanager/helpers" "github.com/ooaklee/ghatd/external/toolbox" "github.com/ritwickdey/querydecoder" ) @@ -53,7 +52,6 @@ func MapRequestToGetUsersRequest(request *http.Request, validator UserValidator) var err error parsedRequest := &GetUsersRequest{} - parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(request.Context()) // get request queries query := request.URL.Query() diff --git a/external/user/handler_test.go b/external/user/handler_test.go deleted file mode 100644 index b5020f9..0000000 --- a/external/user/handler_test.go +++ /dev/null @@ -1,956 +0,0 @@ -package user_test - -// func TestHandler_CreateUser(t *testing.T) { - -// tests := []struct { -// name string -// userService *servicestubs.User -// request *http.Request -// assertResponse func(w *httptest.ResponseRecorder, t *testing.T) -// expectedStatusCode int -// }{ -// { -// name: "Failure - Invalid request body", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodPost, "/v1/users", strings.NewReader( -// string(`{ "title": "Mr", "full_name": "John D Doe" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Check submitted user information."}, res.Status) -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// { -// name: "Failure - Invalid Email", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodPost, "/v1/users", strings.NewReader( -// string(`{ "first_name": "john", "last_name": "doe", "email" : "johndoe.gmail.com" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Check submitted user information."}, res.Status) -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// { -// name: "Failure - First name needed", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodPost, "/v1/users", strings.NewReader( -// string(`{ "first_name": "", "last_name": "doe", "email" : "johndoe@gmail.com" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Check submitted user information."}, res.Status) -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// { -// name: "Failure - Last name needed", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodPost, "/v1/users", strings.NewReader( -// string(`{ "first_name": "john", "last_name": "", "email" : "johndoe@gmail.com" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Check submitted user information."}, res.Status) -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// { -// name: "Failure - User already exists", -// userService: &servicestubs.User{ -// CreateUserError: errors.New(user.ErrKeyResourceConflict), -// }, -// request: httptest.NewRequest(http.MethodPost, "/v1/users", strings.NewReader( -// string(`{ "first_name": "john", "last_name": "doe", "email" : "johndoe@gmail.com" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "User registered on system."}, res.Status) -// }, -// expectedStatusCode: http.StatusConflict, -// }, -// { -// name: "Failure - Service Error", -// userService: &servicestubs.User{ -// CreateUserError: errors.New("UnknownServiceError"), -// }, -// request: httptest.NewRequest(http.MethodPost, "/v1/users", strings.NewReader( -// string(`{ "first_name": "john", "last_name": "doe", "email" : "johndoe@gmail.com" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Internal Server Error"}, res.Status) -// }, -// expectedStatusCode: http.StatusInternalServerError, -// }, -// { -// name: "Success", -// userService: &servicestubs.User{ -// CreateUserResponse: &user.CreateUserResponse{ -// User: *getMockCreatedUser(), -// }, -// }, -// request: httptest.NewRequest(http.MethodPost, "/v1/users", strings.NewReader( -// string(`{ "first_name": "john", "last_name": "doe", "email" : "johndoe@gmail.com" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// embeddedResponse := user.User{} - -// res := response.DTO{ -// Data: &embeddedResponse, -// } - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("CreateUser() failed, cannot get res content: %v", err) -// } - -// assert.Equal(t, user.User{ -// ID: "fcbd2a74-22ee-4b6f-8709-11772fce4afd", -// FirstName: "John", -// LastName: "Doe", -// Email: "johndoe@gmail.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-05-12T21:05:05", -// }, -// }, embeddedResponse) -// }, -// expectedStatusCode: http.StatusCreated, -// }, -// } - -// for _, test := range tests { -// t.Run(test.name, func(t *testing.T) { - -// v := validator.NewValidator() -// w := httptest.NewRecorder() -// user.NewHandler(test.userService, v).CreateUser(w, test.request) - -// assert.Equal(t, test.expectedStatusCode, w.Code) -// test.assertResponse(w, t) - -// }) -// } -// } - -// func TestHandler_GetUserByID(t *testing.T) { -// tests := []struct { -// name string -// userService *servicestubs.User -// request *http.Request -// assertResponse func(w *httptest.ResponseRecorder, t *testing.T) -// expectedStatusCode int -// expectedMessage string -// }{ -// { -// name: "Success - User found", -// userService: &servicestubs.User{ -// GetUserByIDResponse: &user.GetUserByIDResponse{ -// User: getMockSampleUser()[0], -// }, -// }, -// request: httptest.NewRequest(http.MethodGet, "/user/6ab2144b-692d-41e0-a4d3-9e811ed673b7", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// embeddedResponse := user.User{} - -// res := response.DTO{ -// Data: &embeddedResponse, -// } - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("GetUserByID() failed, cannot get res content: %v", err) -// } - -// expectedBody := user.User{ -// ID: "6ab2144b-692d-41e0-a4d3-9e811ed673b7", -// FirstName: "John", -// LastName: "Doe", -// Email: "johndoe@domain.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-02-11T11:09:33", -// }, -// } - -// assert.Equal(t, &expectedBody, res.Data) -// }, -// expectedStatusCode: http.StatusOK, -// }, -// { -// name: "Failure - User not found", -// userService: &servicestubs.User{ -// GetUserByIDError: errors.New(user.ErrKeyResourceNotFound), -// }, -// request: httptest.NewRequest(http.MethodGet, "/user/bd2cbad1-6ccf-48e3-bb92-bc9961bc011e", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "User resource not found."}, res.Status) - -// }, -// expectedStatusCode: http.StatusNotFound, -// }, -// { -// name: "Failure - ID validation failure", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodGet, "/user/incorrect-uuid-4", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. User ID missing or malformatted."}, res.Status) - -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// } - -// for _, test := range tests { -// t.Run(test.name, func(t *testing.T) { - -// w := httptest.NewRecorder() -// v := validator.NewValidator() - -// router := mux.NewRouter() - -// router.HandleFunc("/user/{userID}", user.NewHandler(test.userService, v).GetUserByID) -// router.ServeHTTP(w, test.request) - -// test.assertResponse(w, t) -// assert.Equal(t, test.expectedStatusCode, w.Code) - -// }) -// } -// } - -// func TestHandler_UpdateUser(t *testing.T) { - -// tests := []struct { -// name string -// userService *servicestubs.User -// request *http.Request -// assertResponse func(w *httptest.ResponseRecorder, t *testing.T) -// expectedStatusCode int -// }{ -// { -// name: "Failure - Invalid request body", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodPatch, "/v1/users/6ab2144b-692d-41e0-a4d3-9e811ed673b7", strings.NewReader( -// string(`{ "title": "Mr", "full_name": "John D Doe" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Check submitted user information."}, res.Status) -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// { -// name: "Failure - Name length too short", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodPatch, "/v1/users/6ab2144b-692d-41e0-a4d3-9e811ed673b7", strings.NewReader( -// string(`{ "first_name": "lee", "last_name": "p" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Check submitted user information."}, res.Status) -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// { -// name: "Failure - User not found", -// userService: &servicestubs.User{ -// UpdateUserError: errors.New("UserResourceNotFound"), -// }, -// request: httptest.NewRequest(http.MethodPatch, "/v1/users/021b68ff-eaf4-476a-87a0-01b5cf07fb31", strings.NewReader( -// string(`{ "first_name": "lee", "last_name": "anderson" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "User resource not found."}, res.Status) -// }, -// expectedStatusCode: http.StatusNotFound, -// }, -// { -// name: "Failure - Service Error", -// userService: &servicestubs.User{ -// UpdateUserError: errors.New("UnknownServiceError"), -// }, -// request: httptest.NewRequest(http.MethodPatch, "/v1/users/6ab2144b-692d-41e0-a4d3-9e811ed673b7", strings.NewReader( -// string(`{ "first_name": "lee", "last_name": "anderson" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Internal Server Error"}, res.Status) -// }, -// expectedStatusCode: http.StatusInternalServerError, -// }, -// { -// name: "Success", -// userService: &servicestubs.User{ -// UpdateUserResponse: &user.UpdateUserResponse{ -// User: *getMockUpdatedUser(), -// }, -// }, -// request: httptest.NewRequest(http.MethodPatch, "/v1/users/6ab2144b-692d-41e0-a4d3-9e811ed673b7", strings.NewReader( -// string(`{ "first_name": "lee", "last_name": "anderson" }`))), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// embeddedResponse := user.User{} - -// res := response.DTO{ -// Data: &embeddedResponse, -// } - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("UpdateUser() failed, cannot get res content: %v", err) -// } - -// assert.Equal(t, user.User{ -// ID: "6ab2144b-692d-41e0-a4d3-9e811ed673b7", -// FirstName: "Lee", -// LastName: "Anderson", -// Email: "johndoe@domain.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-02-11T11:09:33", -// UpdatedAt: "2021-05-20T16:49:05", -// }, -// }, embeddedResponse) -// }, -// expectedStatusCode: http.StatusOK, -// }, -// } - -// for _, test := range tests { -// t.Run(test.name, func(t *testing.T) { - -// w := httptest.NewRecorder() -// v := validator.NewValidator() - -// router := mux.NewRouter() - -// router.HandleFunc("/v1/users/{userID}", user.NewHandler(test.userService, v).UpdateUser) -// router.ServeHTTP(w, test.request) - -// assert.Equal(t, test.expectedStatusCode, w.Code) -// test.assertResponse(w, t) - -// }) -// } -// } - -// func TestHandler_GetUsers(t *testing.T) { - -// expectedFullGetUsersBody := []user.User{ -// { -// ID: "6ab2144b-692d-41e0-a4d3-9e811ed673b7", -// FirstName: "John", -// LastName: "Doe", -// Email: "johndoe@domain.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-02-11T11:09:33", -// }, -// }, -// { -// ID: "59e2fda6-2e86-4847-a186-6775bcfdecc1", -// FirstName: "Oliver", -// LastName: "Abraham", -// Email: "oliverabraham@domain.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-01-11T11:09:33", -// }, -// }, -// { -// ID: "bf894231-1267-4f84-b186-a1232f043fe9", -// FirstName: "Phil", -// LastName: "Anderson", -// Email: "philanderson@domain.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-01-12T12:09:33", -// }, -// }, -// { -// ID: "78c6d206-b4c7-4088-941b-afee13dc8fdc", -// FirstName: "Sam", -// LastName: "Carr", -// Email: "samcarr@domain.com", -// Roles: []string{ -// "ADMIN", -// }, -// Status: "ACTIVATE", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: true, -// EmailVerifiedAt: "2021-01-12T12:11:33", -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-01-12T12:09:33", -// StatusChangedAt: "2021-01-12T12:12:33", -// ActivatedAt: "2021-01-12T12:12:33", -// }, -// }, -// { -// ID: "8e9db5f6-2ecb-4a86-befc-8a8117cfc403", -// FirstName: "Sean", -// LastName: "Gill", -// Email: "seangill@domain.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-01-12T17:00:33", -// }, -// }, -// } - -// tests := []struct { -// name string -// userService *servicestubs.User -// request *http.Request -// assertResponse func(w *httptest.ResponseRecorder, t *testing.T) -// expectedStatusCode int -// expectedMessage string -// }{ -// { -// name: "Success - With Meta", -// userService: &servicestubs.User{ -// GetUsersResponse: &user.GetUsersResponse{ -// Total: 5, -// TotalPages: 1, -// UsersPerPage: 5, -// Page: 1, -// Users: getMockUsers(), -// }, -// }, -// request: httptest.NewRequest(http.MethodGet, "/users?order=created_at_asc&per_page=5&meta=true", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// embeddedResponse := []user.User{} - -// metaResponse := make(map[string]interface{}) - -// res := response.DTO{ -// Meta: metaResponse, -// Data: &embeddedResponse, -// } - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("GetUsers() failed, cannot get res content: %v", err) -// } - -// expectedBody := expectedFullGetUsersBody - -// expectedMeta := map[string]interface{}{ -// "users_per_page": float64(5), -// "total_users": float64(5), -// "total_pages": float64(1), -// "page": float64(1), -// } - -// assert.Equal(t, &expectedBody, res.Data) -// assert.Equal(t, expectedMeta, res.Meta) -// }, -// expectedStatusCode: http.StatusOK, -// }, -// { -// name: "Success - Without Meta", -// userService: &servicestubs.User{ -// GetUsersResponse: &user.GetUsersResponse{ -// Total: 4, -// TotalPages: 1, -// UsersPerPage: 4, -// Page: 1, -// Users: getMockUsers(), -// }, -// }, -// request: httptest.NewRequest(http.MethodGet, "/users?order=created_at_asc&per_page=4", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// embeddedResponse := []user.User{} - -// metaResponse := make(map[string]interface{}) - -// res := response.DTO{ -// Meta: metaResponse, -// Data: &embeddedResponse, -// } - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("GetUsers() failed, cannot get res content: %v", err) -// } - -// expectedBody := expectedFullGetUsersBody - -// assert.Equal(t, &expectedBody, res.Data) -// assert.Empty(t, res.Meta) -// }, -// expectedStatusCode: http.StatusOK, -// }, -// { -// name: "Success - Random With Meta", -// userService: &servicestubs.User{ -// GetUsersResponse: &user.GetUsersResponse{ -// Total: 1, -// TotalPages: 1, -// UsersPerPage: 1, -// Page: 1, -// Users: getMockSampleUser(), -// }, -// }, -// request: httptest.NewRequest(http.MethodGet, "/users?rand=true&meta=true", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// embeddedResponse := []user.User{} - -// metaResponse := make(map[string]interface{}) - -// res := response.DTO{ -// Meta: metaResponse, -// Data: &embeddedResponse, -// } - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("GetUsers() failed, cannot get res content: %v", err) -// } - -// expectedBody := []user.User{ -// { -// ID: "6ab2144b-692d-41e0-a4d3-9e811ed673b7", -// FirstName: "John", -// LastName: "Doe", -// Email: "johndoe@domain.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-02-11T11:09:33", -// }, -// }} -// expectedMeta := map[string]interface{}{ -// "users_per_page": float64(1), -// "total_users": float64(1), -// "total_pages": float64(1), -// "page": float64(1), -// } - -// assert.Equal(t, &expectedBody, res.Data) -// assert.Equal(t, expectedMeta, res.Meta) -// }, -// expectedStatusCode: http.StatusOK, -// }, -// { -// name: "Success - Random No Meta", -// userService: &servicestubs.User{ -// GetUsersResponse: &user.GetUsersResponse{ -// Total: 1, -// TotalPages: 1, -// UsersPerPage: 1, -// Page: 1, -// Users: getMockSampleUser(), -// }, -// }, -// request: httptest.NewRequest(http.MethodGet, "/users?rand=true", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// embeddedResponse := []user.User{} - -// metaResponse := make(map[string]interface{}) - -// res := response.DTO{ -// Meta: metaResponse, -// Data: &embeddedResponse, -// } - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("GetUsers() failed, cannot get res content: %v", err) -// } - -// expectedBody := []user.User{ -// { -// ID: "6ab2144b-692d-41e0-a4d3-9e811ed673b7", -// FirstName: "John", -// LastName: "Doe", -// Email: "johndoe@domain.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-02-11T11:09:33", -// }, -// }} - -// assert.Equal(t, &expectedBody, res.Data) -// assert.Empty(t, res.Meta) -// }, -// expectedStatusCode: http.StatusOK, -// }, -// { -// name: "Success - No Users in repo", -// userService: &servicestubs.User{ -// GetUsersResponse: &user.GetUsersResponse{ -// Total: 0, -// TotalPages: 0, -// UsersPerPage: 0, -// Page: 0, -// Users: []user.User{}, -// }, -// }, -// request: httptest.NewRequest(http.MethodGet, "/users", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// embeddedResponse := []user.User{} - -// res := response.DTO{ -// Data: &embeddedResponse, -// } - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("GetUsers() failed, cannot get res content: %v", err) -// } - -// expectedBody := []user.User{} - -// assert.Equal(t, &expectedBody, res.Data) -// }, -// expectedStatusCode: http.StatusOK, -// }, -// { -// name: "Failure - Unrecgnoised error", -// userService: &servicestubs.User{ -// GetUsersError: errors.New("UnknownServiceError"), -// }, -// request: httptest.NewRequest(http.MethodGet, "/users?order=created_at_asc&per_page=4", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Internal Server Error"}, res.Status) - -// }, -// expectedStatusCode: http.StatusInternalServerError, -// }, -// { -// name: "Failure - Page out of range", -// userService: &servicestubs.User{ -// GetUsersError: errors.New("PageOutOfRange"), -// }, -// request: httptest.NewRequest(http.MethodGet, "/users?order=created_at_asc&per_page=4", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Page out of range."}, res.Status) - -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// { -// name: "Failure - Query param validation failure (Order)", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodGet, "/users?order=invalid&per_page=4", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Invalid query param(s) passed."}, res.Status) - -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// { -// name: "Failure - Query param validation failure (status)", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodGet, "/users?status=invalid&per_page=4", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. Invalid query param(s) passed."}, res.Status) - -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// } - -// for _, test := range tests { -// t.Run(test.name, func(t *testing.T) { - -// w := httptest.NewRecorder() -// v := validator.NewValidator() - -// user.NewHandler(test.userService, v).GetUsers(w, test.request) - -// test.assertResponse(w, t) -// assert.Equal(t, test.expectedStatusCode, w.Code) - -// }) -// } -// } - -// func TestHandler_DeleteUser(t *testing.T) { -// tests := []struct { -// name string -// userService *servicestubs.User -// request *http.Request -// assertResponse func(w *httptest.ResponseRecorder, t *testing.T) -// expectedStatusCode int -// expectedError error -// }{ -// { -// name: "Success - User deleted", -// userService: &servicestubs.User{ -// DeleteUserError: nil, -// }, -// request: httptest.NewRequest(http.MethodDelete, "/v1/users/6ab2144b-692d-41e0-a4d3-9e811ed673b7", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("UpdateUser() failed, cannot get res content: %v", err) -// } - -// assert.Empty(t, res.Data) - -// }, -// expectedStatusCode: http.StatusOK, -// }, -// { -// name: "Failure - User not found", -// userService: &servicestubs.User{ -// DeleteUserError: errors.New("UserResourceNotFound"), -// }, -// request: httptest.NewRequest(http.MethodDelete, "/v1/users/6ab2144b-692d-41e0-a4d3-9e811ed673b7", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "User resource not found."}, res.Status) - -// }, -// expectedStatusCode: http.StatusNotFound, -// }, -// { -// name: "Failure - Internal error", -// userService: &servicestubs.User{ -// DeleteUserError: errors.New("boom boom pow"), -// }, -// request: httptest.NewRequest(http.MethodDelete, "/v1/users/6ab2144b-692d-41e0-a4d3-9e811ed673b7", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Internal Server Error"}, res.Status) - -// }, -// expectedStatusCode: http.StatusInternalServerError, -// }, -// { -// name: "Failure - ID validation failure", -// userService: &servicestubs.User{}, -// request: httptest.NewRequest(http.MethodDelete, "/v1/users/incorrect-uuid-4", nil), -// assertResponse: func(w *httptest.ResponseRecorder, t *testing.T) { - -// res := response.DTO{} - -// err := responsehelpers.UnmarshalResponseBody(w, &res) -// if err != nil { -// t.Fatalf("Cannot get response content: %v", err) -// } - -// assert.Equal(t, &response.StatusDTO{Message: "Bad Request. User ID missing or malformatted."}, res.Status) - -// }, -// expectedStatusCode: http.StatusBadRequest, -// }, -// } - -// for _, test := range tests { -// t.Run(test.name, func(t *testing.T) { - -// w := httptest.NewRecorder() -// v := validator.NewValidator() - -// router := mux.NewRouter() - -// router.HandleFunc("/v1/users/{userID}", user.NewHandler(test.userService, v).DeleteUser) -// router.ServeHTTP(w, test.request) - -// test.assertResponse(w, t) -// assert.Equal(t, test.expectedStatusCode, w.Code) - -// }) -// } -// } - -// func getMockUpdatedUser() *user.User { -// user := getMockUsers()[0] -// user.FirstName = "Lee" -// user.LastName = "Anderson" -// user.Meta.UpdatedAt = "2021-05-20T16:49:05" - -// return &user - -// } - -// func getMockCreatedUser() *user.User { -// return &user.User{ -// ID: "fcbd2a74-22ee-4b6f-8709-11772fce4afd", -// FirstName: "John", -// LastName: "Doe", -// Email: "johndoe@gmail.com", -// Roles: []string{}, -// Status: "PROVISIONED", -// Verified: user.UserVerifcationStatus{ -// EmailVerified: false, -// }, -// Meta: user.UserMeta{ -// CreatedAt: "2021-05-12T21:05:05", -// }, -// } -// } diff --git a/external/user/v2/errormap.go b/external/user/v2/errormap.go index 00d956e..4066698 100644 --- a/external/user/v2/errormap.go +++ b/external/user/v2/errormap.go @@ -104,9 +104,9 @@ var UserErrorMap reply.ErrorManifest = reply.ErrorManifest{ Code: "USV2-016", }, ErrKeyNoChangesDetected: { - Title: "Bad Request", + Title: "Conflict", Detail: "No changes detected", - StatusCode: 400, + StatusCode: 409, Code: "USV2-017", }, ErrKeyInvalidEmail: { diff --git a/external/user/v2/model.go b/external/user/v2/model.go index b5278dd..10d367a 100644 --- a/external/user/v2/model.go +++ b/external/user/v2/model.go @@ -7,7 +7,6 @@ import ( "time" "github.com/PaesslerAG/jsonpath" - "github.com/ooaklee/ghatd/external/user" ) // IDGenerator generates unique identifiers @@ -35,29 +34,12 @@ type UserConfig struct { DefaultStatus string StatusTransitions map[string][]string RequiredFields []string + DefaultRole string ValidRoles []string EmailVerificationRequired bool MultipleIdentifiers bool // Support both UUID and NanoID } -// DefaultUserConfig returns a sensible default configuration -func DefaultUserConfig() *UserConfig { - return &UserConfig{ - DefaultStatus: "PROVISIONED", - StatusTransitions: map[string][]string{ - "ACTIVE": {"PROVISIONED"}, - "DEACTIVATED": {"PROVISIONED", "ACTIVE", "LOCKED_OUT", "RECOVERY", "SUSPENDED"}, - "SUSPENDED": {"ACTIVE"}, - "LOCKED_OUT": {"ACTIVE"}, - "RECOVERY": {"ACTIVE"}, - }, - RequiredFields: []string{"email"}, - ValidRoles: []string{"ADMIN", "USER"}, - EmailVerificationRequired: true, - MultipleIdentifiers: true, - } -} - // UniversalUser represents a flexible user model type UniversalUser struct { // Core required fields @@ -190,6 +172,42 @@ func (u *UniversalUser) SetDependencies( // Core Methods +// GetType handles return the resource type +func (u *UniversalUser) GetType() string { + return "USER" +} + +// Standardise handles common user tasks like making sure email is lowercase +func (u *UniversalUser) Standardise() *UniversalUser { + if u.stringUtils != nil { + u.Email = u.stringUtils.ToLowerCase(u.Email) + } + + if u.PersonalInfo != nil && u.stringUtils != nil { + u.PersonalInfo.FirstName = u.stringUtils.ToTitleCase(u.PersonalInfo.FirstName) + u.PersonalInfo.LastName = u.stringUtils.ToTitleCase(u.PersonalInfo.LastName) + u.SetFullName() + } + + if len(u.Roles) == 0 && u.config != nil { + for i, role := range u.Roles { + u.Roles[i] = u.stringUtils.ToUpperCase(role) + } + } + + return u +} + +// SetFullName sets the full name based on first and last names +func (u *UniversalUser) SetFullName() *UniversalUser { + if u.PersonalInfo != nil && u.stringUtils != nil { + u.PersonalInfo.FullName = fmt.Sprintf("%s %s", + u.stringUtils.ToTitleCase(u.PersonalInfo.FirstName), + u.stringUtils.ToTitleCase(u.PersonalInfo.LastName)) + } + return u +} + // GenerateNewUUID creates a new UUID for the user func (u *UniversalUser) GenerateNewUUID() *UniversalUser { if u.idGenerator != nil { @@ -300,6 +318,15 @@ func (u *UniversalUser) UpdateStatus(desiredStatus string) (*UniversalUser, erro return u, errors.New(ErrKeyUserInvalidStatusTransition) } + // Handle email change special case + if desiredStatus == "EMAIL_CHANGE" { + desiredStatus = u.config.DefaultStatus + + if u.config.EmailVerificationRequired { + u.UnverifyEmail() + } + } + // Update status u.Status = desiredStatus u.SetUpdatedAtNow() @@ -494,11 +521,28 @@ func (u *UniversalUser) GetAttributeByJSONPath(jsonPath string) (interface{}, er } -// Profile Generation (backward compatibility) +// UserProfile represents a simplified user profile +type UserProfile struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Status string `json:"status"` + Roles []string `json:"roles"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified" ` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// UserMicroProfile represents a minimal user profile +type UserMicroProfile struct { + ID string `json:"id"` + Roles []string `json:"roles"` + Status string `json:"status"` +} // GetAsProfile returns a profile representation -func (u *UniversalUser) GetAsProfile() *user.UserProfile { - profile := &user.UserProfile{ +func (u *UniversalUser) GetAsProfile() *UserProfile { + profile := &UserProfile{ ID: u.ID, Status: u.Status, Roles: u.Roles, @@ -522,8 +566,8 @@ func (u *UniversalUser) GetAsProfile() *user.UserProfile { } // GetAsMicroProfile returns a minimal profile representation -func (u *UniversalUser) GetAsMicroProfile() *user.UserMicroProfile { - return &user.UserMicroProfile{ +func (u *UniversalUser) GetAsMicroProfile() *UserMicroProfile { + return &UserMicroProfile{ ID: u.ID, Roles: u.Roles, Status: u.Status, @@ -536,6 +580,31 @@ func (u *UniversalUser) GetUserEmail() string { } // Legacy method aliases for backward compatibility -func (u *UniversalUser) GetUserId() string { return u.ID } +func (u *UniversalUser) GetUserId() string { return u.ID } + func (u *UniversalUser) GetUserStatus() string { return u.Status } -func (u *UniversalUser) IsAdmin() bool { return u.HasRole("ADMIN") } + +func (u *UniversalUser) IsAdmin() bool { return u.HasRole("ADMIN") } + +func (u *UniversalUser) SetLastLoginAtTimeToNow() *UniversalUser { + return u.SetLastLoginAtNow() +} + +func (u *UniversalUser) SetLastFreshLoginAtTimeToNow() *UniversalUser { + if u.timeProvider != nil { + u.Metadata.LastFreshLoginAt = u.timeProvider.NowUTC() + } + return u +} + +func (u *UniversalUser) SetUpdatedAtTimeToNow() *UniversalUser { + return u.SetUpdatedAtNow() +} + +func (u *UniversalUser) VerifyEmailNow() *UniversalUser { + return u.VerifyEmail() +} + +func (u *UniversalUser) GetAttributeByJsonPath(jsonPath string) (interface{}, error) { + return u.GetAttributeByJSONPath(jsonPath) +} diff --git a/external/user/v2/request.go b/external/user/v2/request.go index d524519..6b19f24 100644 --- a/external/user/v2/request.go +++ b/external/user/v2/request.go @@ -26,6 +26,8 @@ type UpdateUserRequest struct { Phone string `json:"phone,omitempty"` Status string `json:"status,omitempty"` Extensions map[string]interface{} `json:"extensions,omitempty"` + + User *UniversalUser `json:"-"` } // GetUserByIDRequest holds data for retrieving a user by ID diff --git a/external/user/v2/response.go b/external/user/v2/response.go index 82acc96..c46fa10 100644 --- a/external/user/v2/response.go +++ b/external/user/v2/response.go @@ -1,7 +1,6 @@ package user import ( - userv1 "github.com/ooaklee/ghatd/external/user" "github.com/ooaklee/reply" ) @@ -39,7 +38,7 @@ type GetUsersResponse struct { // PaginationMetadata holds pagination information type PaginationMetadata struct { Page int `json:"page"` - PerPage int `json:"per_page"` + PerPage int `json:"resources_per_page"` TotalResources int64 `json:"total_resources"` TotalPages int `json:"total_pages"` } @@ -103,12 +102,12 @@ type RecordUserLoginResponse struct { // GetUserProfileResponse holds the response for retrieving a user profile type GetUserProfileResponse struct { - Profile *userv1.UserProfile `json:"profile"` + Profile *UserProfile `json:"profile"` } // GetUserMicroProfileResponse holds the response for retrieving a user micro profile type GetUserMicroProfileResponse struct { - MicroProfile *userv1.UserMicroProfile `json:"micro_profile"` + MicroProfile *UserMicroProfile `json:"micro_profile"` } // ValidateUserResponse holds the response for validating a user @@ -150,10 +149,10 @@ type DeleteUserResponse struct { // GetMetaData converts PaginationMetadata to map for reply.WithMeta func (p *PaginationMetadata) GetMetaData() map[string]interface{} { return map[string]interface{}{ - "page": p.Page, - "per_page": p.PerPage, - "total_resources": p.TotalResources, - "total_pages": p.TotalPages, + "page": p.Page, + "resources_per_page": p.PerPage, + "total_resources": p.TotalResources, + "total_pages": p.TotalPages, } } diff --git a/external/user/v2/routes.go b/external/user/v2/routes.go index 766d17f..088e852 100644 --- a/external/user/v2/routes.go +++ b/external/user/v2/routes.go @@ -48,9 +48,6 @@ type AttachRoutesRequest struct { // AdminOnlyMiddleware middleware used to lock endpoints down to admin only AdminOnlyMiddleware mux.MiddlewareFunc - - // AuthenticatedMiddleware middleware used for authenticated users - AuthenticatedMiddleware mux.MiddlewareFunc } // AttachRoutes attaches user handler to corresponding routes on router @@ -84,11 +81,4 @@ func AttachRoutes(request *AttachRoutesRequest) { usersAdminOnlyRoutes.HandleFunc("/by-status", request.Handler.GetUsersByStatus).Methods(http.MethodGet, http.MethodOptions) usersAdminOnlyRoutes.HandleFunc("/bulk/status", request.Handler.BulkUpdateUsersStatus).Methods(http.MethodPost, http.MethodOptions) usersAdminOnlyRoutes.Use(request.AdminOnlyMiddleware) - - // Authenticated routes (if needed for self-service operations) - // Uncomment and customise as needed: - // usersAuthenticatedRoutes := httpRouter.PathPrefix(APIUsersV2Prefix).Subrouter() - // usersAuthenticatedRoutes.HandleFunc("/me", request.Handler.GetCurrentUser).Methods(http.MethodGet, http.MethodOptions) - // usersAuthenticatedRoutes.HandleFunc("/me", request.Handler.UpdateCurrentUser).Methods(http.MethodPatch, http.MethodOptions) - // usersAuthenticatedRoutes.Use(request.AuthenticatedMiddleware) } diff --git a/external/user/v2/service.go b/external/user/v2/service.go index a1f3701..6e165be 100644 --- a/external/user/v2/service.go +++ b/external/user/v2/service.go @@ -94,18 +94,24 @@ func (s *Service) CreateUser(ctx context.Context, req *CreateUserRequest) (*Crea Avatar: req.Avatar, Phone: req.Phone, } + + user.SetFullName() } // Set roles if len(req.Roles) > 0 { user.Roles = req.Roles } else { + user.Roles = []string{} + + if s.Config.DefaultRole != "" { + user.Roles = append(user.Roles, s.Config.DefaultRole) + } + // Check if email matches auto-admin regex isAutoAdmin := s.shouldBeAutoAdmin(user.Email) if isAutoAdmin { - user.Roles = []string{UserRoleAdmin} - } else { - user.Roles = []string{UserRoleUser} + user.Roles = append(user.Roles, UserRoleAdmin) } } @@ -135,6 +141,8 @@ func (s *Service) CreateUser(ctx context.Context, req *CreateUserRequest) (*Crea // Set initial timestamps and state user.SetInitialState() + user.Standardise() + // Validate user if err := user.Validate(); err != nil { log.Error("user-validation-failed", zap.Error(err)) @@ -227,72 +235,102 @@ func (s *Service) GetUserByEmail(ctx context.Context, req *GetUserByEmailRequest func (s *Service) UpdateUser(ctx context.Context, req *UpdateUserRequest) (*UpdateUserResponse, error) { log := logger.AcquireFrom(ctx).With(zap.String("method", "update-user")).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + targetUserId := req.ID + if req.User != nil && req.User.ID != "" { + targetUserId = req.User.ID + } + // Get existing user - user, err := s.UserRepository.GetUserByID(ctx, req.ID) + user, err := s.UserRepository.GetUserByID(ctx, targetUserId) if err != nil { - log.Error("failed to get user for update", zap.Error(err), zap.String("id", req.ID)) + log.Error("failed-to-get-user-for-update", zap.Error(err), zap.String("id", targetUserId)) return nil, errors.New(ErrKeyUserNotFound) } - // Reinject dependencies - user.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) + if req.User != nil { + userWithProvidedData := req.User - // Update fields - hasChanges := false + userWithProvidedData.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) - if req.Email != "" && req.Email != user.Email { - // Check if new email already exists - existingUser, _ := s.UserRepository.GetUserByEmail(ctx, req.Email, false) - if existingUser != nil && existingUser.ID != user.ID { - return nil, errors.New(ErrKeyEmailAlreadyExists) + if userWithProvidedData.Email != "" && userWithProvidedData.Email != user.Email { + // Check if new email already exists + existingUser, _ := s.UserRepository.GetUserByEmail(ctx, userWithProvidedData.Email, false) + if existingUser != nil && existingUser.ID != user.ID { + return nil, errors.New(ErrKeyEmailAlreadyExists) + } + user.Email = normaliseUserEmail(userWithProvidedData.Email) } - user.Email = normaliseUserEmail(req.Email) - hasChanges = true - } - if req.FirstName != "" && req.FirstName != user.PersonalInfo.FirstName { - user.PersonalInfo.FirstName = req.FirstName - hasChanges = true - } + userWithProvidedData.SetFullName() - if req.LastName != "" && req.LastName != user.PersonalInfo.LastName { - user.PersonalInfo.LastName = req.LastName - hasChanges = true - } + user = userWithProvidedData - if req.FullName != "" && req.FullName != user.PersonalInfo.FullName { - user.PersonalInfo.FullName = req.FullName - hasChanges = true } - if req.Avatar != "" && req.Avatar != user.PersonalInfo.Avatar { - user.PersonalInfo.Avatar = req.Avatar - hasChanges = true - } + if req.User == nil { + user.SetDependencies(s.Config, s.IDGenerator, s.TimeProvider, s.StringUtils) - if req.Phone != "" && req.Phone != user.PersonalInfo.Phone { - user.PersonalInfo.Phone = req.Phone - hasChanges = true - } + // Update fields + hasChanges := false - if req.Status != "" && req.Status != user.Status { - _, err := user.UpdateStatus(req.Status) - if err != nil { - log.Error("failed to update user status", zap.Error(err)) - return nil, err + if req.Email != "" && req.Email != user.Email { + // Check if new email already exists + existingUser, _ := s.UserRepository.GetUserByEmail(ctx, req.Email, false) + if existingUser != nil && existingUser.ID != user.ID { + return nil, errors.New(ErrKeyEmailAlreadyExists) + } + user.Email = normaliseUserEmail(req.Email) + hasChanges = true } - hasChanges = true - } - if req.Extensions != nil { - for key, value := range req.Extensions { - user.SetExtension(key, value) + if req.FirstName != "" && req.FirstName != user.PersonalInfo.FirstName { + user.PersonalInfo.FirstName = req.FirstName + hasChanges = true } - hasChanges = true - } - if !hasChanges { - return nil, errors.New(ErrKeyNoChangesDetected) + if req.LastName != "" && req.LastName != user.PersonalInfo.LastName { + user.PersonalInfo.LastName = req.LastName + hasChanges = true + } + + if hasChanges { + user.SetFullName() + } + + if req.FullName != "" && req.FullName != user.PersonalInfo.FullName { + user.PersonalInfo.FullName = req.FullName + hasChanges = true + } + + if req.Avatar != "" && req.Avatar != user.PersonalInfo.Avatar { + user.PersonalInfo.Avatar = req.Avatar + hasChanges = true + } + + if req.Phone != "" && req.Phone != user.PersonalInfo.Phone { + user.PersonalInfo.Phone = req.Phone + hasChanges = true + } + + if req.Status != "" && req.Status != user.Status { + _, err := user.UpdateStatus(req.Status) + if err != nil { + log.Error("failed-to-update-user-status", zap.Error(err)) + return nil, err + } + hasChanges = true + } + + if req.Extensions != nil { + for key, value := range req.Extensions { + user.SetExtension(key, value) + } + hasChanges = true + } + + if !hasChanges { + return &UpdateUserResponse{User: user}, nil + } } // Update timestamps @@ -300,17 +338,19 @@ func (s *Service) UpdateUser(ctx context.Context, req *UpdateUserRequest) (*Upda // Validate user if err := user.Validate(); err != nil { - log.Error("user validation failed", zap.Error(err)) + log.Error("user-validation-failed", zap.Error(err)) return nil, errors.New(ErrKeyValidationFailed) } // Ensure version is set to 2 for migrated users user.EnsureVersion() + user.Standardise() + // Update in repository updatedUser, err := s.UserRepository.UpdateUser(ctx, user) if err != nil { - log.Error("failed to update user", zap.Error(err)) + log.Error("failed-to-update-user", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -324,7 +364,7 @@ func (s *Service) UpdateUser(ctx context.Context, req *UpdateUserRequest) (*Upda }) } - log.Info("user updated successfully", zap.String("user-id", updatedUser.ID)) + log.Info("user-updated-successfully", zap.String("user-id", updatedUser.ID)) return &UpdateUserResponse{User: updatedUser}, nil } @@ -336,14 +376,14 @@ func (s *Service) DeleteUser(ctx context.Context, req *DeleteUserRequest) error // Verify user exists _, err := s.UserRepository.GetUserByID(ctx, req.ID) if err != nil { - log.Error("user not found", zap.Error(err), zap.String("id", req.ID)) + log.Error("user-not-found", zap.Error(err), zap.String("id", req.ID)) return errors.New(ErrKeyUserNotFound) } // Delete user err = s.UserRepository.DeleteUserByID(ctx, req.ID) if err != nil { - log.Error("failed to delete user", zap.Error(err), zap.String("id", req.ID)) + log.Error("failed-to-delete-user", zap.Error(err), zap.String("id", req.ID)) return errors.New(ErrKeyDatabaseError) } @@ -357,7 +397,7 @@ func (s *Service) DeleteUser(ctx context.Context, req *DeleteUserRequest) error }) } - log.Info("user deleted successfully", zap.String("user-id", req.ID)) + log.Info("user-deleted-successfully", zap.String("user-id", req.ID)) return nil } @@ -389,7 +429,7 @@ func (s *Service) GetUsers(ctx context.Context, req *GetUsersRequest) (*GetUsers total, err := s.UserRepository.GetTotalUsers(ctx, totalReq) if err != nil { - log.Error("failed to get total users", zap.Error(err)) + log.Error("failed-to-get-total-users", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -404,7 +444,7 @@ func (s *Service) GetUsers(ctx context.Context, req *GetUsersRequest) (*GetUsers // Get users users, err := s.UserRepository.GetUsers(ctx, req) if err != nil { - log.Error("failed to get users", zap.Error(err)) + log.Error("failed-to-get-users", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -432,7 +472,7 @@ func (s *Service) GetTotalUsers(ctx context.Context, req *GetTotalUsersRequest) total, err := s.UserRepository.GetTotalUsers(ctx, req) if err != nil { - log.Error("failed to get total users", zap.Error(err)) + log.Error("failed-to-get-total-users", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -446,7 +486,7 @@ func (s *Service) UpdateUserStatus(ctx context.Context, req *UpdateUserStatusReq // Get user user, err := s.UserRepository.GetUserByID(ctx, req.ID) if err != nil { - log.Error("failed to get user for status update", zap.Error(err), zap.String("id", req.ID)) + log.Error("failed-to-get-user-for-status-update", zap.Error(err), zap.String("id", req.ID)) return nil, errors.New(ErrKeyUserNotFound) } @@ -456,14 +496,14 @@ func (s *Service) UpdateUserStatus(ctx context.Context, req *UpdateUserStatusReq // Update status updatedUser, err := user.UpdateStatus(req.DesiredStatus) if err != nil { - log.Error("failed to update user status", zap.Error(err)) + log.Error("failed-to-update-user-status", zap.Error(err)) return nil, err } // Save to repository updatedUser, err = s.UserRepository.UpdateUser(ctx, updatedUser) if err != nil { - log.Error("failed to save user after status update", zap.Error(err)) + log.Error("failed-to-save-user-after-status-update", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -477,7 +517,7 @@ func (s *Service) UpdateUserStatus(ctx context.Context, req *UpdateUserStatusReq }) } - log.Info("user status updated successfully", zap.String("user-id", updatedUser.ID), zap.String("status", req.DesiredStatus)) + log.Info("user-status-updated-successfully", zap.String("user-id", updatedUser.ID), zap.String("status", req.DesiredStatus)) return &UpdateUserStatusResponse{User: updatedUser}, nil } @@ -489,7 +529,7 @@ func (s *Service) AddUserRole(ctx context.Context, req *AddUserRoleRequest) (*Ad // Get user user, err := s.UserRepository.GetUserByID(ctx, req.ID) if err != nil { - log.Error("failed to get user for adding role", zap.Error(err), zap.String("id", req.ID)) + log.Error("failed-to-get-user-for-adding-role", zap.Error(err), zap.String("id", req.ID)) return nil, errors.New(ErrKeyUserNotFound) } @@ -502,7 +542,7 @@ func (s *Service) AddUserRole(ctx context.Context, req *AddUserRoleRequest) (*Ad // Save to repository updatedUser, err := s.UserRepository.UpdateUser(ctx, user) if err != nil { - log.Error("failed to save user after adding role", zap.Error(err)) + log.Error("failed-to-save-user-after-adding-role", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -516,7 +556,7 @@ func (s *Service) AddUserRole(ctx context.Context, req *AddUserRoleRequest) (*Ad }) } - log.Info("user role added successfully", zap.String("user-id", updatedUser.ID), zap.String("role", req.Role)) + log.Info("user-role-added-successfully", zap.String("user-id", updatedUser.ID), zap.String("role", req.Role)) return &AddUserRoleResponse{User: updatedUser}, nil } @@ -528,7 +568,7 @@ func (s *Service) RemoveUserRole(ctx context.Context, req *RemoveUserRoleRequest // Get user user, err := s.UserRepository.GetUserByID(ctx, req.ID) if err != nil { - log.Error("failed to get user for removing role", zap.Error(err), zap.String("id", req.ID)) + log.Error("failed-to-get-user-for-removing-role", zap.Error(err), zap.String("id", req.ID)) return nil, errors.New(ErrKeyUserNotFound) } @@ -541,7 +581,7 @@ func (s *Service) RemoveUserRole(ctx context.Context, req *RemoveUserRoleRequest // Save to repository updatedUser, err := s.UserRepository.UpdateUser(ctx, user) if err != nil { - log.Error("failed to save user after removing role", zap.Error(err)) + log.Error("failed-to-save-user-after-removing-role", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -555,7 +595,7 @@ func (s *Service) RemoveUserRole(ctx context.Context, req *RemoveUserRoleRequest }) } - log.Info("user role removed successfully", zap.String("user-id", updatedUser.ID), zap.String("role", req.Role)) + log.Info("user-role-removed-successfully", zap.String("user-id", updatedUser.ID), zap.String("role", req.Role)) return &RemoveUserRoleResponse{User: updatedUser}, nil } @@ -567,7 +607,7 @@ func (s *Service) VerifyUserEmail(ctx context.Context, req *VerifyUserEmailReque // Get user user, err := s.UserRepository.GetUserByID(ctx, req.ID) if err != nil { - log.Error("failed to get user for email verification", zap.Error(err), zap.String("id", req.ID)) + log.Error("failed-to-get-user-for-email-verification", zap.Error(err), zap.String("id", req.ID)) return nil, errors.New(ErrKeyUserNotFound) } @@ -580,7 +620,7 @@ func (s *Service) VerifyUserEmail(ctx context.Context, req *VerifyUserEmailReque // Save to repository updatedUser, err := s.UserRepository.UpdateUser(ctx, user) if err != nil { - log.Error("failed to save user after email verification", zap.Error(err)) + log.Error("failed-to-save-user-after-email-verification", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -594,7 +634,7 @@ func (s *Service) VerifyUserEmail(ctx context.Context, req *VerifyUserEmailReque }) } - log.Info("user email verified successfully", zap.String("user-id", updatedUser.ID)) + log.Info("user-email-verified-successfully", zap.String("user-id", updatedUser.ID)) return &VerifyUserEmailResponse{User: updatedUser}, nil } @@ -606,7 +646,7 @@ func (s *Service) UnverifyUserEmail(ctx context.Context, req *UnverifyUserEmailR // Get user user, err := s.UserRepository.GetUserByID(ctx, req.ID) if err != nil { - log.Error("failed to get user for email unverification", zap.Error(err), zap.String("id", req.ID)) + log.Error("failed-to-get-user-for-email-unverification", zap.Error(err), zap.String("id", req.ID)) return nil, errors.New(ErrKeyUserNotFound) } @@ -619,7 +659,7 @@ func (s *Service) UnverifyUserEmail(ctx context.Context, req *UnverifyUserEmailR // Save to repository updatedUser, err := s.UserRepository.UpdateUser(ctx, user) if err != nil { - log.Error("failed to save user after email unverification", zap.Error(err)) + log.Error("failed-to-save-user-after-email-unverification", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -633,7 +673,7 @@ func (s *Service) UnverifyUserEmail(ctx context.Context, req *UnverifyUserEmailR }) } - log.Info("user email unverified successfully", zap.String("user-id", updatedUser.ID)) + log.Info("user-email-unverified-successfully", zap.String("user-id", updatedUser.ID)) return &UnverifyUserEmailResponse{User: updatedUser}, nil } @@ -645,7 +685,7 @@ func (s *Service) VerifyUserPhone(ctx context.Context, req *VerifyUserPhoneReque // Get user user, err := s.UserRepository.GetUserByID(ctx, req.ID) if err != nil { - log.Error("failed to get user for phone verification", zap.Error(err), zap.String("id", req.ID)) + log.Error("failed-to-get-user-for-phone-verification", zap.Error(err), zap.String("id", req.ID)) return nil, errors.New(ErrKeyUserNotFound) } @@ -658,7 +698,7 @@ func (s *Service) VerifyUserPhone(ctx context.Context, req *VerifyUserPhoneReque // Save to repository updatedUser, err := s.UserRepository.UpdateUser(ctx, user) if err != nil { - log.Error("failed to save user after phone verification", zap.Error(err)) + log.Error("failed-to-save-user-after-phone-verification", zap.Error(err)) return nil, errors.New(ErrKeyDatabaseError) } @@ -672,7 +712,7 @@ func (s *Service) VerifyUserPhone(ctx context.Context, req *VerifyUserPhoneReque }) } - log.Info("user phone verified successfully", zap.String("user-id", updatedUser.ID)) + log.Info("user-phone-verified-successfully", zap.String("user-id", updatedUser.ID)) return &VerifyUserPhoneResponse{User: updatedUser}, nil } diff --git a/external/user/v2/utils.go b/external/user/v2/utils.go index 0fc6c7e..4da03d0 100644 --- a/external/user/v2/utils.go +++ b/external/user/v2/utils.go @@ -89,81 +89,6 @@ func (s *DefaultStringUtils) InSlice(item string, slice []string) bool { return false } -// UserFactory provides convenient user creation -type UserFactory struct { - config *UserConfig - idGenerator IDGenerator - timeProvider TimeProvider - stringUtils StringUtils -} - -// NewUserFactory creates a new user factory with default implementations -func NewUserFactory(config *UserConfig) *UserFactory { - if config == nil { - config = DefaultUserConfig() - } - - return &UserFactory{ - config: config, - idGenerator: &DefaultIDGenerator{}, - timeProvider: &DefaultTimeProvider{}, - stringUtils: &DefaultStringUtils{}, - } -} - -// NewUserFactoryWithDependencies creates a factory with custom implementations -func NewUserFactoryWithDependencies( - config *UserConfig, - idGenerator IDGenerator, - timeProvider TimeProvider, - stringUtils StringUtils, -) *UserFactory { - if config == nil { - config = DefaultUserConfig() - } - - return &UserFactory{ - config: config, - idGenerator: idGenerator, - timeProvider: timeProvider, - stringUtils: stringUtils, - } -} - -// CreateUser creates a new user with initial setup -func (f *UserFactory) CreateUser(email string) *UniversalUser { - user := NewUniversalUser(f.config, f.idGenerator, f.timeProvider, f.stringUtils) - - user.Email = f.stringUtils.ToLowerCase(email) - user.GenerateNewUUID() - - if f.config.MultipleIdentifiers { - user.GenerateNewNanoID() - } - - user.SetInitialState() - - return user -} - -// CreateUserWithPersonalInfo creates a user with personal information -func (f *UserFactory) CreateUserWithPersonalInfo(email, firstName, lastName string) *UniversalUser { - user := f.CreateUser(email) - - user.PersonalInfo.FirstName = f.stringUtils.ToTitleCase(firstName) - user.PersonalInfo.LastName = f.stringUtils.ToTitleCase(lastName) - user.PersonalInfo.FullName = fmt.Sprintf("%s %s", - user.PersonalInfo.FirstName, - user.PersonalInfo.LastName) - - return user -} - -// LoadExistingUser loads an existing user and sets up dependencies -func (f *UserFactory) LoadExistingUser(user *UniversalUser) *UniversalUser { - return user.SetDependencies(f.config, f.idGenerator, f.timeProvider, f.stringUtils) -} - // Migration helpers for existing projects // MigrateFromLegacyUser converts your existing User to UniversalUser @@ -236,54 +161,3 @@ func MigrateToLegacyUser(universalUser *UniversalUser) *user.User { return legacyUser } - -// Project-specific configurations - -// WebAppUserConfig returns configuration suitable for web applications -func WebAppUserConfig() *UserConfig { - return &UserConfig{ - DefaultStatus: "PROVISIONED", - StatusTransitions: map[string][]string{ - "ACTIVE": {"PROVISIONED"}, - "SUSPENDED": {"ACTIVE"}, - "DEACTIVATED": {"ACTIVE", "SUSPENDED"}, - "REACTIVATE": {"DEACTIVATED"}, - "UNSUSPEND": {"SUSPENDED"}, - }, - RequiredFields: []string{"email", "first_name", "last_name"}, - ValidRoles: []string{"ADMIN", "USER", "MODERATOR"}, - EmailVerificationRequired: true, - MultipleIdentifiers: false, - } -} - -// APIServiceUserConfig returns configuration suitable for API services -func APIServiceUserConfig() *UserConfig { - return &UserConfig{ - DefaultStatus: "ACTIVE", - StatusTransitions: map[string][]string{ - "ACTIVE": {"PROVISIONED"}, - "SUSPENDED": {"ACTIVE"}, - "DISABLED": {"ACTIVE", "SUSPENDED"}, - }, - RequiredFields: []string{"email"}, - ValidRoles: []string{"SERVICE", "CLIENT", "ADMIN"}, - EmailVerificationRequired: false, - MultipleIdentifiers: true, - } -} - -// MicroserviceUserConfig returns minimal configuration for microservices -func MicroserviceUserConfig() *UserConfig { - return &UserConfig{ - DefaultStatus: "ACTIVE", - StatusTransitions: map[string][]string{ - "ACTIVE": {}, - "INACTIVE": {"ACTIVE"}, - }, - RequiredFields: []string{"email"}, - ValidRoles: []string{}, // Allow any roles - EmailVerificationRequired: false, - MultipleIdentifiers: true, - } -} diff --git a/external/user/v2/utils.toolbox.go b/external/user/v2/utils.toolbox.go new file mode 100644 index 0000000..63a9fc0 --- /dev/null +++ b/external/user/v2/utils.toolbox.go @@ -0,0 +1,61 @@ +package user + +import ( + "slices" + "time" + + "github.com/ooaklee/ghatd/external/toolbox" +) + +// UniversalUserToolbox is a utility struct for user operations +type UniversalUserToolbox struct { + IDGenerator + TimeProvider + StringUtils +} + +// NewUniversalUserToolbox create a new universal toolbox with all +// necessary implementations +func NewUniversalUserToolbox() *UniversalUserToolbox { + return &UniversalUserToolbox{} +} + +// GenerateNanoID generates nano ID using toolbox +func (t *UniversalUserToolbox) GenerateNanoID() string { + return toolbox.GenerateNanoId() +} + +// GenerateUUID generates UUID v4 using toolbox +func (t *UniversalUserToolbox) GenerateUUID() string { + return toolbox.GenerateUuidV4() +} + +// Now returns current time +func (t *UniversalUserToolbox) Now() time.Time { + return time.Now() +} + +// NowUTC returns now as a string in a standardised format using the toolbox +func (t *UniversalUserToolbox) NowUTC() string { + return toolbox.TimeNowUTC() +} + +// ToTitleCase formats provided string in title case using toolbox +func (t *UniversalUserToolbox) ToTitleCase(s string) string { + return toolbox.StringConvertToTitleCase(s) +} + +// ToLowerCase formats the provided string to lowercase using the toolbox +func (t *UniversalUserToolbox) ToLowerCase(s string) string { + return toolbox.StringStandardisedToLower(s) +} + +// ToUpperCase formats the provided string to uppercase using the toolbox +func (t *UniversalUserToolbox) ToUpperCase(s string) string { + return toolbox.StringStandardisedToUpper(s) +} + +// InSlice checks if the given string is in the slice +func (t *UniversalUserToolbox) InSlice(item string, slice []string) bool { + return slices.Contains(slice, item) +} diff --git a/external/user/v2/utils.userconfig.go b/external/user/v2/utils.userconfig.go new file mode 100644 index 0000000..026361a --- /dev/null +++ b/external/user/v2/utils.userconfig.go @@ -0,0 +1,73 @@ +package user + +// DefaultUserConfig returns a sensible default configuration +func DefaultUserConfig() *UserConfig { + return &UserConfig{ + DefaultStatus: "PROVISIONED", + StatusTransitions: map[string][]string{ + "ACTIVE": {"PROVISIONED"}, + "DEACTIVATED": {"PROVISIONED", "ACTIVE", "LOCKED_OUT", "RECOVERY", "SUSPENDED"}, + "SUSPENDED": {"ACTIVE"}, + "EMAIL_CHANGE": {"PROVISIONED", "ACTIVE"}, + "LOCKED_OUT": {"ACTIVE"}, + "RECOVERY": {"ACTIVE"}, + }, + RequiredFields: []string{"email"}, + DefaultRole: "USER", + ValidRoles: []string{"ADMIN", "USER"}, + EmailVerificationRequired: true, + MultipleIdentifiers: true, + } +} + +// WebAppUserConfig returns configuration suitable for web applications +func WebAppUserConfig() *UserConfig { + return &UserConfig{ + DefaultStatus: "PROVISIONED", + StatusTransitions: map[string][]string{ + "ACTIVE": {"PROVISIONED", "DEACTIVATED"}, + "SUSPENDED": {"ACTIVE"}, + "DEACTIVATED": {"ACTIVE", "SUSPENDED"}, + "UNSUSPEND": {"SUSPENDED"}, + "EMAIL_CHANGE": {"PROVISIONED", "ACTIVE"}, + }, + RequiredFields: []string{"email", "first_name", "last_name"}, + DefaultRole: "USER", + ValidRoles: []string{"ADMIN", "USER"}, + EmailVerificationRequired: true, + MultipleIdentifiers: false, + } +} + +// APIServiceUserConfig returns configuration suitable for API services +func APIServiceUserConfig() *UserConfig { + return &UserConfig{ + DefaultStatus: "ACTIVE", + StatusTransitions: map[string][]string{ + "ACTIVE": {"PROVISIONED"}, + "SUSPENDED": {"ACTIVE"}, + "DEACTIVATED": {"ACTIVE", "SUSPENDED"}, + "EMAIL_CHANGE": {"PROVISIONED", "ACTIVE"}, + }, + RequiredFields: []string{"email"}, + ValidRoles: []string{"SERVICE", "CLIENT", "ADMIN"}, + EmailVerificationRequired: false, + MultipleIdentifiers: true, + } +} + +// MicroserviceUserConfig returns minimal configuration for microservices +func MicroserviceUserConfig() *UserConfig { + return &UserConfig{ + DefaultStatus: "ACTIVE", + StatusTransitions: map[string][]string{ + "ACTIVE": {}, + "DEACTIVATED": {"ACTIVE"}, + "EMAIL_CHANGE": {"DEACTIVATED", "ACTIVE"}, + }, + RequiredFields: []string{"email"}, + ValidRoles: []string{}, // Allow any roles + EmailVerificationRequired: false, + MultipleIdentifiers: true, + } +} diff --git a/external/user/v2/utils.userfactory.go b/external/user/v2/utils.userfactory.go new file mode 100644 index 0000000..2478609 --- /dev/null +++ b/external/user/v2/utils.userfactory.go @@ -0,0 +1,78 @@ +package user + +import "fmt" + +// UserFactory provides convenient user creation +type UserFactory struct { + config *UserConfig + idGenerator IDGenerator + timeProvider TimeProvider + stringUtils StringUtils +} + +// NewUserFactory creates a new user factory with default implementations +func NewUserFactory(config *UserConfig) *UserFactory { + if config == nil { + config = DefaultUserConfig() + } + + return &UserFactory{ + config: config, + idGenerator: &DefaultIDGenerator{}, + timeProvider: &DefaultTimeProvider{}, + stringUtils: &DefaultStringUtils{}, + } +} + +// NewUserFactoryWithDependencies creates a factory with custom implementations +func NewUserFactoryWithDependencies( + config *UserConfig, + idGenerator IDGenerator, + timeProvider TimeProvider, + stringUtils StringUtils, +) *UserFactory { + if config == nil { + config = DefaultUserConfig() + } + + return &UserFactory{ + config: config, + idGenerator: idGenerator, + timeProvider: timeProvider, + stringUtils: stringUtils, + } +} + +// CreateUser creates a new user with initial setup +func (f *UserFactory) CreateUser(email string) *UniversalUser { + user := NewUniversalUser(f.config, f.idGenerator, f.timeProvider, f.stringUtils) + + user.Email = f.stringUtils.ToLowerCase(email) + user.GenerateNewUUID() + + if f.config.MultipleIdentifiers { + user.GenerateNewNanoID() + } + + user.SetInitialState() + + return user +} + +// CreateUserWithPersonalInfo creates a user with personal information +func (f *UserFactory) CreateUserWithPersonalInfo(email, firstName, lastName string) *UniversalUser { + user := f.CreateUser(email) + + user.PersonalInfo.FirstName = f.stringUtils.ToTitleCase(firstName) + user.PersonalInfo.LastName = f.stringUtils.ToTitleCase(lastName) + user.PersonalInfo.FullName = fmt.Sprintf("%s %s", + user.PersonalInfo.FirstName, + user.PersonalInfo.LastName) + + return user +} + +// LoadExistingUser loads an existing user and sets up dependencies +func (f *UserFactory) LoadExistingUser(user *UniversalUser) *UniversalUser { + return user.SetDependencies(f.config, f.idGenerator, f.timeProvider, f.stringUtils) +} diff --git a/external/usermanager/const.go b/external/usermanager/const.go index 852fa7d..62c25e2 100644 --- a/external/usermanager/const.go +++ b/external/usermanager/const.go @@ -1,6 +1,15 @@ package usermanager -const UserManagerURIVariableID = "blankpackagID" +const ( + // UserManagerURIVariableID placeholder for URI variable ID + UserManagerURIVariableID = "blankpackagID" + + // UserManagerURIVariableGroupID is the URI variable for group ID + UserManagerURIVariableGroupID = "groupID" + + // UserManagerURIVariableGroupType is the URI variable for group type + UserManagerURIVariableGroupType = "groupType" +) const ( @@ -16,4 +25,33 @@ const ( // ErrKeyInvalidUserBody returned when a request that is request body dependent fails // validation ErrKeyInvalidUserBody = "UserManagerInvalidUserBody" + + // Group/Team related errors + + // ErrKeyGroupNotFound returned when the requested group cannot be found + ErrKeyGroupNotFound = "GroupNotFound" + + // ErrKeyUserNotFound returned when the requested user cannot be found + ErrKeyUserNotFound = "UserNotFound" + + // ErrKeyUserAlreadyMemberOfGroup returned when user is already a member of the group + ErrKeyUserAlreadyMemberOfGroup = "UserAlreadyMemberOfGroup" + + // ErrKeyFailedToAddUserToGroup returned when adding user to group fails + ErrKeyFailedToAddUserToGroup = "FailedToAddUserToGroup" + + // ErrKeyFailedToRemoveUserFromGroup returned when removing user from group fails + ErrKeyFailedToRemoveUserFromGroup = "FailedToRemoveUserFromGroup" + + // ErrKeyInvalidGroupType returned when an invalid group type is provided + ErrKeyInvalidGroupType = "InvalidGroupType" + + // ErrKeyNoGroupsFound returned when no groups match the search criteria + ErrKeyNoGroupsFound = "NoGroupsFound" + + // ErrKeyBulkOperationPartialFailure returned when bulk operation has some failures + ErrKeyBulkOperationPartialFailure = "BulkOperationPartialFailure" + + // ErrKeyGroupServiceNotEnabled is returned when group features are requested but GroupService is not configured + ErrKeyGroupServiceNotEnabled = "GroupServiceNotEnabled" ) diff --git a/external/usermanager/errormap.go b/external/usermanager/errormap.go index bdae3b2..ba2b8ca 100644 --- a/external/usermanager/errormap.go +++ b/external/usermanager/errormap.go @@ -5,11 +5,21 @@ import ( ) // UsermanagerErrorMap holds Error keys, their corresponding human-friendly message, and response status code -// TODO: remove nolint -// nolint will be used later var UsermanagerErrorMap reply.ErrorManifest = map[string]reply.ErrorManifestItem{ + // General errors ErrKeyUserManagerError: {Title: "Bad Request", Detail: "Some user manager related error.", StatusCode: 400, Code: "USM00-001"}, - ErrKeyUnableToIdentifyUser: {Title: "Unauthorized", Detail: "Please contact support.", StatusCode: 401, Code: "USM00-002"}, + ErrKeyUnableToIdentifyUser: {Title: "Unauthorised", Detail: "Please contact support.", StatusCode: 401, Code: "USM00-002"}, ErrKeyInvalidUserBody: {Title: "Bad Request", Detail: "Check your submitted user information.", StatusCode: 400, Code: "USM00-003"}, - ErrKeyRequestFailedValidation: {Title: "Bad Request", Detail: "Request failed validation, please check provided data", StatusCode: 400, Code: "USM00-004"}, + ErrKeyRequestFailedValidation: {Title: "Bad Request", Detail: "Request failed validation, please check provided data.", StatusCode: 400, Code: "USM00-004"}, + + // Group/Team related errors + ErrKeyGroupNotFound: {Title: "Not Found", Detail: "The requested group could not be found.", StatusCode: 404, Code: "USM00-005"}, + ErrKeyUserNotFound: {Title: "Not Found", Detail: "The requested user could not be found.", StatusCode: 404, Code: "USM00-006"}, + ErrKeyUserAlreadyMemberOfGroup: {Title: "Conflict", Detail: "User is already a member of this group.", StatusCode: 409, Code: "USM00-007"}, + ErrKeyFailedToAddUserToGroup: {Title: "Internal Error", Detail: "Failed to add user to the group. Please try again.", StatusCode: 500, Code: "USM00-008"}, + ErrKeyFailedToRemoveUserFromGroup: {Title: "Internal Error", Detail: "Failed to remove user from the group. Please try again.", StatusCode: 500, Code: "USM00-009"}, + ErrKeyInvalidGroupType: {Title: "Bad Request", Detail: "The provided group type is invalid.", StatusCode: 400, Code: "USM00-010"}, + ErrKeyNoGroupsFound: {Title: "Not Found", Detail: "No groups match the search criteria.", StatusCode: 404, Code: "USM00-011"}, + ErrKeyBulkOperationPartialFailure: {Title: "Partial Success", Detail: "Some operations in the bulk update failed. Check response details.", StatusCode: 207, Code: "USM00-012"}, + ErrKeyGroupServiceNotEnabled: {Title: "Service Unavailable", Detail: "Group management features have not been enabled for this service.", StatusCode: 503, Code: "USM00-013"}, } diff --git a/external/usermanager/examples/examples.go b/external/usermanager/examples/examples.go new file mode 100644 index 0000000..e48ad6a --- /dev/null +++ b/external/usermanager/examples/examples.go @@ -0,0 +1,788 @@ +package examples + +import ( + "context" + "fmt" + "time" + + "github.com/ooaklee/ghatd/external/apitoken" + "github.com/ooaklee/ghatd/external/audit" + "github.com/ooaklee/ghatd/external/common" + "github.com/ooaklee/ghatd/external/contacter" + "github.com/ooaklee/ghatd/external/group" + user "github.com/ooaklee/ghatd/external/user/v2" + "github.com/ooaklee/ghatd/external/usermanager" +) + +// Example1_GetEnrichedUserProfile demonstrates fetching a user profile with group memberships +func Example1_GetEnrichedUserProfile() { + service := setupService() + + ctx := context.Background() + resp, err := service.GetEnrichedUserProfile(ctx, &usermanager.GetEnrichedUserProfileRequest{ + UserId: "user-123", + IncludeTeams: true, + IncludeDepartments: true, + IncludeAllGroups: true, + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + profile := resp.Profile + fmt.Printf("User: %s (%s)\n", profile.FullName, profile.Email) + fmt.Printf("Teams: %d\n", len(profile.Teams)) + fmt.Printf("Departments: %d\n", len(profile.Departments)) + + for _, team := range profile.Teams { + fmt.Printf(" - %s (%s)\n", team.Group.Name, team.Role) + } +} + +// Example2_GetUserTeamMemberships demonstrates getting detailed team membership information +func Example2_GetUserTeamMemberships() { + service := setupService() + + ctx := context.Background() + resp, err := service.GetUserTeamMemberships(ctx, &usermanager.GetUserTeamMembershipsRequest{ + UserId: "user-123", + IncludeInactive: false, + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Printf("User %s team memberships:\n", resp.UserID) + for teamName, membership := range resp.Memberships { + if membership.IsMember { + fmt.Printf(" - %s: %s", teamName, membership.Role) + if membership.IsOwner { + fmt.Print(" (Owner)") + } + if membership.IsLead { + fmt.Print(" (Lead)") + } + fmt.Println() + } + } +} + +// Example3_UpdateUserTeamMembership demonstrates adding a user to a team +func Example3_UpdateUserTeamMembership() { + service := setupService() + + ctx := context.Background() + resp, err := service.UpdateUserTeamMembership(ctx, &usermanager.UpdateUserTeamMembershipRequest{ + UserId: "user-456", + GroupID: "team-frontend", + Role: group.MemberRoleMember, + RemoveFromOtherTeams: false, + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + if resp.Success { + fmt.Printf("Successfully added user to team: %s\n", resp.Membership.Group.Name) + fmt.Printf("Role: %s\n", resp.Membership.Role) + if len(resp.PreviousMemberships) > 0 { + fmt.Printf("Removed from %d previous teams\n", len(resp.PreviousMemberships)) + } + } +} + +// Example4_RemoveUserFromGroup demonstrates removing a user from a group +func Example4_RemoveUserFromGroup() { + service := setupService() + + ctx := context.Background() + resp, err := service.RemoveUserFromGroup(ctx, &usermanager.RemoveUserFromGroupRequest{ + UserId: "user-456", + GroupID: "team-frontend", + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + if resp.Success { + fmt.Printf("Successfully removed user from group: %s\n", resp.GroupID) + } +} + +// Example5_GetUserGroups demonstrates fetching groups for a user with filtering +func Example5_GetUserGroups() { + service := setupService() + + ctx := context.Background() + resp, err := service.GetUserGroups(ctx, &usermanager.GetUserGroupsRequest{ + UserId: "user-123", + GroupType: group.GroupTypeTeam, + Status: group.GroupStatusActive, + Page: 1, + PerPage: 10, + Meta: true, + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Printf("Found %d teams:\n", len(resp.Groups)) + for _, g := range resp.Groups { + fmt.Printf(" - %s (%s) - %d members\n", g.Name, g.Type, g.MemberCount) + } + + if resp.Meta != nil { + fmt.Printf("Total: %d, Page: %d\n", resp.Total, resp.Meta["page"]) + } +} + +// Example6_GetGroupsByType demonstrates fetching groups filtered by type +func Example6_GetGroupsByType() { + service := setupService() + + ctx := context.Background() + resp, err := service.GetGroupsByType(ctx, &usermanager.GetGroupsByTypeRequest{ + UserId: "user-123", + GroupType: group.GroupTypeDepartment, + Status: group.GroupStatusActive, + OnlyUserMemberships: true, + Page: 1, + PerPage: 20, + Meta: true, + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Printf("Found %d departments:\n", len(resp.Groups)) + for _, g := range resp.Groups { + fmt.Printf(" - %s (%d members)\n", g.Name, g.MemberCount) + } +} + +// Example7_FindUserInfo demonstrates finding user information with group memberships +func Example7_FindUserInfo() { + service := setupService() + + ctx := context.Background() + resp, err := service.FindUserInfo(ctx, &usermanager.FindUserInfoRequest{ + Email: "john.doe@company.com", + IncludeTeamMemberships: true, + IncludeGroupMemberships: true, + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Printf("Found user: %s (%s)\n", resp.User.FirstName+" "+resp.User.LastName, resp.User.Email) + if resp.MembershipDataFetched { + fmt.Printf("Team memberships: %d\n", len(resp.TeamMemberships)) + fmt.Printf("All group memberships: %d\n", len(resp.GroupMemberships)) + } +} + +// Example8_BulkUpdateUserGroupMemberships demonstrates bulk membership operations +func Example8_BulkUpdateUserGroupMemberships() { + service := setupService() + + ctx := context.Background() + resp, err := service.BulkUpdateUserGroupMemberships(ctx, &usermanager.BulkUpdateUserGroupMembershipsRequest{ + UserId: "admin-user", + TargetUserId: "user-789", + Actions: []usermanager.GroupMembershipAction{ + { + Action: "ADD", + GroupID: "team-backend", + Role: group.MemberRoleMember, + }, + { + Action: "ADD", + GroupID: "dept-engineering", + Role: group.MemberRoleMember, + }, + { + Action: "REMOVE", + GroupID: "team-frontend", + }, + }, + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Printf("Bulk update completed: %d success, %d failures\n", + resp.SuccessCount, resp.FailureCount) + + for _, result := range resp.Results { + status := "SUCCESS" + if !result.Success { + status = "FAILED: " + result.Error + } + fmt.Printf(" %s %s: %s\n", result.Action, result.GroupID, status) + } +} + +// Example9_UserProfileManagement demonstrates basic user profile operations +func Example9_UserProfileManagement() { + service := setupService() + + ctx := context.Background() + + // Get user profile + profileResp, err := service.GetUserProfile(ctx, &usermanager.GetUserProfileRequest{ + UserId: "user-123", + }) + if err != nil { + fmt.Println("Error getting profile:", err) + return + } + + fmt.Printf("User profile: %s %s\n", profileResp.Profile.FirstName, profileResp.Profile.LastName) + + // Update user profile + updateResp, err := service.UpdateUserProfile(ctx, &usermanager.UpdateUserProfileRequest{ + UserId: "user-123", + UpdateUserRequest: &user.UpdateUserRequest{ + FirstName: "John", + LastName: "Smith", + }, + }) + if err != nil { + fmt.Println("Error updating profile:", err) + return + } + + fmt.Printf("Updated user: %s %s\n", + updateResp.UpdateUserResponse.User.PersonalInfo.FirstName, + updateResp.UpdateUserResponse.User.PersonalInfo.LastName) +} + +// Example10_CommunicationManagement demonstrates creating and retrieving communications +func Example10_CommunicationManagement() { + service := setupService() + + ctx := context.Background() + + // Create a communication + createResp, err := service.CreateComms(ctx, &usermanager.CreateCommsRequest{ + CreateCommsRequest: &contacter.CreateCommsRequest{ + UserId: "user-123", + FullName: "John Doe", + Email: "john.doe@company.com", + Type: contacter.CommsTypeFeedback, + Message: "Welcome to our engineering team...", + Meta: map[string]interface{}{ + "subject": "Welcome to the team!", + }, + }, + }) + if err != nil { + fmt.Println("Error creating comms:", err) + return + } + + fmt.Printf("Created communication: %s\n", createResp.Comms.Id) + + // Get communications + getResp, err := service.GetComms(ctx, &usermanager.GetCommsRequest{ + UserId: "user-123", + GetCommsRequest: &contacter.GetCommsRequest{ + Page: 1, + PerPage: 10, + }, + }) + if err != nil { + fmt.Println("Error getting comms:", err) + return + } + + fmt.Printf("Found %d communications\n", len(getResp.Comms)) +} + +// Example11_GroupManagementWorkflow demonstrates a complete group management workflow +func Example11_GroupManagementWorkflow() { + service := setupService() + + ctx := context.Background() + userID := "user-new-hire" + + fmt.Println("=== Group Management Workflow ===") + + // 1. Get user's current memberships + fmt.Println("1. Checking current memberships...") + currentResp, err := service.GetUserGroups(ctx, &usermanager.GetUserGroupsRequest{ + UserId: userID, + Page: 1, + PerPage: 50, + }) + if err != nil { + fmt.Printf("Error getting current memberships: %v\n", err) + return + } + fmt.Printf(" Current groups: %d\n", len(currentResp.Groups)) + + // 2. Add to engineering department + fmt.Println("2. Adding to Engineering department...") + deptResp, err := service.UpdateUserTeamMembership(ctx, &usermanager.UpdateUserTeamMembershipRequest{ + UserId: userID, + GroupID: "dept-engineering", + Role: group.MemberRoleMember, + }) + if err != nil { + fmt.Printf("Error adding to department: %v\n", err) + return + } + fmt.Printf(" Added to: %s\n", deptResp.Membership.Group.Name) + + // 3. Add to frontend team + fmt.Println("3. Adding to Frontend team...") + teamResp, err := service.UpdateUserTeamMembership(ctx, &usermanager.UpdateUserTeamMembershipRequest{ + UserId: userID, + GroupID: "team-frontend", + Role: group.MemberRoleMember, + RemoveFromOtherTeams: true, // Remove from other teams + }) + if err != nil { + fmt.Printf("Error adding to team: %v\n", err) + return + } + fmt.Printf(" Added to: %s\n", teamResp.Membership.Group.Name) + if len(teamResp.PreviousMemberships) > 0 { + fmt.Printf(" Removed from %d other teams\n", len(teamResp.PreviousMemberships)) + } + + // 4. Verify final memberships + fmt.Println("4. Verifying final memberships...") + finalResp, err := service.GetEnrichedUserProfile(ctx, &usermanager.GetEnrichedUserProfileRequest{ + UserId: userID, + IncludeAllGroups: true, + }) + if err != nil { + fmt.Printf("Error verifying memberships: %v\n", err) + return + } + + fmt.Printf(" Final teams: %d\n", len(finalResp.Profile.Teams)) + fmt.Printf(" Final departments: %d\n", len(finalResp.Profile.Departments)) + fmt.Printf(" Total groups: %d\n", len(finalResp.Profile.Groups)) + + fmt.Println("=== Workflow completed successfully ===") +} + +// Example12_AdminCreateGroup demonstrates admin creating a new group +func Example12_AdminCreateGroup() { + service := setupService() + + ctx := context.Background() + + fmt.Println("=== Admin Create Group Example ===") + + // Create a new engineering team + createResp, err := service.CreateGroup(ctx, &usermanager.CreateGroupRequest{ + AdminUserId: "admin-user-123", + Name: "Machine Learning Team", + Type: group.GroupTypeTeam, + Description: "Team focused on ML/AI projects and research", + Email: "ml-team@company.com", + Icon: "🤖", + Visibility: group.VisibilityPrivate, + InitialMemberIDs: []string{"user-123", "user-456", "user-789"}, + InitialMemberRole: group.MemberRoleMember, + HeadID: "user-123", + Extensions: map[string]interface{}{ + "slack_channel": "#ml-team", + "cost_center": "ENG-ML-001", + "budget": 150000, + }, + }) + if err != nil { + fmt.Printf("Error creating group: %v\n", err) + return + } + + fmt.Printf("Created group: %s (ID: %s)\n", createResp.Group.Name, createResp.Group.ID) + fmt.Printf(" Type: %s\n", createResp.Group.Type) + fmt.Printf(" Members: %d\n", createResp.Group.MemberCount) + fmt.Printf(" Status: %s\n", createResp.Group.Status) + fmt.Printf(" Created: %s\n", createResp.Group.CreatedAt) + + fmt.Println("=== Group created successfully ===") +} + +// Helper functions and types + +func setupService() *usermanager.Service { + // Create mock services + userSvc := &MockUserService{} + apiTokenSvc := &MockApiTokenService{} + auditSvc := &MockAuditService{} + contacterSvc := &MockContacterService{} + groupSvc := &MockGroupService{} + + // Create usermanager service + service := usermanager.NewService(&usermanager.NewServiceRequest{ + UserService: userSvc, + ApiTokenService: apiTokenSvc, + AuditService: auditSvc, + ContacterService: contacterSvc, + }) + + // Add group service + service.WithGroupService(groupSvc) + + return service +} + +// Mock services for examples + +type MockUserService struct{} + +func (m *MockUserService) GetUserMicroProfile(ctx context.Context, r *user.GetUserMicroProfileRequest) (*user.GetUserMicroProfileResponse, error) { + return &user.GetUserMicroProfileResponse{ + MicroProfile: &user.UserMicroProfile{ + ID: r.ID, + Roles: []string{"USER"}, + Status: "ACTIVE", + }, + }, nil +} + +func (m *MockUserService) GetUserProfile(ctx context.Context, r *user.GetUserProfileRequest) (*user.GetUserProfileResponse, error) { + return &user.GetUserProfileResponse{ + Profile: &user.UserProfile{ + ID: r.ID, + Email: "user@example.com", + FirstName: "John", + LastName: "Doe", + Status: "ACTIVE", + Roles: []string{"USER"}, + EmailVerified: true, + UpdatedAt: time.Now().Format(time.RFC3339), + }, + }, nil +} + +func (m *MockUserService) GetUserByID(ctx context.Context, r *user.GetUserByIDRequest) (*user.GetUserByIDResponse, error) { + return &user.GetUserByIDResponse{ + User: &user.UniversalUser{ + ID: r.ID, + Email: "user@example.com", + PersonalInfo: &user.PersonalInfo{ + FullName: "John Doe", + FirstName: "John", + LastName: "Doe", + }, + Status: "ACTIVE", + Roles: []string{"USER"}, + Metadata: &user.UserMetadata{ + CreatedAt: time.Now().Add(-30 * 24 * time.Hour).Format(common.RFC3339NanoUTC), + }, + }, + }, nil +} + +func (m *MockUserService) GetUserByEmail(ctx context.Context, r *user.GetUserByEmailRequest) (*user.GetUserByEmailResponse, error) { + return &user.GetUserByEmailResponse{ + User: &user.UniversalUser{ + ID: "user-123", + Email: r.Email, + PersonalInfo: &user.PersonalInfo{ + FullName: "John Doe", + FirstName: "John", + LastName: "Doe", + }, + Status: "ACTIVE", + Roles: []string{"USER"}, + }, + }, nil +} + +func (m *MockUserService) UpdateUser(ctx context.Context, r *user.UpdateUserRequest) (*user.UpdateUserResponse, error) { + return &user.UpdateUserResponse{ + User: &user.UniversalUser{ + ID: "user-123", + Email: "user@example.com", + PersonalInfo: &user.PersonalInfo{ + FullName: r.FirstName + " " + r.LastName, + FirstName: r.FirstName, + LastName: r.LastName, + }, + }, + }, nil +} + +func (m *MockUserService) DeleteUser(ctx context.Context, r *user.DeleteUserRequest) error { + return nil +} + +type MockApiTokenService struct{} + +func (m *MockApiTokenService) DeleteApiTokensByOwnerId(ctx context.Context, ownerId string) error { + return nil +} + +func (m *MockApiTokenService) GetTotalApiTokens(ctx context.Context, r *apitoken.GetTotalApiTokensRequest) (int64, error) { + return 5, nil +} + +type MockAuditService struct{} + +func (m *MockAuditService) GetTotalAuditLogEvents(ctx context.Context, r *audit.GetTotalAuditLogEventsRequest) (int64, error) { + return 25, nil +} + +type MockContacterService struct{} + +func (m *MockContacterService) CreateComms(ctx context.Context, req *contacter.CreateCommsRequest) (*contacter.CreateCommsResponse, error) { + return &contacter.CreateCommsResponse{ + Comms: &contacter.Comms{ + Id: "comms-123", + FullName: req.FullName, + Email: req.Email, + Type: req.Type, + Message: req.Message, + Meta: req.Meta, + UserId: req.UserId, + UserLoggedIn: true, + CreatedAt: time.Now().Format(time.RFC3339), + }, + }, nil +} + +func (m *MockContacterService) GetComms(ctx context.Context, req *contacter.GetCommsRequest) (*contacter.GetCommsResponse, error) { + return &contacter.GetCommsResponse{ + Comms: []contacter.Comms{ + { + Id: "comms-123", + FullName: "John Doe", + Email: "john.doe@company.com", + Type: contacter.CommsTypeFeedback, + Message: "Welcome!", + UserId: "user-123", + UserLoggedIn: true, + CreatedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }, + }, + Total: 1, + }, nil +} + +type MockGroupService struct{} + +func (m *MockGroupService) GetGroups(ctx context.Context, r *group.GetGroupsRequest) (*group.GetGroupsResponse, error) { + var groups []*group.UniversalGroup + + // Mock some teams + if r.Types == nil || contains(r.Types, group.GroupTypeTeam) { + teams := []*group.UniversalGroup{ + createMockGroup("team-frontend", "Frontend Team", group.GroupTypeTeam, 5), + createMockGroup("team-backend", "Backend Team", group.GroupTypeTeam, 8), + createMockGroup("team-devops", "DevOps Team", group.GroupTypeTeam, 4), + } + + // Add common test users to teams for consistent testing + for _, team := range teams { + team.AddMember("user-123", group.MemberTypeUser, group.MemberRoleMember) + team.AddMember("user-456", group.MemberTypeUser, group.MemberRoleMember) + team.AddMember("user-new-hire", group.MemberTypeUser, group.MemberRoleMember) + } + + // Filter by member if specified + if r.MemberID != "" { + for _, team := range teams { + if team.HasMember(r.MemberID) { + groups = append(groups, team) + } + } + } else { + groups = append(groups, teams...) + } + } + + // Mock departments + if r.Types == nil || contains(r.Types, group.GroupTypeDepartment) { + depts := []*group.UniversalGroup{ + createMockGroup("dept-engineering", "Engineering", group.GroupTypeDepartment, 25), + createMockGroup("dept-product", "Product", group.GroupTypeDepartment, 15), + } + + // Add common test users to departments + for _, dept := range depts { + dept.AddMember("user-123", group.MemberTypeUser, group.MemberRoleMember) + dept.AddMember("user-456", group.MemberTypeUser, group.MemberRoleMember) + dept.AddMember("user-new-hire", group.MemberTypeUser, group.MemberRoleMember) + } + + if r.MemberID != "" { + for _, dept := range depts { + if dept.HasMember(r.MemberID) { + groups = append(groups, dept) + } + } + } else { + groups = append(groups, depts...) + } + } + + // Apply status filter if specified + if len(r.Statuses) > 0 { + filteredGroups := []*group.UniversalGroup{} + for _, g := range groups { + for _, status := range r.Statuses { + if g.Status == status { + filteredGroups = append(filteredGroups, g) + break + } + } + } + groups = filteredGroups + } + + return &group.GetGroupsResponse{ + Groups: groups, + Total: len(groups), + TotalPages: 1, + Page: r.Page, + PerPage: r.PerPage, + }, nil +} + +func (m *MockGroupService) GetGroupByID(ctx context.Context, r *group.GetGroupByIDRequest) (*group.GetGroupByIDResponse, error) { + // Return specific groups based on ID for better example testing + var mockGroup *group.UniversalGroup + + switch r.ID { + case "team-frontend": + mockGroup = createMockGroup("team-frontend", "Frontend Team", group.GroupTypeTeam, 5) + case "team-backend": + mockGroup = createMockGroup("team-backend", "Backend Team", group.GroupTypeTeam, 8) + case "team-devops": + mockGroup = createMockGroup("team-devops", "DevOps Team", group.GroupTypeTeam, 4) + case "dept-engineering": + mockGroup = createMockGroup("dept-engineering", "Engineering", group.GroupTypeDepartment, 25) + case "dept-product": + mockGroup = createMockGroup("dept-product", "Product", group.GroupTypeDepartment, 15) + default: + mockGroup = createMockGroup(r.ID, "Mock Group", group.GroupTypeTeam, 3) + } + + // Add common test users + mockGroup.AddMember("user-123", group.MemberTypeUser, group.MemberRoleMember) + mockGroup.AddMember("user-456", group.MemberTypeUser, group.MemberRoleMember) + mockGroup.AddMember("user-new-hire", group.MemberTypeUser, group.MemberRoleMember) + + return &group.GetGroupByIDResponse{ + Group: mockGroup, + }, nil +} + +func (m *MockGroupService) AddMember(ctx context.Context, r *group.AddMemberRequest) (*group.AddMemberResponse, error) { + // Return a properly populated group + mockGroup := createMockGroup(r.GroupID, "Updated Group", group.GroupTypeTeam, 5) + mockGroup.AddMember(r.MemberID, r.Type, r.Role) + + return &group.AddMemberResponse{ + Group: mockGroup, + }, nil +} + +func (m *MockGroupService) RemoveMember(ctx context.Context, r *group.RemoveMemberRequest) error { + return nil +} + +func (m *MockGroupService) UpdateMemberRole(ctx context.Context, r *group.UpdateMemberRoleRequest) error { + return nil +} + +func (m *MockGroupService) CreateGroup(ctx context.Context, req *group.CreateGroupRequest) (*group.CreateGroupResponse, error) { + // Create a new mock group based on the request + newGroup := createMockGroup( + fmt.Sprintf("group-%s", time.Now().Format("20060102150405")), + req.Name, + req.Type, + len(req.InitialMembers), + ) + + // Set additional fields from request + newGroup.DisplayInfo.Description = req.Description + newGroup.DisplayInfo.Email = req.Email + newGroup.DisplayInfo.Icon = req.Icon + + if req.Visibility != "" { + newGroup.Settings.Visibility = req.Visibility + } + + if len(req.Extensions) > 0 { + newGroup.Extensions = req.Extensions + } + + // Set leadership + if req.OwnerID != "" || req.HeadID != "" || req.LeadID != "" { + if newGroup.Leadership == nil { + newGroup.Leadership = &group.Leadership{} + } + newGroup.Leadership.OwnerID = req.OwnerID + newGroup.Leadership.HeadID = req.HeadID + newGroup.Leadership.LeadID = req.LeadID + } + + // Add initial members + for _, member := range req.InitialMembers { + newGroup.AddMember(member.ID, member.Type, member.Role) + } + + return &group.CreateGroupResponse{ + Group: newGroup, + }, nil +} + +func createMockGroup(id, name, groupType string, memberCount int) *group.UniversalGroup { + config := group.DefaultGroupConfig() + idGen := group.NewDefaultIDGenerator() + timeProvider := group.NewDefaultTimeProvider() + stringUtils := group.NewDefaultStringUtils() + + g := group.NewUniversalGroup(config, idGen, timeProvider, stringUtils) + g.ID = id + g.Name = name + g.Type = groupType + g.Status = group.GroupStatusActive + g.DisplayInfo.Description = fmt.Sprintf("%s - A collaborative group", name) + g.Metadata.CreatedAt = time.Now().Add(-7 * 24 * time.Hour).Format(time.RFC3339) + + // Initialize leadership + if g.Leadership == nil { + g.Leadership = &group.Leadership{} + } + + // Add some mock members with varied roles + for i := 0; i < memberCount; i++ { + role := group.MemberRoleMember + if i == 0 { + role = group.MemberRoleHead + g.Leadership.HeadID = fmt.Sprintf("user-%d", i+1) + } else if i == 1 { + role = group.MemberRoleAdmin + } + g.AddMember(fmt.Sprintf("user-%d", i+1), group.MemberTypeUser, role) + } + + return g +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/external/usermanager/fender.go b/external/usermanager/fender.go index d2df751..7428030 100644 --- a/external/usermanager/fender.go +++ b/external/usermanager/fender.go @@ -8,22 +8,26 @@ import ( "github.com/ooaklee/ghatd/external/contacter" "github.com/ooaklee/ghatd/external/logger" "github.com/ooaklee/ghatd/external/toolbox" + userv2 "github.com/ooaklee/ghatd/external/user/v2" "github.com/ritwickdey/querydecoder" + "go.uber.org/zap" ) // MapRequestToUpdateUserProfileRequest maps incoming UpdateUserProfile request to correct // struct. -func MapRequestToUpdateUserProfileRequest(request *http.Request, validator UsermanagerValidator) (*UpdateUserProfileRequest, error) { - var parsedRequest UpdateUserProfileRequest - log := logger.AcquireFrom(request.Context()) +func MapRequestToUpdateUserProfileRequest(r *http.Request, validator UsermanagerValidator) (*UpdateUserProfileRequest, error) { + var parsedRequest = UpdateUserProfileRequest{ + UpdateUserRequest: &userv2.UpdateUserRequest{}, + } + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) - parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(request.Context()) + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) if parsedRequest.UserId == "" { log.Error("unable-get-user-id") return nil, errors.New(ErrKeyUnableToIdentifyUser) } - err := toolbox.DecodeRequestBody(request, parsedRequest) + err := toolbox.DecodeRequestBody(r, parsedRequest.UpdateUserRequest) if err != nil { return nil, errors.New(ErrKeyInvalidUserBody) } @@ -38,10 +42,10 @@ func MapRequestToUpdateUserProfileRequest(request *http.Request, validator Userm // MapRequestToGetUserMicroProfileRequest maps incoming GetUserMicroProfile request to correct // struct. -func MapRequestToGetUserMicroProfileRequest(request *http.Request, validator UsermanagerValidator) (*GetUserMicroProfileRequest, error) { +func MapRequestToGetUserMicroProfileRequest(r *http.Request, validator UsermanagerValidator) (*GetUserMicroProfileRequest, error) { var parsedRequest GetUserMicroProfileRequest - parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(request.Context()) - log := logger.AcquireFrom(request.Context()) + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) if parsedRequest.UserId == "" { log.Error("unable-get-user-id") @@ -53,10 +57,10 @@ func MapRequestToGetUserMicroProfileRequest(request *http.Request, validator Use // MapRequestToGetUserProfileRequest maps incoming GetUserProfile request to correct // struct. -func MapRequestToGetUserProfileRequest(request *http.Request, validator UsermanagerValidator) (*GetUserProfileRequest, error) { +func MapRequestToGetUserProfileRequest(r *http.Request, validator UsermanagerValidator) (*GetUserProfileRequest, error) { var parsedRequest GetUserProfileRequest - parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(request.Context()) - log := logger.AcquireFrom(request.Context()) + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) if parsedRequest.UserId == "" { log.Error("unable-get-user-id") @@ -68,10 +72,10 @@ func MapRequestToGetUserProfileRequest(request *http.Request, validator Usermana // MapRequestToDeleteUserPermanentlyRequest maps incoming GetUserMicroProfile request to correct // struct. -func MapRequestToDeleteUserPermanentlyRequest(request *http.Request, validator UsermanagerValidator) (*DeleteUserPermanentlyRequest, error) { +func MapRequestToDeleteUserPermanentlyRequest(r *http.Request, validator UsermanagerValidator) (*DeleteUserPermanentlyRequest, error) { var parsedRequest DeleteUserPermanentlyRequest - parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(request.Context()) - log := logger.AcquireFrom(request.Context()) + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) if parsedRequest.UserId == "" { log.Error("unable-get-user-id") @@ -84,17 +88,25 @@ func MapRequestToDeleteUserPermanentlyRequest(request *http.Request, validator U // MapRequestToCreateCommsRequest maps the request to a CreateCommsRequest func MapRequestToCreateCommsRequest(r *http.Request, validator UsermanagerValidator) (*CreateCommsRequest, error) { + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + parsedRequest := &CreateCommsRequest{ CreateCommsRequest: &contacter.CreateCommsRequest{}, } - parsedRequest.CreateCommsRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) - err := toolbox.DecodeRequestBody(r, parsedRequest) + baseRequest := contacter.CreateCommsRequest{} + + baseRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + + err := toolbox.DecodeRequestBody(r, &baseRequest) if err != nil { return nil, errors.New(contacter.ErrKeyInvalidCommsPayload) } - if err := validateParsedRequest(parsedRequest, validator); err != nil { + parsedRequest.CreateCommsRequest = &baseRequest + + if err := validateParsedRequest(&baseRequest, validator); err != nil { + log.Error("create-comms-request-validation-failed", zap.Error(err)) return nil, errors.New(ErrKeyRequestFailedValidation) } @@ -131,3 +143,235 @@ func mapGetCommsRequest(r *http.Request, validator UsermanagerValidator) (*GetCo func validateParsedRequest(request interface{}, validator UsermanagerValidator) error { return validator.Validate(request) } + +// MapRequestToGetEnrichedUserProfileRequest maps incoming GetEnrichedUserProfile request to correct struct +func MapRequestToGetEnrichedUserProfileRequest(r *http.Request, validator UsermanagerValidator) (*GetEnrichedUserProfileRequest, error) { + var parsedRequest GetEnrichedUserProfileRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + if parsedRequest.UserId == "" { + log.Error("unable-get-user-id") + return nil, errors.New(ErrKeyUnableToIdentifyUser) + } + + query := r.URL.Query() + err := querydecoder.New(query).Decode(&parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} + +// MapRequestToGetUserGroupsRequest maps incoming GetUserGroups request to correct struct +func MapRequestToGetUserGroupsRequest(r *http.Request, validator UsermanagerValidator) (*GetUserGroupsRequest, error) { + var parsedRequest GetUserGroupsRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + if parsedRequest.UserId == "" { + log.Error("unable-get-user-id") + return nil, errors.New(ErrKeyUnableToIdentifyUser) + } + + query := r.URL.Query() + err := querydecoder.New(query).Decode(&parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} + +// MapRequestToGetUserTeamMembershipsRequest maps incoming GetUserTeamMemberships request to correct struct +func MapRequestToGetUserTeamMembershipsRequest(r *http.Request, validator UsermanagerValidator) (*GetUserTeamMembershipsRequest, error) { + var parsedRequest GetUserTeamMembershipsRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + if parsedRequest.UserId == "" { + log.Error("unable-get-user-id") + return nil, errors.New(ErrKeyUnableToIdentifyUser) + } + + query := r.URL.Query() + err := querydecoder.New(query).Decode(&parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} + +// MapRequestToUpdateUserTeamMembershipRequest maps incoming UpdateUserTeamMembership request to correct struct +func MapRequestToUpdateUserTeamMembershipRequest(r *http.Request, validator UsermanagerValidator) (*UpdateUserTeamMembershipRequest, error) { + var parsedRequest UpdateUserTeamMembershipRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + if parsedRequest.UserId == "" { + log.Error("unable-get-user-id") + return nil, errors.New(ErrKeyUnableToIdentifyUser) + } + + var err error + parsedRequest.GroupID, err = toolbox.GetVariableValueFromUri(r, UserManagerURIVariableGroupID) + if err != nil { + log.Error("unable-get-group-id-from-uri") + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + err = toolbox.DecodeRequestBody(r, &parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} + +// MapRequestToRemoveUserFromGroupRequest maps incoming RemoveUserFromGroup request to correct struct +func MapRequestToRemoveUserFromGroupRequest(r *http.Request, validator UsermanagerValidator) (*RemoveUserFromGroupRequest, error) { + var parsedRequest RemoveUserFromGroupRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + if parsedRequest.UserId == "" { + log.Error("unable-get-user-id") + return nil, errors.New(ErrKeyUnableToIdentifyUser) + } + + var err error + parsedRequest.GroupID, err = toolbox.GetVariableValueFromUri(r, UserManagerURIVariableGroupID) + if err != nil { + log.Error("unable-get-group-id-from-uri") + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} + +// MapRequestToFindUserInfoRequest maps incoming FindUserInfo request to correct struct +func MapRequestToFindUserInfoRequest(r *http.Request, validator UsermanagerValidator) (*FindUserInfoRequest, error) { + var parsedRequest FindUserInfoRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + // This endpoint doesn't require user ID from context for admin lookups + _ = log + + query := r.URL.Query() + err := querydecoder.New(query).Decode(&parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} + +// MapRequestToBulkUpdateUserGroupMembershipsRequest maps incoming BulkUpdateUserGroupMemberships request to correct struct +func MapRequestToBulkUpdateUserGroupMembershipsRequest(r *http.Request, validator UsermanagerValidator) (*BulkUpdateUserGroupMembershipsRequest, error) { + var parsedRequest BulkUpdateUserGroupMembershipsRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + if parsedRequest.UserId == "" { + log.Error("unable-get-user-id") + return nil, errors.New(ErrKeyUnableToIdentifyUser) + } + + var err error + parsedRequest.TargetUserId, err = toolbox.GetVariableValueFromUri(r, userv2.UserURIVariableID) + if err != nil { + log.Error("unable-get-target-user-id-from-uri") + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + err = toolbox.DecodeRequestBody(r, &parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} + +// MapRequestToGetGroupsByTypeRequest maps incoming GetGroupsByType request to correct struct +func MapRequestToGetGroupsByTypeRequest(r *http.Request, validator UsermanagerValidator) (*GetGroupsByTypeRequest, error) { + var parsedRequest GetGroupsByTypeRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + parsedRequest.UserId = accessmanagerhelpers.AcquireFrom(r.Context()) + if parsedRequest.UserId == "" { + log.Error("unable-get-user-id") + return nil, errors.New(ErrKeyUnableToIdentifyUser) + } + + var err error + parsedRequest.GroupType, err = toolbox.GetVariableValueFromUri(r, UserManagerURIVariableGroupType) + if err != nil { + log.Error("unable-get-group-type-from-uri") + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + query := r.URL.Query() + err = querydecoder.New(query).Decode(&parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} + +// MapRequestToCreateGroupRequest maps incoming CreateGroup request to correct struct +func MapRequestToCreateGroupRequest(r *http.Request, validator UsermanagerValidator) (*CreateGroupRequest, error) { + var parsedRequest CreateGroupRequest + log := logger.AcquireFrom(r.Context()).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + parsedRequest.AdminUserId = accessmanagerhelpers.AcquireFrom(r.Context()) + if parsedRequest.AdminUserId == "" { + log.Error("unable-get-admin-user-id") + return nil, errors.New(ErrKeyUnableToIdentifyUser) + } + + err := toolbox.DecodeRequestBody(r, &parsedRequest) + if err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + if err := validateParsedRequest(parsedRequest, validator); err != nil { + return nil, errors.New(ErrKeyRequestFailedValidation) + } + + return &parsedRequest, nil +} diff --git a/external/usermanager/handler.go b/external/usermanager/handler.go index 2184a65..77fec8c 100644 --- a/external/usermanager/handler.go +++ b/external/usermanager/handler.go @@ -17,6 +17,16 @@ type UsermanagerService interface { DeleteUserPermanently(ctx context.Context, r *DeleteUserPermanentlyRequest) error CreateComms(ctx context.Context, req *CreateCommsRequest) (*CreateCommsResponse, error) GetComms(ctx context.Context, req *GetCommsRequest) (*GetCommsResponse, error) + // Group/Team management methods + GetEnrichedUserProfile(ctx context.Context, r *GetEnrichedUserProfileRequest) (*GetEnrichedUserProfileResponse, error) + GetUserGroups(ctx context.Context, r *GetUserGroupsRequest) (*GetUserGroupsResponse, error) + GetUserTeamMemberships(ctx context.Context, r *GetUserTeamMembershipsRequest) (*GetUserTeamMembershipsResponse, error) + UpdateUserTeamMembership(ctx context.Context, r *UpdateUserTeamMembershipRequest) (*UpdateUserTeamMembershipResponse, error) + RemoveUserFromGroup(ctx context.Context, r *RemoveUserFromGroupRequest) (*RemoveUserFromGroupResponse, error) + FindUserInfo(ctx context.Context, r *FindUserInfoRequest) (*FindUserInfoResponse, error) + BulkUpdateUserGroupMemberships(ctx context.Context, r *BulkUpdateUserGroupMembershipsRequest) (*BulkUpdateUserGroupMembershipsResponse, error) + GetGroupsByType(ctx context.Context, r *GetGroupsByTypeRequest) (*GetGroupsByTypeResponse, error) + CreateGroup(ctx context.Context, r *CreateGroupRequest) (*CreateGroupResponse, error) } // UsermanagerValidator expected methods of a valid @@ -204,6 +214,204 @@ func (h *Handler) GetComms(w http.ResponseWriter, r *http.Request) { h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, getCommsResponse.Comms) } +// GetEnrichedUserProfile handles the request to get an enriched user profile with group memberships +func (h *Handler) GetEnrichedUserProfile(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetEnrichedUserProfileRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetEnrichedUserProfile(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Profile) +} + +// GetUserGroups handles the request to get a user's group memberships +func (h *Handler) GetUserGroups(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetUserGroupsRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetUserGroups(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + if request.Meta { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups, reply.WithMeta(response.Meta)) + return + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups) +} + +// GetUserTeamMemberships handles the request to get team membership status for a user +func (h *Handler) GetUserTeamMemberships(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetUserTeamMembershipsRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetUserTeamMemberships(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Memberships) +} + +// UpdateUserTeamMembership handles the request to update a user's team membership +func (h *Handler) UpdateUserTeamMembership(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToUpdateUserTeamMembershipRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.UpdateUserTeamMembership(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Membership) +} + +// RemoveUserFromGroup handles the request to remove a user from a group +func (h *Handler) RemoveUserFromGroup(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToRemoveUserFromGroupRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.RemoveUserFromGroup(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response) +} + +// FindUserInfo handles the request to find user information with optional group membership data +func (h *Handler) FindUserInfo(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToFindUserInfoRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.FindUserInfo(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.User) +} + +// BulkUpdateUserGroupMemberships handles the request to perform bulk group membership operations +func (h *Handler) BulkUpdateUserGroupMemberships(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToBulkUpdateUserGroupMembershipsRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.BulkUpdateUserGroupMemberships(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + // Use 207 Multi-Status if there are failures, otherwise 200 OK + statusCode := http.StatusOK + if response.FailureCount > 0 { + statusCode = http.StatusMultiStatus + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, statusCode, response) +} + +// GetGroupsByType handles the request to get groups filtered by type +func (h *Handler) GetGroupsByType(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToGetGroupsByTypeRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.GetGroupsByType(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + if request.Meta { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups, reply.WithMeta(response.Meta)) + return + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusOK, response.Groups) +} + +// CreateGroup handles the admin request to create a new group +func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) { + request, err := MapRequestToCreateGroupRequest(r, h.Validator) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + response, err := h.Service.CreateGroup(r.Context(), request) + if err != nil { + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPErrorResponse(w, err) + return + } + + //nolint will set up default fallback later + h.GetBaseResponseHandler().NewHTTPDataResponse(w, http.StatusCreated, response.Group) +} + // GetBaseResponseHandler returns response handler configured with auth error map func (h *Handler) GetBaseResponseHandler() *reply.Replier { return reply.NewReplier(h.ErrorMaps) diff --git a/external/usermanager/model.go b/external/usermanager/model.go index 9f3b12e..f6f6140 100644 --- a/external/usermanager/model.go +++ b/external/usermanager/model.go @@ -1,7 +1,164 @@ package usermanager +import "time" + +// UsageInfo represents usage statistics type UsageInfo struct { Allowance string `json:"allowance"` Used string `json:"used"` UsedPercentage string `json:"used_percentage"` } + +// GroupMemberInfo represents minimal information about a group member +// Used for displaying membership details without full user/group data +type GroupMemberInfo struct { + // ID is the unique identifier of the member (user or group) + ID string `json:"id"` + + // FullName is the complete name of the member + FullName string `json:"full_name"` + + // Type indicates the member type (USER or GROUP) + Type string `json:"type"` + + // Email is the email address (for users) + Email string `json:"email,omitempty"` + + // Role is the member's role within the group + Role string `json:"role,omitempty"` + + // JoinedAt is when the member joined the group + JoinedAt string `json:"joined_at,omitempty"` +} + +// GroupSummary represents summary information about a group +type GroupSummary struct { + // ID is the group's unique identifier + ID string `json:"id"` + + // NanoID is the group's alternative identifier + NanoID string `json:"nano_id,omitempty"` + + // Name is the group's name + Name string `json:"name"` + + // Type is the group type (TEAM, DEPARTMENT, etc.) + Type string `json:"type"` + + // Description is a brief description of the group + Description string `json:"description,omitempty"` + + // MemberCount is the total number of members + MemberCount int `json:"member_count"` + + // Status is the group's current status + Status string `json:"status"` + + // CreatedAt is when the group was created + CreatedAt string `json:"created_at,omitempty"` +} + +// UserGroupMembership represents a user's membership in a specific group +type UserGroupMembership struct { + // Group contains summary information about the group + Group GroupSummary `json:"group"` + + // IsMember indicates if the user is a member + IsMember bool `json:"is_member"` + + // Role is the user's role in the group + Role string `json:"role,omitempty"` + + // IsOwner indicates if the user owns the group + IsOwner bool `json:"is_owner"` + + // IsLead indicates if the user leads the group + IsLead bool `json:"is_lead"` + + // IsHead indicates if the user is the head of the group + IsHead bool `json:"is_head"` + + // JoinedAt is when the user joined the group + JoinedAt string `json:"joined_at,omitempty"` +} + +// EnrichedUserProfile represents a user profile enriched with group membership data +type EnrichedUserProfile struct { + // ID is the user's unique identifier + ID string `json:"id"` + + // Email is the user's email address + Email string `json:"email"` + + // FullName is the user's full name + FullName string `json:"full_name"` + + // FirstName is the user's first name + FirstName string `json:"first_name,omitempty"` + + // LastName is the user's last name + LastName string `json:"last_name,omitempty"` + + // Status is the user's account status + Status string `json:"status"` + + // Roles are the user's platform roles + Roles []string `json:"roles,omitempty"` + + // Teams are the teams the user belongs to + Teams []UserGroupMembership `json:"teams,omitempty"` + + // Departments are the departments the user belongs to + Departments []UserGroupMembership `json:"departments,omitempty"` + + // Groups are all groups the user belongs to + Groups []UserGroupMembership `json:"groups,omitempty"` + + // CreatedAt is when the user account was created + CreatedAt string `json:"created_at,omitempty"` + + // LastLoginAt is when the user last logged in + LastLoginAt string `json:"last_login_at,omitempty"` +} + +// GroupMembershipAction represents an action to be performed on group membership +type GroupMembershipAction struct { + // Action is the type of action (ADD, REMOVE, UPDATE_ROLE) + Action string `json:"action"` + + // GroupID is the target group identifier + GroupID string `json:"group_id"` + + // Role is the role to assign (for ADD/UPDATE_ROLE actions) + Role string `json:"role,omitempty"` + + // RemoveFromOtherGroups indicates whether to remove user from other groups of the same type + RemoveFromOtherGroups bool `json:"remove_from_other_groups,omitempty"` +} + +// BulkGroupMembershipUpdate represents a batch update of group memberships +type BulkGroupMembershipUpdate struct { + // UserID is the user whose memberships are being updated + UserID string `json:"user_id"` + + // Actions are the membership actions to perform + Actions []GroupMembershipAction `json:"actions"` +} + +// GroupMembershipUpdateResult represents the result of a membership update +type GroupMembershipUpdateResult struct { + // Success indicates if the operation succeeded + Success bool `json:"success"` + + // GroupID is the affected group + GroupID string `json:"group_id"` + + // Action is the action that was performed + Action string `json:"action"` + + // Error is any error message (if Success is false) + Error string `json:"error,omitempty"` + + // UpdatedAt is when the update occurred + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/external/usermanager/request.go b/external/usermanager/request.go index 973f9d3..5351c9b 100644 --- a/external/usermanager/request.go +++ b/external/usermanager/request.go @@ -2,7 +2,7 @@ package usermanager import ( "github.com/ooaklee/ghatd/external/contacter" - "github.com/ooaklee/ghatd/external/user" + userv2 "github.com/ooaklee/ghatd/external/user/v2" ) // GetUserMicroProfileRequest holds all the data needed to action request @@ -13,7 +13,7 @@ type GetUserMicroProfileRequest struct { } // GetUserProfileRequest holds all the data needed to action user -// profile retrival request +// profile retrieval request type GetUserProfileRequest struct { // UserId the ID of the user requesting their profile @@ -27,7 +27,7 @@ type UpdateUserProfileRequest struct { // UserId the ID of the user requesting their profile UserId string - *user.UpdateUserRequest + *userv2.UpdateUserRequest } // DeleteUserPermanentlyRequest holds all the data needed to delete user and resources @@ -64,3 +64,192 @@ type GetCommsRequest struct { *contacter.GetCommsRequest } + +// GetEnrichedUserProfileRequest holds the data needed to get an enriched user profile +type GetEnrichedUserProfileRequest struct { + + // UserId is the ID of the user requesting their enriched profile + UserId string + + // IncludeTeams indicates whether to include team memberships + IncludeTeams bool + + // IncludeDepartments indicates whether to include department memberships + IncludeDepartments bool + + // IncludeAllGroups indicates whether to include all group memberships + IncludeAllGroups bool +} + +// GetUserGroupsRequest holds the data needed to get groups for a user +type GetUserGroupsRequest struct { + + // UserId is the ID of the user + UserId string + + // GroupType filters by group type (optional: TEAM, DEPARTMENT, etc.) + GroupType string + + // Status filters by group status (optional: ACTIVE, INACTIVE, etc.) + Status string + + // Page specifies the page results should be taken from. Default 1. + Page int + + // PerPage specifies the number of groups to return per page. Default 25. Max 100. + PerPage int `validate:"min=1,max=100"` + + // Meta indicates whether response should contain meta information + Meta bool +} + +// GetUserTeamMembershipsRequest holds the data needed to get team memberships for a user +type GetUserTeamMembershipsRequest struct { + + // UserId is the ID of the user (can be current user or admin checking another user) + UserId string + + // TargetUserId is the ID of the user to check memberships for (admin feature) + TargetUserId string + + // IncludeInactive indicates whether to include inactive teams + IncludeInactive bool +} + +// UpdateUserTeamMembershipRequest holds the data needed to update a user's team membership +type UpdateUserTeamMembershipRequest struct { + + // UserId is the ID of the user making the request + UserId string + + // GroupID is the ID of the group to join/update + GroupID string + + // Role is the role to assign (optional, uses default if not specified) + Role string + + // RemoveFromOtherTeams indicates whether to leave other teams of the same type + RemoveFromOtherTeams bool + + // DisableNotification specifies whether to disable notifications for this action + DisableNotification bool +} + +// RemoveUserFromGroupRequest holds the data needed to remove a user from a group +type RemoveUserFromGroupRequest struct { + + // UserId is the ID of the user making the request + UserId string + + // GroupID is the ID of the group to leave + GroupID string + + // DisableNotification specifies whether to disable notifications for this action + DisableNotification bool +} + +// FindUserInfoRequest holds the data needed to find user information with flexible lookup +type FindUserInfoRequest struct { + + // UserId is the ID of the user to find (direct lookup) + UserId string + + // Email is the email address to search by + Email string + + // IncludeTeamMemberships indicates whether to include team membership data + IncludeTeamMemberships bool + + // IncludeGroupMemberships indicates whether to include all group membership data + IncludeGroupMemberships bool +} + +// BulkUpdateUserGroupMembershipsRequest holds the data for bulk membership updates +type BulkUpdateUserGroupMembershipsRequest struct { + + // UserId is the ID of the user making the request (must be admin) + UserId string + + // TargetUserId is the ID of the user whose memberships are being updated + TargetUserId string + + // Actions are the membership actions to perform + Actions []GroupMembershipAction + + // DisableNotifications specifies whether to disable notifications for all actions + DisableNotifications bool +} + +// GetGroupsByTypeRequest holds the data needed to get groups filtered by type +type GetGroupsByTypeRequest struct { + + // UserId is the ID of the user making the request + UserId string + + // GroupType is the type to filter by (TEAM, DEPARTMENT, ORGANIZATION, etc.) + GroupType string + + // Name filters groups by name (optional, partial match) + Name string + + // Status filters by status (optional: ACTIVE, INACTIVE, etc.) + Status string + + // OnlyUserMemberships returns only groups the user belongs to + OnlyUserMemberships bool + + // Page specifies the page results should be taken from. Default 1. + Page int + + // PerPage specifies the number of groups to return per page. Default 25. Max 100. + PerPage int `validate:"min=1,max=100"` + + // Meta indicates whether response should contain meta information + Meta bool + + // Order defines how results should be sorted (e.g., "name_asc", "created_at_desc") + Order string +} + +// CreateGroupRequest holds the data needed for an admin to create a new group +type CreateGroupRequest struct { + + // AdminUserId is the ID of the admin making the request + AdminUserId string + + // Name is the name of the group + Name string `json:"name" validate:"required"` + + // Type is the type of group (TEAM, DEPARTMENT, etc.) + Type string `json:"type" validate:"required"` + + // Description is the group description + Description string `json:"description,omitempty"` + + // Email is the group contact email + Email string `json:"email,omitempty"` + + // Icon is the group icon/emoji + Icon string `json:"icon,omitempty"` + + // Visibility controls group visibility (PUBLIC, PRIVATE, etc.) + Visibility string `json:"visibility,omitempty"` + + // Extensions are custom key-value pairs + Extensions map[string]interface{} `json:"extensions,omitempty"` + + // InitialMemberIDs are user IDs to add as initial members + InitialMemberIDs []string `json:"initial_member_ids,omitempty"` + + // InitialMemberRole is the default role for initial members + InitialMemberRole string `json:"initial_member_role,omitempty"` + + // OwnerID is the ID of the group owner + OwnerID string `json:"owner_id,omitempty"` + + // HeadID is the ID of the group head + HeadID string `json:"head_id,omitempty"` + + // LeadID is the ID of the group lead + LeadID string `json:"lead_id,omitempty"` +} diff --git a/external/usermanager/response.go b/external/usermanager/response.go index c8a8cd9..e2e5e12 100644 --- a/external/usermanager/response.go +++ b/external/usermanager/response.go @@ -2,22 +2,22 @@ package usermanager import ( "github.com/ooaklee/ghatd/external/contacter" - "github.com/ooaklee/ghatd/external/user" + userv2 "github.com/ooaklee/ghatd/external/user/v2" ) // GetUserMicroProfileResponse holds response data for GetUserMicroProfile request type GetUserMicroProfileResponse struct { - *user.GetMicroProfileResponse + *userv2.GetUserMicroProfileResponse } // GetUserProfileResponse holds response data for GetUserProfile request type GetUserProfileResponse struct { - *user.GetProfileResponse + *userv2.GetUserProfileResponse } // UpdateUserProfileResponse holds response data for UpdateUserProfile request type UpdateUserProfileResponse struct { - *user.UpdateUserResponse + *userv2.UpdateUserResponse } // CreateCommsResponse holds the response from creating a comms @@ -30,3 +30,83 @@ type GetCommsResponse struct { Comms []contacter.Comms `json:"comms"` Meta map[string]interface{} `json:"-"` } + +// GetEnrichedUserProfileResponse holds the response for an enriched user profile +type GetEnrichedUserProfileResponse struct { + Profile *EnrichedUserProfile `json:"profile"` +} + +// GetUserGroupsResponse holds the response for user groups +type GetUserGroupsResponse struct { + Groups []GroupSummary `json:"groups"` + Total int `json:"-"` + Meta map[string]interface{} `json:"-"` +} + +// GetMetaData returns formatted metadata for pagination +func (r *GetUserGroupsResponse) GetMetaData() map[string]interface{} { + if r.Meta != nil { + return r.Meta + } + return map[string]interface{}{ + "total_resources": r.Total, + } +} + +// GetUserTeamMembershipsResponse holds the response for team memberships +type GetUserTeamMembershipsResponse struct { + Memberships map[string]UserGroupMembership `json:"memberships"` + UserID string `json:"user_id"` +} + +// UpdateUserTeamMembershipResponse holds the response for updating team membership +type UpdateUserTeamMembershipResponse struct { + Success bool `json:"success"` + Membership UserGroupMembership `json:"membership"` + PreviousMemberships []GroupSummary `json:"previous_memberships,omitempty"` +} + +// RemoveUserFromGroupResponse holds the response for removing a user from a group +type RemoveUserFromGroupResponse struct { + Success bool `json:"success"` + GroupID string `json:"group_id"` + Message string `json:"message,omitempty"` +} + +// FindUserInfoResponse holds the response for finding user information +type FindUserInfoResponse struct { + User *userv2.UserProfile `json:"user"` + TeamMemberships []UserGroupMembership `json:"team_memberships,omitempty"` + GroupMemberships []UserGroupMembership `json:"group_memberships,omitempty"` + MembershipDataFetched bool `json:"membership_data_fetched"` +} + +// BulkUpdateUserGroupMembershipsResponse holds the response for bulk membership updates +type BulkUpdateUserGroupMembershipsResponse struct { + Results []GroupMembershipUpdateResult `json:"results"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + TotalActions int `json:"total_actions"` +} + +// GetGroupsByTypeResponse holds the response for getting groups by type +type GetGroupsByTypeResponse struct { + Groups []GroupSummary `json:"groups"` + Total int `json:"-"` + Meta map[string]interface{} `json:"-"` +} + +// GetMetaData returns formatted metadata for pagination +func (r *GetGroupsByTypeResponse) GetMetaData() map[string]interface{} { + if r.Meta != nil { + return r.Meta + } + return map[string]interface{}{ + "total_resources": r.Total, + } +} + +// CreateGroupResponse holds the response for creating a new group +type CreateGroupResponse struct { + Group *GroupSummary `json:"group"` +} diff --git a/external/usermanager/routes.go b/external/usermanager/routes.go index 68b0acb..df715f2 100644 --- a/external/usermanager/routes.go +++ b/external/usermanager/routes.go @@ -1,11 +1,9 @@ package usermanager import ( - "fmt" "net/http" "github.com/gorilla/mux" - "github.com/ooaklee/ghatd/external/common" "github.com/ooaklee/ghatd/external/router" ) @@ -17,25 +15,21 @@ type UsermanagerHandler interface { DeleteUserPermanently(w http.ResponseWriter, r *http.Request) CreateComms(w http.ResponseWriter, r *http.Request) GetComms(w http.ResponseWriter, r *http.Request) + // Group/Team management methods + GetEnrichedUserProfile(w http.ResponseWriter, r *http.Request) + GetUserGroups(w http.ResponseWriter, r *http.Request) + GetUserTeamMemberships(w http.ResponseWriter, r *http.Request) + UpdateUserTeamMembership(w http.ResponseWriter, r *http.Request) + RemoveUserFromGroup(w http.ResponseWriter, r *http.Request) + FindUserInfo(w http.ResponseWriter, r *http.Request) + BulkUpdateUserGroupMemberships(w http.ResponseWriter, r *http.Request) + GetGroupsByType(w http.ResponseWriter, r *http.Request) + CreateGroup(w http.ResponseWriter, r *http.Request) } const ( // APIUserManagerPrefix base URI prefix for all usermanager routes - APIUserManagerPrefix = common.ApiV1UriPrefix + "/ums" - - // APIUserManagerMe URI section used for actions related to requestor - APIUserManagerMe = "/me" - - // APIUserManagerInsights URI section used for insights related calls - APIUserManagerInsights = "/insights" -) - -var ( - // APIUserManagerIDVariable URI variable used to get usermanager ID out of URI - APIUserManagerIDVariable = fmt.Sprintf("/{%s}", UserManagerURIVariableID) - - // APIUserManagerMeMicro URI section used for getting requestor's micro account - APIUserManagerMeMicro = APIUserManagerMe + "/micro" + APIUserManagerV2Prefix = "/api/v1/ums" ) // AttachRoutesRequest holds everything needed to attach usermanager @@ -73,21 +67,34 @@ type AttachRoutesRequest struct { func AttachRoutes(request *AttachRoutesRequest) { httpRouter := request.Router.GetRouter() - userManagerOpenRoutes := httpRouter.PathPrefix(APIUserManagerPrefix).Subrouter() + userManagerOpenRoutes := httpRouter.PathPrefix(APIUserManagerV2Prefix).Subrouter() userManagerOpenRoutes.HandleFunc("/comms", request.Handler.CreateComms).Methods(http.MethodPost, http.MethodOptions) userManagerOpenRoutes.Use(request.RateLimitOrActiveMiddleware) - usermanagerAuthenticatedRoutes := httpRouter.PathPrefix(APIUserManagerPrefix).Subrouter() - usermanagerAuthenticatedRoutes.HandleFunc(APIUserManagerMe, request.Handler.GetUserProfile).Methods(http.MethodGet, http.MethodOptions) - usermanagerAuthenticatedRoutes.HandleFunc(APIUserManagerMe, request.Handler.DeleteUserPermanently).Methods(http.MethodDelete, http.MethodOptions) - usermanagerAuthenticatedRoutes.HandleFunc(APIUserManagerMeMicro, request.Handler.GetUserMicroProfile).Methods(http.MethodGet, http.MethodOptions) + usermanagerAuthenticatedRoutes := httpRouter.PathPrefix(APIUserManagerV2Prefix).Subrouter() + usermanagerAuthenticatedRoutes.HandleFunc("/me", request.Handler.GetUserProfile).Methods(http.MethodGet, http.MethodOptions) + usermanagerAuthenticatedRoutes.HandleFunc("/me", request.Handler.DeleteUserPermanently).Methods(http.MethodDelete, http.MethodOptions) + usermanagerAuthenticatedRoutes.HandleFunc("/me/micro", request.Handler.GetUserMicroProfile).Methods(http.MethodGet, http.MethodOptions) + usermanagerAuthenticatedRoutes.HandleFunc("/me/enriched", request.Handler.GetEnrichedUserProfile).Methods(http.MethodGet, http.MethodOptions) + usermanagerAuthenticatedRoutes.HandleFunc("/me/groups", request.Handler.GetUserGroups).Methods(http.MethodGet, http.MethodOptions) + usermanagerAuthenticatedRoutes.HandleFunc("/me/teams/memberships", request.Handler.GetUserTeamMemberships).Methods(http.MethodGet, http.MethodOptions) + usermanagerAuthenticatedRoutes.HandleFunc("/groups/{"+UserManagerURIVariableGroupType+"}", request.Handler.GetGroupsByType).Methods(http.MethodGet, http.MethodOptions) usermanagerAuthenticatedRoutes.Use(request.ValidApiTokenOrJWTMiddleware) - usermanagerAdminRoutes := httpRouter.PathPrefix(APIUserManagerPrefix).Subrouter() + usermanagerAdminRoutes := httpRouter.PathPrefix(APIUserManagerV2Prefix).Subrouter() usermanagerAdminRoutes.HandleFunc("/comms", request.Handler.GetComms).Methods(http.MethodGet, http.MethodOptions) + usermanagerAdminRoutes.HandleFunc("/admin/users/{id}/groups/bulk", request.Handler.BulkUpdateUserGroupMemberships).Methods(http.MethodPost, http.MethodOptions) + usermanagerAdminRoutes.HandleFunc("/admin/groups", request.Handler.CreateGroup).Methods(http.MethodPost, http.MethodOptions) usermanagerAdminRoutes.Use(request.AdminOnlyMiddleware) - usermanagerActiveOnlyRoutes := httpRouter.PathPrefix(APIUserManagerPrefix).Subrouter() - usermanagerActiveOnlyRoutes.HandleFunc(APIUserManagerMe, request.Handler.UpdateUserProfile).Methods(http.MethodPatch, http.MethodOptions) + usermanagerActiveOnlyRoutes := httpRouter.PathPrefix(APIUserManagerV2Prefix).Subrouter() + usermanagerActiveOnlyRoutes.HandleFunc("/me", request.Handler.UpdateUserProfile).Methods(http.MethodPatch, http.MethodOptions) + usermanagerActiveOnlyRoutes.HandleFunc("/me/teams/memberships/{"+UserManagerURIVariableGroupID+"}", request.Handler.UpdateUserTeamMembership).Methods(http.MethodPut, http.MethodOptions) + usermanagerActiveOnlyRoutes.HandleFunc("/me/teams/memberships/{"+UserManagerURIVariableGroupID+"}", request.Handler.RemoveUserFromGroup).Methods(http.MethodDelete, http.MethodOptions) usermanagerActiveOnlyRoutes.Use(request.ActiveValidApiTokenOrJWTMiddleware) + + // User info lookup - available to both admin and active users + usermanagerUserInfoRoutes := httpRouter.PathPrefix(APIUserManagerV2Prefix).Subrouter() + usermanagerUserInfoRoutes.HandleFunc("/users/info", request.Handler.FindUserInfo).Methods(http.MethodGet, http.MethodOptions) + usermanagerUserInfoRoutes.Use(request.ValidApiTokenOrJWTMiddleware) } diff --git a/external/usermanager/service.go b/external/usermanager/service.go index b2b8a13..4f13cb6 100644 --- a/external/usermanager/service.go +++ b/external/usermanager/service.go @@ -6,17 +6,20 @@ import ( "github.com/ooaklee/ghatd/external/apitoken" "github.com/ooaklee/ghatd/external/audit" "github.com/ooaklee/ghatd/external/contacter" + "github.com/ooaklee/ghatd/external/group" "github.com/ooaklee/ghatd/external/logger" - "github.com/ooaklee/ghatd/external/user" + userv2 "github.com/ooaklee/ghatd/external/user/v2" "go.uber.org/zap" ) // UserService expected methods of a valid user service type UserService interface { - GetMicroProfile(ctx context.Context, r *user.GetMicroProfileRequest) (*user.GetMicroProfileResponse, error) - GetProfile(ctx context.Context, r *user.GetProfileRequest) (*user.GetProfileResponse, error) - UpdateUser(ctx context.Context, r *user.UpdateUserRequest) (*user.UpdateUserResponse, error) - DeleteUser(ctx context.Context, r *user.DeleteUserRequest) error + GetUserMicroProfile(ctx context.Context, r *userv2.GetUserMicroProfileRequest) (*userv2.GetUserMicroProfileResponse, error) + GetUserProfile(ctx context.Context, r *userv2.GetUserProfileRequest) (*userv2.GetUserProfileResponse, error) + GetUserByID(ctx context.Context, r *userv2.GetUserByIDRequest) (*userv2.GetUserByIDResponse, error) + GetUserByEmail(ctx context.Context, r *userv2.GetUserByEmailRequest) (*userv2.GetUserByEmailResponse, error) + UpdateUser(ctx context.Context, r *userv2.UpdateUserRequest) (*userv2.UpdateUserResponse, error) + DeleteUser(ctx context.Context, r *userv2.DeleteUserRequest) error } // ApiTokenService expected methods of a valid api token service @@ -36,12 +39,23 @@ type ContacterService interface { GetComms(ctx context.Context, req *contacter.GetCommsRequest) (*contacter.GetCommsResponse, error) } +// GroupService expected methods of a valid group service +type GroupService interface { + GetGroups(ctx context.Context, r *group.GetGroupsRequest) (*group.GetGroupsResponse, error) + GetGroupByID(ctx context.Context, r *group.GetGroupByIDRequest) (*group.GetGroupByIDResponse, error) + AddMember(ctx context.Context, r *group.AddMemberRequest) (*group.AddMemberResponse, error) + RemoveMember(ctx context.Context, r *group.RemoveMemberRequest) error + UpdateMemberRole(ctx context.Context, r *group.UpdateMemberRoleRequest) error + CreateGroup(ctx context.Context, req *group.CreateGroupRequest) (*group.CreateGroupResponse, error) +} + // Service holds and manages usermanager business logic type Service struct { UserService UserService ApiTokenService ApiTokenService AuditService AuditService ContacterService ContacterService + GroupService GroupService } // NewServiceRequest holds all expected dependencies for an usermanager service @@ -70,10 +84,16 @@ func NewService(r *NewServiceRequest) *Service { } } +// WithGroupService adds group service integration +func (s *Service) WithGroupService(groupSvc GroupService) *Service { + s.GroupService = groupSvc + return s +} + // UpdateUserProfile handles the business logic of updating the requesting user's profile func (s *Service) UpdateUserProfile(ctx context.Context, r *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error) { - serviceResponse, err := s.UserService.UpdateUser(ctx, &user.UpdateUserRequest{ - Id: r.UserId, + serviceResponse, err := s.UserService.UpdateUser(ctx, &userv2.UpdateUserRequest{ + ID: r.UserId, FirstName: r.FirstName, LastName: r.LastName, }) @@ -89,30 +109,30 @@ func (s *Service) UpdateUserProfile(ctx context.Context, r *UpdateUserProfileReq // GetUserMicroProfile handles the business logic of fetching the requesting user's micro profile func (s *Service) GetUserMicroProfile(ctx context.Context, r *GetUserMicroProfileRequest) (*GetUserMicroProfileResponse, error) { - serviceResponse, err := s.UserService.GetMicroProfile(ctx, &user.GetMicroProfileRequest{ - Id: r.UserId, + serviceResponse, err := s.UserService.GetUserMicroProfile(ctx, &userv2.GetUserMicroProfileRequest{ + ID: r.UserId, }) if err != nil { return nil, err } return &GetUserMicroProfileResponse{ - GetMicroProfileResponse: serviceResponse, + GetUserMicroProfileResponse: serviceResponse, }, nil } // GetUserProfile handles the business logic of fetching the requesting user's profile func (s *Service) GetUserProfile(ctx context.Context, r *GetUserProfileRequest) (*GetUserProfileResponse, error) { - serviceResponse, err := s.UserService.GetProfile(ctx, &user.GetProfileRequest{ - Id: r.UserId, + serviceResponse, err := s.UserService.GetUserProfile(ctx, &userv2.GetUserProfileRequest{ + ID: r.UserId, }) if err != nil { return nil, err } return &GetUserProfileResponse{ - GetProfileResponse: serviceResponse, + GetUserProfileResponse: serviceResponse, }, nil } @@ -126,7 +146,7 @@ func (s *Service) DeleteUserPermanently(ctx context.Context, r *DeleteUserPerman loggr.Warn("wiping-user-and-resources-from-platform-started", zap.String("user-id", r.UserId)) loggr.Info("initiate-wiping-user-account", zap.String("user-id", r.UserId)) - err = s.UserService.DeleteUser(ctx, &user.DeleteUserRequest{Id: r.UserId}) + err = s.UserService.DeleteUser(ctx, &userv2.DeleteUserRequest{ID: r.UserId}) if err != nil { return err } diff --git a/external/usermanager/service.group.admin.go b/external/usermanager/service.group.admin.go new file mode 100644 index 0000000..1d826fe --- /dev/null +++ b/external/usermanager/service.group.admin.go @@ -0,0 +1,76 @@ +package usermanager + +import ( + "context" + "errors" + + "github.com/ooaklee/ghatd/external/group" + "github.com/ooaklee/ghatd/external/logger" + "go.uber.org/zap" +) + +// CreateGroup handles creating a new group (admin only) +func (s *Service) CreateGroup(ctx context.Context, r *CreateGroupRequest) (*CreateGroupResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled", zap.String("admin-user-id", r.AdminUserId)) + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + // Build the group creation request + createReq := &group.CreateGroupRequest{ + Name: r.Name, + Type: r.Type, + Description: r.Description, + Email: r.Email, + Icon: r.Icon, + Visibility: r.Visibility, + Extensions: r.Extensions, + OwnerID: r.OwnerID, + HeadID: r.HeadID, + LeadID: r.LeadID, + } + + // Add initial members if provided + if len(r.InitialMemberIDs) > 0 { + defaultRole := r.InitialMemberRole + if defaultRole == "" { + defaultRole = group.MemberRoleMember + } + + createReq.InitialMembers = make([]group.CreateMemberRequest, len(r.InitialMemberIDs)) + for i, memberID := range r.InitialMemberIDs { + createReq.InitialMembers[i] = group.CreateMemberRequest{ + ID: memberID, + Type: group.MemberTypeUser, + Role: defaultRole, + } + } + } + + // Create the group via group service + groupResp, err := s.GroupService.CreateGroup(ctx, createReq) + if err != nil { + log.Error("failed-to-create-group", zap.String("name", r.Name), zap.String("type", r.Type), zap.Error(err)) + return nil, err + } + + // Convert to group summary + groupSummary := &GroupSummary{ + ID: groupResp.Group.ID, + NanoID: groupResp.Group.NanoID, + Name: groupResp.Group.Name, + Type: groupResp.Group.Type, + Description: groupResp.Group.DisplayInfo.Description, + MemberCount: groupResp.Group.GetMemberCount(), + Status: groupResp.Group.Status, + CreatedAt: groupResp.Group.Metadata.CreatedAt, + } + + log.Info("group-created-successfully", zap.String("group-id", groupSummary.ID), zap.String("group-name", groupSummary.Name)) + + return &CreateGroupResponse{ + Group: groupSummary, + }, nil +} diff --git a/external/usermanager/service.group.go b/external/usermanager/service.group.go new file mode 100644 index 0000000..98eea0c --- /dev/null +++ b/external/usermanager/service.group.go @@ -0,0 +1,662 @@ +package usermanager + +import ( + "context" + "errors" + "time" + + "github.com/ooaklee/ghatd/external/group" + "github.com/ooaklee/ghatd/external/logger" + userv2 "github.com/ooaklee/ghatd/external/user/v2" + "go.uber.org/zap" +) + +// GetEnrichedUserProfile handles fetching an enriched user profile with group membership data +func (s *Service) GetEnrichedUserProfile(ctx context.Context, r *GetEnrichedUserProfileRequest) (*GetEnrichedUserProfileResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled", zap.String("user-id", r.UserId)) + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + // Get base user profile + userResp, err := s.UserService.GetUserByID(ctx, &userv2.GetUserByIDRequest{ + ID: r.UserId, + }) + if err != nil { + log.Error("failed-to-get-user-profile", zap.String("user-id", r.UserId), zap.Error(err)) + return nil, err + } + + enrichedProfile := &EnrichedUserProfile{ + ID: userResp.User.ID, + Email: userResp.User.Email, + FullName: userResp.User.PersonalInfo.FullName, + FirstName: userResp.User.PersonalInfo.FirstName, + LastName: userResp.User.PersonalInfo.LastName, + Status: userResp.User.Status, + Roles: userResp.User.Roles, + CreatedAt: userResp.User.Metadata.CreatedAt, + LastLoginAt: userResp.User.Metadata.LastLoginAt, + Teams: []UserGroupMembership{}, + Departments: []UserGroupMembership{}, + Groups: []UserGroupMembership{}, + } + + // Fetch group memberships if requested + if r.IncludeTeams || r.IncludeAllGroups { + teams, err := s.getUserGroupsByType(ctx, r.UserId, group.GroupTypeTeam) + if err != nil { + log.Warn("failed-to-fetch-team-memberships", zap.String("user-id", r.UserId), zap.Error(err)) + } else { + enrichedProfile.Teams = teams + } + } + + if r.IncludeDepartments || r.IncludeAllGroups { + departments, err := s.getUserGroupsByType(ctx, r.UserId, group.GroupTypeDepartment) + if err != nil { + log.Warn("failed-to-fetch-department-memberships", zap.String("user-id", r.UserId), zap.Error(err)) + } else { + enrichedProfile.Departments = departments + } + } + + if r.IncludeAllGroups { + allGroups, err := s.getUserAllGroups(ctx, r.UserId) + if err != nil { + log.Warn("failed-to-fetch-all-group-memberships", zap.String("user-id", r.UserId), zap.Error(err)) + } else { + enrichedProfile.Groups = allGroups + } + } + + return &GetEnrichedUserProfileResponse{ + Profile: enrichedProfile, + }, nil +} + +// GetUserGroups handles fetching groups for a user with filtering +func (s *Service) GetUserGroups(ctx context.Context, r *GetUserGroupsRequest) (*GetUserGroupsResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled", zap.String("user-id", r.UserId)) + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + groupReq := &group.GetGroupsRequest{ + MemberID: r.UserId, + MemberType: group.MemberTypeUser, + Page: r.Page, + PerPage: r.PerPage, + Meta: r.Meta, + } + + if r.GroupType != "" { + groupReq.Types = []string{r.GroupType} + } + + if r.Status != "" { + groupReq.Statuses = []string{r.Status} + } + + groupsResp, err := s.GroupService.GetGroups(ctx, groupReq) + if err != nil { + log.Error("failed-to-get-user-groups", zap.String("user-id", r.UserId), zap.Error(err)) + return nil, err + } + + groupSummaries := make([]GroupSummary, 0, len(groupsResp.Groups)) + for _, g := range groupsResp.Groups { + groupSummaries = append(groupSummaries, GroupSummary{ + ID: g.ID, + NanoID: g.NanoID, + Name: g.Name, + Type: g.Type, + Description: g.DisplayInfo.Description, + MemberCount: g.GetMemberCount(), + Status: g.Status, + CreatedAt: g.Metadata.CreatedAt, + }) + } + + response := &GetUserGroupsResponse{ + Groups: groupSummaries, + Total: groupsResp.Total, + } + + if r.Meta { + response.Meta = map[string]interface{}{ + "total_resources": groupsResp.Total, + "total_pages": groupsResp.TotalPages, + "resources_per_page": groupsResp.PerPage, + "page": groupsResp.Page, + } + } + + return response, nil +} + +// GetUserTeamMemberships handles fetching team memberships for a user +func (s *Service) GetUserTeamMemberships(ctx context.Context, r *GetUserTeamMembershipsRequest) (*GetUserTeamMembershipsResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled", zap.String("user-id", r.UserId)) + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + targetUserID := r.UserId + if r.TargetUserId != "" { + targetUserID = r.TargetUserId + } + + // Get all teams + teamsReq := &group.GetGroupsRequest{ + Types: []string{group.GroupTypeTeam}, + PerPage: 100, // Get all teams + } + + if !r.IncludeInactive { + teamsReq.Statuses = []string{group.GroupStatusActive} + } + + teamsResp, err := s.GroupService.GetGroups(ctx, teamsReq) + if err != nil { + log.Error("failed-to-get-teams", zap.Error(err)) + return nil, err + } + + memberships := make(map[string]UserGroupMembership) + + for _, team := range teamsResp.Groups { + membership := UserGroupMembership{ + Group: GroupSummary{ + ID: team.ID, + NanoID: team.NanoID, + Name: team.Name, + Type: team.Type, + Description: team.DisplayInfo.Description, + MemberCount: team.GetMemberCount(), + Status: team.Status, + CreatedAt: team.Metadata.CreatedAt, + }, + IsMember: team.HasMember(targetUserID), + } + + if membership.IsMember { + // Find the member details + member, err := team.GetMemberByID(targetUserID) + if err == nil { + membership.Role = member.Role + membership.JoinedAt = member.JoinedAt + } + } + + // Check leadership roles + if team.Leadership != nil { + membership.IsOwner = team.Leadership.OwnerID == targetUserID + membership.IsLead = team.Leadership.LeadID == targetUserID + membership.IsHead = team.Leadership.HeadID == targetUserID + } + + memberships[team.Name] = membership + } + + return &GetUserTeamMembershipsResponse{ + Memberships: memberships, + UserID: targetUserID, + }, nil +} + +// UpdateUserTeamMembership handles updating a user's team membership +func (s *Service) UpdateUserTeamMembership(ctx context.Context, r *UpdateUserTeamMembershipRequest) (*UpdateUserTeamMembershipResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled", zap.String("user-id", r.UserId)) + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + // Get the target group + groupResp, err := s.GroupService.GetGroupByID(ctx, &group.GetGroupByIDRequest{ + ID: r.GroupID, + }) + if err != nil { + log.Error("failed-to-get-group", zap.String("group-id", r.GroupID), zap.Error(err)) + return nil, errors.New(ErrKeyGroupNotFound) + } + + targetGroup := groupResp.Group + + // Check if user is already a member + isMember := targetGroup.HasMember(r.UserId) + if isMember { + log.Warn("user-already-member-of-group", zap.String("user-id", r.UserId), zap.String("group-id", r.GroupID)) + return nil, errors.New(ErrKeyUserAlreadyMemberOfGroup) + } + + var previousMemberships []GroupSummary + + // If removing from other teams, fetch and remove + if r.RemoveFromOtherTeams { + currentTeams, err := s.getUserGroupsByType(ctx, r.UserId, targetGroup.Type) + if err != nil { + log.Warn("failed-to-fetch-current-memberships", zap.String("user-id", r.UserId), zap.Error(err)) + } else { + for _, membership := range currentTeams { + if membership.IsMember && membership.Group.ID != r.GroupID { + // Remove from this group + err := s.GroupService.RemoveMember(ctx, &group.RemoveMemberRequest{ + GroupID: membership.Group.ID, + MemberID: r.UserId, + }) + if err != nil { + log.Warn("failed-to-remove-from-previous-group", zap.String("group-id", membership.Group.ID), zap.Error(err)) + } else { + previousMemberships = append(previousMemberships, membership.Group) + } + } + } + } + } + + // Add user to the new group + role := r.Role + if role == "" { + role = group.MemberRoleMember // Default role + } + + _, err = s.GroupService.AddMember(ctx, &group.AddMemberRequest{ + GroupID: r.GroupID, + MemberID: r.UserId, + Type: group.MemberTypeUser, + Role: role, + }) + if err != nil { + log.Error("failed-to-add-member-to-group", zap.String("group-id", r.GroupID), zap.String("user-id", r.UserId), zap.Error(err)) + return nil, errors.New(ErrKeyFailedToAddUserToGroup) + } + + // Fetch updated membership + updatedGroup, err := s.GroupService.GetGroupByID(ctx, &group.GetGroupByIDRequest{ + ID: r.GroupID, + }) + if err != nil { + log.Warn("failed-to-fetch-updated-group", zap.String("group-id", r.GroupID), zap.Error(err)) + } + + membership := UserGroupMembership{ + Group: GroupSummary{ + ID: targetGroup.ID, + NanoID: targetGroup.NanoID, + Name: targetGroup.Name, + Type: targetGroup.Type, + Description: targetGroup.DisplayInfo.Description, + Status: targetGroup.Status, + CreatedAt: targetGroup.Metadata.CreatedAt, + }, + IsMember: true, + Role: role, + } + + if updatedGroup != nil { + membership.Group.MemberCount = updatedGroup.Group.GetMemberCount() + member, err := updatedGroup.Group.GetMemberByID(r.UserId) + if err == nil { + membership.JoinedAt = member.JoinedAt + } + } + + return &UpdateUserTeamMembershipResponse{ + Success: true, + Membership: membership, + PreviousMemberships: previousMemberships, + }, nil +} + +// RemoveUserFromGroup handles removing a user from a group +func (s *Service) RemoveUserFromGroup(ctx context.Context, r *RemoveUserFromGroupRequest) (*RemoveUserFromGroupResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled", zap.String("user-id", r.UserId)) + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + err := s.GroupService.RemoveMember(ctx, &group.RemoveMemberRequest{ + GroupID: r.GroupID, + MemberID: r.UserId, + }) + if err != nil { + log.Error("failed-to-remove-user-from-group", zap.String("group-id", r.GroupID), zap.String("user-id", r.UserId), zap.Error(err)) + return nil, errors.New(ErrKeyFailedToRemoveUserFromGroup) + } + + return &RemoveUserFromGroupResponse{ + Success: true, + GroupID: r.GroupID, + Message: "Successfully removed from group", + }, nil +} + +// FindUserInfo handles finding user information with flexible lookup +func (s *Service) FindUserInfo(ctx context.Context, r *FindUserInfoRequest) (*FindUserInfoResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled") + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + var userResp *userv2.GetUserProfileResponse + var err error + + // Attempt to find user by ID or email + if r.UserId != "" { + userResp, err = s.UserService.GetUserProfile(ctx, &userv2.GetUserProfileRequest{ + ID: r.UserId, + }) + } else if r.Email != "" { + emailResp, err := s.UserService.GetUserByEmail(ctx, &userv2.GetUserByEmailRequest{ + Email: r.Email, + }) + if err == nil && emailResp != nil { + userResp, err = s.UserService.GetUserProfile(ctx, &userv2.GetUserProfileRequest{ + ID: emailResp.User.ID, + }) + } + } + + if err != nil { + log.Error("failed-to-find-user", zap.String("user-id", r.UserId), zap.String("email", r.Email), zap.Error(err)) + return nil, errors.New(ErrKeyUserNotFound) + } + + response := &FindUserInfoResponse{ + User: userResp.Profile, + MembershipDataFetched: false, + } + + // Fetch team memberships if requested + if r.IncludeTeamMemberships { + teams, err := s.getUserGroupsByType(ctx, userResp.Profile.ID, group.GroupTypeTeam) + if err != nil { + log.Warn("failed-to-fetch-team-memberships", zap.String("user-id", userResp.Profile.ID), zap.Error(err)) + } else { + response.TeamMemberships = teams + response.MembershipDataFetched = true + } + } + + // Fetch all group memberships if requested + if r.IncludeGroupMemberships { + allGroups, err := s.getUserAllGroups(ctx, userResp.Profile.ID) + if err != nil { + log.Warn("failed-to-fetch-group-memberships", zap.String("user-id", userResp.Profile.ID), zap.Error(err)) + } else { + response.GroupMemberships = allGroups + response.MembershipDataFetched = true + } + } + + return response, nil +} + +// BulkUpdateUserGroupMemberships handles bulk updates of group memberships +func (s *Service) BulkUpdateUserGroupMemberships(ctx context.Context, r *BulkUpdateUserGroupMembershipsRequest) (*BulkUpdateUserGroupMembershipsResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled", zap.String("requesting-user-id", r.UserId), zap.String("target-user-id", r.TargetUserId)) + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + results := make([]GroupMembershipUpdateResult, 0, len(r.Actions)) + successCount := 0 + failureCount := 0 + + for _, action := range r.Actions { + result := GroupMembershipUpdateResult{ + GroupID: action.GroupID, + Action: action.Action, + UpdatedAt: time.Now().UTC(), + } + + switch action.Action { + case "ADD": + role := action.Role + if role == "" { + role = group.MemberRoleMember + } + + _, err := s.GroupService.AddMember(ctx, &group.AddMemberRequest{ + GroupID: action.GroupID, + MemberID: r.TargetUserId, + Type: group.MemberTypeUser, + Role: role, + }) + if err != nil { + result.Success = false + result.Error = err.Error() + failureCount++ + log.Warn("bulk-add-failed", zap.String("group-id", action.GroupID), zap.Error(err)) + } else { + result.Success = true + successCount++ + } + + case "REMOVE": + err := s.GroupService.RemoveMember(ctx, &group.RemoveMemberRequest{ + GroupID: action.GroupID, + MemberID: r.TargetUserId, + }) + if err != nil { + result.Success = false + result.Error = err.Error() + failureCount++ + log.Warn("bulk-remove-failed", zap.String("group-id", action.GroupID), zap.Error(err)) + } else { + result.Success = true + successCount++ + } + + case "UPDATE_ROLE": + err := s.GroupService.UpdateMemberRole(ctx, &group.UpdateMemberRoleRequest{ + GroupID: action.GroupID, + MemberID: r.TargetUserId, + NewRole: action.Role, + }) + if err != nil { + result.Success = false + result.Error = err.Error() + failureCount++ + log.Warn("bulk-update-role-failed", zap.String("group-id", action.GroupID), zap.Error(err)) + } else { + result.Success = true + successCount++ + } + + default: + result.Success = false + result.Error = "unknown action" + failureCount++ + } + + results = append(results, result) + } + + return &BulkUpdateUserGroupMembershipsResponse{ + Results: results, + SuccessCount: successCount, + FailureCount: failureCount, + TotalActions: len(r.Actions), + }, nil +} + +// GetGroupsByType handles fetching groups filtered by type +func (s *Service) GetGroupsByType(ctx context.Context, r *GetGroupsByTypeRequest) (*GetGroupsByTypeResponse, error) { + log := logger.AcquireFrom(ctx).WithOptions(zap.AddStacktrace(zap.DPanicLevel)) + + if s.GroupService == nil { + log.Error("group-service-not-enabled", zap.String("user-id", r.UserId), zap.String("group-type", r.GroupType)) + return nil, errors.New(ErrKeyGroupServiceNotEnabled) + } + + groupReq := &group.GetGroupsRequest{ + Types: []string{r.GroupType}, + Page: r.Page, + PerPage: r.PerPage, + OrderBy: r.Order, + Meta: r.Meta, + NameSearch: r.Name, + } + + if r.Status != "" { + groupReq.Statuses = []string{r.Status} + } + + if r.OnlyUserMemberships { + groupReq.MemberID = r.UserId + groupReq.MemberType = group.MemberTypeUser + } + + groupsResp, err := s.GroupService.GetGroups(ctx, groupReq) + if err != nil { + log.Error("failed-to-get-groups-by-type", zap.String("type", r.GroupType), zap.Error(err)) + return nil, err + } + + groupSummaries := make([]GroupSummary, 0, len(groupsResp.Groups)) + for _, g := range groupsResp.Groups { + groupSummaries = append(groupSummaries, GroupSummary{ + ID: g.ID, + NanoID: g.NanoID, + Name: g.Name, + Type: g.Type, + Description: g.DisplayInfo.Description, + MemberCount: g.GetMemberCount(), + Status: g.Status, + CreatedAt: g.Metadata.CreatedAt, + }) + } + + response := &GetGroupsByTypeResponse{ + Groups: groupSummaries, + Total: groupsResp.Total, + } + + if r.Meta { + response.Meta = map[string]interface{}{ + "total_resources": groupsResp.Total, + "total_pages": groupsResp.TotalPages, + "resources_per_page": groupsResp.PerPage, + "page": groupsResp.Page, + } + } + + return response, nil +} + +// Helper functions + +// getUserGroupsByType fetches groups of a specific type for a user +func (s *Service) getUserGroupsByType(ctx context.Context, userID, groupType string) ([]UserGroupMembership, error) { + groupReq := &group.GetGroupsRequest{ + Types: []string{groupType}, + MemberID: userID, + MemberType: group.MemberTypeUser, + Statuses: []string{group.GroupStatusActive}, + PerPage: 100, + } + + groupsResp, err := s.GroupService.GetGroups(ctx, groupReq) + if err != nil { + return nil, err + } + + memberships := make([]UserGroupMembership, 0, len(groupsResp.Groups)) + for _, g := range groupsResp.Groups { + member, err := g.GetMemberByID(userID) + + membership := UserGroupMembership{ + Group: GroupSummary{ + ID: g.ID, + NanoID: g.NanoID, + Name: g.Name, + Type: g.Type, + Description: g.DisplayInfo.Description, + MemberCount: g.GetMemberCount(), + Status: g.Status, + CreatedAt: g.Metadata.CreatedAt, + }, + IsMember: true, + } + + if err == nil { + membership.Role = member.Role + membership.JoinedAt = member.JoinedAt + } + + if g.Leadership != nil { + membership.IsOwner = g.Leadership.OwnerID == userID + membership.IsLead = g.Leadership.LeadID == userID + membership.IsHead = g.Leadership.HeadID == userID + } + + memberships = append(memberships, membership) + } + + return memberships, nil +} + +// getUserAllGroups fetches all groups for a user +func (s *Service) getUserAllGroups(ctx context.Context, userID string) ([]UserGroupMembership, error) { + groupReq := &group.GetGroupsRequest{ + MemberID: userID, + MemberType: group.MemberTypeUser, + Statuses: []string{group.GroupStatusActive}, + PerPage: 100, + } + + groupsResp, err := s.GroupService.GetGroups(ctx, groupReq) + if err != nil { + return nil, err + } + + memberships := make([]UserGroupMembership, 0, len(groupsResp.Groups)) + for _, g := range groupsResp.Groups { + member, err := g.GetMemberByID(userID) + + membership := UserGroupMembership{ + Group: GroupSummary{ + ID: g.ID, + NanoID: g.NanoID, + Name: g.Name, + Type: g.Type, + Description: g.DisplayInfo.Description, + MemberCount: g.GetMemberCount(), + Status: g.Status, + CreatedAt: g.Metadata.CreatedAt, + }, + IsMember: true, + } + + if err == nil { + membership.Role = member.Role + membership.JoinedAt = member.JoinedAt + } + + if g.Leadership != nil { + membership.IsOwner = g.Leadership.OwnerID == userID + membership.IsLead = g.Leadership.LeadID == userID + membership.IsHead = g.Leadership.HeadID == userID + } + + memberships = append(memberships, membership) + } + + return memberships, nil +}