Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@pratham-ez
Copy link
Contributor

@pratham-ez pratham-ez commented Oct 13, 2025

Important

Introduces a new import/export feature for scheduled jobs, enabling data export to S3 with comprehensive support through services, activities, workflows, and types.

  • Scheduled Job Service:
    • Adds ScheduledJobService in scheduled_job.go for managing scheduled jobs, including creation, retrieval, update, deletion, and manual sync triggering.
    • Implements ScheduledJobOrchestrator in scheduled_job_orchestrator.go to manage Temporal schedules for jobs.
  • Export Service:
    • Introduces ExportService in sync/export/base.go to handle export operations for different entity types.
    • Implements UsageExporter in sync/export/usage_export.go for exporting feature usage data to S3.
  • Temporal Workflows and Activities:
    • Registers new workflows and activities in temporal/registration.go for export operations.
    • Defines ExecuteExportWorkflow and ScheduledExportWorkflow in workflows/export for handling export logic.
    • Adds ExportActivity, ScheduledJobActivity, and TaskActivity in activities/export for managing export tasks and job details.
  • Types and Configurations:
    • Defines new types in types/scheduled_job.go and types/sync_config.go for scheduled job intervals, entity types, and S3 configurations.
    • Updates types/temporal.go to include new task queues and workflow types for export operations.

This description was created by Ellipsis for 1729c13. You can customize this summary. It will automatically update as commits are pushed.

Summary by CodeRabbit

  • New Features

    • Scheduled exports to S3: CRUD for scheduled tasks, manual force-run, Temporal workflows/activities for export task lifecycle, and test endpoints to upload sample CSV and run usage export (responses include file URL, counts, sizes, timestamps).
  • Improvements

    • Full S3 integration: credential decryption, configurable export settings, object key/layout, compression/encryption; incremental and interval-based export timing with tenant override for analytics path.
  • Bug Fixes

    • Reduced use of expired prices in analytics; improved transactional handling and Stripe mapping logic.
  • Documentation

    • Added S3 integration guide.

@coderabbitai
Copy link

coderabbitai bot commented Oct 13, 2025

Walkthrough

Adds 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

Cohort / File(s) Summary
Server wiring & DI
cmd/server/main.go, internal/service/factory.go, internal/repository/factory.go, go.mod
Wire ExportService, S3/Temporal providers and ScheduledTask repository/service/orchestrator into DI graph; expose Temporal SDK client; add AWS credentials dependency.
S3 integration
internal/integration/s3/*
New S3 integration: config builder, credential decryption, s3 client wrapper, UploadFile/UploadCSV/UploadJSON, validation, object key/URL generation, connection validation, README.
API routing & handlers
internal/api/router.go, internal/api/v1/test_export.go, internal/api/v1/test_usage_export.go, internal/api/v1/scheduled_task.go, internal/api/dto/scheduled_task.go, internal/api/dto/connection.go, internal/api/v1/events.go
Add test export and test-usage endpoints, scheduled-task CRUD/force-run handlers/routes and DTOs; convert flat connection metadata ↔ S3 metadata; conditional analytics routing via feature flag + tenant override.
Scheduled Task domain & service
internal/domain/scheduledtask/*, internal/service/scheduled_task*.go, internal/service/scheduled_task_orchestrator.go, internal/service/scheduled_task_test.go
New ScheduledTask domain model, repository interface, Ent-backed repo, service (CRUD/list/trigger), orchestrator that manages Temporal schedules and force-run triggering, plus unit tests.
Temporal workflows & activities (export)
internal/temporal/activities/export/*, internal/temporal/workflows/export/*, internal/temporal/registration.go, internal/types/temporal.go
Add ExportActivity, TaskActivity, ScheduledTaskActivity, ExecuteExport and ScheduledExport workflows, register export task queue and activities, update worker config/registration.
Ent schema/models (ScheduledTask)
ent/scheduledtask*, ent/schema/scheduledtask.go, ent/hook/hook.go, ent/ent.go, ent/client.go, ent/tx.go, ent/predicate/predicate.go, ent/runtime.go, ent/migrate/schema.go
Generated Ent scheduled_task entity: model, predicates, create/query/update/delete builders, runtime wiring, client/tx integration, and migration schema; hook adapter added.
Task entity extensions
ent/schema/task.go, ent/task.go, ent/task/task.go, ent/task_create.go, ent/task_update.go, ent/task/where.go, internal/domain/task/model.go
Add scheduled_task_id and workflow_id fields to Task schema and domain, update scanning/assignment, CRUD builders, predicates, and query helpers; file_url defaulting adjusted.
Connection metadata & types
internal/types/secret.go, internal/types/connection.go, internal/types/sync_config.go, internal/domain/connection/model.go, internal/repository/ent/connection.go
Add S3 secret provider/type, S3ConnectionMetadata and S3ExportConfig types/validation, conversion routines flat↔structured, and SyncConfig S3 fields.
Feature usage export path
internal/service/sync/export/*, internal/repository/clickhouse/feature_usage.go, internal/domain/events/feature_usage.go
New ExportService and UsageExporter (batched CSV generation and S3 upload), repository method GetFeatureUsageForExport and interface extension.
Repositories (Ent + in-memory)
internal/repository/ent/scheduledtask.go, internal/repository/ent/task.go, internal/testutil/inmemory_task_store.go
New Ent ScheduledTaskRepository (CRUD, list, due queries, UpdateLastRun), Task repo adds GetLastSuccessfulExportTask and nillable setters; in-memory store implements same.
Feature flags & config
internal/config/config.go, internal/config/config.yaml, internal/api/v1/events.go
Add force_v1_for_tenant feature-flag and tenant-based override to analytics routing; keep existing feature flag.
Stripe integration adjustments
internal/integration/stripe/customer.go, internal/integration/stripe/subscription.go
Refactor customer mapping creation, wrap subscription update flows in transactions and add helper to avoid nested transactions.
Validation tweaks for Connection
ent/connection_create.go, ent/connection_update.go
Validate SyncConfig via its Validate() during connection create/update flows.
Internal integration factory
internal/integration/factory.go
Add storage provider accessors (GetStorageProvider, GetS3Client) and cache S3 client.
Misc service/task update
internal/service/task.go, internal/domain/task/model.go
Task status updates now set UpdatedAt and UpdatedBy; domain Task gains UpdatedAt/UpdatedBy fields.

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)
Loading
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
Loading
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}
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

need-db-migration

Suggested reviewers

  • nkmishra1997
  • Manish-FP

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title Check ⚠️ Warning The title “Feat/import export” does not conform to the required “type(module): message” convention: it uses a slash instead of parentheses, omits a module, lacks a colon before the description, and capitalizes the type rather than using lowercase. Please rename the PR title to follow the “type(module): message” format using lowercase for the type, a valid module in parentheses, a colon, and an imperative message, for example “feat(export): add scheduled task export to S3”.
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 1153e34 and 6ad0706.

📒 Files selected for processing (6)
  • cmd/server/main.go (10 hunks)
  • internal/api/v1/test_export.go (1 hunks)
  • internal/service/factory.go (4 hunks)
  • internal/service/sync/export/base.go (1 hunks)
  • internal/service/sync/export/usage_export.go (1 hunks)
  • internal/temporal/activities/export/export_activity.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/service/factory.go
🧰 Additional context used
🧬 Code graph analysis (5)
internal/api/v1/test_export.go (3)
internal/integration/factory.go (1)
  • Factory (22-34)
internal/logger/logger.go (1)
  • Logger (13-15)
internal/types/scheduled_task.go (1)
  • S3JobConfig (92-101)
internal/temporal/activities/export/export_activity.go (9)
internal/integration/factory.go (1)
  • Factory (22-34)
internal/logger/logger.go (1)
  • Logger (13-15)
internal/types/sync_config.go (2)
  • ExportEntityType (37-37)
  • ExportEntityTypeEvents (40-40)
internal/types/scheduled_task.go (1)
  • S3JobConfig (92-101)
internal/types/context.go (2)
  • SetTenantID (60-62)
  • SetEnvironmentID (65-67)
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/service/sync/export/usage_export.go (1)
  • NewUsageExporter (26-36)
internal/errors/errors.go (1)
  • ErrValidation (16-16)
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/service/sync/export/base.go (2)
  • ExportService (24-28)
  • NewExportService (31-41)
internal/api/v1/test_usage_export.go (1)
  • NewTestUsageExportHandler (20-30)
ent/scheduledtask.go (2)
  • ScheduledTask (17-56)
  • ScheduledTask (59-76)
internal/api/v1/scheduled_task.go (1)
  • NewScheduledTaskHandler (19-27)
internal/api/router.go (1)
  • Handlers (16-60)
internal/temporal/client/interface.go (1)
  • TemporalClient (12-35)
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/factory.go (1)
  • Factory (22-34)
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 (92-101)
internal/errors/errors.go (1)
  • ErrValidation (16-16)
internal/service/sync/export/usage_export.go (1)
  • NewUsageExporter (26-36)
internal/service/sync/export/usage_export.go (8)
internal/domain/events/feature_usage.go (1)
  • FeatureUsageRepository (11-32)
internal/integration/factory.go (1)
  • Factory (22-34)
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)
⏰ 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 (4)
internal/temporal/activities/export/export_activity.go (1)

15-113: LGTM!

The implementation is well-structured with:

  • Proper context enrichment for tenant/environment IDs
  • Clean entity type routing via switch statement
  • Comprehensive logging for export lifecycle
  • Appropriate error handling and propagation
  • Extensible design with placeholder comments for future entity types
internal/service/sync/export/base.go (1)

14-107: LGTM!

The export framework design is solid:

  • Clean Exporter interface with PrepareData and Export methods
  • Proper service-level routing via getExporter
  • Appropriate validation and error handling
  • Placeholder TODO comments for future exporters are reasonable
cmd/server/main.go (1)

17-17: LGTM!

The dependency injection and server wiring changes are well-structured:

  • Proper import aliases to avoid naming conflicts
  • Consistent provider function patterns (provideExportService, provideTemporalSDKClient)
  • Correct repository and service registration sequence
  • Handler wiring follows existing conventions
  • Raw Temporal client exposure via GetRawClient() enables advanced use cases

Also applies to: 41-41, 52-52, 170-170, 189-189, 226-227, 237-237, 297-299, 339-341, 349-351, 388-390

internal/service/sync/export/usage_export.go (1)

225-225: Code is correct; review comment references wrong line.

The original review incorrectly cites line 50 as the GetS3Client method signature, but line 50 contains only writer := csv.NewWriter(&buf). The actual method definition is at internal/integration/s3/client.go:55:

func (c *Client) GetS3Client(ctx context.Context, jobConfig *types.S3JobConfig, connectionID ...string) (*s3Client, *S3Config, error)

The call at line 225 correctly passes all three parameters (ctx, request.JobConfig, request.ConnectionID) to match this signature. The connectionID parameter is variadic and optional, allowing both two-parameter and three-parameter calls (as seen in test_export.go:50).


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@ellipsis-dev ellipsis-dev bot left a 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 12013 lines of code in 69 files
  • Skipped 0 files when reviewing.
  • Skipped posting 12 draft 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 with SecretProviderS3 and references to S3 elsewhere, 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 Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

Copy link

@coderabbitai coderabbitai bot left a 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

fetchStripeSubscription expands items.data.price.product, but isPlanChange reads Items[0].Plan.Product.ID. Plan can be nil and isn’t expanded, causing panics and false negatives. Compare against Price.Product.ID instead 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 new S3 block, callers that relied on SyncConfig.Validate() (e.g. connection create/update flows) will happily persist an invalid S3 export config, because S3.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-miss

Treating 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 ID

Use 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 path

The 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 to SubscriptionService.CreateSubscription(...).


372-385: Guard against missing/partial price info in billing-period calc

If Items or Price.Recurring is 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 the externalID assignment for clarity.

The externalID variable is assigned twice:

  • Line 204: Set to stripeCustomer.ID
  • Line 219: Overwritten with lookupKey if it exists

While 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 = lookupKey
internal/repository/clickhouse/feature_usage.go (1)

1393-1422: Consider cursor-based pagination for better performance.

Using OFFSET for pagination in ClickHouse, especially with ORDER 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 a WHERE 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 data

file_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.id

Enforcing 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 check

Unique(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 fields

Returning 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 values

Using 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 FK

Define:

// 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 constraint

If 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 production

They’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 GetByEntityType

Comment 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 filters

Please 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 streaming

Returning 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 upfront

Add 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 ExportService

Avoid 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 schedule

Start paused if the job is disabled.

-  Paused: false, // Start immediately
+  Paused: !job.Enabled, // Respect job enabled state

68-71: Timezone/overlap policy

Consider 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 alignment

Current 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 window

Exporting 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 PROCESSING

If 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 use

Call 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 prefix

Add .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 strings to imports.


191-244: Large export memory use; consider streaming or multipart upload

Buffering 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 types

Manual 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 MockScheduledJobRepo

It’s unused and can confuse future readers. Drop it or add tests that leverage it.


227-230: Unused helper

boolPtr is unused; remove to satisfy linters.


114-141: Expand test coverage

Add 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 PENDING

StartedAt 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 directly

Take 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 = &total
internal/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 Postgres

ContainsFold 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 IDs

IDGT/GTE/LT/LTE compare lexicographically. Unless IDs are ULIDs (time‑sortable), prefer CreatedAt/UpdatedAt for pagination/range queries.

Comment on lines 1828 to 1830
{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)"}},
Copy link

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.

Comment on lines 39649 to 39666
// 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)
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.go

Length 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.go

Length 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.

Comment on lines 412 to 420
// 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))
}
Copy link

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.go

Length of output: 1621


rg -nP '(LastRunStatus|LastRunError|TemporalScheduleID)' -A2 ent/scheduledjob.go

🏁 Script executed:

rg -nP 'type\s+ScheduledJob\s+struct' -A100 ent/scheduledjob.go

Length 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.

Comment on lines 11 to 17
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"`
}
Copy link

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:

  1. If the API enforces required validation, the schema's Optional() will never be used
  2. If defaults are provided by the service layer, document this behavior
  3. 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 -B2

Length of output: 704


Align JobConfig and Enabled defaults between API and schema

  • JobConfig is marked binding:"required" in internal/api/dto/scheduled_job.go but defined as .Optional() (with default {}) in ent/schema/scheduledjob.go. Either remove Optional() from the schema or make job_config optional in the DTO to match.
  • Enabled is a non-pointer bool (zero = false) in the DTO—so omitting it yields false—yet the schema sets Default(true). To respect the schema default, change Enabled to *bool in the DTO (and apply true when nil), or remove the schema default if false is 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).

Comment on lines 27 to 35
// CreateTaskInput represents input for creating a task
type CreateTaskInput struct {
ScheduledJobID string
TenantID string
EnvID string
EntityType string
StartTime time.Time
EndTime time.Time
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines 42 to 87
// 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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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.

Comment on lines 55 to 71
// 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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +118 to +143
// 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +118 to +129
// 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
}
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject negative or empty entity entries in S3 exports

Two validation gaps:

  1. MaxFileSizeMB accepts negative values (zero already means “use default”). A negative size will flow downstream to chunking logic and break exports.
  2. 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.

Copy link

@coderabbitai coderabbitai bot left a 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 new createFlexPriceSubscriptionWithoutTx (lines 485, 517) unconditionally set billingAnchor = time.Unix(stripeSub.BillingCycleAnchor, 0).UTC(). When BillingCycleAnchor == 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-Type

Set 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 from S3ConnectionMetadata.

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_usage is backed by ReplacingMergeTree; without FINAL, exports can include obsolete rows. Please update the query to FROM feature_usage FINAL … to ensure merged results.

Apply:

-	FROM feature_usage
+	FROM feature_usage FINAL
internal/service/feature_usage_tracking.go (1)

1252-1254: Re-allow expired prices tied to active subscriptions.

Setting priceFilter.AllowExpiredPrices = false drops sunsetted-but-still-billable prices, so meters referencing those prices vanish from data.Prices and 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 = false
internal/api/v1/scheduled_job.go (1)

50-53: Respect explicit enabled=false on create.

Because CreateScheduledJobRequest.Enabled is a plain bool, this block turns an explicit false from clients into true, so you can’t create a disabled job. Please drop the forced default (or switch the DTO field to *bool and 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 TaskID from 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/**' -C3
internal/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:

  1. JobConfig: Marked binding:"required" in the DTO but the schema defines it as .Optional() with default {}. Either remove binding:"required" from the DTO (treating omitted as empty map) or remove .Optional() from the schema.

  2. Enabled: As a non-pointer bool, it defaults to false when omitted in the request, but the schema has Default(true). Change Enabled to *bool in the DTO and apply the schema default (true) when nil, or remove the schema default if false is 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

MaxFileSizeMB accepts negatives, and EntityTypes lets empty strings slip through because ExportEntityType.Validate() treats "" as optional. A negative size will break chunking downstream, and an empty entity will blow up lookups later. Please reject MaxFileSizeMB < 0 and short-circuit when an entity string is blank before calling Validate().

internal/repository/ent/scheduledjob.go (5)

106-150: Scope Update by tenant/environment

UpdateOneID bypasses tenant/environment predicates, so any caller with an ID can modify another tenant’s job. Use Update().Where(...ID..., TenantID, EnvironmentID...) before applying setters.


152-168: Scope Delete by tenant/environment

DeleteOneID ignores tenant/env, allowing cross-tenant deletes. Switch to Delete().Where(scheduledjob.ID(id), scheduledjob.TenantID(...), scheduledjob.EnvironmentID(...)).


174-215: Guard nil filters before dereferencing

filters.TenantID etc. panic when filters is nil. Add a nil guard (e.g., default to an empty ListFilters) before dereferencing.


295-323: Scope UpdateLastRun by tenant/environment

UpdateOneID again skips tenant/env enforcement for last-run updates. Use Update().Where(...TenantID..., ...EnvironmentID...) to avoid cross-tenant writes.


331-349: Propagate TemporalScheduleID to domain model

The mapper drops TemporalScheduleID, so callers never see stored schedule IDs. Include TemporalScheduleID: entJob.TemporalScheduleID.

internal/integration/s3/client.go (2)

168-175: Stop leaking decrypted keys (and fix undefined min)

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

jobConfig only 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 MaxFileSizeMB

Validate 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 externalID is assigned to stripeCustomer.ID upfront but may be overridden by flexprice_lookup_key at 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 the created_via metadata field:

  • This helper uses "provider_to_flexprice" (webhook direction)
  • CreateCustomerInStripe uses "flexprice_to_provider" (API direction)

Consider adding a direction parameter 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 allocations

Change 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 performance

The 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-safe

Edge-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” query

To 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 patterns

The 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 production

Recommend 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 slash

Indexing 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 FileName

You 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:kms

If 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 confusion

Using 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-Type

Consider "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 files

Current 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 connectionID parameter 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 ambiguity

DB 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/encryption

Avoid 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

Comment on lines 1374 to 1427
// 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]},
},
},
}
Copy link

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"),

Comment on lines 243 to 257
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,
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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.

Comment on lines 280 to 297
// 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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.

Comment on lines 660 to 677
// 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines 51 to 52
SetNillableScheduledJobID(&t.ScheduledJobID).
SetNillableWorkflowID(t.WorkflowID).
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.

Suggested change
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.

Comment on lines 62 to 100
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)
})

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines 148 to 171
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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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.

Comment on lines 102 to 114
// 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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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.

Comment on lines 43 to 75
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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.

Copy link

@coderabbitai coderabbitai bot left a 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 rejects MaxFileSizeMB < 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 fields

The ClearCreatedBy, ClearUpdatedBy, and ClearEnvironmentID methods 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 field

The ClearScheduledTaskID method allows setting NULL on scheduled_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_id Optional().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.id

No 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 empty scheduled_task_id strings.

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 GetByEntityType without tenant/environment on ctx. 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: Populate ConnectionID on the export request.

ExportRequest (Line 128) still omits the scheduled task's ConnectionID, so GetS3Client cannot 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 ConnectionID is 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 via workflow.Now(ctx) instead.

Apply this diff:

-	now := time.Now()
+	now := workflow.Now(ctx)
internal/api/v1/scheduled_task.go (1)

51-53: Stop forcing enabled to true

This block flips an explicit enabled:false payload back to true, so clients can’t disable a task. Drop the defaulting (or switch to a *bool in the DTO and only default when nil).

internal/integration/s3/client.go (2)

187-193: Remove secret logging and fix the undefined min helper.

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 the min call.

-	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) for jobConfig.Bucket and jobConfig.Region and 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.

boolPtr is 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 call SetDefaults():

 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 validators

To 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 spec

If 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 completion

Use 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 inputs

Reject 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 22373c7 and 047f59f.

⛔ Files ignored due to path filters (1)
  • go.sum is 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

Comment on lines +24 to +80
// 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"),
}
Copy link

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.

Comment on lines +508 to +513
// Test export
test := v1Private.Group("/test")
{
test.POST("/export", handlers.TestExport.TestExport)
test.POST("/export-usage", handlers.TestUsageExport.TestUsageExport)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +107 to +147
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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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.

Comment on lines +154 to +168
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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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)
}

Comment on lines +174 to +223
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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +155 to +160
// Convert QueryFilter to ListFilters
listFilters := &scheduledtask.ListFilters{
Limit: int(*filter.Limit),
Offset: int(*filter.Offset),
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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.

Comment on lines 263 to 275
// 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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +99 to +129
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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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`.

Comment on lines +100 to +125
// 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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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).

Copy link

@coderabbitai coderabbitai bot left a 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 allowedTypes slice in Validate()
  • 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 against MaxFileSizeMB < 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/export and /v1/test/export-usage on 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 tenantID and envID from 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 listFilters without including TenantID and EnvironmentID from 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 negative MaxFileSizeMB values. While S3JobConfig.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 negative MaxFileSizeMB values, similar to the past review comment on internal/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 Enabled field (line 17) is a bool with no binding:"required" tag, so it defaults to false when not provided in the JSON payload. Line 98 comments "default will be handled by service if needed," but the service cannot distinguish between:

  1. User explicitly sent "enabled": false
  2. User omitted the enabled field (defaults to false)

This may cause confusion if the intended default is true when not specified.

Consider changing the field to Enabled *bool with json:"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 request

This 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 4305b9b and 1153e34.

📒 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 TemporalTaskQueueExport task 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 Enabled flag and ensures Status == "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 startTime and endTime are 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 enabled field 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 StartTime and EndTime are 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 ScheduledTask to 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/scheduled follow 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 calculateStartTime method 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 recalculates NextRunAt based on the task's interval. This keeps the scheduling system synchronized with execution results.

@nkmishra1997 nkmishra1997 merged commit 270521f into flexprice:main Oct 25, 2025
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants