-
Notifications
You must be signed in to change notification settings - Fork 121
Feat/import export #647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/import export #647
Conversation
…ub-sync fix(stripe): Fix the subscription sync issue
Add customer sync
…s-api fix(analytics): Add config based execution of api
…usage_tracking fix(feature_usage): Add filter to remove expired price ids
WalkthroughAdds S3 export support and a scheduled-export subsystem: new Ent scheduled_task schema/models, repositories, services and orchestrator, Temporal activities/workflows for export scheduling, S3 integration (client/config/export), API handlers/routes (test export, usage export, scheduled tasks), DI/server wiring, and related task/schema and config changes. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Client
participant API as API (Gin)
participant STSvc as ScheduledTaskService
participant Repo as ScheduledTaskRepo
participant Orch as ScheduledTaskOrchestrator
participant Temporal as Temporal
Note over Client,Temporal: Create scheduled export and start schedule
Client->>API: POST /v1/tasks/scheduled
API->>STSvc: CreateScheduledTask(req)
STSvc->>Repo: Create(task)
STSvc-->>API: ScheduledTaskResponse (201)
API-->>Client: 201 Created
STSvc->>Orch: StartScheduledTask(taskID)
Orch->>Temporal: Create/Unpause schedule (cron/child workflow)
Orch->>Repo: Update(task.TemporalScheduleID)
sequenceDiagram
autonumber
participant TemporalScheduler as Temporal Schedule
participant WF as ScheduledExportWorkflow
participant ActST as ScheduledTaskActivity
participant ActTask as TaskActivity
participant ActExport as ExportActivity
participant FRepo as FeatureUsageRepo
participant S3 as S3 Integration
Note over TemporalScheduler,ActExport: Scheduled run flow
TemporalScheduler->>WF: Trigger(workflow)
WF->>ActST: GetScheduledTaskDetails(taskID)
ActST->>ActST: Calculate Start/End (last success or interval)
WF->>ActTask: CreateTask(...)
WF->>ActTask: UpdateTaskStatus(Processing)
WF->>ActExport: ExportData(input)
ActExport->>FRepo: GetFeatureUsageForExport(batch...)
ActExport->>S3: GetS3Client -> UploadCSV
ActExport-->>WF: FileURL, RecordCount, Size
WF->>ActTask: CompleteTask(file info)
WF->>ActST: UpdateScheduledTaskLastRun(...)
WF-->>TemporalScheduler: Done/Failed
sequenceDiagram
autonumber
participant API as API (Gin)
participant EUH as TestUsageExportHandler
participant ESvc as ExportService
participant STRepo as ScheduledTaskRepo
participant S3C as S3 Integration
Dev->>API: POST /v1/test/export-usage
API->>EUH: TestUsageExport()
EUH->>STRepo: GetByEntityType(events)
EUH->>ESvc: Export(request)
ESvc->>S3C: GetS3Client + UploadCSV
ESvc-->>EUH: ExportResponse
EUH-->>Dev: 200 {file_url, counts, window}
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro Disabled knowledge base sources:
📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (1)
🧰 Additional context used🧬 Code graph analysis (5)internal/api/v1/test_export.go (3)
internal/temporal/activities/export/export_activity.go (9)
cmd/server/main.go (10)
internal/service/sync/export/base.go (8)
internal/service/sync/export/usage_export.go (8)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
🔇 Additional comments (4)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Important
Looks good to me! 👍
Reviewed everything up to 1729c13 in 3 minutes and 58 seconds. Click for details.
- Reviewed
12013lines of code in69files - Skipped
0files when reviewing. - Skipped posting
12draft comments. View those below. - Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.
1. internal/service/feature_usage_tracking.go:204
- Draft comment:
Using context.Background() in processMessage discards the caller’s context and its cancellation; consider using the context passed from the message handler. - Reason this comment was not posted:
Comment was not on a location in the diff, so it can't be submitted as a review comment.
2. internal/service/feature_usage_tracking.go:1619
- Draft comment:
A hardcoded delay (commented in the reprocess loop) is used to avoid rate limiting; consider making this delay configurable. - Reason this comment was not posted:
Comment was not on a location in the diff, so it can't be submitted as a review comment.
3. internal/service/scheduled_job.go:60
- Draft comment:
The S3 job config validation uses a marshal-then-unmarshal approach; consider converting req.JobConfig directly to the S3JobConfig struct to improve efficiency and clarity. - Reason this comment was not posted:
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 20% vs. threshold = 50% The marshal/unmarshal approach is actually a valid pattern that ensures the data strictly conforms to JSON schema. Direct conversion might miss some validation cases. The current approach, while seemingly inefficient, provides good validation and is a common idiom. The performance impact is likely negligible for this use case. The current approach might be intentional for validation purposes. Without seeing the types.S3JobConfig definition, we can't be certain a direct conversion would maintain the same validation guarantees. While direct conversion could be cleaner, the current approach is a valid pattern that ensures proper JSON validation. The suggested change might not be worth the risk of missing edge cases. The comment should be removed as the current implementation is a valid pattern and the suggested change might not provide meaningful benefits while potentially reducing validation thoroughness.
4. internal/service/scheduled_job_orchestrator.go:207
- Draft comment:
Cron expressions in getCronExpression are hardcoded; extracting these into constants or configuration would improve maintainability. - Reason this comment was not posted:
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 20% vs. threshold = 50% The cron expressions are simple, well-commented, and grouped logically in a dedicated function. They correspond directly to enum values from types.ScheduledJobInterval. Moving them to constants wouldn't significantly improve readability or maintainability since they're already well-organized. The current implementation is clear and maintainable. The suggestion could have merit if these cron expressions needed to be configurable or reused elsewhere in the codebase. Constants could also help catch typos at compile-time rather than runtime. The expressions are only used in this one place, are simple standard patterns, and moving them to constants would add complexity without significant benefit. The current implementation is sufficiently maintainable. This comment should be deleted as the current implementation is clear and maintainable, and the suggested change would add unnecessary complexity without meaningful benefit.
5. internal/temporal/workflows/export/execute_export_workflow.go:52
- Draft comment:
The workflow declares zero-value activity variables (e.g. 'var taskActivity export.TaskActivity') without explicit initialization; consider instantiating these or using dependency injection to ensure proper configuration. - Reason this comment was not posted:
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 20% vs. threshold = 50% In Go, it's perfectly valid to use zero-value structs if they don't require initialization. Looking at the usage, these appear to be simple method holders without state. The suggestion to use var taskActivity = export.TaskActivity{} is equivalent to var taskActivity export.TaskActivity in this case. Without seeing the activity implementations, we can't be certain initialization is needed. I don't have access to the TaskActivity and ExportActivity type definitions, so I can't be 100% certain they don't require initialization. The code could potentially be hiding subtle bugs. However, since the code compiles and the structs appear to be simple method holders, explicit initialization would just be more verbose without adding value. In Go, using zero-value structs is idiomatic when no initialization is needed. The comment suggests a change that would be equivalent in behavior and doesn't clearly improve code quality. Without evidence that initialization is required, this comment should be removed.
6. internal/types/sync_config.go:89
- Draft comment:
The S3ExportConfig Validate function returns nil when the config is nil. If this is intentional, consider documenting that a nil value is treated as valid. - Reason this comment was not posted:
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 10% vs. threshold = 50% The nil check is a common Go pattern and is already used consistently throughout this file. The code is clear and follows standard Go practices. Adding a comment about nil being valid would be redundant since it's a common pattern and already consistently used in this codebase. The comment doesn't point out a real issue that needs fixing. The comment does point out something that could be unclear to junior developers who aren't familiar with Go patterns. Documentation can help make code more maintainable. While documentation can be helpful, this is a standard Go pattern that's used consistently throughout the file. Adding comments for every standard pattern would add noise without significant value. Delete the comment as it suggests documenting a standard Go pattern that's already used consistently throughout the file.
7. ent/hook/hook.go:348
- Draft comment:
Typo suggestion: Consider changing the comment to 'The ScheduledJobFunc type is an adapter to allow the use of an ordinary function as a ScheduledJob mutator.' (inserting 'an' before 'ordinary') for grammatical correctness. - Reason this comment was not posted:
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 0% vs. threshold = 50% 1. This is a generated file that should not be manually edited. 2. The comment style is consistent throughout the file. 3. The grammatical suggestion, while technically correct, would make this comment inconsistent with all others. 4. Making manual edits to generated files is generally a bad practice as they'll be overwritten. The grammatical suggestion is technically correct - it would improve the English. Maybe consistency isn't as important as grammatical correctness? For generated code, consistency and avoiding manual edits is more important than perfect grammar. The fix should be made in the code generator if desired, not in the generated file. Delete this comment since it suggests editing a generated file, which should not be manually modified.
8. ent/hook/hook.go:357
- Draft comment:
Typo suggestion: In the error message, 'expect *ent.ScheduledJobMutation' might be more grammatically correct as 'expected *ent.ScheduledJobMutation'. - Reason this comment was not posted:
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 0% vs. threshold = 50% 1. This is a generated file that should not be edited directly. 2. The error message format is consistent throughout the file. 3. Both "expect" and "expected" are grammatically acceptable in error messages. 4. Changing this would make this one message inconsistent with all others. 5. The current format is clear and understandable. The comment does point out a valid grammatical improvement. In isolation, "expected" would be more correct than "expect". However, consistency in generated code is more important than perfect grammar, and the current format is clear and understandable. Delete the comment since this is generated code that should not be edited, and the current format is consistently used throughout the file.
9. internal/integration/stripe/subscription.go:189
- Draft comment:
Typo in comment: "Get the exisitng subcription" should be corrected to "Get the existing subscription". - Reason this comment was not posted:
Comment was on unchanged code.
10. internal/integration/stripe/subscription.go:464
- Draft comment:
Typographical issue: The comment refers to the function as 'createFlexPriceSubscriptionDirect', but the actual function name is 'createFlexPriceSubscriptionWithoutTx'. Please update the comment for consistency. - Reason this comment was not posted:
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 10% vs. threshold = 50% While the comment is technically correct about the mismatch, this is a very minor documentation issue. The function name in the comment is clear enough to understand the purpose, and the actual function name is also clear. This kind of minor documentation nitpick doesn't warrant a comment in a PR review. The comment is factually accurate - there is indeed a mismatch between the comment and function name. Inconsistent documentation could potentially confuse future developers. While documentation consistency is good, this is an extremely minor issue that doesn't affect functionality. The meaning is clear either way. This kind of minor documentation nitpick creates noise in PR reviews. Delete this comment as it's too minor of an issue to warrant a PR comment. Both names convey the same meaning clearly enough.
11. internal/integration/stripe/subscription.go:554
- Draft comment:
Typographical error: 'exisiting' should be corrected to 'existing'. - Reason this comment was not posted:
Comment was on unchanged code.
12. internal/types/connection.go:122
- Draft comment:
Typo: For consistency withSecretProviderS3and references toS3elsewhere, consider capitalizing 's3' to 'S3' in the error message. - Reason this comment was not posted:
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 10% vs. threshold = 50% While the comment is technically correct about the inconsistency, this is a very minor style issue that doesn't affect functionality. The error message is internal and the inconsistency is minimal. According to the rules, we should not make purely informative comments or comments about obvious/unimportant issues. The inconsistency could potentially cause confusion for developers, and maintaining consistent capitalization throughout the codebase is generally good practice. However, this is such a minor style issue that it doesn't warrant a PR comment. The inconsistency is in an error message string that's only visible during errors, and fixing it would not materially improve the code. Delete the comment as it's too minor of a style issue to warrant a PR comment.
Workflow ID: wflow_Vr5v18B414mOIU4v
You can customize by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 32
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
internal/integration/stripe/subscription.go (1)
553-581: Critical: possible nil deref and mismatch using Plan.Product; use Price.Product (expanded) with guards
fetchStripeSubscriptionexpandsitems.data.price.product, butisPlanChangereadsItems[0].Plan.Product.ID.Plancan be nil and isn’t expanded, causing panics and false negatives. Compare againstPrice.Product.IDinstead and add guards.Apply this diff:
func (s *stripeSubscriptionService) isPlanChange(ctx context.Context, existingSubscription *dto.SubscriptionResponse, stripeSubscription *stripe.Subscription, services *ServiceDependencies) (bool, error) { @@ - existingPlanID := existingPlanMapping.Items[0].ProviderEntityID - - if existingPlanID != stripeSubscription.Items.Data[0].Plan.Product.ID { - return true, nil - } + existingStripeProductID := existingPlanMapping.Items[0].ProviderEntityID + + if stripeSubscription.Items == nil || len(stripeSubscription.Items.Data) == 0 { + return false, ierr.NewError("no items found in Stripe subscription"). + WithHint("Stripe subscription must have at least one item"). + Mark(ierr.ErrValidation) + } + firstItem := stripeSubscription.Items.Data[0] + if firstItem.Price == nil || firstItem.Price.Product == nil { + return false, ierr.NewError("no product found in Stripe subscription item"). + WithHint("Stripe subscription item must have a price with product"). + Mark(ierr.ErrValidation) + } + if existingStripeProductID != firstItem.Price.Product.ID { + return true, nil + }internal/types/sync_config.go (1)
172-190: Wire S3 config into SyncConfig.Validate
SyncConfig.Validate()still only checks the legacy integration switches. With the newS3block, callers that relied onSyncConfig.Validate()(e.g. connection create/update flows) will happily persist an invalid S3 export config, becauseS3.Validate()is never invoked. Please hook it up (and bubble its error) so we don’t accept malformed export settings.cmd/server/main.go (1)
400-412: Don’t start Temporal service in a provider and never return nil values; propagate errors.Starting inside fx.Provide and returning nil on failure breaks DI and hides startup errors.
-func provideTemporalService(temporalClient client.TemporalClient, workerManager worker.TemporalWorkerManager, log *logger.Logger) temporalservice.TemporalService { +func provideTemporalService(temporalClient client.TemporalClient, workerManager worker.TemporalWorkerManager, log *logger.Logger) (temporalservice.TemporalService, error) { // Initialize the global Temporal service instance temporalservice.InitializeGlobalTemporalService(temporalClient, workerManager, log) // Get the global instance and start it service := temporalservice.GetGlobalTemporalService() if err := service.Start(context.Background()); err != nil { log.Error("Failed to start global Temporal service", "error", err) - return nil + return nil, fmt.Errorf("failed to start temporal service: %w", err) } - return service + return service, nil }Fx supports providers returning (T, error); this will fail fast and avoid injecting a nil instance.
♻️ Duplicate comments (2)
ent/mutation.go (2)
53985-53987: ClearedFields includes scheduled_job_id.
Duplicate of earlier nillable concern for this field.
54032-54034: ClearField handles scheduled_job_id.
Duplicate of earlier nillable concern for this field.
🧹 Nitpick comments (42)
internal/integration/stripe/subscription.go (6)
181-185: Return NotFound (not Internal) when mapping is missing; optionally import-on-missTreating a missing mapping as Internal masks a real “not found” case and hampers recovery paths.
Apply this diff to reclassify:
- return ierr.NewError("no existing subscription mapping found"). - WithHint("Existing subscription mapping not found"). - Mark(ierr.ErrInternal) + return ierr.NewError("no existing subscription mapping found"). + WithHint("No FlexPrice mapping found for the given Stripe subscription ID"). + WithReportableDetails(map[string]interface{}{"stripe_subscription_id": stripeSubscriptionID}). + Mark(ierr.ErrNotFound)Optionally, you can choose to auto-import when missing:
- Call
s.CreateSubscription(txCtx, stripeSubscriptionID, services)and return nil on success.- Guard against duplicates with a unique constraint on the mapping. I can draft this change if desired.
206-211: Fix log key: it's a Stripe subscription IDUse a precise field name to avoid confusion in logs.
- s.logger.Errorw("failed to update subscription", - "error", err, - "subscription_id", stripeSubscriptionID) + s.logger.Errorw("failed to update subscription", + "error", err, + "stripe_subscription_id", stripeSubscriptionID)
415-462: Apply the same billing-anchor fix in the transactional create pathThe same epoch-anchor issue exists in
createFlexPriceSubscription. Mirror the change to avoid 1970-01-01 defaults.Suggested change (illustrative):
- billingAnchor := time.Unix(stripeSub.BillingCycleAnchor, 0).UTC() + var billingAnchor *time.Time + if stripeSub.BillingCycleAnchor != 0 { + t := time.Unix(stripeSub.BillingCycleAnchor, 0).UTC() + billingAnchor = &t + } @@ - BillingAnchor: &billingAnchor, + BillingAnchor: billingAnchor,Also applies to: 505-520
464-551: Clarify naming and dedupe request-building logic
- The helper name “createFlexPriceSubscriptionWithoutTx” is misleading: it still uses the passed context, which is a txCtx when called from UpdateSubscription. Consider renaming to “createFlexPriceSubscriptionDirect” and document “no additional transaction wrapper.”
- Extract a small builder to remove duplication between the two create helpers.
Example extraction:
func (s *stripeSubscriptionService) buildCreateReq(stripeSub *stripe.Subscription, customerID, planID string) (dto.CreateSubscriptionRequest, error) { // compute billingPeriod, dates, trial, optional billingAnchor (as per the anchor fix) // return dto.CreateSubscriptionRequest{...}, nil }Then both helpers call
buildCreateReq(...)and pass toSubscriptionService.CreateSubscription(...).
372-385: Guard against missing/partial price info in billing-period calcIf
ItemsorPrice.Recurringis absent, this can panic. Add simple guards and default gracefully.Example:
func (s *stripeSubscriptionService) calculateBillingPeriod(stripeSub *stripe.Subscription) types.BillingPeriod { - switch stripeSub.Items.Data[0].Price.Recurring.Interval { + if stripeSub.Items == nil || len(stripeSub.Items.Data) == 0 || stripeSub.Items.Data[0].Price == nil || stripeSub.Items.Data[0].Price.Recurring == nil { + return types.BILLING_PERIOD_MONTHLY + } + switch stripeSub.Items.Data[0].Price.Recurring.Interval {
189-194: Minor: fix typos in comments“exisitng” → “existing”; “subcription” → “subscription”.
internal/integration/stripe/customer.go (1)
204-220: Consider restructuring theexternalIDassignment for clarity.The
externalIDvariable is assigned twice:
- Line 204: Set to
stripeCustomer.ID- Line 219: Overwritten with
lookupKeyif it existsWhile functionally correct, this pattern can be confusing. Consider moving the initial assignment closer to where it's used in Step 3 (line 254) or using a more explicit conditional assignment.
Apply this diff to improve clarity:
func (s *CustomerService) CreateCustomerFromStripe(ctx context.Context, stripeCustomer *stripe.Customer, environmentID string, customerService interfaces.CustomerService) error { - externalID := stripeCustomer.ID - // Step 1: Check by flexprice_customer_id, if exists just return if flexpriceID, exists := stripeCustomer.Metadata["flexprice_customer_id"]; exists && flexpriceID != "" { existing, err := customerService.GetCustomer(ctx, flexpriceID) if err == nil && existing != nil { s.logger.Infow("FlexPrice customer already exists, skipping creation", "flexprice_customer_id", flexpriceID, "stripe_customer_id", stripeCustomer.ID) return nil } } + // Determine external ID: prefer lookup key, fallback to Stripe customer ID + externalID := stripeCustomer.ID // Step 2: Check by flexprice_lookup_key if lookupKey, exists := stripeCustomer.Metadata["flexprice_lookup_key"]; exists && lookupKey != "" { externalID = lookupKeyinternal/repository/clickhouse/feature_usage.go (1)
1393-1422: Consider cursor-based pagination for better performance.Using
OFFSETfor pagination in ClickHouse, especially withORDER BY timestamp DESC, can be expensive for large datasets. Each query must scan and skip all preceding rows. For exports that may process millions of records, consider refactoring to use cursor-based pagination with aWHERE timestamp < ?clause instead.Example cursor-based approach:
// First batch: no cursor query := `SELECT ... FROM feature_usage FINAL WHERE tenant_id = ? AND environment_id = ? AND timestamp >= ? AND timestamp < ? AND sign = 1 ORDER BY timestamp DESC LIMIT ?` // Subsequent batches: add cursor query := `SELECT ... FROM feature_usage FINAL WHERE tenant_id = ? AND environment_id = ? AND timestamp >= ? AND timestamp < ? AND timestamp < ? -- cursor from last batch AND sign = 1 ORDER BY timestamp DESC LIMIT ?`This eliminates the need to skip rows and improves query performance, especially for large offsets.
ent/migrate/schema.go (3)
1828-1830: Confirm file_url default/non-nullability won’t break existing datafile_url is reintroduced with Default: "" and not marked Nullable. If previous rows had NULLs, ALTER might fail or coerce unexpectedly.
If nulls existed, consider making file_url Nullable (and enforce non-empty at write-time) to avoid migration issues.
1374-1427: Consider FK from scheduled_jobs.connection_id to connections.idEnforcing referential integrity prevents pointing to deleted/missing connections.
Do this in ent/schema/scheduledjob.go via an edge to Connection and regenerate. Prefer ON DELETE RESTRICT (or SET NULL if business rules allow).
1417-1425: Unique index scope checkUnique(connection_id, entity_type) is fine if connection IDs are globally unique. If not guaranteed globally, include tenant/environment in the unique index.
internal/integration/s3/config.go (1)
26-31: Prefer returning (Config, error) with validation of required fieldsReturning nil hides cause; bucket/region/creds should be validated early.
Example:
- Signature: NewConfigFromConnection(...) (*Config, error)
- Validate Bucket, Region, AWS creds; normalize Compression/Encryption to allowed set.
Also applies to: 50-63
ent/schema/scheduledjob.go (3)
24-80: Constrain interval/entity_type using enums to prevent invalid valuesUsing free-form strings risks invalid data. Define ent.Enum with allowed values (daily/weekly/monthly, valid entity types), or central constants mirror.
24-80: Add edge to Connection to generate FKDefine:
// In ScheduledJob schema edge.To("connection", Connection.Type). Field("connection_id"). Unique()Then regenerate to emit FK and improve integrity.
83-97: Consider tenant/environment-scoped unique constraintIf connection IDs aren’t globally unique, make the unique index include tenant_id/environment_id.
internal/api/router.go (1)
508-513: Gate /v1/test endpoints behind feature flags in productionThey’re private but still sensitive. Recommend env/flag guard or build tags to disable in prod.
internal/domain/scheduledjob/repository.go (2)
28-30: Clarify “enabled-only” semantics for GetByEntityTypeComment says “enabled scheduled jobs,” but signature doesn’t convey that. Either:
- enforce enabled=true in implementation and update comment to be explicit, or
- accept an Enabled filter parameter for parity with List.
38-48: Document/validate pagination and filtersPlease specify behavior for Limit/Offset when zero/negative, and enforce sane caps (e.g., max page size) in implementations to avoid unbounded scans. Also note whether TenantID/EnvironmentID are required.
internal/service/sync/export/base.go (2)
14-21: Avoid []byte for large exports; prefer streamingReturning raw []byte encourages loading entire datasets in memory. Consider:
- Replace PrepareData with a streaming API (e.g., io.Reader, csv.Writer/JSON encoder) or
- Have Export handle streaming directly without an intermediate []byte.
63-71: Validate request upfrontAdd lightweight checks:
- EntityType not empty/supported,
- ConnectionID/TenantID/EnvID non-empty,
- StartTime <= EndTime.
Fail fast with ErrValidation + hint.internal/temporal/activities/export/export_activity.go (1)
16-21: Deduplicate exporter selection; delegate to ExportServiceAvoid drift by wiring ExportService into the activity and delegating ExportData to it.
Apply this refactor:
type ExportActivity struct { featureUsageRepo events.FeatureUsageRepository s3Client *s3Integration.Client logger *logger.Logger + exportService *syncExport.ExportService } func NewExportActivity( featureUsageRepo events.FeatureUsageRepository, s3Client *s3Integration.Client, logger *logger.Logger, ) *ExportActivity { return &ExportActivity{ featureUsageRepo: featureUsageRepo, s3Client: s3Client, logger: logger, + exportService: syncExport.NewExportService(featureUsageRepo, s3Client, logger), } } @@ - // Get the appropriate exporter based on entity type - var response *syncExport.ExportResponse - var err error - - switch input.EntityType { - case types.ExportEntityTypeFeatureUsage: - exporter := syncExport.NewUsageExporter(a.featureUsageRepo, a.s3Client, a.logger) - response, err = exporter.Export(ctx, request) - // Add more entity types as needed - // case types.ExportEntityTypeCustomer: - // exporter := syncExport.NewCustomerExporter(...) - // response, err = exporter.Export(ctx, request) - default: - return nil, ierr.NewError("unsupported entity type"). - WithHintf("Entity type '%s' is not supported for export", input.EntityType). - Mark(ierr.ErrValidation) - } + // Delegate to ExportService (handles unknown entity validation) + response, err := a.exportService.Export(ctx, request) + if err != nil { + a.logger.Errorw("export failed", "error", err, "entity_type", input.EntityType) + return nil, err + }Also applies to: 23-33, 77-89
internal/service/scheduled_job_orchestrator.go (3)
87-88: Honor job.Enabled when creating scheduleStart paused if the job is disabled.
- Paused: false, // Start immediately + Paused: !job.Enabled, // Respect job enabled state
68-71: Timezone/overlap policyConsider setting an explicit timezone (e.g., UTC or tenant-specific) and an overlap policy to prevent concurrent runs if one overruns. Verify available fields in your Temporal SDK version (e.g., ScheduleSpec Timezone and Overlap options).
222-239: Manual window alignmentCurrent logic subtracts a fixed duration from now, which may split periods. Consider aligning to period boundaries (start of hour/day/week/month) or using LastRunAt/NextRunAt when available to avoid gaps/overlaps.
internal/temporal/workflows/export/execute_export_workflow.go (2)
43-51: Use per-activity timeouts; export likely needs a longer windowExporting can exceed 10m. Keep short timeouts for task CRUD; extend only the export step.
Apply this diff to add a longer timeout context for the export step:
// Activity options activityOptions := workflow.ActivityOptions{ - StartToCloseTimeout: 10 * time.Minute, // Max time for each activity + StartToCloseTimeout: 2 * time.Minute, // Task create/update/complete are quick RetryPolicy: &temporal.RetryPolicy{ MaximumAttempts: 3, }, } ctx = workflow.WithActivityOptions(ctx, activityOptions) + + // Longer timeout only for the export step + exportActivityOptions := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Minute, + RetryPolicy: &temporal.RetryPolicy{ + MaximumAttempts: 3, + BackoffCoefficient: 2.0, + }, + } + ctxExport := workflow.WithActivityOptions(ctx, exportActivityOptions)And update the export call to use ctxExport:
// change at Line 101 err = workflow.ExecuteActivity(ctxExport, exportActivity.ExportData, exportInput).Get(ctx, &exportOutput)
132-136: Completion failure can leave task stuck in PROCESSINGIf CompleteTask fails, the task may remain PROCESSING despite a successful export.
Proposed approach:
- Retry CompleteTask with backoff.
- If it still fails, attempt a best-effort UpdateTaskStatus to a terminal status (e.g., COMPLETED) with minimal metadata.
Please confirm the existence of a Completed/Success status in internal/types/task.go and wire a fallback update if available. If desired, I can generate a patch once the status constant name is confirmed.
internal/service/sync/export/usage_export.go (3)
193-199: Validate and default JobConfig before useCall SetDefaults + Validate on S3 job config to catch early misconfiguration.
- // Validate job config is provided - if request.JobConfig == nil { + // Validate job config is provided and correct + if request.JobConfig == nil { return nil, ierr.NewError("job configuration is required"). WithHint("S3 job configuration must be provided in the export request"). Mark(ierr.ErrValidation) } + request.JobConfig.SetDefaults() + if err := request.JobConfig.Validate(); err != nil { + return nil, ierr.WithError(err). + WithHint("Invalid S3 job configuration"). + Mark(ierr.ErrValidation) + }
223-231: Filename should include extension and reflect compression; honor key prefixAdd .csv (or .csv.gz) for better content-type/UX and include optional KeyPrefix.
- // Generate filename with timestamp - filename := fmt.Sprintf("feature_usage_%s", time.Now().Format("20060102_150405")) + // Generate filename with timestamp and extension + ts := time.Now().Format("20060102_150405") + compressed := request.JobConfig != nil && request.JobConfig.Compression == "gzip" + base := fmt.Sprintf("feature_usage_%s.csv", ts) + if compressed { + base += ".gz" + } + // Prepend optional key prefix (ensure single slash) + filename := base + if kp := request.JobConfig.KeyPrefix; kp != "" { + // avoid double slashes + if strings.HasSuffix(kp, "/") { + filename = kp + base + } else { + filename = kp + "/" + base + } + }Note: Add
stringsto imports.
191-244: Large export memory use; consider streaming or multipart uploadBuffering full CSV in memory can OOM on large ranges. Prefer:
- Stream rows via io.Pipe to S3 multipart upload.
- Or chunk by MaxFileSizeMB (config) and upload multiple parts/files.
I can draft a streaming UploadCSV variant with io.Pipe and S3 multipart if your S3 client supports it.
internal/domain/scheduledjob/model.go (2)
31-34: Avoid magic string for status "published"Use a typed constant (e.g., types.StatusPublished) to prevent drift with DB/Ent values.
68-106: Simplify and harden S3 config parsing using JSON round-trip; handle int/float typesManual map type assertions are brittle. Marshal the map and unmarshal into S3JobConfig to normalize types (e.g., float64 vs int).
Add import:
import "encoding/json"Apply this diff within GetS3JobConfig:
- config := &types.S3JobConfig{} - - // Parse from map - if bucket, ok := j.JobConfig["bucket"].(string); ok { - config.Bucket = bucket - } - if region, ok := j.JobConfig["region"].(string); ok { - config.Region = region - } - if keyPrefix, ok := j.JobConfig["key_prefix"].(string); ok { - config.KeyPrefix = keyPrefix - } - if compression, ok := j.JobConfig["compression"].(string); ok { - config.Compression = compression - } - if encryption, ok := j.JobConfig["encryption"].(string); ok { - config.Encryption = encryption - } - if endpointURL, ok := j.JobConfig["endpoint_url"].(string); ok { - config.EndpointURL = endpointURL - } - if virtualHostStyle, ok := j.JobConfig["virtual_host_style"].(bool); ok { - config.VirtualHostStyle = virtualHostStyle - } - if maxFileSizeMB, ok := j.JobConfig["max_file_size_mb"].(float64); ok { - config.MaxFileSizeMB = int(maxFileSizeMB) - } + config := &types.S3JobConfig{} + raw, err := json.Marshal(j.JobConfig) + if err != nil { + return nil, fmt.Errorf("marshal job_config: %w", err) + } + if err := json.Unmarshal(raw, config); err != nil { + return nil, fmt.Errorf("unmarshal job_config: %w", err) + }Keep defaults + validate as you already do.
internal/service/scheduled_job_test.go (3)
15-51: Remove or use MockScheduledJobRepoIt’s unused and can confuse future readers. Drop it or add tests that leverage it.
227-230: Unused helperboolPtr is unused; remove to satisfy linters.
114-141: Expand test coverageAdd tests for:
- ScheduledJob.GetS3JobConfig (defaults, validation errors, int/float handling).
- IsEnabled/IsDue behavior (status + enabled, NextRunAt cases).
I can scaffold table-driven tests for these quickly if you’d like.
Also applies to: 169-190, 192-225
internal/integration/s3/client.go (2)
79-82: Preserve root cause when wrapping GetDecryptedS3Config error.NewError discards the original error stack/context.
- if err != nil { - return nil, nil, ierr.NewError("failed to get S3 configuration"). - WithHint("Invalid S3 configuration"). - Mark(ierr.ErrValidation) - } + if err != nil { + return nil, nil, ierr.WithError(err). + WithHint("failed to get S3 configuration"). + Mark(ierr.ErrValidation) + }
59-75: Differentiate NotFound vs other repository errors when fetching connection.All errors are marked ErrNotFound, potentially masking DB/system failures. Prefer preserving the cause and marking appropriately when the repo indicates not-found.
- If the repository exposes a sentinel/not-found check, use it to decide ErrNotFound vs ErrDatabase/ErrInternal.
- Otherwise, wrap with WithError(err) and add a hint.
Would you like a targeted patch after confirming the repository error contract?
internal/temporal/activities/export/task_activity.go (3)
65-65: Don't set StartedAt when status is PENDINGStartedAt should be set on first transition to PROCESSING (UpdateTaskStatus already handles it). Setting it at creation skews timing and metrics.
- StartedAt: &now, + StartedAt: nil,
51-51: Normalize to UTC and keep UpdatedAt current on writes
- Use time.Now().UTC() for timestamps and store/format times in UTC.
- Set BaseModel.UpdatedAt on updates to preserve auditability.
- now := time.Now() + now := time.Now().UTC() @@ - now := time.Now() + now := time.Now().UTC() + existingTask.BaseModel.UpdatedAt = now @@ - now := time.Now() + now := time.Now().UTC() + existingTask.BaseModel.UpdatedAt = now @@ - "start_time": input.StartTime.Format(time.RFC3339), - "end_time": input.EndTime.Format(time.RFC3339), + "start_time": input.StartTime.UTC().Format(time.RFC3339), + "end_time": input.EndTime.UTC().Format(time.RFC3339), @@ - existingTask.Metadata["completed_at"] = now.Format(time.RFC3339) + existingTask.Metadata["completed_at"] = now.Format(time.RFC3339)Please confirm if repository hooks already maintain UpdatedAt; if so, we can skip manual assignment.
Also applies to: 130-131, 186-187, 62-64, 193-195
122-127: Avoid taking a pointer to an input field directlyTake a local copy before assigning to a pointer field to avoid holding references into the input struct.
- existingTask.TotalRecords = &input.RecordInfo.TotalRecords + total := input.RecordInfo.TotalRecords + existingTask.TotalRecords = &totalinternal/service/scheduled_job.go (1)
80-81: Use UTC for persisted timestamps.Consistency across services; avoids TZ drift.
-now := time.Now() +now := time.Now().UTC() @@ - now := time.Now() + now := time.Now().UTC()Also applies to: 195-197
ent/scheduledjob/where.go (2)
272-275: Case‑insensitive contains may be slow in PostgresContainsFold typically lowers both sides, defeating indexes. If these filters are on hot paths, consider CITEXT columns, trigram/GIN indexes, or dedicated searchable columns.
Also applies to: 707-710
37-55: Caution using GT/LT on opaque string IDsIDGT/GTE/LT/LTE compare lexicographically. Unless IDs are ULIDs (time‑sortable), prefer CreatedAt/UpdatedAt for pagination/range queries.
ent/migrate/schema.go
Outdated
| {Name: "scheduled_job_id", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "varchar(50)"}}, | ||
| {Name: "file_url", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "varchar(255)"}}, | ||
| {Name: "file_name", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "varchar(255)"}}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add FK and index for tasks.scheduled_job_id to ensure referential integrity and query performance
Currently there’s no foreign key or index for scheduled_job_id. This risks orphaned references and slow lookups.
Proposed (in Ent schema, not this generated file):
- In ent/schema/task.go, declare the field and an edge to ScheduledJob (Unique, Nillable), and add an index on scheduled_job_id.
- In ent/schema/scheduledjob.go, add the back-reference edge if needed.
Example (ent/schema/task.go):
field.String("scheduled_job_id").Optional().Nillable().SchemaType(map[string]string{"postgres":"varchar(50)"}),
edge.From("scheduled_job", ScheduledJob.Type).
Ref("tasks").
Field("scheduled_job_id").
Unique().
Nillable(),Then ent generate to emit the FK (prefer ON DELETE SET NULL).
🤖 Prompt for AI Agents
In ent/migrate/schema.go around lines 1828-1830 the generated schema shows the
tasks table columns but lacks a foreign key and index for scheduled_job_id; do
not edit this generated file—instead update the Ent schema: in
ent/schema/task.go declare the field as Optional().Nillable() with SchemaType
postgres varchar(50), add an edge.From("scheduled_job",
ScheduledJob.Type).Ref("tasks").Field("scheduled_job_id").Unique().Nillable()
and add an index on scheduled_job_id; in ent/schema/scheduledjob.go add the
back-reference edge if desired; configure the edge to emit the DB FK with
OnDelete SET NULL, then run ent generate to produce the migration and ensure the
generated migration creates the FK and index.
| // ClearCreatedBy clears the value of the "created_by" field. | ||
| func (m *ScheduledJobMutation) ClearCreatedBy() { | ||
| m.created_by = nil | ||
| m.clearedFields[scheduledjob.FieldCreatedBy] = struct{}{} | ||
| } | ||
|
|
||
| // CreatedByCleared returns if the "created_by" field was cleared in this mutation. | ||
| func (m *ScheduledJobMutation) CreatedByCleared() bool { | ||
| _, ok := m.clearedFields[scheduledjob.FieldCreatedBy] | ||
| return ok | ||
| } | ||
|
|
||
| // ResetCreatedBy resets all changes to the "created_by" field. | ||
| func (m *ScheduledJobMutation) ResetCreatedBy() { | ||
| m.created_by = nil | ||
| delete(m.clearedFields, scheduledjob.FieldCreatedBy) | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Potential mismatch: Clear methods added for non-nillable fields (CreatedBy, UpdatedBy, EnvironmentID).*
ScheduledJob struct shows these as non-pointer strings; ClearCreatedBy/ClearUpdatedBy/ClearEnvironmentID mark them NULL in mutations. If schema fields are not nillable, updates will fail at runtime with NOT NULL violations. Either:
- Make these schema fields Optional().Nillable() and regenerate, or
- Remove Clear* support for these fields.
Run to confirm schema definitions:
Also applies to: 39698-39715, 39747-39764
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "ScheduledJob schema fields:"
rg -n -C2 'field\.(String|Text)\("created_by"| "updated_by"| "environment_id")' ent/schema/scheduledjob.go
echo
echo "ScheduledJob struct field types:"
rg -n -C2 'CreatedBy|UpdatedBy|EnvironmentID' ent/scheduledjob.goLength of output: 212
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "ScheduledJob schema fields (looking for created_by, updated_by, environment_id):"
rg -P -nC2 'field\.(String|Text|UUID)\("(created_by|updated_by|environment_id)"\)' ent/schema/scheduledjob.go || echo "No schema matches found"
echo
echo "Check if these schema fields are Optional and Nillable:"
rg -P -nC3 'field\.(String|Text|UUID)\("(created_by|updated_by|environment_id)"\).*(Optional\(\)|Nillable\(\))' ent/schema/scheduledjob.go || echo "No Optional/Nillable chains found"
echo
echo "Generated ScheduledJob struct fields (pointer vs value):"
rg -n 'type ScheduledJob struct' -C3 ent/scheduledjob.go
rg -nC2 'CreatedBy ' ent/scheduledjob.go
rg -nC2 'UpdatedBy ' ent/scheduledjob.go
rg -nC2 'EnvironmentID ' ent/scheduledjob.goLength of output: 2096
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Schema files in ent/schema:"
ls ent/schema
echo
echo "Searching for 'created_by' in schema files:"
rg -n 'created_by' ent/schema || echo "No matches for created_by"
echo
echo "Searching for 'updated_by' in schema files:"
rg -n 'updated_by' ent/schema || echo "No matches for updated_by"
echo
echo "Searching for 'environment_id' in schema files:"
rg -n 'environment_id' ent/schema || echo "No matches for environment_id"Length of output: 13958
Clear methods generate NULLs for required fields — fix schema or remove Clear**
CreatedBy, UpdatedBy and EnvironmentID are defined via mixins as non-nillable strings; calling Clear* will emit NULL and trigger NOT NULL violations. Either mark these fields Optional().Nillable() in your mixins and regenerate, or disable/remove the Clear* support for them in ent/mutation.go.
🤖 Prompt for AI Agents
In ent/mutation.go around lines 39649 to 39666, the generated ClearCreatedBy /
CreatedByCleared / ResetCreatedBy methods allow clearing a non-nullable field
(created_by) which will emit NULLs and violate NOT NULL constraints; to fix,
either update the mixin/schema that defines CreatedBy, UpdatedBy and
EnvironmentID to make those fields Optional().Nillable() and re-run codegen, or
remove/disable generation of Clear* support for these fields by adjusting the
codegen/schema settings so Clear* methods are not emitted for required fields,
then regenerate the ent code.
ent/scheduledjob/where.go
Outdated
| // CreatedByIsNil applies the IsNil predicate on the "created_by" field. | ||
| func CreatedByIsNil() predicate.ScheduledJob { | ||
| return predicate.ScheduledJob(sql.FieldIsNull(FieldCreatedBy)) | ||
| } | ||
|
|
||
| // CreatedByNotNil applies the NotNil predicate on the "created_by" field. | ||
| func CreatedByNotNil() predicate.ScheduledJob { | ||
| return predicate.ScheduledJob(sql.FieldNotNull(FieldCreatedBy)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
Nullability mismatch: IsNil/NotNil generated, but model shows non‑pointer strings
Predicates expose IsNil/NotNil for fields (created_by, updated_by, environment_id, last_run_status, last_run_error, temporal_schedule_id), but ent/scheduledjob.go shows these as string (not *string). Align schema/types: either make these fields Nillable() (model becomes *string) or make them NOT NULL and drop IsNil/NotNil by updating schema, then re‑generate. Otherwise queries may rely on NULL semantics that the model cannot represent.
As seen in ent/scheduledjob.go lines 16–55 (Relevant code snippets).
Also applies to: 487-495, 562-570, 952-960, 1027-1035, 1102-1110
#!/bin/bash
# Extract ScheduledJob struct fields to verify nullability types
rg -nP 'type\s+ScheduledJob\s+struct' -A30 ent/scheduledjob.go#!/bin/bash
# Extract ScheduledJob struct fields to verify nullability types
rg -nP 'type\s+ScheduledJob\s+struct' -A30 ent/scheduledjob.go#!/bin/bash
# Extract ScheduledJob struct fields to verify nullability types
rg -nP 'type\s+ScheduledJob\s+struct' -A30 ent/scheduledjob.go#!/bin/bash
# Extract ScheduledJob struct to verify field nullability types
rg -nP 'type\s+ScheduledJob\s+struct' -A30#!/usr/bin/env bash
# Extract ScheduledJob struct fields to verify nullability types
rg -nP 'type\s+ScheduledJob\s+struct' -A30 ent/scheduledjob.go#!/usr/bin/env bash
# Extract ScheduledJob struct to verify field nullability
rg -nP 'type\s+ScheduledJob\s+struct' -A30 ent/scheduledjob.go🏁 Script executed:
#!/usr/bin/env bash
# Extract ScheduledJob struct fields to verify nullability types
rg -nP 'type\s+ScheduledJob\s+struct' -A30 ent/scheduledjob.goLength of output: 1621
rg -nP '(LastRunStatus|LastRunError|TemporalScheduleID)' -A2 ent/scheduledjob.go🏁 Script executed:
rg -nP 'type\s+ScheduledJob\s+struct' -A100 ent/scheduledjob.goLength of output: 4811
Align field nullability in schema or drop IsNil/NotNil
CreatedBy, UpdatedBy, EnvironmentID, LastRunStatus, LastRunError and TemporalScheduleID are generated as non‐pointer strings yet still expose IsNil/NotNil. Update your schema to either mark these fields Nillable() (→ *string) or enforce NOT NULL (→ drop IsNil/NotNil), then re-generate.
🤖 Prompt for AI Agents
In ent/scheduledjob/where.go around lines 412 to 420, the generated IsNil/NotNil
predicates exist for fields that are non-pointer strings in the schema; fix this
by updating the ent schema: either mark the affected fields (CreatedBy,
UpdatedBy, EnvironmentID, LastRunStatus, LastRunError, TemporalScheduleID) as
Nillable() so they become *string in generated code, or make them Required() /
leave non-nillable and remove the IsNil/NotNil usages; after changing the schema
run `entc generate` (or your project’s codegen step) to regenerate models and
predicates so the generated where.go matches the schema nullability.
internal/api/dto/scheduled_job.go
Outdated
| type CreateScheduledJobRequest struct { | ||
| ConnectionID string `json:"connection_id" binding:"required"` | ||
| EntityType string `json:"entity_type" binding:"required"` | ||
| Interval string `json:"interval" binding:"required"` | ||
| Enabled bool `json:"enabled"` | ||
| JobConfig map[string]interface{} `json:"job_config" binding:"required"` | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
Verify the JobConfig requirement alignment.
JobConfig is marked as binding:"required" in the API request, but the Ent schema (from the relevant snippets) marks it as Optional(). This mismatch could cause issues:
- If the API enforces required validation, the schema's Optional() will never be used
- If defaults are provided by the service layer, document this behavior
- If JobConfig should always be present, update the schema to remove Optional()
Additionally, the Enabled field has no default value in the DTO. Consider whether a default should be applied when the field is omitted.
Run this script to check how JobConfig and Enabled are handled in the service layer:
🏁 Script executed:
#!/bin/bash
# Search for service layer handling of CreateScheduledJobInput
rg -A 10 "CreateScheduledJobInput" --type=go -g '!**/dto/**'Length of output: 798
🏁 Script executed:
#!/bin/bash
# Locate JobConfig JSON field definition in ent schema
rg -nP 'field\.JSON\("job_config"' -g 'ent/schema/*.go' -A2 -B2
# Locate Enabled boolean field definition in ent schema
rg -nP 'field\.Bool\("enabled"' -g 'ent/schema/*.go' -A2 -B2Length of output: 704
Align JobConfig and Enabled defaults between API and schema
- JobConfig is marked
binding:"required"ininternal/api/dto/scheduled_job.gobut defined as.Optional()(with default{}) inent/schema/scheduledjob.go. Either removeOptional()from the schema or makejob_configoptional in the DTO to match. - Enabled is a non-pointer
bool(zero = false) in the DTO—so omitting it yieldsfalse—yet the schema setsDefault(true). To respect the schema default, changeEnabledto*boolin the DTO (and applytruewhennil), or remove the schema default iffalseis intended.
🤖 Prompt for AI Agents
In internal/api/dto/scheduled_job.go around lines 11–17, the DTO disagrees with
the ent schema: JobConfig is declared required in the DTO but the schema marks
job_config Optional() with a default {}; Enabled is a non-pointer bool (defaults
to false when omitted) while the schema Default(true). Make the DTO match the
schema by removing the `binding:"required"` from JobConfig and changing Enabled
to `*bool`; update request handling to treat a nil JobConfig as an empty map and
to apply `true` when Enabled is nil before creating the ent entity (or
alternatively, if you prefer schema changes, remove Optional()/Default() from
ent/schema/scheduledjob.go instead—apply one consistent approach).
| // CreateTaskInput represents input for creating a task | ||
| type CreateTaskInput struct { | ||
| ScheduledJobID string | ||
| TenantID string | ||
| EnvID string | ||
| EntityType string | ||
| StartTime time.Time | ||
| EndTime time.Time | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add basic input validation (time window, required fields)
Guard against invalid ranges and missing identifiers to prevent unusable tasks (e.g., EndTime < StartTime, empty TenantID/EnvID/EntityType/ScheduledJobID).
Also applies to: 42-49
🤖 Prompt for AI Agents
In internal/temporal/activities/export/task_activity.go around lines 27-35 (and
similarly for the struct usage at 42-49), the CreateTaskInput has no validation;
add input checks to prevent invalid tasks by verifying ScheduledJobID, TenantID,
EnvID and EntityType are non-empty strings and that StartTime/EndTime are set
(not zero) and EndTime is not before StartTime. Implement this as a Validate()
method on CreateTaskInput or validate at the constructor/handler entry: check
time.IsZero for unset times, compare EndTime.After(StartTime) or
EndTime.Equal(StartTime) as allowed, and return descriptive errors (fmt.Errorf)
when any check fails so callers can handle invalid inputs early.
| // CreateTask creates a new export task | ||
| func (a *TaskActivity) CreateTask(ctx context.Context, input CreateTaskInput) (*CreateTaskOutput, error) { | ||
| a.logger.Infow("creating export task", | ||
| "scheduled_job_id", input.ScheduledJobID, | ||
| "entity_type", input.EntityType, | ||
| "start_time", input.StartTime, | ||
| "end_time", input.EndTime) | ||
|
|
||
| // Create task | ||
| now := time.Now() | ||
| newTask := &task.Task{ | ||
| ID: types.GenerateUUIDWithPrefix("task"), | ||
| EnvironmentID: input.EnvID, | ||
| TaskType: types.TaskTypeExport, | ||
| EntityType: types.EntityType(input.EntityType), | ||
| ScheduledJobID: input.ScheduledJobID, | ||
| FileURL: "", // Will be set after upload | ||
| FileType: types.FileTypeCSV, | ||
| TaskStatus: types.TaskStatusPending, | ||
| Metadata: map[string]interface{}{ | ||
| "start_time": input.StartTime.Format(time.RFC3339), | ||
| "end_time": input.EndTime.Format(time.RFC3339), | ||
| }, | ||
| StartedAt: &now, | ||
| BaseModel: types.BaseModel{ | ||
| TenantID: input.TenantID, | ||
| Status: types.StatusPublished, | ||
| CreatedAt: now, | ||
| UpdatedAt: now, | ||
| }, | ||
| } | ||
|
|
||
| err := a.taskRepo.Create(ctx, newTask) | ||
| if err != nil { | ||
| a.logger.Errorw("failed to create task", "error", err) | ||
| return nil, ierr.WithError(err). | ||
| WithHint("Failed to create export task"). | ||
| Mark(ierr.ErrDatabase) | ||
| } | ||
|
|
||
| a.logger.Infow("export task created", "task_id", newTask.ID) | ||
|
|
||
| return &CreateTaskOutput{ | ||
| TaskID: newTask.ID, | ||
| }, nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make CreateTask idempotent to avoid duplicates under Temporal retries
Activities can be retried; this will create duplicate tasks. Provide an idempotency key or accept a caller-supplied TaskID so repeats are safe.
Minimal change: allow TaskID in input and use it if provided.
@@
type CreateTaskInput struct {
+ TaskID string
ScheduledJobID string
TenantID string
EnvID string
EntityType string
StartTime time.Time
EndTime time.Time
}
@@
- now := time.Now()
- newTask := &task.Task{
- ID: types.GenerateUUIDWithPrefix("task"),
+ now := time.Now().UTC()
+ id := input.TaskID
+ if id == "" {
+ id = types.GenerateUUIDWithPrefix("task")
+ }
+ newTask := &task.Task{
+ ID: id,
EnvironmentID: input.EnvID,
TaskType: types.TaskTypeExport,
EntityType: types.EntityType(input.EntityType),
ScheduledJobID: input.ScheduledJobID,
FileURL: "", // Will be set after upload
FileType: types.FileTypeCSV,
TaskStatus: types.TaskStatusPending,
Metadata: map[string]interface{}{
- "start_time": input.StartTime.Format(time.RFC3339),
- "end_time": input.EndTime.Format(time.RFC3339),
+ "start_time": input.StartTime.UTC().Format(time.RFC3339),
+ "end_time": input.EndTime.UTC().Format(time.RFC3339),
},
- StartedAt: &now,
+ StartedAt: nil,
BaseModel: types.BaseModel{
TenantID: input.TenantID,
Status: types.StatusPublished,
CreatedAt: now,
UpdatedAt: now,
},
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // CreateTask creates a new export task | |
| func (a *TaskActivity) CreateTask(ctx context.Context, input CreateTaskInput) (*CreateTaskOutput, error) { | |
| a.logger.Infow("creating export task", | |
| "scheduled_job_id", input.ScheduledJobID, | |
| "entity_type", input.EntityType, | |
| "start_time", input.StartTime, | |
| "end_time", input.EndTime) | |
| // Create task | |
| now := time.Now() | |
| newTask := &task.Task{ | |
| ID: types.GenerateUUIDWithPrefix("task"), | |
| EnvironmentID: input.EnvID, | |
| TaskType: types.TaskTypeExport, | |
| EntityType: types.EntityType(input.EntityType), | |
| ScheduledJobID: input.ScheduledJobID, | |
| FileURL: "", // Will be set after upload | |
| FileType: types.FileTypeCSV, | |
| TaskStatus: types.TaskStatusPending, | |
| Metadata: map[string]interface{}{ | |
| "start_time": input.StartTime.Format(time.RFC3339), | |
| "end_time": input.EndTime.Format(time.RFC3339), | |
| }, | |
| StartedAt: &now, | |
| BaseModel: types.BaseModel{ | |
| TenantID: input.TenantID, | |
| Status: types.StatusPublished, | |
| CreatedAt: now, | |
| UpdatedAt: now, | |
| }, | |
| } | |
| err := a.taskRepo.Create(ctx, newTask) | |
| if err != nil { | |
| a.logger.Errorw("failed to create task", "error", err) | |
| return nil, ierr.WithError(err). | |
| WithHint("Failed to create export task"). | |
| Mark(ierr.ErrDatabase) | |
| } | |
| a.logger.Infow("export task created", "task_id", newTask.ID) | |
| return &CreateTaskOutput{ | |
| TaskID: newTask.ID, | |
| }, nil | |
| } | |
| // -- in the same file, update the input type -- | |
| type CreateTaskInput struct { | |
| TaskID string | |
| ScheduledJobID string | |
| TenantID string | |
| EnvID string | |
| EntityType string | |
| StartTime time.Time | |
| EndTime time.Time | |
| } | |
| // CreateTask creates a new export task | |
| func (a *TaskActivity) CreateTask(ctx context.Context, input CreateTaskInput) (*CreateTaskOutput, error) { | |
| a.logger.Infow("creating export task", | |
| "scheduled_job_id", input.ScheduledJobID, | |
| "entity_type", input.EntityType, | |
| "start_time", input.StartTime, | |
| "end_time", input.EndTime, | |
| ) | |
| // Create task | |
| now := time.Now().UTC() | |
| id := input.TaskID | |
| if id == "" { | |
| id = types.GenerateUUIDWithPrefix("task") | |
| } | |
| newTask := &task.Task{ | |
| ID: id, | |
| EnvironmentID: input.EnvID, | |
| TaskType: types.TaskTypeExport, | |
| EntityType: types.EntityType(input.EntityType), | |
| ScheduledJobID: input.ScheduledJobID, | |
| FileURL: "", // Will be set after upload | |
| FileType: types.FileTypeCSV, | |
| TaskStatus: types.TaskStatusPending, | |
| Metadata: map[string]interface{}{ | |
| "start_time": input.StartTime.UTC().Format(time.RFC3339), | |
| "end_time": input.EndTime.UTC().Format(time.RFC3339), | |
| }, | |
| StartedAt: nil, | |
| BaseModel: types.BaseModel{ | |
| TenantID: input.TenantID, | |
| Status: types.StatusPublished, | |
| CreatedAt: now, | |
| UpdatedAt: now, | |
| }, | |
| } | |
| err := a.taskRepo.Create(ctx, newTask) | |
| if err != nil { | |
| a.logger.Errorw("failed to create task", "error", err) | |
| return nil, ierr.WithError(err). | |
| WithHint("Failed to create export task"). | |
| Mark(ierr.ErrDatabase) | |
| } | |
| a.logger.Infow("export task created", "task_id", newTask.ID) | |
| return &CreateTaskOutput{ | |
| TaskID: newTask.ID, | |
| }, nil | |
| } |
🤖 Prompt for AI Agents
In internal/temporal/activities/export/task_activity.go around lines 42 to 87,
make CreateTask idempotent by accepting a caller-supplied TaskID and returning
the existing task when called repeatedly: update the CreateTaskInput to include
an optional TaskID, then in CreateTask use input.TaskID if present (fallback to
GenerateUUIDWithPrefix if not), before creating call the task repository to load
a task by that ID and if found return its ID immediately, otherwise proceed to
create the new task with that deterministic ID and persist it; keep logging and
error handling the same.
| // Step 1: Create task | ||
| logger.Info("Step 1: Creating task") | ||
| createTaskInput := export.CreateTaskInput{ | ||
| ScheduledJobID: input.ScheduledJobID, | ||
| TenantID: input.TenantID, | ||
| EnvID: input.EnvID, | ||
| EntityType: string(input.EntityType), | ||
| StartTime: input.StartTime, | ||
| EndTime: input.EndTime, | ||
| } | ||
|
|
||
| var createTaskOutput export.CreateTaskOutput | ||
| err := workflow.ExecuteActivity(ctx, taskActivity.CreateTask, createTaskInput).Get(ctx, &createTaskOutput) | ||
| if err != nil { | ||
| logger.Error("Failed to create task", "error", err) | ||
| return nil, fmt.Errorf("failed to create task: %w", err) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make CreateTask idempotent (Temporal will retry activities)
Activity retries (and at-least-once semantics) can create duplicate tasks if CreateTask isn’t idempotent.
Suggestions:
- Add an IdempotencyKey (e.g., scheduled_job_id + entity_type + start + end) to CreateTaskInput and enforce unique constraint in repo layer.
- Mark AlreadyExists-like errors as non-retryable in RetryPolicy, or handle them as success (return existing TaskID).
- Alternatively, move dedup logic into CompleteTask by upserting against a deterministic key.
| // Validate compression type if provided | ||
| if s.Compression != "" && s.Compression != "gzip" && s.Compression != "none" { | ||
| return ierr.NewError("invalid compression type"). | ||
| WithHint("Compression must be one of: gzip, none"). | ||
| Mark(ierr.ErrValidation) | ||
| } | ||
|
|
||
| // Validate encryption type if provided | ||
| if s.Encryption != "" && s.Encryption != "AES256" && s.Encryption != "aws:kms" { | ||
| return ierr.NewError("invalid encryption type"). | ||
| WithHint("Encryption must be one of: AES256, aws:kms"). | ||
| Mark(ierr.ErrValidation) | ||
| } | ||
|
|
||
| // Set defaults | ||
| if s.Compression == "" { | ||
| s.Compression = "gzip" | ||
| } | ||
| if s.Encryption == "" { | ||
| s.Encryption = "AES256" | ||
| } | ||
| if s.MaxFileSizeMB == 0 { | ||
| s.MaxFileSizeMB = 100 | ||
| } | ||
|
|
||
| return nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disallow negative S3 job file sizes
S3JobConfig.Validate() sets defaults but never guards against MaxFileSizeMB < 0. Downstream batching logic assumes non‑negative sizes; a negative number will either panic or stall exports. Please return a validation error when the value is below zero (zero can keep meaning “use default”).
🤖 Prompt for AI Agents
In internal/types/scheduled_job.go around lines 118 to 143, add a validation
that checks if s.MaxFileSizeMB is negative and return a validation error if so
(keeping zero as the sentinel meaning “use default”); specifically, after
existing compression/encryption checks and before setting defaults, if
s.MaxFileSizeMB < 0 return ierr.NewError("invalid max file
size").WithHint("MaxFileSizeMB must be >= 0; use 0 to apply the
default").Mark(ierr.ErrValidation) so downstream batching never receives a
negative size.
| // Validate sync interval | ||
| if err := s.Interval.Validate(); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| // Validate entity types | ||
| for _, entityType := range s.EntityTypes { | ||
| if err := entityType.Validate(); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reject negative or empty entity entries in S3 exports
Two validation gaps:
MaxFileSizeMBaccepts negative values (zero already means “use default”). A negative size will flow downstream to chunking logic and break exports.- The loop delegates to
ExportEntityType.Validate(), which treats""as optional. That means a config like["", "invoice"]passes validation, later causing lookups against an empty entity.
Please hard‑fail on MaxFileSizeMB < 0 and explicitly reject empty entity strings before calling Validate().
🤖 Prompt for AI Agents
In internal/types/sync_config.go around lines 118 to 129, add two explicit
validation checks: first, hard-fail when s.MaxFileSizeMB is less than 0 (return
a clear error indicating negative max file size is invalid), and second, inside
the loop over s.EntityTypes, check if entityType is an empty string and return
an error rejecting empty entity entries before calling entityType.Validate();
keep the existing Validate() calls for other errors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/testutil/inmemory_task_store.go (1)
29-48: copyTask drops fields (WorkflowID, FileName)These are present in domain.Task but not copied, causing data loss in tests/in-memory store.
Apply:
return &task.Task{ ID: t.ID, TaskType: t.TaskType, EntityType: t.EntityType, ScheduledJobID: t.ScheduledJobID, + WorkflowID: t.WorkflowID, FileURL: t.FileURL, + FileName: t.FileName, FileType: t.FileType, TaskStatus: t.TaskStatus, TotalRecords: t.TotalRecords, ProcessedRecords: t.ProcessedRecords, SuccessfulRecords: t.SuccessfulRecords, FailedRecords: t.FailedRecords, ErrorSummary: t.ErrorSummary, Metadata: t.Metadata, StartedAt: t.StartedAt, CompletedAt: t.CompletedAt, FailedAt: t.FailedAt, EnvironmentID: t.EnvironmentID, BaseModel: t.BaseModel, }
♻️ Duplicate comments (32)
internal/integration/stripe/subscription.go (1)
424-424: Avoid epoch billing anchor when BillingCycleAnchor is 0 (duplicate issue).Both
createFlexPriceSubscription(line 424) and the newcreateFlexPriceSubscriptionWithoutTx(lines 485, 517) unconditionally setbillingAnchor = time.Unix(stripeSub.BillingCycleAnchor, 0).UTC(). WhenBillingCycleAnchor == 0, this produces 1970-01-01, which is likely unintended. Only set the anchor when provided; otherwise leave it nil.Apply this diff to
createFlexPriceSubscription:- billingAnchor := time.Unix(stripeSub.BillingCycleAnchor, 0).UTC() + // Only set billing anchor when present + var billingAnchor *time.Time + if stripeSub.BillingCycleAnchor != 0 { + t := time.Unix(stripeSub.BillingCycleAnchor, 0).UTC() + billingAnchor = &t + } // Set trial dates if applicable ... createReq := dto.CreateSubscriptionRequest{ ... - BillingAnchor: &billingAnchor, + BillingAnchor: billingAnchor, ... }Apply the same fix to
createFlexPriceSubscriptionWithoutTx:- billingAnchor := time.Unix(stripeSub.BillingCycleAnchor, 0).UTC() + // Only set billing anchor when present + var billingAnchor *time.Time + if stripeSub.BillingCycleAnchor != 0 { + t := time.Unix(stripeSub.BillingCycleAnchor, 0).UTC() + billingAnchor = &t + } // Set trial dates if applicable ... createReq := dto.CreateSubscriptionRequest{ ... - BillingAnchor: &billingAnchor, + BillingAnchor: billingAnchor, ... }Also applies to: 485-485, 517-517
internal/api/v1/test_usage_export.go (2)
98-115: Retain the selected job to use its ConnectionID.Capture the enabled job you choose so you can pass ConnectionID to the export request.
@@ - // Use the first enabled job - var jobConfig *types.S3JobConfig + // Use the first enabled job + var ( + selectedJob *scheduledjob.ScheduledJob + jobConfig *types.S3JobConfig + ) var configErr error for _, job := range scheduledJobs { if job.Enabled { jobConfig, configErr = job.GetS3JobConfig() if configErr != nil { @@ - break + selectedJob = job + break } } @@ - if jobConfig == nil { + if jobConfig == nil || selectedJob == nil { h.logger.Warnw("no enabled scheduled job found for feature usage export") c.JSON(http.StatusNotFound, gin.H{ "error": "No enabled scheduled job found for feature usage export. Please enable a scheduled job.", }) return }
128-135: Include ConnectionID in ExportRequest (required for S3 credentials).ConnectionID is needed to resolve/decrypt S3 creds. Use the selected job’s ConnectionID.
request := &export.ExportRequest{ - EntityType: types.ExportEntityTypeEvents, + EntityType: types.ExportEntityTypeEvents, + ConnectionID: selectedJob.ConnectionID, TenantID: tenantID, EnvID: envID, StartTime: startTime, EndTime: endTime, JobConfig: jobConfig, }internal/temporal/activities/export/task_activity.go (2)
27-37: Add input validation for CreateTaskInput.Validate required fields and time window to prevent unusable tasks.
Add:
// Validate checks required fields and time window. func (in CreateTaskInput) Validate() error { if in.TaskID == "" { return fmt.Errorf("task_id is required") } if in.WorkflowID == "" { return fmt.Errorf("workflow_id is required") } if in.ScheduledJobID == "" || in.TenantID == "" || in.EnvID == "" || in.EntityType == "" { return fmt.Errorf("scheduled_job_id, tenant_id, env_id, and entity_type are required") } if in.StartTime.IsZero() || in.EndTime.IsZero() { return fmt.Errorf("start_time and end_time are required") } if in.EndTime.Before(in.StartTime) { return fmt.Errorf("end_time must be >= start_time") } return nil }Then call it at the start of CreateTask.
45-92: Make CreateTask idempotent and normalize timestamps.
- Idempotency: check for existing task by TaskID and return it (Temporal retries).
- Time: use UTC; metadata times in UTC; don’t set StartedAt at creation (set when processing begins).
- Context: set tenant/env on ctx for repo access.
- WorkflowID: store nil when empty.
func (a *TaskActivity) CreateTask(ctx context.Context, input CreateTaskInput) (*CreateTaskOutput, error) { + // Validate input + if err := input.Validate(); err != nil { + return nil, ierr.NewError(err.Error()).Mark(ierr.ErrValidation) + } a.logger.Infow("creating export task", @@ - // Create task with pre-generated ID - now := time.Now() + // Bind tenant/env to context for repository operations + ctx = types.SetTenantID(ctx, input.TenantID) + ctx = types.SetEnvironmentID(ctx, input.EnvID) + + // Idempotency: if task already exists, return it + if existing, err := a.taskRepo.Get(ctx, input.TaskID); err == nil && existing != nil { + a.logger.Infow("export task already exists (idempotent)", "task_id", input.TaskID) + return &CreateTaskOutput{TaskID: existing.ID}, nil + } + + now := time.Now().UTC() + var wfID *string + if input.WorkflowID != "" { + wfID = &input.WorkflowID + } newTask := &task.Task{ - ID: input.TaskID, // Use pre-generated ID - WorkflowID: &input.WorkflowID, // Store Temporal workflow ID + ID: input.TaskID, // Use pre-generated ID + WorkflowID: wfID, // Store Temporal workflow ID if present EnvironmentID: input.EnvID, TaskType: types.TaskTypeExport, EntityType: types.EntityType(input.EntityType), ScheduledJobID: input.ScheduledJobID, FileURL: "", // Will be set after upload FileType: types.FileTypeCSV, TaskStatus: types.TaskStatusPending, Metadata: map[string]interface{}{ - "start_time": input.StartTime.Format(time.RFC3339), - "end_time": input.EndTime.Format(time.RFC3339), + "start_time": input.StartTime.UTC().Format(time.RFC3339), + "end_time": input.EndTime.UTC().Format(time.RFC3339), }, - StartedAt: &now, + StartedAt: nil, BaseModel: types.BaseModel{ TenantID: input.TenantID, Status: types.StatusPublished, CreatedAt: now, UpdatedAt: now, }, }ent/mutation.go (2)
39649-39666: Clear methods for non-nillable fields (see past review comments)*The issue with ClearCreatedBy(), ClearUpdatedBy(), and ClearEnvironmentID() methods for non-nillable fields has already been identified in past review comments with verification scripts and fix suggestions. These methods will cause NOT NULL violations at runtime.
Also applies to: 39698-39715, 39747-39764
52824-52871: Clear methods for non-nillable scheduled_job_id field (see past review comments)*The issue with ClearScheduledJobID() method for the non-nillable scheduled_job_id field has already been identified in past review comments with verification scripts and fix suggestions. This method will cause NOT NULL violations at runtime.
internal/integration/s3/export.go (2)
82-86: Use Content-Encoding: gzip; keep original Content-TypeSet ContentType based on the uncompressed format and add ContentEncoding="gzip" when compressing. This avoids mislabeling payloads as application/gzip.
Apply this diff:
- // Determine content type - contentType := request.ContentType - if contentType == "" - { - contentType = c.getContentType(request.Format, request.Compress) - } + // Determine content type (based on uncompressed format) + contentType := request.ContentType + if contentType == "" { + contentType = c.getContentType(request.Format, false) + } @@ - uploadInput := &s3.PutObjectInput{ + uploadInput := &s3.PutObjectInput{ Bucket: aws.String(c.config.Bucket), Key: aws.String(key), Body: bytes.NewReader(data), ContentType: aws.String(contentType), + // Optional: set ContentLength for clarity + ContentLength: aws.Int64(int64(len(data))), } + // Add content encoding when compressed + if request.Compress && c.config.Compression == "gzip" { + uploadInput.ContentEncoding = aws.String("gzip") + }Also applies to: 88-95
176-183: MaxFileSizeMB=0 currently blocks all uploads; treat 0 as “no limit”Only enforce the size check when the configured limit is > 0.
Apply this diff:
- // Check max file size - maxSizeBytes := int64(c.config.MaxFileSizeMB * 1024 * 1024) - if int64(len(request.Data)) > maxSizeBytes { - return ierr.NewErrorf("file size exceeds maximum allowed size of %d MB", c.config.MaxFileSizeMB). - WithHintf("Reduce the file size or increase the max_file_size_mb limit"). - Mark(ierr.ErrValidation) - } + // Check max file size (0 or negative => no limit) + if c.config.MaxFileSizeMB > 0 { + maxSizeBytes := int64(c.config.MaxFileSizeMB) * 1024 * 1024 + if int64(len(request.Data)) > maxSizeBytes { + return ierr.NewErrorf("file size exceeds maximum allowed size of %d MB", c.config.MaxFileSizeMB). + WithHintf("Reduce the file size or increase the max_file_size_mb limit"). + Mark(ierr.ErrValidation) + } + }internal/service/scheduled_job.go (4)
135-145: Authorize Get by tenant/environment.Add a post-fetch scope check to avoid ID enumeration across tenants (as flagged in previous review).
func (s *scheduledJobService) GetScheduledJob(ctx context.Context, id string) (*dto.ScheduledJobResponse, error) { job, err := s.repo.Get(ctx, id) if err != nil { s.logger.Errorw("failed to get scheduled job", "id", id, "error", err) return nil, ierr.WithError(err). WithHint("Scheduled job not found"). Mark(ierr.ErrNotFound) } + + // Enforce tenant/environment scoping + if job.TenantID != types.GetTenantID(ctx) || job.EnvironmentID != types.GetEnvironmentID(ctx) { + return nil, ierr.ErrNotFound + } return dto.ToScheduledJobResponse(job), nil }
174-258: Authorize Update by tenant/environment; recalc next run when enabling.Missing authorization check and NextRunAt recalculation when enabling (as flagged in previous review).
// Get existing job job, err := s.repo.Get(ctx, id) if err != nil { s.logger.Errorw("failed to get scheduled job for update", "id", id, "error", err) return nil, ierr.WithError(err). WithHint("Scheduled job not found"). Mark(ierr.ErrNotFound) } + // Enforce tenant/environment scoping + if job.TenantID != types.GetTenantID(ctx) || job.EnvironmentID != types.GetEnvironmentID(ctx) { + return nil, ierr.ErrNotFound + } // Track if enabled status changed wasEnabled := job.Enabled @@ if req.Enabled != nil { job.Enabled = *req.Enabled + // Recalc NextRunAt if just enabled + if !wasEnabled && job.Enabled { + now := time.Now() + nextRun := job.CalculateNextRunTime(now) + job.NextRunAt = &nextRun + } }
260-272: Stop Temporal schedule and authorize before delete.Currently deletes DB record only; schedule may keep running. Also no scope check (as flagged in previous review).
func (s *scheduledJobService) DeleteScheduledJob(ctx context.Context, id string) error { - err := s.repo.Delete(ctx, id) + // Fetch for scoping and to stop schedule + job, getErr := s.repo.Get(ctx, id) + if getErr != nil { + s.logger.Errorw("failed to get scheduled job for delete", "id", id, "error", getErr) + return ierr.WithError(getErr). + WithHint("Failed to delete scheduled job"). + Mark(ierr.ErrDatabase) + } + // Enforce tenant/environment scoping + if job.TenantID != types.GetTenantID(ctx) || job.EnvironmentID != types.GetEnvironmentID(ctx) { + return ierr.ErrNotFound + } + // Stop schedule first + if s.orchestrator != nil { + if err := s.orchestrator.StopScheduledJob(ctx, job.ID); err != nil { + s.logger.Errorw("failed to stop temporal schedule before delete", "id", job.ID, "error", err) + } + } + err := s.repo.Delete(ctx, id) if err != nil { s.logger.Errorw("failed to delete scheduled job", "id", id, "error", err) return ierr.WithError(err). WithHint("Failed to delete scheduled job"). Mark(ierr.ErrDatabase) } s.logger.Infow("scheduled job deleted successfully", "id", id) return nil }
293-308: Scope GetByEntityType by tenant/environment; prefer List(filters).Avoid unscoped reads; reuse ListFilters to include tenant/environment (as flagged in previous review).
-func (s *scheduledJobService) GetScheduledJobsByEntityType(ctx context.Context, entityType types.ScheduledJobEntityType) ([]*dto.ScheduledJobResponse, error) { - jobs, err := s.repo.GetByEntityType(ctx, string(entityType)) +func (s *scheduledJobService) GetScheduledJobsByEntityType(ctx context.Context, entityType types.ScheduledJobEntityType) ([]*dto.ScheduledJobResponse, error) { + filters := &scheduledjob.ListFilters{ + TenantID: types.GetTenantID(ctx), + EnvironmentID: types.GetEnvironmentID(ctx), + EntityType: string(entityType), + Limit: 1000, + Offset: 0, + } + jobs, err := s.repo.List(ctx, filters) if err != nil { s.logger.Errorw("failed to get scheduled jobs by entity type", "entity_type", entityType, "error", err) return nil, ierr.WithError(err). WithHint("Failed to retrieve scheduled jobs"). Mark(ierr.ErrDatabase) } responses := make([]*dto.ScheduledJobResponse, 0, len(jobs)) for _, job := range jobs { responses = append(responses, dto.ToScheduledJobResponse(job)) } return responses, nil }ent/migrate/schema.go (1)
1828-1830: Add FK and indexes for tasks.scheduled_job_id and workflow_id
- Missing FK: tasks.scheduled_job_id should reference scheduled_jobs.id (ON DELETE SET NULL) to prevent orphans.
- Missing indexes: queries by scheduled_job_id/workflow_id will scan without indexes.
Implement in Ent schema (not this generated file), then re-generate:
// ent/schema/task.go field.String("scheduled_job_id").Optional().Nillable(). SchemaType(map[string]string{"postgres":"varchar(50)"}), edge.From("scheduled_job", ScheduledJob.Type). Ref("tasks"). Field("scheduled_job_id"). Unique(). Nillable(). Annotations(entsql.OnDelete(entsql.SetNull)), field.String("workflow_id").Optional().Nillable(). SchemaType(map[string]string{"postgres":"varchar(255)"}), index.Fields("tenant_id", "environment_id", "scheduled_job_id"), index.Fields("tenant_id", "environment_id", "workflow_id"),Also applies to: 1845-1866
internal/integration/s3/config.go (1)
21-24: Still missing AWSSessionToken propagation.The configuration struct and constructor continue to drop
AWSSessionToken, so STS/assumed-role credentials remain unusable. Please add the session token field and copy the value fromS3ConnectionMetadata.Apply:
type Config struct { @@ - AWSAccessKeyID string - AWSSecretAccessKey string + AWSAccessKeyID string + AWSSecretAccessKey string + AWSSessionToken string } @@ // From encrypted_secret_data AWSAccessKeyID: secretData.AWSAccessKeyID, AWSSecretAccessKey: secretData.AWSSecretAccessKey, + AWSSessionToken: secretData.AWSSessionToken, }Also applies to: 46-48
internal/repository/clickhouse/feature_usage.go (1)
1395-1422: Add FINAL when reading from ReplacingMergeTree.
feature_usageis backed byReplacingMergeTree; withoutFINAL, exports can include obsolete rows. Please update the query toFROM feature_usage FINAL …to ensure merged results.Apply:
- FROM feature_usage + FROM feature_usage FINALinternal/service/feature_usage_tracking.go (1)
1252-1254: Re-allow expired prices tied to active subscriptions.Setting
priceFilter.AllowExpiredPrices = falsedrops sunsetted-but-still-billable prices, so meters referencing those prices vanish fromdata.Pricesand downstream cost math reports zero. This regresses any customer still on grandfathered pricing. Please drop the assignment (or explicitly allow expired prices) so we still load the referenced price records.priceFilter := types.NewNoLimitPriceFilter() priceFilter.PriceIDs = priceIDs priceFilter.WithStatus(types.StatusPublished) - priceFilter.AllowExpiredPrices = falseinternal/api/v1/scheduled_job.go (1)
50-53: Respect explicitenabled=falseon create.Because
CreateScheduledJobRequest.Enabledis a plain bool, this block turns an explicitfalsefrom clients intotrue, so you can’t create a disabled job. Please drop the forced default (or switch the DTO field to*booland only default when nil) so client intent is preserved.- // Default enabled to true if not provided - if !req.Enabled { - req.Enabled = true - }internal/integration/s3/README.md (1)
158-203: Keep Parquet in one section only.Parquet still shows up under “Supported Formats” even though it’s also listed as a future enhancement. Please remove it from the supported list (or the future list if it’s actually implemented) so the doc is consistent.
internal/temporal/workflows/export/execute_export_workflow.go (1)
56-79: Verify that CreateTask is idempotent with pre-generated TaskID.The workflow uses a pre-generated
TaskIDfrom input (line 58), which should help with idempotency. However, ensure the repository layer enforces unique constraint on TaskID to prevent duplicate task creation on activity retries.Run this script to verify the task repository's handling of duplicate TaskIDs:
#!/bin/bash # Check for unique constraints or idempotency handling in task repository rg -nP '(UNIQUE|unique|ON CONFLICT|OnConflict|Upsert|CheckExists)' --type=go -g '**/task/**' -C3internal/api/dto/scheduled_job.go (1)
11-17: Align JobConfig and Enabled defaults with schema.There's a mismatch between the DTO and the ent schema:
JobConfig: Marked
binding:"required"in the DTO but the schema defines it as.Optional()with default{}. Either removebinding:"required"from the DTO (treating omitted as empty map) or remove.Optional()from the schema.Enabled: As a non-pointer
bool, it defaults tofalsewhen omitted in the request, but the schema hasDefault(true). ChangeEnabledto*boolin the DTO and apply the schema default (true) whennil, or remove the schema default iffalseis the intended default.Apply this diff to align with schema defaults:
type CreateScheduledJobRequest struct { ConnectionID string `json:"connection_id" binding:"required"` EntityType string `json:"entity_type" binding:"required"` Interval string `json:"interval" binding:"required"` - Enabled bool `json:"enabled"` - JobConfig map[string]interface{} `json:"job_config" binding:"required"` + Enabled *bool `json:"enabled"` + JobConfig map[string]interface{} `json:"job_config"` }Then update
ToCreateInput()to apply defaults:func (r *CreateScheduledJobRequest) ToCreateInput() *types.CreateScheduledJobInput { - enabled := r.Enabled // default will be handled by service if needed + enabled := true // schema default + if r.Enabled != nil { + enabled = *r.Enabled + } + jobConfig := r.JobConfig + if jobConfig == nil { + jobConfig = make(map[string]interface{}) + } return &types.CreateScheduledJobInput{ ConnectionID: r.ConnectionID, EntityType: types.ScheduledJobEntityType(r.EntityType), Interval: types.ScheduledJobInterval(r.Interval), Enabled: enabled, - JobConfig: r.JobConfig, + JobConfig: jobConfig, } }internal/service/sync/export/usage_export.go (1)
118-156: Mitigate CSV injection risk (formula injection).Cells starting with
=,+,-, or@can be interpreted as formulas by spreadsheet applications, creating a security risk (CSV injection).Apply this diff to add sanitization:
+// sanitizeCSVValue prevents CSV injection by escaping values that start with formula indicators +func sanitizeCSVValue(s string) string { + if len(s) == 0 { + return s + } + switch s[0] { + case '=', '+', '-', '@': + return "'" + s // Prefix with single quote to escape + default: + return s + } +} + // Write batch data to CSV for _, usage := range usageData { // Convert properties map to JSON string @@ row := []string{ - usage.ID, - usage.TenantID, - usage.EnvironmentID, - usage.ExternalCustomerID, - usage.CustomerID, - usage.SubscriptionID, - usage.SubLineItemID, - usage.PriceID, - usage.MeterID, - usage.FeatureID, - usage.EventName, - usage.Source, + sanitizeCSVValue(usage.ID), + sanitizeCSVValue(usage.TenantID), + sanitizeCSVValue(usage.EnvironmentID), + sanitizeCSVValue(usage.ExternalCustomerID), + sanitizeCSVValue(usage.CustomerID), + sanitizeCSVValue(usage.SubscriptionID), + sanitizeCSVValue(usage.SubLineItemID), + sanitizeCSVValue(usage.PriceID), + sanitizeCSVValue(usage.MeterID), + sanitizeCSVValue(usage.FeatureID), + sanitizeCSVValue(usage.EventName), + sanitizeCSVValue(usage.Source), usage.Timestamp.Format(time.RFC3339), usage.IngestedAt.Format(time.RFC3339), fmt.Sprintf("%d", usage.PeriodID), usage.QtyTotal.String(), string(propertiesJSON), - usage.UniqueHash, + sanitizeCSVValue(usage.UniqueHash), }internal/types/sync_config.go (1)
81-131: Block empty entities & negative file size in S3 config
MaxFileSizeMBaccepts negatives, andEntityTypeslets empty strings slip through becauseExportEntityType.Validate()treats""as optional. A negative size will break chunking downstream, and an empty entity will blow up lookups later. Please rejectMaxFileSizeMB < 0and short-circuit when an entity string is blank before callingValidate().internal/repository/ent/scheduledjob.go (5)
106-150: Scope Update by tenant/environment
UpdateOneIDbypasses tenant/environment predicates, so any caller with an ID can modify another tenant’s job. UseUpdate().Where(...ID..., TenantID, EnvironmentID...)before applying setters.
152-168: Scope Delete by tenant/environment
DeleteOneIDignores tenant/env, allowing cross-tenant deletes. Switch toDelete().Where(scheduledjob.ID(id), scheduledjob.TenantID(...), scheduledjob.EnvironmentID(...)).
174-215: Guard nil filters before dereferencing
filters.TenantIDetc. panic whenfiltersis nil. Add a nil guard (e.g., default to an emptyListFilters) before dereferencing.
295-323: Scope UpdateLastRun by tenant/environment
UpdateOneIDagain skips tenant/env enforcement for last-run updates. UseUpdate().Where(...TenantID..., ...EnvironmentID...)to avoid cross-tenant writes.
331-349: Propagate TemporalScheduleID to domain modelThe mapper drops
TemporalScheduleID, so callers never see stored schedule IDs. IncludeTemporalScheduleID: entJob.TemporalScheduleID.internal/integration/s3/client.go (2)
168-175: Stop leaking decrypted keys (and fix undefinedmin)Logging decrypted lengths/prefixes both exposes secrets and calls an undefined
min(), breaking the build. Replace the log with a non-sensitive message (e.g., just connection ID + session token flag).
176-205: Validate bucket/region before building S3 config
jobConfigonly checked for nil; missing bucket/region slips through and fails later. Hard-fail when either is empty (trim whitespace) right after the nil check.ent/scheduledjob/where.go (1)
412-420: Align field nullability with predicates (schema vs generated code)IsNil/NotNil predicates exist for fields that are non-pointer strings in the model (CreatedBy, UpdatedBy, EnvironmentID, LastRunStatus, LastRunError, TemporalScheduleID). Update schema to mark fields Nillable() (making them *string) or make them NOT NULL and drop IsNil/NotNil, then re-generate.
Also applies to: 487-495, 562-570, 952-960, 1027-1035, 1102-1110
internal/types/scheduled_job.go (1)
118-141: Add validation: disallow negative MaxFileSizeMBValidate should reject values < 0 before setting defaults. Otherwise downstream exporters can panic/stall.
Apply this diff:
func (s *S3JobConfig) Validate() error { @@ if s.Encryption != "" && s.Encryption != "AES256" && s.Encryption != "aws:kms" { return ierr.NewError("invalid encryption type"). WithHint("Encryption must be one of: AES256, aws:kms"). Mark(ierr.ErrValidation) } + // Validate max file size + if s.MaxFileSizeMB < 0 { + return ierr.NewError("invalid max file size"). + WithHint("MaxFileSizeMB must be >= 0; use 0 to apply the default"). + Mark(ierr.ErrValidation) + } + // Set defaults if s.Compression == "" { s.Compression = "gzip" } if s.Encryption == "" { s.Encryption = "AES256" } if s.MaxFileSizeMB == 0 { s.MaxFileSizeMB = 100 } return nil }
🧹 Nitpick comments (25)
internal/integration/stripe/customer.go (2)
204-205: Consider deferring externalID initialization.The
externalIDis assigned tostripeCustomer.IDupfront but may be overridden byflexprice_lookup_keyat line 219. While functionally correct due to early returns, initializing it closer to where it's actually used (Step 3, line 254) would improve readability.
427-463: Suggest parameterizing the helper to eliminate code duplication.While the new helper is well-structured, there's code duplication between this helper (lines 427-463) and the inline mapping creation in
CreateCustomerInStripe(lines 172-197). The only meaningful difference is thecreated_viametadata field:
- This helper uses
"provider_to_flexprice"(webhook direction)- CreateCustomerInStripe uses
"flexprice_to_provider"(API direction)Consider adding a
directionparameter to make the helper reusable for both cases, eliminating ~20 lines of duplicate code.Example refactor:
-func (s *CustomerService) createEntityIntegrationMapping(ctx context.Context, customerID string, stripeCustomer *stripe.Customer) error { +func (s *CustomerService) createEntityIntegrationMapping(ctx context.Context, customerID string, stripeCustomer *stripe.Customer, direction string) error { if s.entityIntegrationMappingRepo == nil { return nil } mapping := &entityintegrationmapping.EntityIntegrationMapping{ ID: types.GenerateUUIDWithPrefix(types.UUID_PREFIX_ENTITY_INTEGRATION_MAPPING), EntityID: customerID, EntityType: types.IntegrationEntityTypeCustomer, ProviderType: string(types.SecretProviderStripe), ProviderEntityID: stripeCustomer.ID, Metadata: map[string]interface{}{ - "created_via": "provider_to_flexprice", + "created_via": direction, "stripe_customer_email": stripeCustomer.Email, "stripe_customer_name": stripeCustomer.Name, "synced_at": time.Now().UTC().Format(time.RFC3339), }, EnvironmentID: types.GetEnvironmentID(ctx), BaseModel: types.GetDefaultBaseModel(ctx), }Then update callers:
- Line 240:
s.createEntityIntegrationMapping(ctx, existing.Customer.ID, stripeCustomer, "provider_to_flexprice")- Line 278:
s.createEntityIntegrationMapping(ctx, customerResp.ID, stripeCustomer, "provider_to_flexprice")- Lines 172-197 in CreateCustomerInStripe: Replace inline code with
s.createEntityIntegrationMapping(ctx, ourCustomer.ID, stripeCustomer, "flexprice_to_provider")internal/api/v1/test_usage_export.go (1)
125-127: Use UTC timestamps for export windows.Avoid timezone ambiguity in file names/filters.
- endTime := time.Now() + endTime := time.Now().UTC() startTime := endTime.Add(-24 * time.Hour)internal/temporal/activities/export/task_activity.go (2)
141-154: Use UTC for status timestamps.Normalize timestamps to UTC.
- now := time.Now() + now := time.Now().UTC()
203-212: Use UTC for completion timestamps and metadata.Normalize CompletedAt and metadata timestamps.
- now := time.Now() + now := time.Now().UTC() @@ - existingTask.Metadata["completed_at"] = now.Format(time.RFC3339) + existingTask.Metadata["completed_at"] = now.UTC().Format(time.RFC3339)internal/domain/task/model.go (1)
82-85: Consider implementing basic Task validation.Currently Validate() returns nil; add minimal checks (e.g., ID non-empty, TaskType/EntityType set) if used by services.
internal/types/secret.go (1)
34-38: Providers added correctly; consider hoisting allowed set to avoid per-call allocationsChange is good. To avoid rebuilding the slice on every Validate(), use a package-level set.
Apply within Validate:
-allowedSecretProviders := []SecretProvider{ - SecretProviderFlexPrice, - SecretProviderStripe, - SecretProviderRazorpay, - SecretProviderS3, - SecretProviderAthena, - SecretProviderBigQuery, - SecretProviderSnowflake, - SecretProviderRedshift, -} -if !lo.Contains(allowedSecretProviders, p) { +if _, ok := allowedSecretProvidersSet[p]; !ok { ... }Add once at package level:
var allowedSecretProvidersSet = map[SecretProvider]struct{}{ SecretProviderFlexPrice: {}, SecretProviderStripe: {}, SecretProviderRazorpay: {}, SecretProviderS3: {}, SecretProviderAthena: {}, SecretProviderBigQuery: {}, SecretProviderSnowflake: {}, SecretProviderRedshift: {}, }Also applies to: 42-51
internal/repository/ent/task.go (1)
361-415: Query looks correct; add a supporting index for performanceThe lookup filters by tenant_id, environment_id, scheduled_job_id, task_type, task_status, status and orders by completed_at DESC. Add a composite index (including completed_at) to avoid full scans at scale.
Example (ent schema Task.Indexes):
index.Fields("tenant_id", "environment_id", "scheduled_job_id", "task_type", "task_status", "status", "completed_at"). StorageKey("idx_tasks_export_lookup")internal/testutil/inmemory_task_store.go (1)
100-125: Make CompletedAt comparison nil-safeEdge-case: if one of the CompletedAt pointers is nil, a later valid timestamp may be skipped.
- if lastTask == nil || (t.CompletedAt != nil && lastTask.CompletedAt != nil && t.CompletedAt.After(*lastTask.CompletedAt)) { + if lastTask == nil || + (t.CompletedAt != nil && (lastTask.CompletedAt == nil || t.CompletedAt.After(*lastTask.CompletedAt))) { lastTask = t }ent/schema/task.go (1)
42-55: Add composite index to support “last successful export” queryTo match repository query filters and order, add an index including scheduled_job_id and completed_at.
func (Task) Indexes() []ent.Index { return []ent.Index{ index.Fields("tenant_id", "environment_id", "task_type", "entity_type", "status"). StorageKey("idx_tasks_tenant_env_type_status"), index.Fields("tenant_id", "environment_id", "created_by", "status"). StorageKey("idx_tasks_tenant_env_user"), index.Fields("tenant_id", "environment_id", "task_status", "status"). StorageKey("idx_tasks_tenant_env_task_status"), + index.Fields("tenant_id", "environment_id", "scheduled_job_id", "task_type", "task_status", "status", "completed_at"). + StorageKey("idx_tasks_export_lookup"), } }Optional: If “unset scheduled_job_id” should be NULL (not ""), mark the field Nillable() and adjust domain to use *string. Otherwise keep as Optional.
Also applies to: 110-119
ent/task/where.go (1)
112-121: Predicate additions are consistent; consider indexing for query patternsThe new predicate helpers for scheduled_job_id and workflow_id follow ent conventions and look correct.
Recommendation: if you frequently query tasks by scheduled_job_id/workflow_id (e.g., to fetch last successful export), add DB indexes on these columns (plus ordering column like completed_at) in ent/schema to keep queries efficient at scale.
Also applies to: 747-895
internal/temporal/activities/export/scheduledjob_activity.go (3)
55-105: Time handling and config parsing are good; make UTC explicit and accept RFC3339Nano
- Use time.Now().UTC() to be explicit and avoid env-dependent behavior.
- When parsing last export end_time elsewhere, also accept time.RFC3339Nano to tolerate higher-precision timestamps.
Small improvements; logic otherwise reads well.
109-174: Incremental window: improve robustness (parse tolerance, range clamp, max lookback)
- Accept RFC3339 or RFC3339Nano for end_time parsing.
- If lastEndTime > endTime, clamp start to endTime.Add(-interval) to avoid negative duration windows.
- Optionally cap lookback (e.g., 30/90 days) to prevent huge first-run exports.
These guardrails reduce surprises and heavy loads.
186-226: Update flow is fine; consider partial update and capturing failure details
- To reduce read-modify-write races, a repository partial update (fields: last_run_at, last_run_status, next_run_at) can be used.
- If status indicates failure, optionally persist last_run_error (if available) alongside status.
Nice-to-have; current code is functional.
cmd/server/main.go (1)
343-346: Test endpoints are always registered; gate behind config/flag in productionRecommend exposing TestExport/TestUsageExport only in non-production modes or behind a feature flag to avoid unintended access.
internal/integration/s3/export.go (7)
209-213: Guard against empty key when trimming leading slashIndexing key[0] risks panic if the key is empty. Use a length check.
Apply this diff:
- // Remove leading slash if present - if key[0] == '/' { - key = key[1:] - } + // Remove leading slash if present + if len(key) > 0 && key[0] == '/' { + key = key[1:] + }
159-165: Validation vs. fallback mismatch for FileNameYou error when FileName is empty but also implement a fallback name later. Align behavior by allowing empty FileName and relying on the fallback.
Apply this diff to relax validation:
- if request.FileName == "" { - return ierr.NewError("file name is required"). - WithHint("File name must be provided"). - Mark(ierr.ErrValidation) - }This keeps the fallback at Lines 196-201 meaningful.
Also applies to: 196-201
96-101: Provide KMS key ID when using aws:kmsIf configured, set SSEKMSKeyId; otherwise, KMS encryption may fail or use an unintended key.
Apply this diff:
} else if c.config.Encryption == "aws:kms" { uploadInput.ServerSideEncryption = types.ServerSideEncryptionAwsKms + if c.config.KMSKeyID != "" { + uploadInput.SSEKMSKeyId = aws.String(c.config.KMSKeyID) + } }Please confirm c.config.KMSKeyID (or equivalent) exists in your config.
170-174: Validate supported formats (csv, json, parquet)Fail fast on unknown formats to avoid bad Content-Type/extension.
Apply this diff:
if request.EntityType == "" { return ierr.NewError("entity type is required"). WithHint("Entity type must be provided"). Mark(ierr.ErrValidation) } + + // Validate format + switch request.Format { + case ExportFormatCSV, ExportFormatJSON, ExportFormatParquet: + default: + return ierr.NewErrorf("unsupported export format: %s", request.Format). + Mark(ierr.ErrValidation) + }
10-14: Optional: alias AWS S3 import to avoid self-package name confusionUsing awss3 alias improves clarity in a package also named s3. Update usages accordingly.
Apply this diff:
- "github.com/aws/aws-sdk-go-v2/service/s3" + awss3 "github.com/aws/aws-sdk-go-v2/service/s3"And replace occurrences of s3.PutObjectInput with awss3.PutObjectInput in this file.
246-262: Optional: Parquet Content-TypeConsider "application/vnd.apache.parquet" for parquet payloads; current octet-stream works but is less specific.
Apply this diff:
case ExportFormatParquet: - return "application/octet-stream" + return "application/vnd.apache.parquet"
62-80: Optional: stream compression and multipart uploads for large filesCurrent approach double-buffers data (compress then upload). For big exports, consider:
- io.Pipe to gzip while streaming to S3
- aws-sdk-go-v2/feature/s3/manager Uploader for multipart concurrency and resilience
This reduces memory and speeds uploads.
Also applies to: 225-244
internal/integration/factory.go (1)
216-229: Unused parameter in GetStorageProvider.The
connectionIDparameter is not used in the method body. If this is intentional for future extensibility (e.g., supporting per-connection S3 clients), document this in the method comment. Otherwise, consider removing it to avoid confusion.If the parameter is intended for future use, update the comment:
// GetStorageProvider returns an S3 storage client for the given connection +// Note: connectionID parameter is reserved for future use when supporting +// connection-specific client configurations. // Currently only S3 is supported. In the future, Azure Blob Storage, Google Cloud Storage, // and other providers can be added by checking the connection's provider type. func (f *Factory) GetStorageProvider(ctx context.Context, connectionID string) (*s3.Client, error) {Alternatively, if not needed, remove the parameter:
-func (f *Factory) GetStorageProvider(ctx context.Context, connectionID string) (*s3.Client, error) { +func (f *Factory) GetStorageProvider(ctx context.Context) (*s3.Client, error) {ent/task.go (1)
39-42: Align ScheduledJobID nullability with DB to avoid ambiguityDB column is nullable, but Go field is non-pointer string. This collapses NULL to "", prevents clearing to NULL, and complicates predicates.
Prefer making it nillable in the Ent schema so the generated field is *string (like WorkflowID):
// ent/schema/task.go field.String("scheduled_job_id"). Optional(). Nillable(). SchemaType(map[string]string{"postgres":"varchar(50)"})If you intentionally want non-null with empty default, change the schema to Default("") and drop Nillable so DB is NOT NULL with default "", then re-generate. Re-run ent generate after updating.
Also applies to: 160-172
internal/types/scheduled_job.go (1)
86-97: Replace magic strings with typed enums for compression/encryptionAvoid stringly-typed values for Compression/Encryption. Introduce typed constants to reduce errors.
Example:
+type CompressionType string +const ( + CompressionGzip CompressionType = "gzip" + CompressionNone CompressionType = "none" +) +type EncryptionType string +const ( + EncryptionAES256 EncryptionType = "AES256" + EncryptionKMS EncryptionType = "aws:kms" +) type S3JobConfig struct { Bucket string `json:"bucket"` Region string `json:"region"` KeyPrefix string `json:"key_prefix,omitempty"` - Compression string `json:"compression,omitempty"` - Encryption string `json:"encryption,omitempty"` + Compression CompressionType `json:"compression,omitempty"` + Encryption EncryptionType `json:"encryption,omitempty"` EndpointURL string `json:"endpoint_url,omitempty"` VirtualHostStyle bool `json:"virtual_host_style,omitempty"` MaxFileSizeMB int `json:"max_file_size_mb,omitempty"` }Also applies to: 118-130
ent/migrate/schema.go
Outdated
| // ScheduledJobsColumns holds the columns for the "scheduled_jobs" table. | ||
| ScheduledJobsColumns = []*schema.Column{ | ||
| {Name: "id", Type: field.TypeString, Unique: true, SchemaType: map[string]string{"postgres": "varchar(50)"}}, | ||
| {Name: "tenant_id", Type: field.TypeString, SchemaType: map[string]string{"postgres": "varchar(50)"}}, | ||
| {Name: "status", Type: field.TypeString, Default: "published", SchemaType: map[string]string{"postgres": "varchar(20)"}}, | ||
| {Name: "created_at", Type: field.TypeTime}, | ||
| {Name: "updated_at", Type: field.TypeTime}, | ||
| {Name: "created_by", Type: field.TypeString, Nullable: true}, | ||
| {Name: "updated_by", Type: field.TypeString, Nullable: true}, | ||
| {Name: "environment_id", Type: field.TypeString, Nullable: true, Default: "", SchemaType: map[string]string{"postgres": "varchar(50)"}}, | ||
| {Name: "connection_id", Type: field.TypeString, SchemaType: map[string]string{"postgres": "varchar(50)"}}, | ||
| {Name: "entity_type", Type: field.TypeString, SchemaType: map[string]string{"postgres": "varchar(50)"}}, | ||
| {Name: "interval", Type: field.TypeString, SchemaType: map[string]string{"postgres": "varchar(20)"}}, | ||
| {Name: "enabled", Type: field.TypeBool, Default: true}, | ||
| {Name: "job_config", Type: field.TypeJSON, Nullable: true}, | ||
| {Name: "last_run_at", Type: field.TypeTime, Nullable: true}, | ||
| {Name: "next_run_at", Type: field.TypeTime, Nullable: true}, | ||
| {Name: "last_run_status", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "varchar(20)"}}, | ||
| {Name: "last_run_error", Type: field.TypeString, Nullable: true, Size: 2147483647}, | ||
| {Name: "temporal_schedule_id", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "varchar(100)"}}, | ||
| } | ||
| // ScheduledJobsTable holds the schema information for the "scheduled_jobs" table. | ||
| ScheduledJobsTable = &schema.Table{ | ||
| Name: "scheduled_jobs", | ||
| Columns: ScheduledJobsColumns, | ||
| PrimaryKey: []*schema.Column{ScheduledJobsColumns[0]}, | ||
| Indexes: []*schema.Index{ | ||
| { | ||
| Name: "scheduledjob_tenant_id_environment_id_enabled", | ||
| Unique: false, | ||
| Columns: []*schema.Column{ScheduledJobsColumns[1], ScheduledJobsColumns[7], ScheduledJobsColumns[11]}, | ||
| }, | ||
| { | ||
| Name: "scheduledjob_connection_id_enabled", | ||
| Unique: false, | ||
| Columns: []*schema.Column{ScheduledJobsColumns[8], ScheduledJobsColumns[11]}, | ||
| }, | ||
| { | ||
| Name: "scheduledjob_entity_type_interval_enabled", | ||
| Unique: false, | ||
| Columns: []*schema.Column{ScheduledJobsColumns[9], ScheduledJobsColumns[10], ScheduledJobsColumns[11]}, | ||
| }, | ||
| { | ||
| Name: "scheduledjob_enabled_next_run_at", | ||
| Unique: false, | ||
| Columns: []*schema.Column{ScheduledJobsColumns[11], ScheduledJobsColumns[14]}, | ||
| }, | ||
| { | ||
| Name: "scheduledjob_connection_id_entity_type", | ||
| Unique: true, | ||
| Columns: []*schema.Column{ScheduledJobsColumns[8], ScheduledJobsColumns[9]}, | ||
| }, | ||
| }, | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add FK for scheduled_jobs.connection_id → connections.id (and consider index)
scheduled_jobs.connection_id lacks a foreign key. Add an edge/constraint to enforce integrity, and index for lookups by connection.
// ent/schema/scheduledjob.go
field.String("connection_id").
SchemaType(map[string]string{"postgres":"varchar(50)"}),
edge.From("connection", Connection.Type).
Ref("scheduled_jobs").
Field("connection_id").
Unique().
// Choose semantics:
// .Annotations(entsql.OnDelete(entsql.Cascade)) // or entsql.Restrict/SetNull
index.Fields("tenant_id", "environment_id", "connection_id"),
ent/scheduledjob_query.go
Outdated
| func (sjq *ScheduledJobQuery) Clone() *ScheduledJobQuery { | ||
| if sjq == nil { | ||
| return nil | ||
| } | ||
| return &ScheduledJobQuery{ | ||
| config: sjq.config, | ||
| ctx: sjq.ctx.Clone(), | ||
| order: append([]scheduledjob.OrderOption{}, sjq.order...), | ||
| inters: append([]Interceptor{}, sjq.inters...), | ||
| predicates: append([]predicate.ScheduledJob{}, sjq.predicates...), | ||
| // clone intermediate query. | ||
| sql: sjq.sql.Clone(), | ||
| path: sjq.path, | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix nil deref in Clone() when sql selector is nil
sjq.sql.Clone() panics if sjq.sql is nil. Guard it.
Apply this diff:
func (sjq *ScheduledJobQuery) Clone() *ScheduledJobQuery {
if sjq == nil {
return nil
}
- return &ScheduledJobQuery{
- config: sjq.config,
- ctx: sjq.ctx.Clone(),
- order: append([]scheduledjob.OrderOption{}, sjq.order...),
- inters: append([]Interceptor{}, sjq.inters...),
- predicates: append([]predicate.ScheduledJob{}, sjq.predicates...),
- // clone intermediate query.
- sql: sjq.sql.Clone(),
- path: sjq.path,
- }
+ c := &ScheduledJobQuery{
+ config: sjq.config,
+ ctx: sjq.ctx.Clone(),
+ order: append([]scheduledjob.OrderOption{}, sjq.order...),
+ inters: append([]Interceptor{}, sjq.inters...),
+ predicates: append([]predicate.ScheduledJob{}, sjq.predicates...),
+ path: sjq.path,
+ }
+ if sjq.sql != nil {
+ c.sql = sjq.sql.Clone()
+ }
+ return c
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func (sjq *ScheduledJobQuery) Clone() *ScheduledJobQuery { | |
| if sjq == nil { | |
| return nil | |
| } | |
| return &ScheduledJobQuery{ | |
| config: sjq.config, | |
| ctx: sjq.ctx.Clone(), | |
| order: append([]scheduledjob.OrderOption{}, sjq.order...), | |
| inters: append([]Interceptor{}, sjq.inters...), | |
| predicates: append([]predicate.ScheduledJob{}, sjq.predicates...), | |
| // clone intermediate query. | |
| sql: sjq.sql.Clone(), | |
| path: sjq.path, | |
| } | |
| } | |
| func (sjq *ScheduledJobQuery) Clone() *ScheduledJobQuery { | |
| if sjq == nil { | |
| return nil | |
| } | |
| c := &ScheduledJobQuery{ | |
| config: sjq.config, | |
| ctx: sjq.ctx.Clone(), | |
| order: append([]scheduledjob.OrderOption{}, sjq.order...), | |
| inters: append([]Interceptor{}, sjq.inters...), | |
| predicates: append([]predicate.ScheduledJob{}, sjq.predicates...), | |
| path: sjq.path, | |
| } | |
| if sjq.sql != nil { | |
| c.sql = sjq.sql.Clone() | |
| } | |
| return c | |
| } |
🤖 Prompt for AI Agents
In ent/scheduledjob_query.go around lines 243 to 257, the Clone method calls
sjq.sql.Clone() unguarded which panics when sjq.sql is nil; update the method to
check if sjq.sql is nil and set the cloned struct's sql field to nil in that
case, otherwise call sjq.sql.Clone() and assign the result, preserving existing
behavior; keep the rest of the fields cloned as-is.
ent/scheduledjob_update.go
Outdated
| // check runs all checks and user-defined validators on the builder. | ||
| func (sju *ScheduledJobUpdate) check() error { | ||
| if v, ok := sju.mutation.ConnectionID(); ok { | ||
| if err := scheduledjob.ConnectionIDValidator(v); err != nil { | ||
| return &ValidationError{Name: "connection_id", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.connection_id": %w`, err)} | ||
| } | ||
| } | ||
| if v, ok := sju.mutation.EntityType(); ok { | ||
| if err := scheduledjob.EntityTypeValidator(v); err != nil { | ||
| return &ValidationError{Name: "entity_type", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.entity_type": %w`, err)} | ||
| } | ||
| } | ||
| if v, ok := sju.mutation.Interval(); ok { | ||
| if err := scheduledjob.IntervalValidator(v); err != nil { | ||
| return &ValidationError{Name: "interval", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.interval": %w`, err)} | ||
| } | ||
| } | ||
| return nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard nil validators to avoid panics
Validator function vars may be nil if not set by schema. Add nil-checks before calling.
Apply this diff:
func (sju *ScheduledJobUpdate) check() error {
if v, ok := sju.mutation.ConnectionID(); ok {
- if err := scheduledjob.ConnectionIDValidator(v); err != nil {
+ if scheduledjob.ConnectionIDValidator != nil {
+ if err := scheduledjob.ConnectionIDValidator(v); err != nil {
return &ValidationError{Name: "connection_id", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.connection_id": %w`, err)}
+ }
}
}
if v, ok := sju.mutation.EntityType(); ok {
- if err := scheduledjob.EntityTypeValidator(v); err != nil {
+ if scheduledjob.EntityTypeValidator != nil {
+ if err := scheduledjob.EntityTypeValidator(v); err != nil {
return &ValidationError{Name: "entity_type", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.entity_type": %w`, err)}
+ }
}
}
if v, ok := sju.mutation.Interval(); ok {
- if err := scheduledjob.IntervalValidator(v); err != nil {
+ if scheduledjob.IntervalValidator != nil {
+ if err := scheduledjob.IntervalValidator(v); err != nil {
return &ValidationError{Name: "interval", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.interval": %w`, err)}
+ }
}
}
return nil
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // check runs all checks and user-defined validators on the builder. | |
| func (sju *ScheduledJobUpdate) check() error { | |
| if v, ok := sju.mutation.ConnectionID(); ok { | |
| if err := scheduledjob.ConnectionIDValidator(v); err != nil { | |
| return &ValidationError{Name: "connection_id", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.connection_id": %w`, err)} | |
| } | |
| } | |
| if v, ok := sju.mutation.EntityType(); ok { | |
| if err := scheduledjob.EntityTypeValidator(v); err != nil { | |
| return &ValidationError{Name: "entity_type", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.entity_type": %w`, err)} | |
| } | |
| } | |
| if v, ok := sju.mutation.Interval(); ok { | |
| if err := scheduledjob.IntervalValidator(v); err != nil { | |
| return &ValidationError{Name: "interval", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.interval": %w`, err)} | |
| } | |
| } | |
| return nil | |
| // check runs all checks and user-defined validators on the builder. | |
| func (sju *ScheduledJobUpdate) check() error { | |
| if v, ok := sju.mutation.ConnectionID(); ok { | |
| if scheduledjob.ConnectionIDValidator != nil { | |
| if err := scheduledjob.ConnectionIDValidator(v); err != nil { | |
| return &ValidationError{ | |
| Name: "connection_id", | |
| err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.connection_id": %w`, err), | |
| } | |
| } | |
| } | |
| } | |
| if v, ok := sju.mutation.EntityType(); ok { | |
| if scheduledjob.EntityTypeValidator != nil { | |
| if err := scheduledjob.EntityTypeValidator(v); err != nil { | |
| return &ValidationError{ | |
| Name: "entity_type", | |
| err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.entity_type": %w`, err), | |
| } | |
| } | |
| } | |
| } | |
| if v, ok := sju.mutation.Interval(); ok { | |
| if scheduledjob.IntervalValidator != nil { | |
| if err := scheduledjob.IntervalValidator(v); err != nil { | |
| return &ValidationError{ | |
| Name: "interval", | |
| err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.interval": %w`, err), | |
| } | |
| } | |
| } | |
| } | |
| return nil | |
| } |
🤖 Prompt for AI Agents
In ent/scheduledjob_update.go around lines 280 to 297, the code calls schema
validator function variables (scheduledjob.ConnectionIDValidator,
scheduledjob.EntityTypeValidator, scheduledjob.IntervalValidator) without
guarding for nil which can panic if a validator wasn't generated; add nil-checks
before invoking each validator (e.g., if scheduledjob.ConnectionIDValidator !=
nil { if err := scheduledjob.ConnectionIDValidator(v); err != nil { return
&ValidationError{...} } }) and do the same pattern for EntityType and Interval
so validators are only called when non-nil.
ent/scheduledjob_update.go
Outdated
| // check runs all checks and user-defined validators on the builder. | ||
| func (sjuo *ScheduledJobUpdateOne) check() error { | ||
| if v, ok := sjuo.mutation.ConnectionID(); ok { | ||
| if err := scheduledjob.ConnectionIDValidator(v); err != nil { | ||
| return &ValidationError{Name: "connection_id", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.connection_id": %w`, err)} | ||
| } | ||
| } | ||
| if v, ok := sjuo.mutation.EntityType(); ok { | ||
| if err := scheduledjob.EntityTypeValidator(v); err != nil { | ||
| return &ValidationError{Name: "entity_type", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.entity_type": %w`, err)} | ||
| } | ||
| } | ||
| if v, ok := sjuo.mutation.Interval(); ok { | ||
| if err := scheduledjob.IntervalValidator(v); err != nil { | ||
| return &ValidationError{Name: "interval", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.interval": %w`, err)} | ||
| } | ||
| } | ||
| return nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard nil validators in UpdateOne.check() as well
Mirror the nil checks in UpdateOne to prevent panics.
Apply this diff:
func (sjuo *ScheduledJobUpdateOne) check() error {
if v, ok := sjuo.mutation.ConnectionID(); ok {
- if err := scheduledjob.ConnectionIDValidator(v); err != nil {
+ if scheduledjob.ConnectionIDValidator != nil {
+ if err := scheduledjob.ConnectionIDValidator(v); err != nil {
return &ValidationError{Name: "connection_id", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.connection_id": %w`, err)}
+ }
}
}
if v, ok := sjuo.mutation.EntityType(); ok {
- if err := scheduledjob.EntityTypeValidator(v); err != nil {
+ if scheduledjob.EntityTypeValidator != nil {
+ if err := scheduledjob.EntityTypeValidator(v); err != nil {
return &ValidationError{Name: "entity_type", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.entity_type": %w`, err)}
+ }
}
}
if v, ok := sjuo.mutation.Interval(); ok {
- if err := scheduledjob.IntervalValidator(v); err != nil {
+ if scheduledjob.IntervalValidator != nil {
+ if err := scheduledjob.IntervalValidator(v); err != nil {
return &ValidationError{Name: "interval", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.interval": %w`, err)}
+ }
}
}
return nil
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // check runs all checks and user-defined validators on the builder. | |
| func (sjuo *ScheduledJobUpdateOne) check() error { | |
| if v, ok := sjuo.mutation.ConnectionID(); ok { | |
| if err := scheduledjob.ConnectionIDValidator(v); err != nil { | |
| return &ValidationError{Name: "connection_id", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.connection_id": %w`, err)} | |
| } | |
| } | |
| if v, ok := sjuo.mutation.EntityType(); ok { | |
| if err := scheduledjob.EntityTypeValidator(v); err != nil { | |
| return &ValidationError{Name: "entity_type", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.entity_type": %w`, err)} | |
| } | |
| } | |
| if v, ok := sjuo.mutation.Interval(); ok { | |
| if err := scheduledjob.IntervalValidator(v); err != nil { | |
| return &ValidationError{Name: "interval", err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.interval": %w`, err)} | |
| } | |
| } | |
| return nil | |
| // check runs all checks and user-defined validators on the builder. | |
| func (sjuo *ScheduledJobUpdateOne) check() error { | |
| if v, ok := sjuo.mutation.ConnectionID(); ok { | |
| if scheduledjob.ConnectionIDValidator != nil { | |
| if err := scheduledjob.ConnectionIDValidator(v); err != nil { | |
| return &ValidationError{ | |
| Name: "connection_id", | |
| err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.connection_id": %w`, err), | |
| } | |
| } | |
| } | |
| } | |
| if v, ok := sjuo.mutation.EntityType(); ok { | |
| if scheduledjob.EntityTypeValidator != nil { | |
| if err := scheduledjob.EntityTypeValidator(v); err != nil { | |
| return &ValidationError{ | |
| Name: "entity_type", | |
| err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.entity_type": %w`, err), | |
| } | |
| } | |
| } | |
| } | |
| if v, ok := sjuo.mutation.Interval(); ok { | |
| if scheduledjob.IntervalValidator != nil { | |
| if err := scheduledjob.IntervalValidator(v); err != nil { | |
| return &ValidationError{ | |
| Name: "interval", | |
| err: fmt.Errorf(`ent: validator failed for field "ScheduledJob.interval": %w`, err), | |
| } | |
| } | |
| } | |
| } | |
| return nil | |
| } |
🤖 Prompt for AI Agents
In ent/scheduledjob_update.go around lines 660 to 677, the UpdateOne.check()
calls field validators unguarded which can panic if the package-level validators
are nil; add nil checks before invoking each validator (e.g., if
scheduledjob.ConnectionIDValidator != nil { if err :=
scheduledjob.ConnectionIDValidator(v); ... }), and apply the same nil-guard
pattern for EntityType and Interval validators so UpdateOne.check() mirrors the
safe checks used in Update.check().
| var response *dto.GetUsageAnalyticsResponse | ||
| if !h.config.FeatureFlag.EnableFeatureUsageForAnalytics { | ||
|
|
||
| if !h.config.FeatureFlag.EnableFeatureUsageForAnalytics || h.config.FeatureFlag.ForceV1ForTenant == types.GetTenantID(ctx) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix empty string comparison logic.
The condition will incorrectly route to v1 when both ForceV1ForTenant (default: "") and types.GetTenantID(ctx) return empty strings. This could affect tenants where the context is not properly set.
Apply this diff to fix the logic:
- if !h.config.FeatureFlag.EnableFeatureUsageForAnalytics || h.config.FeatureFlag.ForceV1ForTenant == types.GetTenantID(ctx) {
+ if !h.config.FeatureFlag.EnableFeatureUsageForAnalytics || (h.config.FeatureFlag.ForceV1ForTenant != "" && h.config.FeatureFlag.ForceV1ForTenant == types.GetTenantID(ctx)) {🤖 Prompt for AI Agents
In internal/api/v1/events.go around line 346, the current condition treats empty
ForceV1ForTenant and an empty tenant ID as a match and incorrectly forces v1;
capture the tenant ID into a variable (e.g., tenantID := types.GetTenantID(ctx))
and change the check to only treat ForceV1ForTenant as a forced match when it is
non-empty and equal to tenantID (i.e., ForceV1ForTenant != "" &&
ForceV1ForTenant == tenantID), leaving the existing feature-flag negation
intact.
internal/repository/ent/task.go
Outdated
| SetNillableScheduledJobID(&t.ScheduledJobID). | ||
| SetNillableWorkflowID(t.WorkflowID). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Avoid forcing empty scheduled_job_id; conditionally set or pass nil
Using SetNillableScheduledJobID(&t.ScheduledJobID) always sets the field (even when empty), likely polluting rows with "" instead of leaving it unset.
Minimal inline fix:
- SetNillableScheduledJobID(&t.ScheduledJobID).
+ SetNillableScheduledJobID(func(v string) *string {
+ if v == "" {
+ return nil
+ }
+ return &v
+ }(t.ScheduledJobID)).Apply the same change in Update():
- SetNillableScheduledJobID(&t.ScheduledJobID).
+ SetNillableScheduledJobID(func(v string) *string {
+ if v == "" {
+ return nil
+ }
+ return &v
+ }(t.ScheduledJobID)).If the intended semantics are “no scheduled job = NULL”, consider making scheduled_job_id Nillable in schema; otherwise confirm empty string is acceptable.
Also applies to: 201-201
🏁 Script executed:
#!/bin/bash
rg -n "ScheduledJobID" -C2 .Length of output: 33398
Avoid writing empty scheduled_job_id; only set when non-empty
Replace in Create() and Update():
- SetNillableScheduledJobID(&t.ScheduledJobID).
+ SetNillableScheduledJobID(func(s string) *string {
+ if s == "" { return nil }
+ return &s
+ }(t.ScheduledJobID)).📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| SetNillableScheduledJobID(&t.ScheduledJobID). | |
| SetNillableWorkflowID(t.WorkflowID). | |
| SetNillableScheduledJobID(func(s string) *string { | |
| if s == "" { return nil } | |
| return &s | |
| }(t.ScheduledJobID)). | |
| SetNillableWorkflowID(t.WorkflowID). |
🤖 Prompt for AI Agents
In internal/repository/ent/task.go around lines 51-52, avoid writing an empty
scheduled_job_id by only setting it when non-empty; modify the Create() and
Update() builders so you do not call SetNillableScheduledJobID with an empty
string — either call the setter only when t.ScheduledJobID != "" or pass nil
instead (i.e., only attach SetNillableScheduledJobID(&t.ScheduledJobID) when the
value is non-empty), leaving the field unset when t.ScheduledJobID is empty.
| req := dto.CreateScheduledJobRequest{ | ||
| ConnectionID: "conn-123", | ||
| EntityType: string(types.ScheduledJobEntityTypeEvents), | ||
| Interval: string(types.ScheduledJobIntervalDaily), | ||
| Enabled: true, | ||
| JobConfig: jobConfig, | ||
| } | ||
|
|
||
| t.Run("Success", func(t *testing.T) { | ||
| // This is a basic structure test | ||
| // Full integration test requires database and Temporal | ||
|
|
||
| assert.Equal(t, "feature_usage", req.EntityType) | ||
| assert.Equal(t, "daily", req.Interval) | ||
| assert.True(t, req.Enabled) | ||
| assert.Equal(t, "test-bucket", req.JobConfig["bucket"]) | ||
|
|
||
| // Validate entity type | ||
| entityType := types.ScheduledJobEntityType(req.EntityType) | ||
| err := entityType.Validate() | ||
| assert.NoError(t, err) | ||
|
|
||
| // Validate interval | ||
| interval := types.ScheduledJobInterval(req.Interval) | ||
| err = interval.Validate() | ||
| assert.NoError(t, err) | ||
|
|
||
| // Validate job config can be marshaled | ||
| jobConfigBytes, err := json.Marshal(req.JobConfig) | ||
| assert.NoError(t, err) | ||
| assert.NotEmpty(t, jobConfigBytes) | ||
|
|
||
| var s3Config types.S3JobConfig | ||
| err = json.Unmarshal(jobConfigBytes, &s3Config) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, "test-bucket", s3Config.Bucket) | ||
| assert.Equal(t, "us-east-1", s3Config.Region) | ||
| }) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix entity type assertion in success test
req.EntityType is initialized from ScheduledJobEntityTypeEvents (value "events"), yet the assertion expects "feature_usage". This subtest fails immediately—please align the expected string with the actual constant.
🤖 Prompt for AI Agents
In internal/service/scheduled_job_test.go around lines 62 to 100, the test
asserts req.EntityType equals "feature_usage" but req was initialized from
ScheduledJobEntityTypeEvents (value "events"); update the assertion to match the
actual value by replacing the expected string with
string(types.ScheduledJobEntityTypeEvents) (or the literal "events") so the test
checks the correct entity type.
internal/service/scheduled_job.go
Outdated
| func (s *scheduledJobService) ListScheduledJobs(ctx context.Context, filter *types.QueryFilter) (*dto.ListScheduledJobsResponse, error) { | ||
| if filter == nil { | ||
| filter = types.NewDefaultQueryFilter() | ||
| } | ||
|
|
||
| // Convert QueryFilter to ListFilters | ||
| listFilters := &scheduledjob.ListFilters{ | ||
| Limit: int(*filter.Limit), | ||
| Offset: int(*filter.Offset), | ||
| } | ||
|
|
||
| jobs, err := s.repo.List(ctx, listFilters) | ||
| if err != nil { | ||
| s.logger.Errorw("failed to list scheduled jobs", "error", err) | ||
| return nil, ierr.WithError(err). | ||
| WithHint("Failed to retrieve scheduled jobs"). | ||
| Mark(ierr.ErrDatabase) | ||
| } | ||
|
|
||
| // Get total count (for pagination) | ||
| totalCount := len(jobs) // TODO: implement proper count query if needed | ||
|
|
||
| return dto.ToScheduledJobListResponse(jobs, totalCount), nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scope List by tenant/environment.
ListFilters omits tenant and environment scoping, risking cross-tenant data leakage.
// Convert QueryFilter to ListFilters
listFilters := &scheduledjob.ListFilters{
+ TenantID: types.GetTenantID(ctx),
+ EnvironmentID: types.GetEnvironmentID(ctx),
Limit: int(*filter.Limit),
Offset: int(*filter.Offset),
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func (s *scheduledJobService) ListScheduledJobs(ctx context.Context, filter *types.QueryFilter) (*dto.ListScheduledJobsResponse, error) { | |
| if filter == nil { | |
| filter = types.NewDefaultQueryFilter() | |
| } | |
| // Convert QueryFilter to ListFilters | |
| listFilters := &scheduledjob.ListFilters{ | |
| Limit: int(*filter.Limit), | |
| Offset: int(*filter.Offset), | |
| } | |
| jobs, err := s.repo.List(ctx, listFilters) | |
| if err != nil { | |
| s.logger.Errorw("failed to list scheduled jobs", "error", err) | |
| return nil, ierr.WithError(err). | |
| WithHint("Failed to retrieve scheduled jobs"). | |
| Mark(ierr.ErrDatabase) | |
| } | |
| // Get total count (for pagination) | |
| totalCount := len(jobs) // TODO: implement proper count query if needed | |
| return dto.ToScheduledJobListResponse(jobs, totalCount), nil | |
| } | |
| func (s *scheduledJobService) ListScheduledJobs(ctx context.Context, filter *types.QueryFilter) (*dto.ListScheduledJobsResponse, error) { | |
| if filter == nil { | |
| filter = types.NewDefaultQueryFilter() | |
| } | |
| // Convert QueryFilter to ListFilters | |
| listFilters := &scheduledjob.ListFilters{ | |
| TenantID: types.GetTenantID(ctx), | |
| EnvironmentID: types.GetEnvironmentID(ctx), | |
| Limit: int(*filter.Limit), | |
| Offset: int(*filter.Offset), | |
| } | |
| jobs, err := s.repo.List(ctx, listFilters) | |
| if err != nil { | |
| s.logger.Errorw("failed to list scheduled jobs", "error", err) | |
| return nil, ierr.WithError(err). | |
| WithHint("Failed to retrieve scheduled jobs"). | |
| Mark(ierr.ErrDatabase) | |
| } | |
| // Get total count (for pagination) | |
| totalCount := len(jobs) // TODO: implement proper count query if needed | |
| return dto.ToScheduledJobListResponse(jobs, totalCount), nil | |
| } |
🤖 Prompt for AI Agents
internal/service/scheduled_job.go lines 148-171: ListScheduledJobs currently
builds ListFilters without tenant/environment scoping which can leak
cross-tenant data; extract tenant and environment from the request context or
the incoming QueryFilter and set them on scheduledjob.ListFilters (e.g.,
TenantID and Environment fields), update scheduledjob.ListFilters type if
needed, and ensure s.repo.List (and its DB queries) apply WHERE clauses for
tenant and environment so results are scoped to the caller; make corresponding
repo/query changes rather than returning unscoped results.
| // Update scheduled job's last_run_at | ||
| updateInput := export.UpdateScheduledJobInput{ | ||
| ScheduledJobID: input.ScheduledJobID, | ||
| TenantID: input.TenantID, | ||
| EnvID: input.EnvID, | ||
| LastRunAt: time.Now(), | ||
| LastRunStatus: "success", | ||
| } | ||
| err = workflow.ExecuteActivity(ctx, scheduledJobActivity.UpdateScheduledJobLastRun, updateInput).Get(ctx, nil) | ||
| if err != nil { | ||
| logger.Error("Failed to update scheduled job last run", "error", err) | ||
| // Continue anyway | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use workflow.Now(ctx) for determinism.
Line 107 uses time.Now() directly, which breaks workflow determinism on replay. Use workflow.Now(ctx) instead.
// Update scheduled job's last_run_at
updateInput := export.UpdateScheduledJobInput{
ScheduledJobID: input.ScheduledJobID,
TenantID: input.TenantID,
EnvID: input.EnvID,
- LastRunAt: time.Now(),
+ LastRunAt: workflow.Now(ctx),
LastRunStatus: "success",
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Update scheduled job's last_run_at | |
| updateInput := export.UpdateScheduledJobInput{ | |
| ScheduledJobID: input.ScheduledJobID, | |
| TenantID: input.TenantID, | |
| EnvID: input.EnvID, | |
| LastRunAt: time.Now(), | |
| LastRunStatus: "success", | |
| } | |
| err = workflow.ExecuteActivity(ctx, scheduledJobActivity.UpdateScheduledJobLastRun, updateInput).Get(ctx, nil) | |
| if err != nil { | |
| logger.Error("Failed to update scheduled job last run", "error", err) | |
| // Continue anyway | |
| } | |
| // Update scheduled job's last_run_at | |
| updateInput := export.UpdateScheduledJobInput{ | |
| ScheduledJobID: input.ScheduledJobID, | |
| TenantID: input.TenantID, | |
| EnvID: input.EnvID, | |
| LastRunAt: workflow.Now(ctx), | |
| LastRunStatus: "success", | |
| } | |
| err = workflow.ExecuteActivity(ctx, scheduledJobActivity.UpdateScheduledJobLastRun, updateInput).Get(ctx, nil) | |
| if err != nil { | |
| logger.Error("Failed to update scheduled job last run", "error", err) | |
| // Continue anyway | |
| } |
🤖 Prompt for AI Agents
In internal/temporal/workflows/export/scheduled_export_workflow.go around lines
102 to 114, the code uses time.Now() to set LastRunAt which breaks Temporal
workflow determinism on replay; replace the direct call with now :=
workflow.Now(ctx) and use now for LastRunAt (i.e., capture workflow.Now(ctx)
once before building updateInput and assign LastRunAt = now) so the timestamp is
recorded deterministically.
| const ( | ||
| ScheduledJobEntityTypeEvents ScheduledJobEntityType = "events" | ||
| ScheduledJobEntityTypeCustomer ScheduledJobEntityType = "customer" | ||
| ScheduledJobEntityTypeInvoice ScheduledJobEntityType = "invoice" | ||
| ScheduledJobEntityTypePrice ScheduledJobEntityType = "price" | ||
| ScheduledJobEntityTypeSubscription ScheduledJobEntityType = "subscription" | ||
| ScheduledJobEntityTypeCreditNote ScheduledJobEntityType = "credit_note" | ||
| ) | ||
|
|
||
| // Validate validates the entity type | ||
| func (e ScheduledJobEntityType) Validate() error { | ||
| allowedTypes := []ScheduledJobEntityType{ | ||
| ScheduledJobEntityTypeEvents, | ||
| ScheduledJobEntityTypeCustomer, | ||
| ScheduledJobEntityTypeInvoice, | ||
| ScheduledJobEntityTypePrice, | ||
| ScheduledJobEntityTypeSubscription, | ||
| ScheduledJobEntityTypeCreditNote, | ||
| } | ||
| if e == "" { | ||
| return ierr.NewError("entity type is required"). | ||
| WithHint("Scheduled job entity type must be specified"). | ||
| Mark(ierr.ErrValidation) | ||
| } | ||
| for _, entityType := range allowedTypes { | ||
| if e == entityType { | ||
| return nil | ||
| } | ||
| } | ||
| return ierr.NewError("invalid entity type"). | ||
| WithHint("Entity type must be one of: events, customer, invoice, price, subscription, credit_note"). | ||
| Mark(ierr.ErrValidation) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Entity type mismatch: “events” vs “feature_usage”
Types expose “events”, but ent model comments and PR narrative mention “feature_usage”. If API/domain use “feature_usage”, Validate() will reject it.
Run to verify actual usage across the repo:
🏁 Script executed:
#!/bin/bash
# Search for entity type literals referenced by API/services/workflows
rg -nP -C3 --glob '!**/vendor/**' '\bfeature_usage\b|\bevents\b'Length of output: 267631
Add support for “feature_usage” in ScheduledJobEntityType
The PR narrative, sync_config examples, and S3 integration docs reference “feature_usage”, but Validate() only allows “events” (and the other legacy types), so any “feature_usage” scheduled job will be rejected. In internal/types/scheduled_job.go add
- const ScheduledJobEntityTypeFeatureUsage ScheduledJobEntityType = "feature_usage"
- include it in the allowedTypes slice and in the hint string.
🤖 Prompt for AI Agents
In internal/types/scheduled_job.go around lines 43 to 75, add support for the
"feature_usage" entity type: declare a new constant
ScheduledJobEntityTypeFeatureUsage ScheduledJobEntityType = "feature_usage", add
this constant to the allowedTypes slice in Validate(), and include
"feature_usage" in the hint string returned on invalid entity type so validation
accepts and clearly documents the new type.
… task and implement related changes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/testutil/inmemory_task_store.go (1)
29-48: Preserve WorkflowID and FileName in copies.copyTask drops WorkflowID and FileName, leading to lost data on read paths.
Apply this diff:
return &task.Task{ ID: t.ID, TaskType: t.TaskType, EntityType: t.EntityType, ScheduledTaskID: t.ScheduledTaskID, + WorkflowID: t.WorkflowID, FileURL: t.FileURL, + FileName: t.FileName, FileType: t.FileType, TaskStatus: t.TaskStatus, TotalRecords: t.TotalRecords, ProcessedRecords: t.ProcessedRecords, SuccessfulRecords: t.SuccessfulRecords, FailedRecords: t.FailedRecords, ErrorSummary: t.ErrorSummary, Metadata: t.Metadata, StartedAt: t.StartedAt, CompletedAt: t.CompletedAt, FailedAt: t.FailedAt, EnvironmentID: t.EnvironmentID, BaseModel: t.BaseModel, }
♻️ Duplicate comments (17)
internal/types/scheduled_task.go (2)
45-77: Add support for "feature_usage" entity type.As flagged in the previous review, the PR narrative and related code reference "feature_usage", but
Validate()only accepts "events" and other legacy types. Any "feature_usage" scheduled task will be rejected.Per the existing review, add:
const ScheduledTaskEntityTypeFeatureUsage ScheduledTaskEntityType = "feature_usage"- Include it in
allowedTypes(line 56-62)- Update the hint string (line 75)
const ( ScheduledTaskEntityTypeEvents ScheduledTaskEntityType = "events" + ScheduledTaskEntityTypeFeatureUsage ScheduledTaskEntityType = "feature_usage" ScheduledTaskEntityTypeCustomer ScheduledTaskEntityType = "customer" ScheduledTaskEntityTypeInvoice ScheduledTaskEntityType = "invoice" ScheduledTaskEntityTypePrice ScheduledTaskEntityType = "price" ScheduledTaskEntityTypeSubscription ScheduledTaskEntityType = "subscription" ScheduledTaskEntityTypeCreditNote ScheduledTaskEntityType = "credit_note" ) func (e ScheduledTaskEntityType) Validate() error { allowedTypes := []ScheduledTaskEntityType{ ScheduledTaskEntityTypeEvents, + ScheduledTaskEntityTypeFeatureUsage, ScheduledTaskEntityTypeCustomer, ScheduledTaskEntityTypeInvoice, ScheduledTaskEntityTypePrice, ScheduledTaskEntityTypeSubscription, ScheduledTaskEntityTypeCreditNote, } ... return ierr.NewError("invalid entity type"). - WithHint("Entity type must be one of: events, customer, invoice, price, subscription, credit_note"). + WithHint("Entity type must be one of: events, feature_usage, customer, invoice, price, subscription, credit_note"). Mark(ierr.ErrValidation) }Based on learnings
120-145: Guard against negative MaxFileSizeMB.As flagged in the previous review,
Validate()sets defaults but never rejectsMaxFileSizeMB < 0. Downstream batching assumes non‑negative sizes; a negative value will cause errors or panics.Add validation before setting defaults:
if s.Encryption != "" && s.Encryption != "AES256" && s.Encryption != "aws:kms" { return ierr.NewError("invalid encryption type"). WithHint("Encryption must be one of: AES256, aws:kms"). Mark(ierr.ErrValidation) } + // Validate MaxFileSizeMB if provided + if s.MaxFileSizeMB < 0 { + return ierr.NewError("invalid max file size"). + WithHint("MaxFileSizeMB must be >= 0; use 0 to apply the default"). + Mark(ierr.ErrValidation) + } + // Set defaults if s.Compression == "" { s.Compression = "gzip" }Based on learnings
ent/mutation.go (2)
39649-39665: Clear* methods still present for non-nillable ScheduledTask fieldsThe
ClearCreatedBy,ClearUpdatedBy, andClearEnvironmentIDmethods allow setting NULL on fields defined as non-nillable strings via mixins, which will cause NOT NULL violations at runtime.This was previously flagged. The fix requires updating the mixin/schema definitions to mark these fields as
Optional().Nillable()and regenerating, or removing Clear* support for required fields.Also applies to: 39698-39714, 39747-39763
52824-52871: Clear* method still present for required Task.scheduled_task_id fieldThe
ClearScheduledTaskIDmethod allows setting NULL onscheduled_task_id, which is defined in the schema as required (non-empty). This will fail at runtime with NOT NULL violations.This was previously flagged. Update the Task schema to make
scheduled_task_idOptional().Nillable()and regenerate, or remove the NotEmpty() constraint if the field should be nullable.ent/migrate/schema.go (2)
1374-1427: Missing FK: scheduled_tasks.connection_id → connections.idNo DB FK is generated; orphaned scheduled tasks can occur. Add Ent edges to emit the FK (don’t edit this generated file).
Proposed in ent/schema/scheduledtask.go:
- Edge to Connection with FK and delete semantics.
package schema import ( "entgo.io/ent" + "entgo.io/ent/dialect/entsql" "entgo.io/ent/schema/field" "entgo.io/ent/schema/index" baseMixin "github.com/flexprice/flexprice/ent/schema/mixin" + "entgo.io/ent/schema/edge" ) // ScheduledTask holds the schema definition ... type ScheduledTask struct { ent.Schema } // Fields ... +// Edges of the ScheduledTask. +func (ScheduledTask) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("connection", Connection.Type). + Ref("scheduled_tasks"). + Field("connection_id"). + Unique(). + Annotations(entsql.OnDelete(entsql.Restrict)), + } +}Optionally, consider scoping the unique index to tenant/environment if IDs aren’t globally unique.
1828-1830: Missing FK: tasks.scheduled_task_id → scheduled_tasks.id (and index)The new column lacks an FK/index in the generated schema. Define it in Ent (task schema) so migrations emit the constraint and an index.
Proposed in ent/schema/task.go:
+field.String("scheduled_task_id"). + Optional().Nillable(). + SchemaType(map[string]string{"postgres":"varchar(50)"}), + +edge.From("scheduled_task", ScheduledTask.Type). + Ref("tasks"). + Field("scheduled_task_id"). + Unique(). + Nillable(). + Annotations(entsql.OnDelete(entsql.SetNull)),Also add an index to speed lookups:
+index.Fields("tenant_id", "environment_id", "scheduled_task_id"),Run ent generate to emit the FK and index.
internal/repository/ent/task.go (1)
46-66: Avoid persisting emptyscheduled_task_idstrings.
SetNillableScheduledTaskID(&t.ScheduledTaskID)(Line 51 & Line 201) writes""when the domain value is empty, defeating the nullable semantics and making “no scheduled job” indistinguishable from an actual ID. Only set the field when non-empty.- SetNillableScheduledTaskID(&t.ScheduledTaskID). + func(builder *ent.TaskCreate) *ent.TaskCreate { + if t.ScheduledTaskID != "" { + return builder.SetNillableScheduledTaskID(&t.ScheduledTaskID) + } + return builder + }(client.Task.Create(). + SetID(t.ID). + SetTenantID(t.TenantID). + SetTaskType(string(t.TaskType)). + SetEntityType(string(t.EntityType))). + SetNillableWorkflowID(t.WorkflowID).Apply the same guard in the update builder:
- query. - SetTaskType(string(t.TaskType)). - SetEntityType(string(t.EntityType)). - SetNillableScheduledTaskID(&t.ScheduledTaskID). + query. + SetTaskType(string(t.TaskType)). + SetEntityType(string(t.EntityType)) + + if t.ScheduledTaskID != "" { + query = query.SetNillableScheduledTaskID(&t.ScheduledTaskID) + } else { + query = query.ClearScheduledTaskID() + }(Adjust builder chaining as needed to keep the fluent style.)
Also applies to: 197-207
internal/api/v1/test_usage_export.go (3)
45-57: Prevent panics when reading tenant/environment IDs.Line 45 and Line 52 still use direct
tid.(string)/eid.(string); a non-string value from Gin context will panic (auth middleware can store other types). Guard with safe assertions and fall back to headers if the type assertion fails.- if tid, exists := c.Get("tenant_id"); exists && tid != nil { - tenantID = tid.(string) - } else { - // Fallback to reading from headers directly - tenantID = c.GetHeader("X-Tenant-Id") - } + if tid, exists := c.Get("tenant_id"); exists && tid != nil { + if v, ok := tid.(string); ok && v != "" { + tenantID = v + } + } + if tenantID == "" { + tenantID = c.GetHeader("X-Tenant-Id") + } - if eid, exists := c.Get("environment_id"); exists && eid != nil { - envID = eid.(string) - } else { - // Fallback to reading from headers directly - envID = c.GetHeader("X-Environment-Id") - } + if eid, exists := c.Get("environment_id"); exists && eid != nil { + if v, ok := eid.(string); ok && v != "" { + envID = v + } + } + if envID == "" { + envID = c.GetHeader("X-Environment-Id") + }
78-83: Scope repository queries with tenant/env context.Line 80 calls
GetByEntityTypewithout tenant/environment onctx. That repo pulls tenant/env from context; leaving them blank risks cross-tenant reads. Bind the IDs before the query:// Get scheduled task for events entity type // This will fetch the S3 configuration from the scheduled_tasks table + ctx = types.SetTenantID(ctx, tenantID) + ctx = types.SetEnvironmentID(ctx, envID) scheduledTasks, err := h.scheduledTaskRepo.GetByEntityType(ctx, string(types.ScheduledTaskEntityTypeEvents))
128-135: PopulateConnectionIDon the export request.
ExportRequest(Line 128) still omits the scheduled task'sConnectionID, soGetS3Clientcannot resolve credentials and the export fails. Track the enabled task and pass its connection:- var jobConfig *types.S3JobConfig + var ( + jobConfig *types.S3JobConfig + selectedTask *scheduledtask.ScheduledTask + ) @@ - if task.Enabled { + if task.Enabled { jobConfig, configErr = task.GetS3JobConfig() if configErr != nil { @@ } + selectedTask = task break } } if jobConfig == nil { @@ request := &export.ExportRequest{ EntityType: types.ExportEntityTypeEvents, + ConnectionID: func() string { + if selectedTask != nil { + return selectedTask.ConnectionID + } + return "" + }(), TenantID: tenantID,(If
ConnectionIDis mandatory, return an error before building the request when it’s empty.)internal/service/sync/export/usage_export.go (1)
118-155: Sanitize CSV fields to block formula injection.All string columns (Line 129 onward) are written verbatim; values beginning with
=,+,-, or@will execute as formulas in Excel/Sheets (CSV injection). Normalize them before writing.- // Write batch data to CSV - for _, usage := range usageData { + sanitize := func(s string) string { + if len(s) == 0 { + return s + } + switch s[0] { + case '=', '+', '-', '@': + return "\t" + s + default: + return s + } + } + + // Write batch data to CSV + for _, usage := range usageData { @@ - row := []string{ - usage.ID, - usage.TenantID, - usage.EnvironmentID, - usage.ExternalCustomerID, - usage.CustomerID, - usage.SubscriptionID, - usage.SubLineItemID, - usage.PriceID, - usage.MeterID, - usage.FeatureID, - usage.EventName, - usage.Source, + row := []string{ + sanitize(usage.ID), + sanitize(usage.TenantID), + sanitize(usage.EnvironmentID), + sanitize(usage.ExternalCustomerID), + sanitize(usage.CustomerID), + sanitize(usage.SubscriptionID), + sanitize(usage.SubLineItemID), + sanitize(usage.PriceID), + sanitize(usage.MeterID), + sanitize(usage.FeatureID), + sanitize(usage.EventName), + sanitize(usage.Source), @@ - string(propertiesJSON), - usage.UniqueHash, + string(propertiesJSON), + sanitize(usage.UniqueHash), }internal/temporal/workflows/export/scheduled_export_workflow.go (1)
95-118: Fix nondeterministic timestamp
time.Now()inside a workflow breaks determinism on replay. Capture the timestamp viaworkflow.Now(ctx)instead.Apply this diff:
- now := time.Now() + now := workflow.Now(ctx)internal/api/v1/scheduled_task.go (1)
51-53: Stop forcingenabledto trueThis block flips an explicit
enabled:falsepayload back to true, so clients can’t disable a task. Drop the defaulting (or switch to a*boolin the DTO and only default when nil).internal/integration/s3/client.go (2)
187-193: Remove secret logging and fix the undefinedminhelper.Line 192 calls
min(4, len(accessKey)), but no such helper exists in this file or the stdlib, so the package will not compile. At the same time, Lines 187-193 log decrypted credential lengths and prefixes, leaking sensitive material into logs. Drop the prefix/length logging and keep the message minimal to avoid exposing secrets while also eliminating themincall.- c.logger.Infow("Decrypted S3 credentials", - "connection_id", conn.ID, - "decrypted_access_key_length", len(accessKey), - "decrypted_secret_key_length", len(secretKey), - "has_session_token", sessionToken != "", - "access_key_starts_with", accessKey[:min(4, len(accessKey))], - ) + c.logger.Infow("S3 credentials decrypted", + "connection_id", conn.ID, + "has_session_token", sessionToken != "", + )
202-215: Validate required S3 job config fields before use.We only guard against a nil
jobConfig, so an empty bucket or region proceeds until AWS calls fail downstream. Add explicit validation (trimmed) forjobConfig.BucketandjobConfig.Regionand return a marked validation error when they’re missing.if jobConfig == nil { return nil, ierr.NewError("no job configuration provided"). WithHint("S3 job configuration is required"). Mark(ierr.ErrValidation) } + + if strings.TrimSpace(jobConfig.Bucket) == "" || strings.TrimSpace(jobConfig.Region) == "" { + return nil, ierr.NewError("invalid S3 job configuration"). + WithHint("bucket and region are required"). + Mark(ierr.ErrValidation) + }internal/temporal/activities/export/task_activity.go (2)
27-37: Add basic input validation (required fields, time window)
- Validate ScheduledTaskID, TenantID, EnvID, EntityType non-empty.
- Validate StartTime/EndTime set and EndTime >= StartTime.
Example:
@@ -func (a *TaskActivity) CreateTask(ctx context.Context, input CreateTaskInput) (*CreateTaskOutput, error) { +func (a *TaskActivity) CreateTask(ctx context.Context, input CreateTaskInput) (*CreateTaskOutput, error) { + if input.ScheduledTaskID == "" || input.TenantID == "" || input.EnvID == "" || input.EntityType == "" { + return nil, ierr.WithError(fmt.Errorf("missing required fields")). + WithHint("ScheduledTaskID, TenantID, EnvID, EntityType are required"). + Mark(ierr.ErrValidation) + } + if input.StartTime.IsZero() || input.EndTime.IsZero() || input.EndTime.Before(input.StartTime) { + return nil, ierr.WithError(fmt.Errorf("invalid time window")). + WithHint("Ensure StartTime/EndTime are set and EndTime >= StartTime"). + Mark(ierr.ErrValidation) + }Add import:
@@ -import ( +import ( + "fmt"Also applies to: 45-54
54-79: Make CreateTask idempotent; use UTC and set ctx tenant/env
- Retries can duplicate tasks; return existing task if it already exists by TaskID.
- Normalize timestamps to UTC; avoid setting StartedAt while status is PENDING.
- Ensure repo scoping by setting tenant/env in ctx like Update/Complete do.
@@ - // Create task with pre-generated ID - now := time.Now() + // Scope repo ops + ctx = types.SetTenantID(ctx, input.TenantID) + ctx = types.SetEnvironmentID(ctx, input.EnvID) + + // Idempotency: return existing if present + if existing, err := a.taskRepo.Get(ctx, input.TaskID); err == nil && existing != nil { + a.logger.Infow("export task already exists", "task_id", input.TaskID) + return &CreateTaskOutput{TaskID: input.TaskID}, nil + } + + // Create task with pre-generated ID + now := time.Now().UTC() @@ - Metadata: map[string]interface{}{ - "start_time": input.StartTime.Format(time.RFC3339), - "end_time": input.EndTime.Format(time.RFC3339), - }, - StartedAt: &now, + Metadata: map[string]interface{}{ + "start_time": input.StartTime.UTC().Format(time.RFC3339), + "end_time": input.EndTime.UTC().Format(time.RFC3339), + }, + StartedAt: nil, @@ - CreatedAt: now, - UpdatedAt: now, + CreatedAt: now, + UpdatedAt: now,
🧹 Nitpick comments (7)
internal/service/scheduled_task_test.go (1)
227-230: Remove unused helper.
boolPtris defined but never called in this file.-// Helper function -func boolPtr(b bool) *bool { - return &b -}internal/types/scheduled_task.go (1)
148-159: SetDefaults duplicates Validate logic.Lines 150-158 repeat the default-setting logic from
Validate()(lines 135-143). This creates maintenance burden.Refactor
Validate()to callSetDefaults():func (s *S3JobConfig) Validate() error { if s == nil { return ierr.NewError("S3 job config is required"). WithHint("S3 job configuration must be provided"). Mark(ierr.ErrValidation) } if s.Bucket == "" { return ierr.NewError("bucket is required"). WithHint("S3 bucket name is required"). Mark(ierr.ErrValidation) } if s.Region == "" { return ierr.NewError("region is required"). WithHint("AWS region is required"). Mark(ierr.ErrValidation) } // Validate compression type if provided if s.Compression != "" && s.Compression != "gzip" && s.Compression != "none" { return ierr.NewError("invalid compression type"). WithHint("Compression must be one of: gzip, none"). Mark(ierr.ErrValidation) } // Validate encryption type if provided if s.Encryption != "" && s.Encryption != "AES256" && s.Encryption != "aws:kms" { return ierr.NewError("invalid encryption type"). WithHint("Encryption must be one of: AES256, aws:kms"). Mark(ierr.ErrValidation) } + // Validate MaxFileSizeMB if provided + if s.MaxFileSizeMB < 0 { + return ierr.NewError("invalid max file size"). + WithHint("MaxFileSizeMB must be >= 0; use 0 to apply the default"). + Mark(ierr.ErrValidation) + } + - // Set defaults - if s.Compression == "" { - s.Compression = "gzip" - } - if s.Encryption == "" { - s.Encryption = "AES256" - } - if s.MaxFileSizeMB == 0 { - s.MaxFileSizeMB = 100 - } + s.SetDefaults() return nil }internal/testutil/inmemory_task_store.go (1)
100-101: Name consistency: scheduledTaskID instead of scheduledJobID.Param name differs from field ScheduledTaskID; rename for clarity.
-func (s *InMemoryTaskStore) GetLastSuccessfulExportTask(ctx context.Context, scheduledJobID string) (*task.Task, error) { +func (s *InMemoryTaskStore) GetLastSuccessfulExportTask(ctx context.Context, scheduledTaskID string) (*task.Task, error) { @@ - if t.ScheduledTaskID == scheduledJobID && + if t.ScheduledTaskID == scheduledTaskID &&ent/schema/scheduledtask.go (1)
83-98: Constrain interval/entity/status with enums or validatorsTo prevent invalid values:
- Make interval an enum (hourly/daily/weekly/monthly) or add a validator.
- Consider enums/validators for entity_type and last_run_status.
internal/service/scheduled_task_orchestrator.go (1)
52-66: Unpausing an existing schedule should also reconcile cron specIf the task’s interval changed, simply Unpause leaves the schedule at the old cadence. Patch the schedule with the current cron via handle.Update (or delete+recreate if update isn’t viable).
internal/temporal/activities/export/task_activity.go (2)
142-159: Standardize timestamps to UTC for status updates and completionUse time.Now().UTC() and record metadata timestamps in UTC for consistency.
@@ - now := time.Now() + now := time.Now().UTC() @@ - now := time.Now() + now := time.Now().UTC() @@ - existingTask.Metadata["completed_at"] = now.Format(time.RFC3339) + existingTask.Metadata["completed_at"] = now.UTC().Format(time.RFC3339)Also applies to: 208-219
173-181: Validate CompleteTask inputsReject empty FileURL and negative counts early.
@@ func (a *TaskActivity) CompleteTask(ctx context.Context, input CompleteTaskInput) error { + if input.FileURL == "" || input.RecordCount < 0 || input.FileSize < 0 { + return ierr.WithError(fmt.Errorf("invalid completion details")). + WithHint("FileURL must be set; RecordCount/FileSize must be non-negative"). + Mark(ierr.ErrValidation) + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (49)
cmd/server/main.go(10 hunks)ent/client.go(10 hunks)ent/ent.go(2 hunks)ent/hook/hook.go(1 hunks)ent/migrate/schema.go(4 hunks)ent/mutation.go(13 hunks)ent/predicate/predicate.go(1 hunks)ent/runtime.go(3 hunks)ent/scheduledtask.go(1 hunks)ent/scheduledtask/scheduledtask.go(1 hunks)ent/scheduledtask/where.go(1 hunks)ent/scheduledtask_create.go(1 hunks)ent/scheduledtask_delete.go(1 hunks)ent/scheduledtask_query.go(1 hunks)ent/scheduledtask_update.go(1 hunks)ent/schema/scheduledtask.go(1 hunks)ent/schema/task.go(1 hunks)ent/task.go(4 hunks)ent/task/task.go(4 hunks)ent/task/where.go(2 hunks)ent/task_create.go(3 hunks)ent/task_update.go(4 hunks)ent/tx.go(2 hunks)go.mod(1 hunks)internal/api/dto/scheduled_task.go(1 hunks)internal/api/router.go(3 hunks)internal/api/v1/scheduled_task.go(1 hunks)internal/api/v1/test_usage_export.go(1 hunks)internal/domain/scheduledtask/model.go(1 hunks)internal/domain/scheduledtask/repository.go(1 hunks)internal/domain/task/model.go(2 hunks)internal/integration/s3/client.go(1 hunks)internal/repository/ent/scheduledtask.go(1 hunks)internal/repository/ent/task.go(3 hunks)internal/repository/factory.go(2 hunks)internal/service/factory.go(6 hunks)internal/service/scheduled_task.go(1 hunks)internal/service/scheduled_task_orchestrator.go(1 hunks)internal/service/scheduled_task_test.go(1 hunks)internal/service/sync/export/base.go(1 hunks)internal/service/sync/export/usage_export.go(1 hunks)internal/service/task.go(1 hunks)internal/temporal/activities/export/scheduledtask_activity.go(1 hunks)internal/temporal/activities/export/task_activity.go(1 hunks)internal/temporal/registration.go(4 hunks)internal/temporal/workflows/export/execute_export_workflow.go(1 hunks)internal/temporal/workflows/export/scheduled_export_workflow.go(1 hunks)internal/testutil/inmemory_task_store.go(2 hunks)internal/types/scheduled_task.go(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
- internal/service/factory.go
- ent/schema/task.go
- ent/predicate/predicate.go
- ent/ent.go
🧰 Additional context used
🧬 Code graph analysis (44)
internal/domain/task/model.go (1)
ent/task/where.go (2)
ScheduledTaskID(113-115)WorkflowID(118-120)
internal/repository/factory.go (2)
internal/repository/ent/scheduledtask.go (1)
NewScheduledTaskRepository(23-28)internal/logger/logger.go (1)
Logger(13-15)
internal/api/router.go (5)
internal/api/v1/test_export.go (1)
TestExportHandler(12-15)internal/api/v1/test_usage_export.go (1)
TestUsageExportHandler(14-18)ent/schema/scheduledtask.go (4)
ScheduledTask(12-14)ScheduledTask(17-22)ScheduledTask(25-81)ScheduledTask(84-98)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)internal/api/v1/scheduled_task.go (1)
ScheduledTaskHandler(14-17)
internal/service/sync/export/usage_export.go (8)
internal/domain/events/feature_usage.go (1)
FeatureUsageRepository(11-32)internal/integration/s3/client.go (1)
Client(18-22)internal/logger/logger.go (1)
Logger(13-15)internal/service/sync/export/base.go (2)
ExportRequest(44-52)ExportResponse(55-61)internal/integration/s3/export.go (2)
ExportRequest(26-34)ExportResponse(37-44)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (4)
ErrInternal(22-22)ErrDatabase(20-20)ErrValidation(16-16)ErrHTTPClient(19-19)internal/types/context.go (2)
SetTenantID(60-62)SetEnvironmentID(65-67)
internal/service/sync/export/base.go (8)
internal/integration/s3/export.go (2)
ExportRequest(26-34)ExportResponse(37-44)internal/domain/events/feature_usage.go (1)
FeatureUsageRepository(11-32)internal/integration/s3/client.go (1)
Client(18-22)internal/logger/logger.go (1)
Logger(13-15)internal/types/sync_config.go (7)
ExportEntityType(37-37)ExportEntityTypeEvents(40-40)ExportEntityTypeCustomer(41-41)ExportEntityTypeInvoice(42-42)ExportEntityTypeSubscription(44-44)ExportEntityTypePrice(43-43)ExportEntityTypeCreditNote(45-45)internal/types/scheduled_task.go (1)
S3JobConfig(90-99)internal/errors/errors.go (1)
ErrValidation(16-16)internal/service/sync/export/usage_export.go (1)
NewUsageExporter(26-36)
internal/service/scheduled_task_test.go (2)
internal/api/dto/scheduled_task.go (1)
CreateScheduledTaskRequest(11-17)internal/types/scheduled_task.go (8)
ScheduledTaskEntityTypeEvents(46-46)ScheduledTaskIntervalDaily(13-13)ScheduledTaskEntityType(43-43)ScheduledTaskInterval(8-8)S3JobConfig(90-99)ScheduledTaskIntervalHourly(12-12)ScheduledTaskIntervalWeekly(14-14)ScheduledTaskIntervalMonthly(15-15)
internal/api/v1/scheduled_task.go (6)
internal/service/scheduled_task.go (1)
ScheduledTaskService(16-24)internal/logger/logger.go (1)
Logger(13-15)internal/api/dto/scheduled_task.go (2)
CreateScheduledTaskRequest(11-17)UpdateScheduledTaskRequest(20-24)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (1)
ErrValidation(16-16)internal/types/filter.go (1)
QueryFilter(61-68)
internal/api/dto/scheduled_task.go (3)
ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)internal/types/scheduled_task.go (3)
CreateScheduledTaskInput(162-168)ScheduledTaskEntityType(43-43)ScheduledTaskInterval(8-8)
internal/api/v1/test_usage_export.go (3)
internal/service/sync/export/base.go (2)
ExportService(24-28)ExportRequest(44-52)internal/domain/scheduledtask/repository.go (1)
Repository(9-36)internal/types/scheduled_task.go (2)
ScheduledTaskEntityTypeEvents(46-46)S3JobConfig(90-99)
internal/domain/scheduledtask/model.go (2)
ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)internal/types/scheduled_task.go (7)
ScheduledTaskInterval(8-8)ScheduledTaskIntervalHourly(12-12)ScheduledTaskIntervalDaily(13-13)ScheduledTaskIntervalWeekly(14-14)ScheduledTaskIntervalMonthly(15-15)ScheduledTaskIntervalTesting(11-11)S3JobConfig(90-99)
ent/hook/hook.go (2)
ent/mutation.go (1)
ScheduledTaskMutation(39342-39368)ent/ent.go (2)
Value(65-65)Mutation(76-76)
internal/domain/scheduledtask/repository.go (4)
ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/schema/scheduledtask.go (4)
ScheduledTask(12-14)ScheduledTask(17-22)ScheduledTask(25-81)ScheduledTask(84-98)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)ent/scheduledtask/where.go (6)
TenantID(68-70)EnvironmentID(98-100)ConnectionID(103-105)EntityType(108-110)Interval(113-115)Enabled(118-120)
ent/tx.go (5)
ent/predicate/predicate.go (1)
ScheduledTask(94-94)ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/schema/scheduledtask.go (4)
ScheduledTask(12-14)ScheduledTask(17-22)ScheduledTask(25-81)ScheduledTask(84-98)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)ent/client.go (2)
ScheduledTaskClient(4775-4777)NewScheduledTaskClient(4780-4782)
internal/temporal/activities/export/scheduledtask_activity.go (7)
internal/domain/scheduledtask/repository.go (1)
Repository(9-36)internal/logger/logger.go (1)
Logger(13-15)internal/types/scheduled_task.go (8)
S3JobConfig(90-99)ScheduledTaskInterval(8-8)ScheduledTaskIntervalTesting(11-11)ScheduledTaskIntervalHourly(12-12)ScheduledTaskIntervalDaily(13-13)ScheduledTaskIntervalWeekly(14-14)ScheduledTaskIntervalMonthly(15-15)UpdateScheduledTaskInput(171-175)internal/types/context.go (3)
SetTenantID(60-62)SetEnvironmentID(65-67)GetUserID(24-29)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (2)
ErrDatabase(20-20)ErrValidation(16-16)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)
internal/repository/ent/task.go (7)
ent/task/where.go (9)
ScheduledTaskID(113-115)WorkflowID(118-120)TenantID(68-70)EnvironmentID(98-100)TaskType(103-105)TaskStatus(138-140)Status(73-75)ID(13-15)CompletedAt(173-175)internal/domain/task/model.go (2)
Task(10-31)FromEnt(34-68)internal/types/context.go (2)
GetTenantID(31-36)GetEnvironmentID(52-57)ent/ent.go (4)
Querier(68-68)Query(66-66)Desc(181-190)IsNotFound(293-299)internal/types/task.go (4)
TaskType(10-10)TaskTypeExport(14-14)TaskStatus(84-84)TaskStatusCompleted(89-89)internal/types/status.go (1)
StatusPublished(10-10)ent/task/task.go (1)
FieldCompletedAt(61-61)
internal/temporal/registration.go (7)
internal/temporal/activities/export/task_activity.go (2)
NewTaskActivity(20-25)TaskActivity(14-17)internal/logger/logger.go (1)
Logger(13-15)internal/temporal/activities/export/scheduledtask_activity.go (2)
NewScheduledTaskActivity(22-32)ScheduledTaskActivity(15-19)internal/temporal/activities/export/export_activity.go (2)
NewExportActivity(23-33)ExportActivity(16-20)internal/types/temporal.go (3)
GetAllTaskQueues(122-128)TemporalTaskQueue(12-12)TemporalTaskQueueExport(18-18)internal/temporal/workflows/export/scheduled_export_workflow.go (1)
ScheduledExportWorkflow(22-132)internal/temporal/workflows/export/execute_export_workflow.go (1)
ExecuteExportWorkflow(36-159)
internal/service/scheduled_task.go (11)
internal/api/dto/scheduled_task.go (6)
CreateScheduledTaskRequest(11-17)ScheduledTaskResponse(27-43)ListScheduledTasksResponse(46-49)UpdateScheduledTaskRequest(20-24)ToScheduledTaskResponse(52-70)ToScheduledTaskListResponse(73-83)internal/types/filter.go (2)
QueryFilter(61-68)NewDefaultQueryFilter(71-79)internal/types/scheduled_task.go (3)
ScheduledTaskEntityType(43-43)ScheduledTaskInterval(8-8)S3JobConfig(90-99)internal/domain/scheduledtask/repository.go (2)
Repository(9-36)ListFilters(39-48)internal/service/scheduled_task_orchestrator.go (1)
ScheduledTaskOrchestrator(18-23)internal/logger/logger.go (1)
Logger(13-15)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (4)
ErrValidation(16-16)ErrDatabase(20-20)ErrNotFound(13-13)ErrInternal(22-22)internal/types/context.go (3)
GetTenantID(31-36)GetEnvironmentID(52-57)GetUserID(24-29)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)internal/types/uuid.go (1)
GenerateUUIDWithPrefix(19-24)
ent/runtime.go (3)
ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/schema/scheduledtask.go (4)
ScheduledTask(12-14)ScheduledTask(17-22)ScheduledTask(25-81)ScheduledTask(84-98)ent/scheduledtask/scheduledtask.go (4)
ConnectionIDValidator(100-100)EntityTypeValidator(102-102)IntervalValidator(104-104)DefaultEnabled(106-106)
ent/task/task.go (1)
ent/scheduledtask/scheduledtask.go (1)
OrderOption(110-110)
internal/temporal/activities/export/task_activity.go (8)
internal/domain/task/repository.go (1)
Repository(10-22)internal/logger/logger.go (1)
Logger(13-15)internal/domain/task/model.go (1)
Task(10-31)internal/types/task.go (5)
TaskTypeExport(14-14)TaskStatusPending(87-87)TaskStatusProcessing(88-88)TaskStatusCompleted(89-89)TaskStatusFailed(90-90)internal/types/basemodel.go (1)
BaseModel(10-17)internal/types/context.go (3)
GetUserID(24-29)SetTenantID(60-62)SetEnvironmentID(65-67)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (1)
ErrDatabase(20-20)
internal/temporal/workflows/export/scheduled_export_workflow.go (3)
internal/temporal/activities/export/scheduledtask_activity.go (4)
ScheduledTaskActivity(15-19)ScheduledTaskDetails(42-52)GetScheduledTaskDetailsInput(35-39)UpdateScheduledTaskInput(179-186)internal/types/uuid.go (1)
GenerateUUIDWithPrefix(19-24)internal/temporal/workflows/export/execute_export_workflow.go (3)
ExecuteExportWorkflowInput(14-24)ExecuteExportWorkflowOutput(27-32)ExecuteExportWorkflow(36-159)
ent/mutation.go (6)
ent/predicate/predicate.go (1)
ScheduledTask(94-94)ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/client.go (1)
Client(67-159)ent/task/where.go (11)
ID(13-15)TenantID(68-70)Status(73-75)CreatedAt(78-80)UpdatedAt(83-85)CreatedBy(88-90)UpdatedBy(93-95)EnvironmentID(98-100)EntityType(108-110)ScheduledTaskID(113-115)WorkflowID(118-120)ent/task/task.go (3)
FieldEntityType(33-33)FieldScheduledTaskID(35-35)FieldWorkflowID(37-37)ent/scheduledtask/scheduledtask.go (10)
FieldJobConfig(39-39)FieldLastRunAt(41-41)FieldNextRunAt(43-43)FieldLastRunStatus(45-45)FieldLastRunError(47-47)FieldTemporalScheduleID(49-49)FieldConnectionID(31-31)FieldEntityType(33-33)FieldInterval(35-35)FieldEnabled(37-37)
ent/migrate/schema.go (1)
ent/task/task.go (2)
Table(65-65)Columns(69-95)
internal/service/task.go (2)
ent/task/where.go (2)
UpdatedAt(83-85)UpdatedBy(93-95)internal/types/context.go (1)
GetUserID(24-29)
internal/testutil/inmemory_task_store.go (7)
ent/task/where.go (7)
ScheduledTaskID(113-115)TaskType(103-105)TaskStatus(138-140)Status(73-75)EnvironmentID(98-100)TenantID(68-70)CompletedAt(173-175)ent/task.go (2)
Task(17-70)Task(73-90)internal/domain/task/model.go (1)
Task(10-31)internal/types/task.go (4)
TaskType(10-10)TaskTypeExport(14-14)TaskStatus(84-84)TaskStatusCompleted(89-89)internal/types/status.go (1)
StatusPublished(10-10)internal/testutil/store.go (1)
CheckEnvironmentFilter(161-166)internal/types/context.go (1)
GetTenantID(31-36)
ent/task_create.go (2)
ent/task/where.go (3)
FileURL(123-125)ScheduledTaskID(113-115)WorkflowID(118-120)ent/task/task.go (3)
DefaultFileURL(125-125)FieldScheduledTaskID(35-35)FieldWorkflowID(37-37)
ent/scheduledtask_create.go (4)
ent/mutation.go (1)
ScheduledTaskMutation(39342-39368)ent/ent.go (8)
Hook(64-64)Mutation(76-76)ValidationError(258-261)IsConstraintError(366-372)ConstraintError(350-353)Value(65-65)Mutator(75-75)MutateFunc(77-77)ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/scheduledtask/scheduledtask.go (28)
DefaultStatus(90-90)DefaultCreatedAt(92-92)DefaultUpdatedAt(94-94)DefaultEnvironmentID(98-98)DefaultEnabled(106-106)TenantIDValidator(88-88)ConnectionIDValidator(100-100)EntityTypeValidator(102-102)IntervalValidator(104-104)Table(51-51)FieldID(15-15)FieldTenantID(17-17)FieldStatus(19-19)FieldCreatedAt(21-21)FieldUpdatedAt(23-23)FieldCreatedBy(25-25)FieldUpdatedBy(27-27)FieldEnvironmentID(29-29)FieldConnectionID(31-31)FieldEntityType(33-33)FieldInterval(35-35)FieldEnabled(37-37)FieldJobConfig(39-39)FieldLastRunAt(41-41)FieldNextRunAt(43-43)FieldLastRunStatus(45-45)FieldLastRunError(47-47)FieldTemporalScheduleID(49-49)
ent/scheduledtask/scheduledtask.go (1)
ent/task/task.go (14)
FieldID(15-15)FieldTenantID(17-17)FieldStatus(19-19)FieldCreatedAt(21-21)FieldUpdatedAt(23-23)FieldCreatedBy(25-25)FieldUpdatedBy(27-27)FieldEnvironmentID(29-29)FieldEntityType(33-33)ValidColumn(98-105)Columns(69-95)ByID(142-144)OrderOption(139-139)ByTenantID(147-149)
ent/task.go (2)
ent/task/where.go (2)
ScheduledTaskID(113-115)WorkflowID(118-120)ent/task/task.go (5)
FieldTaskType(31-31)FieldEntityType(33-33)FieldScheduledTaskID(35-35)FieldWorkflowID(37-37)FieldErrorSummary(55-55)
ent/scheduledtask.go (4)
ent/schema/scheduledtask.go (4)
ScheduledTask(12-14)ScheduledTask(17-22)ScheduledTask(25-81)ScheduledTask(84-98)ent/scheduledtask/scheduledtask.go (18)
FieldJobConfig(39-39)FieldEnabled(37-37)FieldID(15-15)FieldTenantID(17-17)FieldStatus(19-19)FieldCreatedBy(25-25)FieldUpdatedBy(27-27)FieldEnvironmentID(29-29)FieldConnectionID(31-31)FieldEntityType(33-33)FieldInterval(35-35)FieldLastRunStatus(45-45)FieldLastRunError(47-47)FieldTemporalScheduleID(49-49)FieldCreatedAt(21-21)FieldUpdatedAt(23-23)FieldLastRunAt(41-41)FieldNextRunAt(43-43)ent/scheduledtask_update.go (1)
ScheduledTaskUpdateOne(391-396)ent/client.go (1)
NewScheduledTaskClient(4780-4782)
ent/task_update.go (2)
ent/task/where.go (2)
ScheduledTaskID(113-115)WorkflowID(118-120)ent/task/task.go (2)
FieldScheduledTaskID(35-35)FieldWorkflowID(37-37)
ent/scheduledtask_delete.go (4)
ent/ent.go (3)
IsConstraintError(366-372)ConstraintError(350-353)NotFoundError(283-285)ent/mutation.go (1)
ScheduledTaskMutation(39342-39368)ent/predicate/predicate.go (1)
ScheduledTask(94-94)ent/scheduledtask/scheduledtask.go (3)
Table(51-51)FieldID(15-15)Label(13-13)
ent/schema/scheduledtask.go (2)
ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)
internal/integration/s3/client.go (6)
internal/security/encryption.go (1)
EncryptionService(18-27)internal/logger/logger.go (1)
Logger(13-15)internal/types/scheduled_task.go (1)
S3JobConfig(90-99)internal/domain/connection/model.go (1)
Connection(10-19)internal/errors/errors.go (4)
ErrNotFound(13-13)ErrValidation(16-16)ErrHTTPClient(19-19)ErrInternal(22-22)internal/types/secret.go (1)
SecretProviderS3(34-34)
internal/temporal/workflows/export/execute_export_workflow.go (4)
internal/types/scheduled_task.go (1)
S3JobConfig(90-99)internal/temporal/activities/export/task_activity.go (4)
CreateTaskInput(28-37)CreateTaskOutput(40-42)UpdateTaskStatusInput(97-104)CompleteTaskInput(174-181)internal/temporal/activities/export/export_activity.go (2)
ExportDataInput(36-44)ExportDataOutput(47-51)internal/types/task.go (2)
TaskStatusProcessing(88-88)TaskStatusFailed(90-90)
ent/client.go (7)
ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/schema/scheduledtask.go (4)
ScheduledTask(12-14)ScheduledTask(17-22)ScheduledTask(25-81)ScheduledTask(84-98)ent/mutation.go (2)
ScheduledTaskMutation(39342-39368)TypeScheduledTask(101-101)ent/scheduledtask_create.go (2)
ScheduledTaskCreate(17-21)ScheduledTaskCreateBulk(437-441)ent/scheduledtask_update.go (2)
ScheduledTaskUpdate(19-23)ScheduledTaskUpdateOne(391-396)ent/scheduledtask_delete.go (2)
ScheduledTaskDelete(16-20)ScheduledTaskDeleteOne(60-62)ent/scheduledtask_query.go (1)
ScheduledTaskQuery(19-28)
ent/task/where.go (4)
ent/predicate/predicate.go (1)
Task(118-118)ent/schema/task.go (5)
Task(11-13)Task(16-21)Task(24-103)Task(106-108)Task(111-120)ent/task.go (2)
Task(17-70)Task(73-90)ent/task/task.go (2)
FieldScheduledTaskID(35-35)FieldWorkflowID(37-37)
internal/service/scheduled_task_orchestrator.go (9)
internal/domain/scheduledtask/repository.go (1)
Repository(9-36)internal/logger/logger.go (1)
Logger(13-15)internal/errors/builder.go (1)
WithError(26-28)internal/types/scheduled_task.go (6)
ScheduledTaskInterval(8-8)ScheduledTaskIntervalTesting(11-11)ScheduledTaskIntervalHourly(12-12)ScheduledTaskIntervalDaily(13-13)ScheduledTaskIntervalWeekly(14-14)ScheduledTaskIntervalMonthly(15-15)internal/temporal/workflows/export/scheduled_export_workflow.go (2)
ScheduledExportWorkflow(22-132)ScheduledExportWorkflowInput(14-18)internal/types/temporal.go (1)
TemporalTaskQueueExport(18-18)internal/types/uuid.go (1)
GenerateUUIDWithPrefix(19-24)internal/temporal/workflows/export/execute_export_workflow.go (2)
ExecuteExportWorkflowInput(14-24)ExecuteExportWorkflow(36-159)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)
ent/scheduledtask_update.go (4)
ent/ent.go (7)
Hook(64-64)Mutation(76-76)ValidationError(258-261)NotFoundError(283-285)IsConstraintError(366-372)ConstraintError(350-353)Value(65-65)ent/mutation.go (1)
ScheduledTaskMutation(39342-39368)ent/predicate/predicate.go (1)
ScheduledTask(94-94)ent/scheduledtask/scheduledtask.go (24)
UpdateDefaultUpdatedAt(96-96)ConnectionIDValidator(100-100)EntityTypeValidator(102-102)IntervalValidator(104-104)Table(51-51)Columns(55-74)FieldID(15-15)FieldStatus(19-19)FieldUpdatedAt(23-23)FieldCreatedBy(25-25)FieldUpdatedBy(27-27)FieldEnvironmentID(29-29)FieldConnectionID(31-31)FieldEntityType(33-33)FieldInterval(35-35)FieldEnabled(37-37)FieldJobConfig(39-39)FieldLastRunAt(41-41)FieldNextRunAt(43-43)FieldLastRunStatus(45-45)FieldLastRunError(47-47)FieldTemporalScheduleID(49-49)Label(13-13)ValidColumn(77-84)
cmd/server/main.go (10)
internal/repository/ent/scheduledtask.go (1)
NewScheduledTaskRepository(23-28)internal/repository/factory.go (1)
NewScheduledTaskRepository(214-216)internal/service/scheduled_task.go (2)
NewScheduledTaskService(33-43)ScheduledTaskService(16-24)internal/service/scheduled_task_orchestrator.go (1)
NewScheduledTaskOrchestrator(26-38)internal/integration/s3/client.go (2)
Client(18-22)NewClient(25-35)internal/service/sync/export/base.go (2)
ExportService(24-28)NewExportService(31-41)internal/api/v1/test_export.go (1)
NewTestExportHandler(17-22)internal/api/v1/test_usage_export.go (1)
NewTestUsageExportHandler(20-30)ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)internal/temporal/client/interface.go (1)
TemporalClient(12-35)
internal/types/scheduled_task.go (2)
internal/domain/entityintegrationmapping/model.go (1)
Validate(84-141)internal/errors/errors.go (1)
ErrValidation(16-16)
ent/scheduledtask_query.go (4)
ent/ent.go (10)
QueryContext(67-67)Interceptor(70-70)NotFoundError(283-285)IsNotFound(293-299)NotSingularError(310-312)Count(207-211)AggregateFunc(193-193)Traverser(72-72)ValidationError(258-261)Query(66-66)ent/scheduledtask/scheduledtask.go (6)
OrderOption(110-110)Label(13-13)FieldID(15-15)ValidColumn(77-84)Columns(55-74)Table(51-51)ent/predicate/predicate.go (1)
ScheduledTask(94-94)ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)
internal/repository/ent/scheduledtask.go (10)
internal/postgres/client.go (1)
IClient(21-30)internal/repository/factory.go (1)
NewScheduledTaskRepository(214-216)ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)internal/types/context.go (4)
GetTenantID(31-36)GetEnvironmentID(52-57)SetTenantID(60-62)SetEnvironmentID(65-67)ent/ent.go (3)
Querier(68-68)Query(66-66)IsNotFound(293-299)ent/scheduledtask/where.go (21)
ID(13-15)ConnectionID(103-105)EntityType(108-110)Interval(113-115)Enabled(118-120)Status(73-75)CreatedBy(88-90)UpdatedBy(93-95)LastRunAt(123-125)NextRunAt(128-130)LastRunStatus(133-135)LastRunError(138-140)TemporalScheduleID(143-145)TenantID(68-70)EnvironmentID(98-100)StatusEQ(213-215)Or(1128-1130)NextRunAtIsNil(888-890)NextRunAtLTE(883-885)CreatedAt(78-80)UpdatedAt(83-85)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (2)
ErrDatabase(20-20)ErrNotFound(13-13)internal/domain/scheduledtask/repository.go (1)
ListFilters(39-48)
ent/scheduledtask/where.go (3)
ent/predicate/predicate.go (1)
ScheduledTask(94-94)ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/scheduledtask/scheduledtask.go (18)
FieldID(15-15)FieldTenantID(17-17)FieldStatus(19-19)FieldCreatedAt(21-21)FieldUpdatedAt(23-23)FieldCreatedBy(25-25)FieldUpdatedBy(27-27)FieldEnvironmentID(29-29)FieldConnectionID(31-31)FieldEntityType(33-33)FieldInterval(35-35)FieldEnabled(37-37)FieldLastRunAt(41-41)FieldNextRunAt(43-43)FieldLastRunStatus(45-45)FieldLastRunError(47-47)FieldTemporalScheduleID(49-49)FieldJobConfig(39-39)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run Tests and Lint
| // Fields of the ScheduledTask. | ||
| func (ScheduledTask) Fields() []ent.Field { | ||
| return []ent.Field{ | ||
| field.String("id"). | ||
| SchemaType(map[string]string{ | ||
| "postgres": "varchar(50)", | ||
| }). | ||
| Unique(). | ||
| Immutable(), | ||
| field.String("connection_id"). | ||
| SchemaType(map[string]string{ | ||
| "postgres": "varchar(50)", | ||
| }). | ||
| NotEmpty(). | ||
| Comment("Reference to the connection (S3, etc.) to use for export"), | ||
| field.String("entity_type"). | ||
| SchemaType(map[string]string{ | ||
| "postgres": "varchar(50)", | ||
| }). | ||
| NotEmpty(). | ||
| Comment("Entity type to export (feature_usage, customer, invoice, etc.)"), | ||
| field.String("interval"). | ||
| SchemaType(map[string]string{ | ||
| "postgres": "varchar(20)", | ||
| }). | ||
| NotEmpty(). | ||
| Comment("Schedule interval (hourly, daily, weekly, monthly)"), | ||
| field.Bool("enabled"). | ||
| Default(true). | ||
| Comment("Whether this scheduled job is active"), | ||
| field.JSON("job_config", map[string]interface{}{}). | ||
| Optional(). | ||
| Comment("Job-specific configuration (bucket, region, key_prefix, compression, etc.)"), | ||
| field.Time("last_run_at"). | ||
| Optional(). | ||
| Nillable(). | ||
| Comment("Timestamp of the last successful run"), | ||
| field.Time("next_run_at"). | ||
| Optional(). | ||
| Nillable(). | ||
| Comment("Timestamp for the next scheduled run"), | ||
| field.String("last_run_status"). | ||
| SchemaType(map[string]string{ | ||
| "postgres": "varchar(20)", | ||
| }). | ||
| Optional(). | ||
| Comment("Status of the last run (success, failed, running)"), | ||
| field.Text("last_run_error"). | ||
| Optional(). | ||
| Comment("Error message from last run if failed"), | ||
| field.String("temporal_schedule_id"). | ||
| SchemaType(map[string]string{ | ||
| "postgres": "varchar(100)", | ||
| }). | ||
| Optional(). | ||
| Comment("Temporal schedule ID for the recurring workflow"), | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add edges to emit DB FKs (Connection, Tasks backref)
Define edges so migrations create FKs:
- From ScheduledTask → Connection via connection_id (restrict delete).
- Backref to Tasks so Task’s scheduled_task_id relationship is explicit (paired with edge.From in task.go).
Apply:
+import (
+ "entgo.io/ent"
+ "entgo.io/ent/dialect/entsql"
+ "entgo.io/ent/schema/field"
+ "entgo.io/ent/schema/index"
+ baseMixin "github.com/flexprice/flexprice/ent/schema/mixin"
+ "entgo.io/ent/schema/edge"
+)
@@
func (ScheduledTask) Fields() []ent.Field { ... }
+func (ScheduledTask) Edges() []ent.Edge {
+ return []ent.Edge{
+ edge.From("connection", Connection.Type).
+ Ref("scheduled_tasks").
+ Field("connection_id").
+ Unique().
+ Annotations(entsql.OnDelete(entsql.Restrict)),
+ // Backref for tasks; pair this with edge.From(...) in ent/schema/task.go
+ edge.To("tasks", Task.Type),
+ }
+}Then add the complementary edge in ent/schema/task.go as noted in the schema.go comment.
🤖 Prompt for AI Agents
In ent/schema/scheduledtask.go lines 24-80, add an edge from ScheduledTask to
Connection using the existing connection_id field and configure it to create a
DB FK with OnDelete set to RESTRICT; also add a reciprocal edge in
ent/schema/task.go so Task has a back-reference (tasks) to ScheduledTask keyed
by scheduled_task_id (use a storage key/column mapping to scheduled_task_id) so
migrations create the proper FK relationships between scheduled_tasks,
connections, and tasks.
| // Test export | ||
| test := v1Private.Group("/test") | ||
| { | ||
| test.POST("/export", handlers.TestExport.TestExport) | ||
| test.POST("/export-usage", handlers.TestUsageExport.TestUsageExport) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test export endpoints should be admin/feature‑flag gated
Exposing “/v1/test/export” and “/v1/test/export-usage” on regular private routes is risky. Gate with API key middleware (or feature flag / dev‑only).
Minimal change:
- test := v1Private.Group("/test")
+ test := v1Private.Group("/test")
+ test.Use(middleware.APIKeyAuthMiddleware(cfg, secretService, logger))Alternatively, register only in non‑prod via config flag.
🤖 Prompt for AI Agents
internal/api/router.go around lines 508 to 513: the test export endpoints are
registered on normal private routes which exposes them to non-admin clients;
modify registration to require an admin/API‑key or dev‑only feature flag before
mounting these routes. Either wrap the test group with the existing admin/apiKey
middleware (e.g., create the group via v1Private.Group("/test",
apiKeyMiddleware) or add a conditional check that only registers these routes
when a feature flag or non‑production config is enabled; ensure the middleware
checks the admin key and returns 401/403 for unauthorized requests and that the
conditional uses the same config variable used elsewhere for dev‑only endpoints.
| client := r.client.Querier(ctx) | ||
| update := client.ScheduledTask. | ||
| UpdateOneID(task.ID). | ||
| SetConnectionID(task.ConnectionID). | ||
| SetEntityType(task.EntityType). | ||
| SetInterval(task.Interval). | ||
| SetEnabled(task.Enabled). | ||
| SetStatus(task.Status). | ||
| SetUpdatedBy(task.UpdatedBy) | ||
|
|
||
| if task.JobConfig != nil { | ||
| update = update.SetJobConfig(task.JobConfig) | ||
| } | ||
| if task.LastRunAt != nil { | ||
| update = update.SetLastRunAt(*task.LastRunAt) | ||
| } | ||
| if task.NextRunAt != nil { | ||
| update = update.SetNextRunAt(*task.NextRunAt) | ||
| } | ||
| if task.LastRunStatus != "" { | ||
| update = update.SetLastRunStatus(task.LastRunStatus) | ||
| } | ||
| if task.LastRunError != "" { | ||
| update = update.SetLastRunError(task.LastRunError) | ||
| } | ||
| if task.TemporalScheduleID != "" { | ||
| update = update.SetTemporalScheduleID(task.TemporalScheduleID) | ||
| } | ||
|
|
||
| err := update.Exec(ctx) | ||
|
|
||
| if err != nil { | ||
| if ent.IsNotFound(err) { | ||
| return ierr.NewError("scheduled task not found"). | ||
| WithHint("Scheduled task with given ID not found"). | ||
| Mark(ierr.ErrNotFound) | ||
| } | ||
| return ierr.WithError(err). | ||
| WithHint("Failed to update scheduled task"). | ||
| Mark(ierr.ErrDatabase) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard updates by tenant/environment
UpdateOneID only filters on the primary key, so a caller who knows another tenant’s task ID can mutate it. For multi-tenancy we must scope the update by tenant and environment. Please switch to an Update() builder (or add an explicit existence check) that includes TenantID and EnvironmentID, and treat “0 rows updated” as not found.
Consider applying:
- update := client.ScheduledTask.
- UpdateOneID(task.ID).
+ tenantID := types.GetTenantID(ctx)
+ environmentID := types.GetEnvironmentID(ctx)
+
+ update := client.ScheduledTask.
+ Update().
+ Where(
+ scheduledtask.ID(task.ID),
+ scheduledtask.TenantID(tenantID),
+ scheduledtask.EnvironmentID(environmentID),
+ ).
SetConnectionID(task.ConnectionID).
@@
- err := update.Exec(ctx)
+ updated, err := update.Save(ctx)
@@
- if err != nil {
+ if err != nil {
if ent.IsNotFound(err) {
return ierr.NewError("scheduled task not found").
WithHint("Scheduled task with given ID not found").
Mark(ierr.ErrNotFound)
}
return ierr.WithError(err).
WithHint("Failed to update scheduled task").
Mark(ierr.ErrDatabase)
}
+ if updated == 0 {
+ return ierr.NewError("scheduled task not found").
+ WithHint("Scheduled task with given ID not found").
+ Mark(ierr.ErrNotFound)
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| client := r.client.Querier(ctx) | |
| update := client.ScheduledTask. | |
| UpdateOneID(task.ID). | |
| SetConnectionID(task.ConnectionID). | |
| SetEntityType(task.EntityType). | |
| SetInterval(task.Interval). | |
| SetEnabled(task.Enabled). | |
| SetStatus(task.Status). | |
| SetUpdatedBy(task.UpdatedBy) | |
| if task.JobConfig != nil { | |
| update = update.SetJobConfig(task.JobConfig) | |
| } | |
| if task.LastRunAt != nil { | |
| update = update.SetLastRunAt(*task.LastRunAt) | |
| } | |
| if task.NextRunAt != nil { | |
| update = update.SetNextRunAt(*task.NextRunAt) | |
| } | |
| if task.LastRunStatus != "" { | |
| update = update.SetLastRunStatus(task.LastRunStatus) | |
| } | |
| if task.LastRunError != "" { | |
| update = update.SetLastRunError(task.LastRunError) | |
| } | |
| if task.TemporalScheduleID != "" { | |
| update = update.SetTemporalScheduleID(task.TemporalScheduleID) | |
| } | |
| err := update.Exec(ctx) | |
| if err != nil { | |
| if ent.IsNotFound(err) { | |
| return ierr.NewError("scheduled task not found"). | |
| WithHint("Scheduled task with given ID not found"). | |
| Mark(ierr.ErrNotFound) | |
| } | |
| return ierr.WithError(err). | |
| WithHint("Failed to update scheduled task"). | |
| Mark(ierr.ErrDatabase) | |
| } | |
| client := r.client.Querier(ctx) | |
| tenantID := types.GetTenantID(ctx) | |
| environmentID := types.GetEnvironmentID(ctx) | |
| update := client.ScheduledTask. | |
| Update(). | |
| Where( | |
| scheduledtask.ID(task.ID), | |
| scheduledtask.TenantID(tenantID), | |
| scheduledtask.EnvironmentID(environmentID), | |
| ). | |
| SetConnectionID(task.ConnectionID). | |
| SetEntityType(task.EntityType). | |
| SetInterval(task.Interval). | |
| SetEnabled(task.Enabled). | |
| SetStatus(task.Status). | |
| SetUpdatedBy(task.UpdatedBy) | |
| if task.JobConfig != nil { | |
| update = update.SetJobConfig(task.JobConfig) | |
| } | |
| if task.LastRunAt != nil { | |
| update = update.SetLastRunAt(*task.LastRunAt) | |
| } | |
| if task.NextRunAt != nil { | |
| update = update.SetNextRunAt(*task.NextRunAt) | |
| } | |
| if task.LastRunStatus != "" { | |
| update = update.SetLastRunStatus(task.LastRunStatus) | |
| } | |
| if task.LastRunError != "" { | |
| update = update.SetLastRunError(task.LastRunError) | |
| } | |
| if task.TemporalScheduleID != "" { | |
| update = update.SetTemporalScheduleID(task.TemporalScheduleID) | |
| } | |
| updated, err := update.Save(ctx) | |
| if err != nil { | |
| if ent.IsNotFound(err) { | |
| return ierr.NewError("scheduled task not found"). | |
| WithHint("Scheduled task with given ID not found"). | |
| Mark(ierr.ErrNotFound) | |
| } | |
| return ierr.WithError(err). | |
| WithHint("Failed to update scheduled task"). | |
| Mark(ierr.ErrDatabase) | |
| } | |
| if updated == 0 { | |
| return ierr.NewError("scheduled task not found"). | |
| WithHint("Scheduled task with given ID not found"). | |
| Mark(ierr.ErrNotFound) | |
| } |
🤖 Prompt for AI Agents
internal/repository/ent/scheduledtask.go around lines 107 to 147: UpdateOneID
only filters by primary key and allows cross-tenant mutation; replace the
UpdateOneID(...) builder with a scoped Update() builder that includes Where
clauses for the task ID plus TenantID and EnvironmentID (e.g.
.Where(ScheduledTaskIDEQ(task.ID), ScheduledTaskTenantIDEQ(task.TenantID),
ScheduledTaskEnvironmentIDEQ(task.EnvironmentID))) then apply the same Set...
calls, execute with Exec(ctx) (or Save/Update().Save(ctx) depending on your ent
version), and if the returned result indicates 0 rows affected treat it as not
found (return ierr.NewError(...).WithHint(...).Mark(ierr.ErrNotFound)); keep the
existing ent.IsNotFound handling for other errors and mark database errors as
before.
| client := r.client.Querier(ctx) | ||
| err := client.ScheduledTask. | ||
| DeleteOneID(id). | ||
| Exec(ctx) | ||
|
|
||
| if err != nil { | ||
| if ent.IsNotFound(err) { | ||
| return ierr.NewError("scheduled task not found"). | ||
| WithHint("Scheduled task with given ID not found"). | ||
| Mark(ierr.ErrNotFound) | ||
| } | ||
| return ierr.WithError(err). | ||
| WithHint("Failed to delete scheduled task"). | ||
| Mark(ierr.ErrDatabase) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ensure delete enforces tenant/environment scope
DeleteOneID ignores tenant/environment, so a user in one tenant can delete another tenant’s task if they guess the ID. Add a scoping check (e.g., Exists query or switch to Delete().Where(ID, TenantID, EnvironmentID)) before deletion, and return not found when the scoped lookup fails.
A concrete fix could look like:
- err := client.ScheduledTask.
- DeleteOneID(id).
- Exec(ctx)
+ tenantID := types.GetTenantID(ctx)
+ environmentID := types.GetEnvironmentID(ctx)
+
+ exists, err := client.ScheduledTask.
+ Query().
+ Where(
+ scheduledtask.ID(id),
+ scheduledtask.TenantID(tenantID),
+ scheduledtask.EnvironmentID(environmentID),
+ ).
+ Exist(ctx)
+ if err != nil {
+ return ierr.WithError(err).
+ WithHint("Failed to verify scheduled task before delete").
+ Mark(ierr.ErrDatabase)
+ }
+ if !exists {
+ return ierr.NewError("scheduled task not found").
+ WithHint("Scheduled task with given ID not found").
+ Mark(ierr.ErrNotFound)
+ }
+
+ err = client.ScheduledTask.DeleteOneID(id).Exec(ctx)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| client := r.client.Querier(ctx) | |
| err := client.ScheduledTask. | |
| DeleteOneID(id). | |
| Exec(ctx) | |
| if err != nil { | |
| if ent.IsNotFound(err) { | |
| return ierr.NewError("scheduled task not found"). | |
| WithHint("Scheduled task with given ID not found"). | |
| Mark(ierr.ErrNotFound) | |
| } | |
| return ierr.WithError(err). | |
| WithHint("Failed to delete scheduled task"). | |
| Mark(ierr.ErrDatabase) | |
| } | |
| client := r.client.Querier(ctx) | |
| tenantID := types.GetTenantID(ctx) | |
| environmentID := types.GetEnvironmentID(ctx) | |
| exists, err := client.ScheduledTask. | |
| Query(). | |
| Where( | |
| scheduledtask.ID(id), | |
| scheduledtask.TenantID(tenantID), | |
| scheduledtask.EnvironmentID(environmentID), | |
| ). | |
| Exist(ctx) | |
| if err != nil { | |
| return ierr.WithError(err). | |
| WithHint("Failed to verify scheduled task before delete"). | |
| Mark(ierr.ErrDatabase) | |
| } | |
| if !exists { | |
| return ierr.NewError("scheduled task not found"). | |
| WithHint("Scheduled task with given ID not found"). | |
| Mark(ierr.ErrNotFound) | |
| } | |
| err = client.ScheduledTask. | |
| DeleteOneID(id). | |
| Exec(ctx) | |
| if err != nil { | |
| if ent.IsNotFound(err) { | |
| return ierr.NewError("scheduled task not found"). | |
| WithHint("Scheduled task with given ID not found"). | |
| Mark(ierr.ErrNotFound) | |
| } | |
| return ierr.WithError(err). | |
| WithHint("Failed to delete scheduled task"). | |
| Mark(ierr.ErrDatabase) | |
| } |
| func (r *ScheduledTaskRepository) List(ctx context.Context, filters *domainst.ListFilters) ([]*domainst.ScheduledTask, error) { | ||
| client := r.client.Querier(ctx) | ||
| query := client.ScheduledTask.Query() | ||
|
|
||
| // Apply tenant and environment filters | ||
| tenantID := filters.TenantID | ||
| if tenantID == "" { | ||
| tenantID = types.GetTenantID(ctx) | ||
| } | ||
| environmentID := filters.EnvironmentID | ||
| if environmentID == "" { | ||
| environmentID = types.GetEnvironmentID(ctx) | ||
| } | ||
|
|
||
| query = query.Where( | ||
| scheduledtask.TenantID(tenantID), | ||
| scheduledtask.EnvironmentID(environmentID), | ||
| ) | ||
|
|
||
| // Apply optional filters | ||
| if filters.ConnectionID != "" { | ||
| query = query.Where(scheduledtask.ConnectionID(filters.ConnectionID)) | ||
| } | ||
| if filters.EntityType != "" { | ||
| query = query.Where(scheduledtask.EntityType(filters.EntityType)) | ||
| } | ||
| if filters.Interval != "" { | ||
| query = query.Where(scheduledtask.Interval(filters.Interval)) | ||
| } | ||
| if filters.Enabled != nil { | ||
| query = query.Where(scheduledtask.Enabled(*filters.Enabled)) | ||
| } | ||
|
|
||
| // Apply pagination | ||
| if filters.Limit > 0 { | ||
| query = query.Limit(filters.Limit) | ||
| } | ||
| if filters.Offset > 0 { | ||
| query = query.Offset(filters.Offset) | ||
| } | ||
|
|
||
| tasks, err := query.All(ctx) | ||
| if err != nil { | ||
| return nil, ierr.WithError(err). | ||
| WithHint("Failed to list scheduled tasks"). | ||
| Mark(ierr.ErrDatabase) | ||
| } | ||
|
|
||
| return r.entSliceToModel(tasks), nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle nil filter input
filters is dereferenced unconditionally, so passing nil (a perfectly reasonable default) will panic. Initialise an empty struct when filters == nil before using it.
Suggested change:
func (r *ScheduledTaskRepository) List(ctx context.Context, filters *domainst.ListFilters) ([]*domainst.ScheduledTask, error) {
- client := r.client.Querier(ctx)
+ if filters == nil {
+ filters = &domainst.ListFilters{}
+ }
+ client := r.client.Querier(ctx)🤖 Prompt for AI Agents
internal/repository/ent/scheduledtask.go around lines 174 to 223: the List
method dereferences the filters pointer without checking for nil which will
panic if callers pass nil; fix by checking if filters == nil and setting filters
= &domainst.ListFilters{} (or a local copy) before using its fields so
subsequent accesses (TenantID, EnvironmentID, ConnectionID, etc.) are safe; keep
the rest of the logic unchanged.
| // Convert QueryFilter to ListFilters | ||
| listFilters := &scheduledtask.ListFilters{ | ||
| Limit: int(*filter.Limit), | ||
| Offset: int(*filter.Offset), | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scope listing by tenant/environment to avoid data leakage
List() builds filters without TenantID/EnvironmentID, risking cross-tenant exposure.
Apply:
@@
- listFilters := &scheduledtask.ListFilters{
- Limit: int(*filter.Limit),
- Offset: int(*filter.Offset),
- }
+ listFilters := &scheduledtask.ListFilters{
+ TenantID: types.GetTenantID(ctx),
+ EnvironmentID: types.GetEnvironmentID(ctx),
+ Limit: int(*filter.Limit),
+ Offset: int(*filter.Offset),
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Convert QueryFilter to ListFilters | |
| listFilters := &scheduledtask.ListFilters{ | |
| Limit: int(*filter.Limit), | |
| Offset: int(*filter.Offset), | |
| } | |
| // Convert QueryFilter to ListFilters | |
| listFilters := &scheduledtask.ListFilters{ | |
| TenantID: types.GetTenantID(ctx), | |
| EnvironmentID: types.GetEnvironmentID(ctx), | |
| Limit: int(*filter.Limit), | |
| Offset: int(*filter.Offset), | |
| } |
🤖 Prompt for AI Agents
In internal/service/scheduled_task.go around lines 155 to 160 the conversion
from QueryFilter to ListFilters omits TenantID and EnvironmentID allowing
cross-tenant/environment listing; update the construction of
scheduledtask.ListFilters to populate TenantID and EnvironmentID from the
incoming filter (e.g. set TenantID: filter.TenantID and EnvironmentID:
filter.EnvironmentID or their pointer values), and ensure any nil/empty checks
or validation are applied before calling List() so requests without
tenant/environment are rejected or scoped appropriately.
internal/service/scheduled_task.go
Outdated
| // DeleteScheduledTask deletes a scheduled task | ||
| func (s *scheduledTaskService) DeleteScheduledTask(ctx context.Context, id string) error { | ||
| err := s.repo.Delete(ctx, id) | ||
| if err != nil { | ||
| s.logger.Errorw("failed to delete scheduled task", "id", id, "error", err) | ||
| return ierr.WithError(err). | ||
| WithHint("Failed to delete scheduled task"). | ||
| Mark(ierr.ErrDatabase) | ||
| } | ||
|
|
||
| s.logger.Infow("scheduled task deleted successfully", "id", id) | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stop/delete Temporal schedule on task deletion
Deleting only from DB leaves the Temporal schedule running and pointing to a non-existent task.
Apply:
@@
func (s *scheduledTaskService) DeleteScheduledTask(ctx context.Context, id string) error {
- err := s.repo.Delete(ctx, id)
+ if s.orchestrator != nil {
+ // Best-effort stop first
+ _ = s.orchestrator.StopScheduledTask(ctx, id)
+ // Optionally implement DeleteSchedule to remove it
+ // _ = s.orchestrator.DeleteSchedule(ctx, id)
+ }
+ err := s.repo.Delete(ctx, id)🤖 Prompt for AI Agents
In internal/service/scheduled_task.go around lines 263 to 275, the current
DeleteScheduledTask only removes the DB record but leaves the Temporal schedule
running; modify the method to first retrieve the task (or its Temporal schedule
ID) from the repository, call the Temporal client to stop/delete the associated
schedule (handle not-found as non-fatal and surface other errors with logging
and wrapping), then proceed to delete the DB record; ensure the Temporal stop is
performed before the DB delete to avoid the schedule triggering a now-missing
task and make the operation idempotent by tolerating missing schedule IDs.
| if err != nil { | ||
| logger.Error("Export workflow failed", "error", err) | ||
| lastRunStatus = "failed" | ||
| lastRunError = err.Error() | ||
| } else { | ||
| logger.Info("Scheduled export completed successfully", | ||
| "task_id", exportOutput.TaskID, | ||
| "record_count", exportOutput.RecordCount) | ||
| lastRunStatus = "success" | ||
| lastRunError = "" | ||
| } | ||
|
|
||
| // Update scheduled task's last run fields | ||
| updateInput := export.UpdateScheduledTaskInput{ | ||
| ScheduledTaskID: input.ScheduledTaskID, | ||
| TenantID: input.TenantID, | ||
| EnvID: input.EnvID, | ||
| LastRunAt: now, | ||
| LastRunStatus: lastRunStatus, | ||
| LastRunError: lastRunError, | ||
| } | ||
| err = workflow.ExecuteActivity(ctx, scheduledTaskActivity.UpdateScheduledTaskLastRun, updateInput).Get(ctx, nil) | ||
| if err != nil { | ||
| logger.Error("Failed to update scheduled task last run", "error", err) | ||
| // Continue anyway | ||
| } | ||
|
|
||
| // Return the original error if the export failed | ||
| if lastRunStatus == "failed" { | ||
| return fmt.Errorf("export workflow failed: %w", err) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve the original export error
When the child export fails, err holds that failure until the last-run update activity runs; its result overwrites err, so you end up returning export workflow failed: %!w(<nil>). Cache the child error (e.g., childErr := err) before invoking the update activity and return that cached error.
🤖 Prompt for AI Agents
internal/temporal/workflows/export/scheduled_export_workflow.go around lines 99
to 129: the code overwrites the child export error `err` when calling the update
activity, causing a nil or wrong error to be returned; before executing the
UpdateScheduledTaskLastRun activity, cache the current error (e.g., childErr :=
err) when lastRunStatus == "failed", call the update activity and ignore its
error for continuation, and when returning the failure at the end, return the
cached childErr (wrapped if desired) instead of the possibly overwritten `err`.
| // GetLastSuccessfulExportTask gets the last completed export task for a scheduled job | ||
| func (s *InMemoryTaskStore) GetLastSuccessfulExportTask(ctx context.Context, scheduledJobID string) (*task.Task, error) { | ||
| s.mu.RLock() | ||
| defer s.mu.RUnlock() | ||
|
|
||
| var lastTask *task.Task | ||
| for _, t := range s.items { | ||
| if t.ScheduledTaskID == scheduledJobID && | ||
| t.TaskType == types.TaskTypeExport && | ||
| t.TaskStatus == types.TaskStatusCompleted && | ||
| t.Status == types.StatusPublished && | ||
| CheckEnvironmentFilter(ctx, t.EnvironmentID) && | ||
| t.TenantID == types.GetTenantID(ctx) { | ||
|
|
||
| if lastTask == nil || (t.CompletedAt != nil && lastTask.CompletedAt != nil && t.CompletedAt.After(*lastTask.CompletedAt)) { | ||
| lastTask = t | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if lastTask == nil { | ||
| return nil, nil // No previous task found - not an error | ||
| } | ||
|
|
||
| return copyTask(lastTask), nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix latest-task selection when CompletedAt may be nil.
Current comparison can keep an older/nil timestamp over a newer non‑nil one.
Use a comparison that prefers non‑nil CompletedAt and picks the max:
- if lastTask == nil || (t.CompletedAt != nil && lastTask.CompletedAt != nil && t.CompletedAt.After(*lastTask.CompletedAt)) {
- lastTask = t
- }
+ if lastTask == nil ||
+ (lastTask.CompletedAt == nil && t.CompletedAt != nil) ||
+ (t.CompletedAt != nil && lastTask.CompletedAt != nil && t.CompletedAt.After(*lastTask.CompletedAt)) {
+ lastTask = t
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // GetLastSuccessfulExportTask gets the last completed export task for a scheduled job | |
| func (s *InMemoryTaskStore) GetLastSuccessfulExportTask(ctx context.Context, scheduledJobID string) (*task.Task, error) { | |
| s.mu.RLock() | |
| defer s.mu.RUnlock() | |
| var lastTask *task.Task | |
| for _, t := range s.items { | |
| if t.ScheduledTaskID == scheduledJobID && | |
| t.TaskType == types.TaskTypeExport && | |
| t.TaskStatus == types.TaskStatusCompleted && | |
| t.Status == types.StatusPublished && | |
| CheckEnvironmentFilter(ctx, t.EnvironmentID) && | |
| t.TenantID == types.GetTenantID(ctx) { | |
| if lastTask == nil || (t.CompletedAt != nil && lastTask.CompletedAt != nil && t.CompletedAt.After(*lastTask.CompletedAt)) { | |
| lastTask = t | |
| } | |
| } | |
| } | |
| if lastTask == nil { | |
| return nil, nil // No previous task found - not an error | |
| } | |
| return copyTask(lastTask), nil | |
| } | |
| // GetLastSuccessfulExportTask gets the last completed export task for a scheduled job | |
| func (s *InMemoryTaskStore) GetLastSuccessfulExportTask(ctx context.Context, scheduledJobID string) (*task.Task, error) { | |
| s.mu.RLock() | |
| defer s.mu.RUnlock() | |
| var lastTask *task.Task | |
| for _, t := range s.items { | |
| if t.ScheduledTaskID == scheduledJobID && | |
| t.TaskType == types.TaskTypeExport && | |
| t.TaskStatus == types.TaskStatusCompleted && | |
| t.Status == types.StatusPublished && | |
| CheckEnvironmentFilter(ctx, t.EnvironmentID) && | |
| t.TenantID == types.GetTenantID(ctx) { | |
| if lastTask == nil || | |
| (lastTask.CompletedAt == nil && t.CompletedAt != nil) || | |
| (t.CompletedAt != nil && lastTask.CompletedAt != nil && t.CompletedAt.After(*lastTask.CompletedAt)) { | |
| lastTask = t | |
| } | |
| } | |
| } | |
| if lastTask == nil { | |
| return nil, nil // No previous task found - not an error | |
| } | |
| return copyTask(lastTask), nil | |
| } |
🤖 Prompt for AI Agents
internal/testutil/inmemory_task_store.go around lines 100 to 125: the current
CompletedAt comparison can retain an older or nil timestamp over a newer non‑nil
one; update the selection logic so a task with a non‑nil CompletedAt is
preferred over one with nil, and when both CompletedAt are non‑nil choose the
later time (i.e., set lastTask when lastTask is nil, or when t.CompletedAt is
non‑nil and lastTask.CompletedAt is nil, or when both CompletedAt are non‑nil
and t.CompletedAt is after lastTask.CompletedAt).
…n and manual sync improvements
…interval boundary calculations
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (5)
internal/types/scheduled_task.go (2)
47-79: Missing "feature_usage" entity type support.As noted in the past review, the PR narrative and sync_config examples reference "feature_usage", but the validation only allows "events" (and other legacy types). Any "feature_usage" scheduled task will be rejected.
This issue was previously flagged. Please add:
const ScheduledTaskEntityTypeFeatureUsage ScheduledTaskEntityType = "feature_usage"- Include it in the
allowedTypesslice inValidate()- Update the hint string to include "feature_usage"
103-148: Missing validation for negative MaxFileSizeMB.As noted in the past review,
S3JobConfig.Validate()sets defaults but never guards againstMaxFileSizeMB < 0. Downstream batching logic assumes non-negative sizes.This issue was previously flagged. Please add validation to reject negative values:
if s.Encryption != "" && s.Encryption != "AES256" && s.Encryption != "aws:kms" { return ierr.NewError("invalid encryption type"). WithHint("Encryption must be one of: AES256, aws:kms"). Mark(ierr.ErrValidation) } + + // Validate MaxFileSizeMB is non-negative + if s.MaxFileSizeMB < 0 { + return ierr.NewError("invalid max file size"). + WithHint("MaxFileSizeMB must be >= 0; use 0 to apply the default"). + Mark(ierr.ErrValidation) + } // Set defaults if s.Compression == "" {internal/api/router.go (1)
508-513: Test endpoints still lack admin/API-key gating.As flagged in previous reviews, exposing
/v1/test/exportand/v1/test/export-usageon regular private routes without admin middleware is a security risk. These endpoints should be restricted to administrators or gated behind a feature flag.Apply this change to require API key authentication:
// Test export -test := v1Private.Group("/test") +test := v1Private.Group("/test") +test.Use(middleware.APIKeyAuthMiddleware(cfg, secretService, logger)) { test.POST("/export", handlers.TestExport.TestExport) test.POST("/export-usage", handlers.TestUsageExport.TestUsageExport) }Alternatively, only register these routes in non-production environments via config check.
internal/service/scheduled_task.go (2)
82-88: Missing tenant/environment validation creates orphaned task risk.Retrieving
tenantIDandenvIDfrom context without validating they're non-empty allows tasks to be created with empty identifiers, potentially causing cross-tenant data leakage or orphaned records.Add validation guards:
tenantID := types.GetTenantID(ctx) envID := types.GetEnvironmentID(ctx) +if tenantID == "" || envID == "" { + return nil, ierr.NewError("missing tenant or environment in context"). + WithHint("Request context must include tenant_id and environment_id"). + Mark(ierr.ErrValidation) +} s.logger.Infow("creating scheduled task",
155-160: List operation missing tenant/environment scoping exposes cross-tenant data.Building
listFilterswithout includingTenantIDandEnvironmentIDfrom context allows users to retrieve scheduled tasks across tenants and environments, creating a critical security vulnerability.Scope the list operation:
+tenantID := types.GetTenantID(ctx) +envID := types.GetEnvironmentID(ctx) + // Convert QueryFilter to ListFilters listFilters := &scheduledtask.ListFilters{ + TenantID: tenantID, + EnvironmentID: envID, Limit: int(*filter.Limit), Offset: int(*filter.Offset), }
🧹 Nitpick comments (4)
internal/domain/scheduledtask/model.go (1)
102-110: Consider validating against negative MaxFileSizeMB.The float64-to-int conversion is standard for JSON parsing, but
GetS3JobConfig()doesn't guard against negativeMaxFileSizeMBvalues. WhileS3JobConfig.Validate()is called at line 109, a check of that method shows it also doesn't reject negative values. Downstream batching logic likely assumes non-negative sizes.Consider adding a validation check in
S3JobConfig.Validate()(called at line 109) to reject negativeMaxFileSizeMBvalues, similar to the past review comment oninternal/types/scheduled_task.go.internal/api/dto/scheduled_task.go (1)
13-19: Consider using pointer for Enabled field to distinguish absent vs. false.The
Enabledfield (line 17) is aboolwith nobinding:"required"tag, so it defaults tofalsewhen not provided in the JSON payload. Line 98 comments "default will be handled by service if needed," but the service cannot distinguish between:
- User explicitly sent
"enabled": false- User omitted the
enabledfield (defaults tofalse)This may cause confusion if the intended default is
truewhen not specified.Consider changing the field to
Enabled *boolwithjson:"enabled,omitempty"to make the absence explicit, allowing the service to apply a true default when the field is nil:type CreateScheduledTaskRequest struct { ConnectionID string `json:"connection_id" binding:"required"` EntityType string `json:"entity_type" binding:"required"` Interval string `json:"interval" binding:"required"` - Enabled bool `json:"enabled"` + Enabled *bool `json:"enabled,omitempty"` JobConfig map[string]interface{} `json:"job_config" binding:"required"` }Then in
ToCreateInput():func (r *CreateScheduledTaskRequest) ToCreateInput() *types.CreateScheduledTaskInput { - enabled := r.Enabled // default will be handled by service if needed + enabled := true // default to true if not provided + if r.Enabled != nil { + enabled = *r.Enabled + } return &types.CreateScheduledTaskInput{Also applies to: 96-106
internal/temporal/registration.go (1)
34-41: Safe to pass nil temporal client; extract boundary logic or add nil guards
CalculateIntervalBoundaries (internal/service/scheduled_task_orchestrator.go:308–390) doesn’t reference o.temporalClient, so passing nil is safe. To future-proof against potential client usage, extract this time-calculation into a standalone pure function or add explicit nil checks before using temporalClient.internal/api/v1/scheduled_task.go (1)
193-231: Lenient JSON binding for optional request body.The TriggerForceRun handler treats JSON binding failures as "no custom range provided" and continues with automatic calculation. While this works for optional bodies, it may silently ignore malformed JSON when the user intended to provide custom times.
Consider explicitly checking for empty body vs. malformed JSON:
// Parse request body (optional) var req dto.TriggerForceRunRequest -// Try to bind JSON - if empty body or no JSON, continue with automatic time calculation -if err := c.ShouldBindJSON(&req); err != nil { - // Empty body or invalid JSON - use automatic calculation +// Check if body is empty +if c.Request.ContentLength == 0 { + // Empty body - use automatic calculation h.logger.Debugw("no custom time range provided, using automatic calculation", "id", id) req = dto.TriggerForceRunRequest{} // Empty request for automatic +} else if err := c.ShouldBindJSON(&req); err != nil { + // Malformed JSON - return error + h.logger.Errorw("invalid JSON in force run request", "id", id, "error", err) + c.Error(ierr.WithError(err). + WithHint("Invalid JSON format"). + Mark(ierr.ErrValidation)) + return } else { // Validate the requestThis makes the intent clearer and catches malformed JSON as an error.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (10)
internal/api/dto/scheduled_task.go(1 hunks)internal/api/router.go(3 hunks)internal/api/v1/scheduled_task.go(1 hunks)internal/domain/scheduledtask/model.go(1 hunks)internal/service/scheduled_task.go(1 hunks)internal/service/scheduled_task_orchestrator.go(1 hunks)internal/service/scheduled_task_orchestrator_test.go(1 hunks)internal/temporal/activities/export/scheduledtask_activity.go(1 hunks)internal/temporal/registration.go(4 hunks)internal/types/scheduled_task.go(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (10)
internal/domain/scheduledtask/model.go (3)
ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/schema/scheduledtask.go (4)
ScheduledTask(12-14)ScheduledTask(17-22)ScheduledTask(25-81)ScheduledTask(84-98)internal/types/scheduled_task.go (8)
ScheduledTaskInterval(8-8)ScheduledTaskIntervalHourly(12-12)ScheduledTaskIntervalDaily(13-13)ScheduledTaskIntervalWeekly(14-14)ScheduledTaskIntervalMonthly(15-15)ScheduledTaskIntervalYearly(16-16)ScheduledTaskIntervalTesting(11-11)S3JobConfig(92-101)
internal/service/scheduled_task_orchestrator_test.go (4)
internal/config/config.go (1)
Configuration(18-40)internal/logger/logger.go (1)
NewLogger(21-39)internal/service/scheduled_task_orchestrator.go (1)
ScheduledTaskOrchestrator(18-23)internal/types/scheduled_task.go (7)
ScheduledTaskInterval(8-8)ScheduledTaskIntervalHourly(12-12)ScheduledTaskIntervalDaily(13-13)ScheduledTaskIntervalWeekly(14-14)ScheduledTaskIntervalMonthly(15-15)ScheduledTaskIntervalYearly(16-16)ScheduledTaskIntervalTesting(11-11)
internal/temporal/registration.go (5)
internal/temporal/activities/export/task_activity.go (2)
NewTaskActivity(20-25)TaskActivity(14-17)internal/service/scheduled_task_orchestrator.go (1)
NewScheduledTaskOrchestrator(26-38)internal/temporal/activities/export/scheduledtask_activity.go (2)
NewScheduledTaskActivity(28-40)ScheduledTaskActivity(20-25)internal/temporal/activities/export/export_activity.go (2)
NewExportActivity(23-33)ExportActivity(16-20)internal/types/temporal.go (3)
GetAllTaskQueues(122-128)TemporalTaskQueue(12-12)TemporalTaskQueueExport(18-18)
internal/service/scheduled_task_orchestrator.go (9)
internal/domain/scheduledtask/repository.go (1)
Repository(9-36)internal/logger/logger.go (1)
Logger(13-15)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (3)
ErrDatabase(20-20)ErrInternal(22-22)ErrValidation(16-16)internal/types/scheduled_task.go (7)
ScheduledTaskInterval(8-8)ScheduledTaskIntervalTesting(11-11)ScheduledTaskIntervalHourly(12-12)ScheduledTaskIntervalDaily(13-13)ScheduledTaskIntervalWeekly(14-14)ScheduledTaskIntervalMonthly(15-15)ScheduledTaskIntervalYearly(16-16)internal/temporal/workflows/export/scheduled_export_workflow.go (2)
ScheduledExportWorkflow(22-132)ScheduledExportWorkflowInput(14-18)internal/types/temporal.go (1)
TemporalTaskQueueExport(18-18)internal/types/uuid.go (1)
GenerateUUIDWithPrefix(19-24)internal/temporal/workflows/export/execute_export_workflow.go (2)
ExecuteExportWorkflowInput(14-24)ExecuteExportWorkflow(36-159)
internal/api/dto/scheduled_task.go (4)
internal/errors/errors.go (1)
ErrValidation(16-16)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)internal/types/scheduled_task.go (3)
CreateScheduledTaskInput(164-170)ScheduledTaskEntityType(45-45)ScheduledTaskInterval(8-8)internal/validator/validator.go (1)
ValidateRequest(35-52)
internal/api/router.go (6)
internal/api/v1/test_export.go (1)
TestExportHandler(12-15)internal/api/v1/test_usage_export.go (1)
TestUsageExportHandler(14-18)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)ent/scheduledtask.go (2)
ScheduledTask(17-56)ScheduledTask(59-76)ent/schema/scheduledtask.go (4)
ScheduledTask(12-14)ScheduledTask(17-22)ScheduledTask(25-81)ScheduledTask(84-98)internal/api/v1/scheduled_task.go (1)
ScheduledTaskHandler(14-17)
internal/api/v1/scheduled_task.go (6)
internal/service/scheduled_task.go (1)
ScheduledTaskService(16-24)internal/logger/logger.go (1)
Logger(13-15)internal/api/dto/scheduled_task.go (3)
CreateScheduledTaskRequest(13-19)UpdateScheduledTaskRequest(23-25)TriggerForceRunRequest(109-112)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (1)
ErrValidation(16-16)internal/types/filter.go (1)
QueryFilter(61-68)
internal/service/scheduled_task.go (11)
internal/api/dto/scheduled_task.go (8)
CreateScheduledTaskRequest(13-19)ScheduledTaskResponse(38-54)ListScheduledTasksResponse(57-60)UpdateScheduledTaskRequest(23-25)TriggerForceRunRequest(109-112)TriggerForceRunResponse(169-175)ToScheduledTaskResponse(63-81)ToScheduledTaskListResponse(84-94)internal/types/filter.go (2)
QueryFilter(61-68)NewDefaultQueryFilter(71-79)internal/types/scheduled_task.go (1)
S3JobConfig(92-101)internal/domain/scheduledtask/repository.go (2)
Repository(9-36)ListFilters(39-48)internal/service/scheduled_task_orchestrator.go (1)
ScheduledTaskOrchestrator(18-23)internal/logger/logger.go (1)
Logger(13-15)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (4)
ErrValidation(16-16)ErrDatabase(20-20)ErrNotFound(13-13)ErrInternal(22-22)internal/types/context.go (3)
GetTenantID(31-36)GetEnvironmentID(52-57)GetUserID(24-29)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)internal/types/uuid.go (1)
GenerateUUIDWithPrefix(19-24)
internal/temporal/activities/export/scheduledtask_activity.go (7)
internal/types/scheduled_task.go (2)
ScheduledTaskInterval(8-8)S3JobConfig(92-101)internal/domain/scheduledtask/repository.go (1)
Repository(9-36)internal/logger/logger.go (1)
Logger(13-15)internal/types/context.go (3)
SetTenantID(60-62)SetEnvironmentID(65-67)GetUserID(24-29)internal/errors/builder.go (1)
WithError(26-28)internal/errors/errors.go (2)
ErrDatabase(20-20)ErrValidation(16-16)internal/domain/scheduledtask/model.go (1)
ScheduledTask(10-29)
internal/types/scheduled_task.go (4)
internal/domain/entityintegrationmapping/model.go (1)
Validate(84-141)internal/errors/errors.go (1)
ErrValidation(16-16)ent/scheduledtask/where.go (4)
ConnectionID(103-105)EntityType(108-110)Interval(113-115)Enabled(118-120)ent/task/where.go (1)
EntityType(108-110)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run Tests and Lint
🔇 Additional comments (26)
internal/temporal/registration.go (1)
83-97: LGTM: Export task queue registration.The export workflows and activities are properly registered for the
TemporalTaskQueueExporttask queue. The registration includes:
- Workflows:
ScheduledExportWorkflow,ExecuteExportWorkflow- Activities: Task management, scheduled task updates, and export data operations
internal/domain/scheduledtask/model.go (3)
31-34: LGTM: IsEnabled logic.The method correctly checks both the
Enabledflag and ensuresStatus == "published"before considering the task enabled. This provides a clear publishing workflow.
36-49: LGTM: IsDue logic.The due-check logic is correct:
- Returns false if not enabled
- Returns true if never run (NextRunAt is nil)
- Compares current time against NextRunAt using After/Equal
51-70: LGTM: CalculateNextRunTime.The interval-based next run calculation correctly handles all supported intervals with a sensible default to daily. The testing interval (10 minutes) provides good support for development/testing workflows.
internal/service/scheduled_task_orchestrator.go (6)
40-123: LGTM: StartScheduledTask implementation.The method properly:
- Handles existing schedules by unpausing them (lines 52-66)
- Creates new Temporal schedules with correct spec and action (lines 68-103)
- Updates the task with the schedule ID and rolls back on failure (lines 109-119)
- Uses comprehensive error handling with proper error wrapping
125-153: LGTM: StopScheduledTask implementation.The method correctly pauses the Temporal schedule with proper error handling. It gracefully handles cases where no schedule exists.
155-185: LGTM: DeleteScheduledTask implementation.The method properly deletes the Temporal schedule with appropriate logging and error handling.
213-237: Note: Custom time ranges bypass interval boundary alignment.When custom
startTimeandendTimeare provided (lines 213-217), the method sets mode to "custom" without validating that the times align with interval boundaries. This may be intentional to provide flexibility, but it means force runs with custom times might export data that doesn't match the regular scheduled intervals.Confirm this is the intended behavior. If users should only export data aligned to interval boundaries, consider adding validation or at least documenting this behavior clearly.
286-304: LGTM: getCronExpression mapping.The cron expression mapping is correct for all supported intervals:
- Testing: Every 10 minutes
- Hourly: Every hour at :00
- Daily: Midnight every day
- Weekly: Monday at midnight
- Monthly: 1st of month at midnight
- Yearly: Jan 1st at midnight
- Default: Daily
306-390: LGTM: CalculateIntervalBoundaries implementation.The interval boundary calculations correctly align data to natural time boundaries for all supported intervals. The weekly calculation properly handles the Sunday edge case (lines 347-351).
internal/types/scheduled_task.go (2)
19-42: LGTM: ScheduledTaskInterval validation.The validation properly checks for empty values and ensures the interval is within the allowed set of values. The error messages are clear and actionable.
150-161: LGTM: SetDefaults implementation.The method correctly applies default values for Compression, Encryption, and MaxFileSizeMB when they are unset.
internal/api/dto/scheduled_task.go (4)
27-35: LGTM: UpdateScheduledTaskRequest validation.The validation properly checks that the
enabledfield is provided (not nil) with a clear, actionable error message.
114-166: LGTM: Comprehensive TriggerForceRunRequest validation.The validation logic is thorough and well-structured:
- Ensures both
StartTimeandEndTimeare provided together or neither (lines 120-125)- Validates chronological order (lines 128-137)
- Prevents future end times (lines 139-149)
- Enforces a maximum 1-year range (lines 151-163)
- Provides detailed error messages with reportable details
62-81: LGTM: ToScheduledTaskResponse conversion.The conversion correctly maps all fields from the domain
ScheduledTaskto the API response DTO.
83-94: LGTM: ToScheduledTaskListResponse conversion.The list conversion properly iterates through domain tasks and includes the total count.
internal/service/scheduled_task_orchestrator_test.go (3)
14-176: Test coverage looks solid.The table-driven tests comprehensively cover interval boundary calculations across all interval types (Testing, Hourly, Daily, Weekly, Monthly, Yearly) with both standard cases and boundary conditions. The assertions correctly verify that start < end and current time falls within the calculated boundaries.
178-235: Edge cases are well covered.The edge-case tests appropriately verify year boundaries, leap year handling, and month transitions. These are critical scenarios for time-based scheduling.
237-289: Cron expression mapping is verified.The test validates all interval-to-cron mappings, ensuring the orchestrator generates correct cron expressions for Temporal schedules.
internal/api/router.go (1)
334-343: Scheduled task routes are properly structured.The new scheduled task endpoints under
/v1/tasks/scheduledfollow RESTful conventions and are appropriately nested under the tasks group with standard CRUD operations plus a sync endpoint.internal/api/v1/scheduled_task.go (1)
40-58: Handler correctly delegates validation to service layer.Request binding and error handling follow the established pattern, with validation properly delegated to the service.
internal/service/scheduled_task.go (2)
272-329: Delete operation now properly cleans up Temporal schedules.The implementation correctly archives the task in the database and then attempts to delete the Temporal schedule, with appropriate error handling that logs but doesn't fail the operation if Temporal cleanup fails. The non-fatal treatment of Temporal errors is reasonable since the task is already archived in the DB.
176-269: Update rollback on Temporal failures ensures consistency.When Temporal schedule pause/resume operations fail, the code correctly rolls back the database change to maintain consistency between DB state and scheduler state. This prevents drift between the two systems.
internal/temporal/activities/export/scheduledtask_activity.go (3)
63-113: Task details retrieval is well-structured.The activity properly enriches the context with tenant/environment IDs, retrieves the task with error handling, parses job config, calculates time ranges, and returns structured details. The logging is comprehensive and aids debugging.
115-169: Incremental sync implementation is robust.The
calculateStartTimemethod implements a sensible incremental sync strategy:
- Attempts to use the last successful export's end_time as the new start_time for continuity
- Falls back to interval-based boundary alignment for the first run or when previous task metadata is unavailable
- Logs decisions clearly for observability
This approach prevents data gaps and reduces redundant data exports.
182-225: Last run update correctly maintains scheduling state.The activity updates all relevant fields (
LastRunAt,LastRunStatus,LastRunError) and recalculatesNextRunAtbased on the task's interval. This keeps the scheduling system synchronized with execution results.
Important
Introduces a new import/export feature for scheduled jobs, enabling data export to S3 with comprehensive support through services, activities, workflows, and types.
ScheduledJobServiceinscheduled_job.gofor managing scheduled jobs, including creation, retrieval, update, deletion, and manual sync triggering.ScheduledJobOrchestratorinscheduled_job_orchestrator.goto manage Temporal schedules for jobs.ExportServiceinsync/export/base.goto handle export operations for different entity types.UsageExporterinsync/export/usage_export.gofor exporting feature usage data to S3.temporal/registration.gofor export operations.ExecuteExportWorkflowandScheduledExportWorkflowinworkflows/exportfor handling export logic.ExportActivity,ScheduledJobActivity, andTaskActivityinactivities/exportfor managing export tasks and job details.types/scheduled_job.goandtypes/sync_config.gofor scheduled job intervals, entity types, and S3 configurations.types/temporal.goto include new task queues and workflow types for export operations.This description was created by
for 1729c13. You can customize this summary. It will automatically update as commits are pushed.
Summary by CodeRabbit
New Features
Improvements
Bug Fixes
Documentation