diff --git a/.gitignore b/.gitignore index 0a83ff203322..85f72775953c 100644 --- a/.gitignore +++ b/.gitignore @@ -167,6 +167,10 @@ docker-compose.override.yaml **/CLAUDE.md .claude .cursorrules + +# Planning files (local only) +.planning + .cursor/* !.cursor/README.md !.cursor/cursor.md diff --git a/api/Makefile b/api/Makefile index 02bcd0165451..b64c15d2c0d1 100644 --- a/api/Makefile +++ b/api/Makefile @@ -65,6 +65,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/access_control.yaml >> $(V4_YAML) @cat $(V4_SRC)/content_flagging.yaml >> $(V4_YAML) @cat $(V4_SRC)/agents.yaml >> $(V4_YAML) + @cat $(V4_SRC)/scheduled_recaps.yaml >> $(V4_YAML) @cat $(V4_SRC)/properties.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 159ca90bbb3a..c074ee4e7e83 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -5436,6 +5436,155 @@ components: type: integer format: int64 description: The time in milliseconds the recap channel was created + RecapLimitStatus: + type: object + description: The current user's recap limit status including usage and cooldown information + properties: + effective_limits: + $ref: "#/components/schemas/EffectiveRecapLimits" + daily: + $ref: "#/components/schemas/DailyUsageStatus" + cooldown: + $ref: "#/components/schemas/CooldownStatus" + EffectiveRecapLimits: + type: object + description: Resolved recap limit values for a user. A value of -1 means the limit is disabled/unlimited. + properties: + max_recaps_per_day: + type: integer + description: Maximum number of recaps the user can create per day (-1 = unlimited) + max_scheduled_recaps: + type: integer + description: Maximum number of scheduled recaps (-1 = unlimited) + max_channels_per_recap: + type: integer + description: Maximum number of channels per recap (-1 = unlimited) + max_posts_per_recap: + type: integer + description: Maximum number of posts per recap (-1 = unlimited) + max_tokens_per_recap: + type: integer + description: Maximum number of tokens per recap (-1 = unlimited) + max_posts_per_day: + type: integer + description: Maximum number of posts that can be processed per day (-1 = unlimited) + cooldown_minutes: + type: integer + description: Cooldown period in minutes between recap creations (-1 = no cooldown) + source: + type: string + enum: [system, group, user] + description: Where the effective limits originated from + source_id: + type: string + description: Group ID or User ID if overridden, empty for system defaults + DailyUsageStatus: + type: object + description: Daily recap usage tracking + properties: + used: + type: integer + description: Number of recaps used today + limit: + type: integer + description: Maximum recaps allowed per day + reset_at: + type: integer + format: int64 + description: Unix timestamp in milliseconds when daily usage resets + CooldownStatus: + type: object + description: Cooldown state for recap creation + properties: + is_active: + type: boolean + description: Whether the cooldown is currently active + available_at: + type: integer + format: int64 + description: Unix timestamp in milliseconds when cooldown ends + retry_after_seconds: + type: integer + description: Seconds until recap creation is available again + ScheduledRecap: + type: object + properties: + id: + type: string + description: Unique identifier for the scheduled recap + user_id: + type: string + description: The ID of the user who owns this scheduled recap + title: + type: string + description: Title for the scheduled recap + maxLength: 255 + days_of_week: + type: integer + description: > + Bitmask for days of the week the recap should run. + Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64. + minimum: 1 + maximum: 127 + time_of_day: + type: string + description: Time of day in HH:MM format (e.g., "09:00") + timezone: + type: string + description: IANA timezone (e.g., "America/New_York") + time_period: + type: string + description: The lookback period for the recap content + enum: + - last_24h + - last_week + - since_last_read + next_run_at: + type: integer + format: int64 + description: The next scheduled execution time in UTC milliseconds + last_run_at: + type: integer + format: int64 + description: The last execution time in UTC milliseconds + run_count: + type: integer + description: Number of times this schedule has executed + channel_mode: + type: string + description: How channels are selected for the recap + enum: + - specific + - all_unreads + channel_ids: + type: array + items: + type: string + description: List of channel IDs to include (when channel_mode is "specific") + custom_instructions: + type: string + description: Custom AI instructions for the recap + agent_id: + type: string + description: ID of the AI agent to use for generating the recap + is_recurring: + type: boolean + description: Whether the recap runs on a recurring schedule or just once + enabled: + type: boolean + description: Whether the scheduled recap is active (false when paused) + create_at: + type: integer + format: int64 + description: The time in milliseconds the scheduled recap was created + update_at: + type: integer + format: int64 + description: The time in milliseconds the scheduled recap was last updated + delete_at: + type: integer + format: int64 + description: The time in milliseconds the scheduled recap was soft-deleted (0 if not deleted) externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/api/v4/source/introduction.yaml b/api/v4/source/introduction.yaml index 8f4efcd0a1e9..a779bbd8052a 100644 --- a/api/v4/source/introduction.yaml +++ b/api/v4/source/introduction.yaml @@ -473,6 +473,8 @@ tags: description: Endpoints for managing audit log certificates and configuration. - name: recaps description: Endpoints for creating and managing AI-powered channel recaps that summarize unread messages. + - name: scheduled recaps + description: Endpoints for creating and managing scheduled recaps that run automatically on a configured schedule. - name: agents description: Endpoints for interacting with AI agents and LLM services. servers: diff --git a/api/v4/source/recaps.yaml b/api/v4/source/recaps.yaml index a42450c59e90..15d1a3c875e6 100644 --- a/api/v4/source/recaps.yaml +++ b/api/v4/source/recaps.yaml @@ -93,6 +93,33 @@ $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "/api/v4/recaps/limit_status": + get: + tags: + - recaps + - ai + summary: Get recap limit status for the current user + description: > + Get the current user's recap usage limits and status, including daily + usage, effective limits, and cooldown information. + + ##### Permissions + + Must be authenticated. + + __Minimum server version__: 11.2 + operationId: GetRecapLimitStatus + responses: + "200": + description: Recap limit status retrieval successful + content: + application/json: + schema: + $ref: "#/components/schemas/RecapLimitStatus" + "401": + $ref: "#/components/responses/Unauthorized" + "501": + description: Recaps feature is not enabled "/api/v4/recaps/mark_viewed": post: tags: diff --git a/api/v4/source/scheduled_recaps.yaml b/api/v4/source/scheduled_recaps.yaml new file mode 100644 index 000000000000..7d45adbc6ec3 --- /dev/null +++ b/api/v4/source/scheduled_recaps.yaml @@ -0,0 +1,424 @@ + /api/v4/scheduled_recaps: + post: + tags: + - scheduled recaps + - ai + summary: Create a scheduled recap + description: > + Create a new scheduled recap configuration. Scheduled recaps run on a + recurring or one-time schedule, generating AI-powered channel summaries + at the configured time and day(s). + + ##### Permissions + + Must be authenticated. The recap is created for the authenticated user. + + __Minimum server version__: 11.2 + operationId: CreateScheduledRecap + requestBody: + content: + application/json: + schema: + type: object + required: + - title + - days_of_week + - time_of_day + - timezone + - time_period + - channel_mode + - agent_id + properties: + title: + type: string + description: Title for the scheduled recap + maxLength: 255 + days_of_week: + type: integer + description: > + Bitmask for days of the week the recap should run. + Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64. + For example, weekdays = 62, every day = 127. + minimum: 1 + maximum: 127 + time_of_day: + type: string + description: Time of day in HH:MM format (e.g., "09:00") + pattern: "^([0-1][0-9]|2[0-3]):([0-5][0-9])$" + timezone: + type: string + description: IANA timezone (e.g., "America/New_York") + time_period: + type: string + description: The lookback period for the recap content + enum: + - last_24h + - last_week + - since_last_read + channel_mode: + type: string + description: How channels are selected for the recap + enum: + - specific + - all_unreads + channel_ids: + type: array + items: + type: string + description: List of channel IDs to include (required when channel_mode is "specific") + custom_instructions: + type: string + description: Custom AI instructions for the recap + agent_id: + type: string + description: ID of the AI agent to use for generating the recap + is_recurring: + type: boolean + description: Whether the recap runs on a recurring schedule or just once + oneOf: + - properties: + channel_mode: + type: string + enum: + - specific + channel_ids: + type: array + items: + type: string + minItems: 1 + required: + - channel_mode + - channel_ids + - properties: + channel_mode: + type: string + enum: + - all_unreads + required: + - channel_mode + description: Scheduled recap configuration + required: true + responses: + "201": + description: Scheduled recap created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledRecap" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "501": + $ref: "#/components/responses/NotImplemented" + get: + tags: + - scheduled recaps + - ai + summary: Get current user's scheduled recaps + description: > + Get a paginated list of scheduled recaps for the authenticated user. + + ##### Permissions + + Must be authenticated. + + __Minimum server version__: 11.2 + operationId: GetScheduledRecaps + parameters: + - name: page + in: query + description: The page to select. + schema: + type: integer + default: 0 + - name: per_page + in: query + description: The number of scheduled recaps per page. + schema: + type: integer + default: 60 + responses: + "200": + description: Scheduled recaps retrieval successful + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ScheduledRecap" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "501": + $ref: "#/components/responses/NotImplemented" + /api/v4/scheduled_recaps/{scheduled_recap_id}: + get: + tags: + - scheduled recaps + - ai + summary: Get a scheduled recap + description: > + Get a scheduled recap by its ID. Only the user who owns the scheduled + recap can retrieve it. + + ##### Permissions + + Must be authenticated. Must own the scheduled recap. + + __Minimum server version__: 11.2 + operationId: GetScheduledRecap + parameters: + - name: scheduled_recap_id + in: path + description: Scheduled Recap GUID + required: true + schema: + type: string + responses: + "200": + description: Scheduled recap retrieval successful + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledRecap" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "501": + $ref: "#/components/responses/NotImplemented" + put: + tags: + - scheduled recaps + - ai + summary: Update a scheduled recap + description: > + Update a scheduled recap configuration. Only the user who owns the + scheduled recap can update it. The `user_id` and `create_at` fields + are preserved from the original and cannot be changed. + + ##### Permissions + + Must be authenticated. Must own the scheduled recap. + + __Minimum server version__: 11.2 + operationId: UpdateScheduledRecap + parameters: + - name: scheduled_recap_id + in: path + description: Scheduled Recap GUID + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + description: Title for the scheduled recap + maxLength: 255 + days_of_week: + type: integer + description: > + Bitmask for days of the week the recap should run. + Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64. + minimum: 1 + maximum: 127 + time_of_day: + type: string + description: Time of day in HH:MM format (e.g., "09:00") + pattern: "^([0-1][0-9]|2[0-3]):([0-5][0-9])$" + timezone: + type: string + description: IANA timezone (e.g., "America/New_York") + time_period: + type: string + description: The lookback period for the recap content + enum: + - last_24h + - last_week + - since_last_read + channel_mode: + type: string + description: How channels are selected for the recap + enum: + - specific + - all_unreads + channel_ids: + type: array + items: + type: string + description: List of channel IDs to include (required when channel_mode is "specific") + custom_instructions: + type: string + description: Custom AI instructions for the recap + agent_id: + type: string + description: ID of the AI agent to use for generating the recap + is_recurring: + type: boolean + description: Whether the recap runs on a recurring schedule or just once + enabled: + type: boolean + description: Whether the scheduled recap is active + oneOf: + - properties: + channel_mode: + type: string + enum: + - specific + channel_ids: + type: array + items: + type: string + minItems: 1 + required: + - channel_mode + - channel_ids + - properties: + channel_mode: + type: string + enum: + - all_unreads + required: + - channel_mode + description: Updated scheduled recap configuration + required: true + responses: + "200": + description: Scheduled recap updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledRecap" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "501": + $ref: "#/components/responses/NotImplemented" + delete: + tags: + - scheduled recaps + - ai + summary: Delete a scheduled recap + description: > + Delete a scheduled recap by its ID. Only the user who owns the scheduled + recap can delete it. + + ##### Permissions + + Must be authenticated. Must own the scheduled recap. + + __Minimum server version__: 11.2 + operationId: DeleteScheduledRecap + parameters: + - name: scheduled_recap_id + in: path + description: Scheduled Recap GUID + required: true + schema: + type: string + responses: + "200": + description: Scheduled recap deleted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "501": + $ref: "#/components/responses/NotImplemented" + /api/v4/scheduled_recaps/{scheduled_recap_id}/pause: + post: + tags: + - scheduled recaps + - ai + summary: Pause a scheduled recap + description: > + Pause a scheduled recap, preventing it from running until resumed. + Only the user who owns the scheduled recap can pause it. + + ##### Permissions + + Must be authenticated. Must own the scheduled recap. + + __Minimum server version__: 11.2 + operationId: PauseScheduledRecap + parameters: + - name: scheduled_recap_id + in: path + description: Scheduled Recap GUID + required: true + schema: + type: string + responses: + "200": + description: Scheduled recap paused successfully + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledRecap" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "501": + $ref: "#/components/responses/NotImplemented" + /api/v4/scheduled_recaps/{scheduled_recap_id}/resume: + post: + tags: + - scheduled recaps + - ai + summary: Resume a scheduled recap + description: > + Resume a previously paused scheduled recap, allowing it to run on its + configured schedule again. Only the user who owns the scheduled recap + can resume it. + + ##### Permissions + + Must be authenticated. Must own the scheduled recap. + + __Minimum server version__: 11.2 + operationId: ResumeScheduledRecap + parameters: + - name: scheduled_recap_id + in: path + description: Scheduled Recap GUID + required: true + schema: + type: string + responses: + "200": + description: Scheduled recap resumed successfully + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledRecap" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "501": + $ref: "#/components/responses/NotImplemented" diff --git a/e2e-tests/cypress/tests/support/api/default_roles_permissions.js b/e2e-tests/cypress/tests/support/api/default_roles_permissions.js index dd8158c1e93d..33aeaa48c574 100644 --- a/e2e-tests/cypress/tests/support/api/default_roles_permissions.js +++ b/e2e-tests/cypress/tests/support/api/default_roles_permissions.js @@ -12,7 +12,7 @@ export const defaultRolesPermissions = { playbook_member: 'playbook_private_manage_members playbook_private_manage_properties playbook_private_view playbook_public_manage_members playbook_public_manage_properties playbook_public_view run_create', run_admin: 'run_manage_members run_manage_properties', run_member: 'run_view', - system_admin: 'add_bookmark_private_channel add_bookmark_public_channel add_ldap_private_cert add_ldap_public_cert add_reaction add_saml_idp_cert add_saml_private_cert add_saml_public_cert add_user_to_team assign_bot assign_system_admin_role bypass_incoming_webhook_channel_lock convert_private_channel_to_public convert_public_channel_to_private create_bot create_compliance_export_job create_custom_group create_data_retention_job create_direct_channel create_elasticsearch_post_aggregation_job create_elasticsearch_post_indexing_job create_emojis create_group_channel create_ldap_sync_job create_post create_post_ephemeral create_post_public create_private_channel create_public_channel create_team create_user_access_token delete_bookmark_private_channel delete_bookmark_public_channel delete_custom_group delete_emojis delete_others_emojis delete_others_posts delete_post delete_private_channel delete_public_channel demote_to_guest download_compliance_export_result edit_bookmark_private_channel edit_bookmark_public_channel edit_brand edit_custom_group edit_file_attachment edit_other_users edit_others_posts edit_post get_analytics get_logs get_public_link get_saml_cert_status get_saml_metadata_from_idp import_team invalidate_caches invalidate_email_invite invite_guest invite_user join_private_teams join_public_channels join_public_teams list_private_teams list_public_teams list_team_channels list_users_without_team manage_bots manage_channel_access_rules manage_channel_join_requests manage_channel_roles manage_compliance_export_job manage_custom_group_members manage_data_retention_job manage_elasticsearch_post_aggregation_job manage_elasticsearch_post_indexing_job manage_jobs manage_ldap_sync_job manage_license_information manage_oauth manage_others_agent manage_others_bots manage_others_incoming_webhooks manage_others_outgoing_webhooks manage_others_slash_commands manage_outgoing_oauth_connections manage_own_agent manage_own_incoming_webhooks manage_own_outgoing_webhooks manage_own_slash_commands manage_private_channel_auto_translation manage_private_channel_banner manage_private_channel_discoverability manage_private_channel_members manage_private_channel_properties manage_public_channel_auto_translation manage_public_channel_banner manage_public_channel_members manage_public_channel_properties manage_roles manage_secure_connections manage_shared_channels manage_system manage_system_wide_oauth manage_team manage_team_access_rules manage_team_roles order_bookmark_private_channel order_bookmark_public_channel playbook_private_create playbook_private_make_public playbook_private_manage_members playbook_private_manage_properties playbook_private_manage_roles playbook_private_view playbook_public_create playbook_public_make_private playbook_public_manage_members playbook_public_manage_properties playbook_public_manage_roles playbook_public_view promote_guest purge_elasticsearch_indexes read_audits read_bots read_channel read_channel_content read_compliance_export_job read_data_retention_job read_deleted_posts read_elasticsearch_post_aggregation_job read_elasticsearch_post_indexing_job read_jobs read_ldap_sync_job read_license_information read_other_users_teams read_others_bots read_private_channel_groups read_public_channel read_public_channel_groups read_user_access_token recycle_database_connections reload_config remove_ldap_private_cert remove_ldap_public_cert remove_others_reactions remove_reaction remove_saml_idp_cert remove_saml_private_cert remove_saml_public_cert remove_user_from_team restore_custom_group revoke_user_access_token run_create run_manage_members run_manage_properties run_view sysconsole_read_about_edition_and_license sysconsole_read_authentication_email sysconsole_read_authentication_guest_access sysconsole_read_authentication_ldap sysconsole_read_authentication_mfa sysconsole_read_authentication_openid sysconsole_read_authentication_password sysconsole_read_authentication_saml sysconsole_read_authentication_signup sysconsole_read_billing sysconsole_read_compliance_compliance_export sysconsole_read_compliance_compliance_monitoring sysconsole_read_compliance_custom_terms_of_service sysconsole_read_compliance_data_retention_policy sysconsole_read_environment_database sysconsole_read_environment_developer sysconsole_read_environment_elasticsearch sysconsole_read_environment_file_storage sysconsole_read_environment_high_availability sysconsole_read_environment_image_proxy sysconsole_read_environment_logging sysconsole_read_environment_mobile_security sysconsole_read_environment_performance_monitoring sysconsole_read_environment_push_notification_server sysconsole_read_environment_rate_limiting sysconsole_read_environment_session_lengths sysconsole_read_environment_smtp sysconsole_read_environment_web_server sysconsole_read_experimental_feature_flags sysconsole_read_experimental_features sysconsole_read_integrations_bot_accounts sysconsole_read_integrations_cors sysconsole_read_integrations_gif sysconsole_read_integrations_integration_management sysconsole_read_plugins sysconsole_read_products_boards sysconsole_read_reporting_server_logs sysconsole_read_reporting_site_statistics sysconsole_read_reporting_team_statistics sysconsole_read_site_announcement_banner sysconsole_read_site_customization sysconsole_read_site_emoji sysconsole_read_site_file_sharing_and_downloads sysconsole_read_site_ip_filters sysconsole_read_site_localization sysconsole_read_site_notices sysconsole_read_site_notifications sysconsole_read_site_posts sysconsole_read_site_public_links sysconsole_read_site_users_and_teams sysconsole_read_user_management_channels sysconsole_read_user_management_groups sysconsole_read_user_management_permissions sysconsole_read_user_management_system_roles sysconsole_read_user_management_teams sysconsole_read_user_management_users sysconsole_write_about_edition_and_license sysconsole_write_authentication_email sysconsole_write_authentication_guest_access sysconsole_write_authentication_ldap sysconsole_write_authentication_mfa sysconsole_write_authentication_openid sysconsole_write_authentication_password sysconsole_write_authentication_saml sysconsole_write_authentication_signup sysconsole_write_billing sysconsole_write_compliance_compliance_export sysconsole_write_compliance_compliance_monitoring sysconsole_write_compliance_custom_terms_of_service sysconsole_write_compliance_data_retention_policy sysconsole_write_environment_database sysconsole_write_environment_developer sysconsole_write_environment_elasticsearch sysconsole_write_environment_file_storage sysconsole_write_environment_high_availability sysconsole_write_environment_image_proxy sysconsole_write_environment_logging sysconsole_write_environment_mobile_security sysconsole_write_environment_performance_monitoring sysconsole_write_environment_push_notification_server sysconsole_write_environment_rate_limiting sysconsole_write_environment_session_lengths sysconsole_write_environment_smtp sysconsole_write_environment_web_server sysconsole_write_experimental_feature_flags sysconsole_write_experimental_features sysconsole_write_integrations_bot_accounts sysconsole_write_integrations_cors sysconsole_write_integrations_gif sysconsole_write_integrations_integration_management sysconsole_write_plugins sysconsole_write_products_boards sysconsole_write_reporting_server_logs sysconsole_write_reporting_site_statistics sysconsole_write_reporting_team_statistics sysconsole_write_site_announcement_banner sysconsole_write_site_customization sysconsole_write_site_emoji sysconsole_write_site_file_sharing_and_downloads sysconsole_write_site_ip_filters sysconsole_write_site_localization sysconsole_write_site_notices sysconsole_write_site_notifications sysconsole_write_site_posts sysconsole_write_site_public_links sysconsole_write_site_users_and_teams sysconsole_write_user_management_channels sysconsole_write_user_management_groups sysconsole_write_user_management_permissions sysconsole_write_user_management_system_roles sysconsole_write_user_management_teams sysconsole_write_user_management_users test_elasticsearch test_email test_ldap test_s3 test_site_url upload_file use_channel_mentions use_group_mentions use_slash_commands view_members view_team', + system_admin: 'add_bookmark_private_channel add_bookmark_public_channel add_ldap_private_cert add_ldap_public_cert add_reaction add_saml_idp_cert add_saml_private_cert add_saml_public_cert add_user_to_team assign_bot assign_system_admin_role bypass_incoming_webhook_channel_lock convert_private_channel_to_public convert_public_channel_to_private create_bot create_compliance_export_job create_custom_group create_data_retention_job create_direct_channel create_elasticsearch_post_aggregation_job create_elasticsearch_post_indexing_job create_emojis create_group_channel create_ldap_sync_job create_post create_post_ephemeral create_post_public create_private_channel create_public_channel create_team create_user_access_token delete_bookmark_private_channel delete_bookmark_public_channel delete_custom_group delete_emojis delete_others_emojis delete_others_posts delete_post delete_private_channel delete_public_channel demote_to_guest download_compliance_export_result edit_bookmark_private_channel edit_bookmark_public_channel edit_brand edit_custom_group edit_file_attachment edit_other_users edit_others_posts edit_post get_analytics get_logs get_public_link get_saml_cert_status get_saml_metadata_from_idp import_team invalidate_caches invalidate_email_invite invite_guest invite_user join_private_teams join_public_channels join_public_teams list_private_teams list_public_teams list_team_channels list_users_without_team manage_bots manage_channel_access_rules manage_channel_join_requests manage_channel_roles manage_compliance_export_job manage_custom_group_members manage_data_retention_job manage_elasticsearch_post_aggregation_job manage_elasticsearch_post_indexing_job manage_jobs manage_ldap_sync_job manage_license_information manage_oauth manage_others_agent manage_others_bots manage_others_incoming_webhooks manage_others_outgoing_webhooks manage_others_slash_commands manage_outgoing_oauth_connections manage_own_agent manage_own_incoming_webhooks manage_own_outgoing_webhooks manage_own_slash_commands manage_private_channel_auto_translation manage_private_channel_banner manage_private_channel_discoverability manage_private_channel_members manage_private_channel_properties manage_public_channel_auto_translation manage_public_channel_banner manage_public_channel_members manage_public_channel_properties manage_roles manage_secure_connections manage_shared_channels manage_system manage_system_wide_oauth manage_team manage_team_access_rules manage_team_roles order_bookmark_private_channel order_bookmark_public_channel playbook_private_create playbook_private_make_public playbook_private_manage_members playbook_private_manage_properties playbook_private_manage_roles playbook_private_view playbook_public_create playbook_public_make_private playbook_public_manage_members playbook_public_manage_properties playbook_public_manage_roles playbook_public_view promote_guest purge_elasticsearch_indexes read_audits read_bots read_channel read_channel_content read_compliance_export_job read_data_retention_job read_deleted_posts read_elasticsearch_post_aggregation_job read_elasticsearch_post_indexing_job read_jobs read_ldap_sync_job read_license_information read_other_users_teams read_others_bots read_private_channel_groups read_public_channel read_public_channel_groups read_user_access_token recycle_database_connections reload_config remove_ldap_private_cert remove_ldap_public_cert remove_others_reactions remove_reaction remove_saml_idp_cert remove_saml_private_cert remove_saml_public_cert remove_user_from_team restore_custom_group revoke_user_access_token run_create run_manage_members run_manage_properties run_view sysconsole_read_about_edition_and_license sysconsole_read_ai_recaps sysconsole_read_authentication_email sysconsole_read_authentication_guest_access sysconsole_read_authentication_ldap sysconsole_read_authentication_mfa sysconsole_read_authentication_openid sysconsole_read_authentication_password sysconsole_read_authentication_saml sysconsole_read_authentication_signup sysconsole_read_billing sysconsole_read_compliance_compliance_export sysconsole_read_compliance_compliance_monitoring sysconsole_read_compliance_custom_terms_of_service sysconsole_read_compliance_data_retention_policy sysconsole_read_environment_database sysconsole_read_environment_developer sysconsole_read_environment_elasticsearch sysconsole_read_environment_file_storage sysconsole_read_environment_high_availability sysconsole_read_environment_image_proxy sysconsole_read_environment_logging sysconsole_read_environment_mobile_security sysconsole_read_environment_performance_monitoring sysconsole_read_environment_push_notification_server sysconsole_read_environment_rate_limiting sysconsole_read_environment_session_lengths sysconsole_read_environment_smtp sysconsole_read_environment_web_server sysconsole_read_experimental_feature_flags sysconsole_read_experimental_features sysconsole_read_integrations_bot_accounts sysconsole_read_integrations_cors sysconsole_read_integrations_gif sysconsole_read_integrations_integration_management sysconsole_read_plugins sysconsole_read_products_boards sysconsole_read_reporting_server_logs sysconsole_read_reporting_site_statistics sysconsole_read_reporting_team_statistics sysconsole_read_site_announcement_banner sysconsole_read_site_customization sysconsole_read_site_emoji sysconsole_read_site_file_sharing_and_downloads sysconsole_read_site_ip_filters sysconsole_read_site_localization sysconsole_read_site_notices sysconsole_read_site_notifications sysconsole_read_site_posts sysconsole_read_site_public_links sysconsole_read_site_users_and_teams sysconsole_read_user_management_channels sysconsole_read_user_management_groups sysconsole_read_user_management_permissions sysconsole_read_user_management_system_roles sysconsole_read_user_management_teams sysconsole_read_user_management_users sysconsole_write_about_edition_and_license sysconsole_write_ai_recaps sysconsole_write_authentication_email sysconsole_write_authentication_guest_access sysconsole_write_authentication_ldap sysconsole_write_authentication_mfa sysconsole_write_authentication_openid sysconsole_write_authentication_password sysconsole_write_authentication_saml sysconsole_write_authentication_signup sysconsole_write_billing sysconsole_write_compliance_compliance_export sysconsole_write_compliance_compliance_monitoring sysconsole_write_compliance_custom_terms_of_service sysconsole_write_compliance_data_retention_policy sysconsole_write_environment_database sysconsole_write_environment_developer sysconsole_write_environment_elasticsearch sysconsole_write_environment_file_storage sysconsole_write_environment_high_availability sysconsole_write_environment_image_proxy sysconsole_write_environment_logging sysconsole_write_environment_mobile_security sysconsole_write_environment_performance_monitoring sysconsole_write_environment_push_notification_server sysconsole_write_environment_rate_limiting sysconsole_write_environment_session_lengths sysconsole_write_environment_smtp sysconsole_write_environment_web_server sysconsole_write_experimental_feature_flags sysconsole_write_experimental_features sysconsole_write_integrations_bot_accounts sysconsole_write_integrations_cors sysconsole_write_integrations_gif sysconsole_write_integrations_integration_management sysconsole_write_plugins sysconsole_write_products_boards sysconsole_write_reporting_server_logs sysconsole_write_reporting_site_statistics sysconsole_write_reporting_team_statistics sysconsole_write_site_announcement_banner sysconsole_write_site_customization sysconsole_write_site_emoji sysconsole_write_site_file_sharing_and_downloads sysconsole_write_site_ip_filters sysconsole_write_site_localization sysconsole_write_site_notices sysconsole_write_site_notifications sysconsole_write_site_posts sysconsole_write_site_public_links sysconsole_write_site_users_and_teams sysconsole_write_user_management_channels sysconsole_write_user_management_groups sysconsole_write_user_management_permissions sysconsole_write_user_management_system_roles sysconsole_write_user_management_teams sysconsole_write_user_management_users test_elasticsearch test_email test_ldap test_s3 test_site_url upload_file use_channel_mentions use_group_mentions use_slash_commands view_members view_team', system_custom_group_admin: 'create_custom_group delete_custom_group edit_custom_group manage_custom_group_members restore_custom_group', system_guest: 'create_direct_channel create_group_channel', system_manager: 'add_user_to_team convert_private_channel_to_public convert_public_channel_to_private create_elasticsearch_post_aggregation_job create_elasticsearch_post_indexing_job delete_private_channel delete_public_channel edit_brand get_analytics get_logs invalidate_caches join_private_teams join_public_teams list_private_teams list_public_teams manage_channel_roles manage_elasticsearch_post_aggregation_job manage_elasticsearch_post_indexing_job manage_outgoing_oauth_connections manage_private_channel_auto_translation manage_private_channel_members manage_private_channel_properties manage_public_channel_auto_translation manage_public_channel_members manage_public_channel_properties manage_team manage_team_roles purge_elasticsearch_indexes read_channel read_elasticsearch_post_aggregation_job read_elasticsearch_post_indexing_job read_ldap_sync_job read_license_information read_private_channel_groups read_public_channel read_public_channel_groups recycle_database_connections reload_config remove_user_from_team sysconsole_read_about_edition_and_license sysconsole_read_authentication_email sysconsole_read_authentication_guest_access sysconsole_read_authentication_ldap sysconsole_read_authentication_mfa sysconsole_read_authentication_openid sysconsole_read_authentication_password sysconsole_read_authentication_saml sysconsole_read_authentication_signup sysconsole_read_environment_database sysconsole_read_environment_developer sysconsole_read_environment_elasticsearch sysconsole_read_environment_file_storage sysconsole_read_environment_high_availability sysconsole_read_environment_image_proxy sysconsole_read_environment_logging sysconsole_read_environment_performance_monitoring sysconsole_read_environment_push_notification_server sysconsole_read_environment_rate_limiting sysconsole_read_environment_session_lengths sysconsole_read_environment_smtp sysconsole_read_environment_web_server sysconsole_read_integrations_bot_accounts sysconsole_read_integrations_cors sysconsole_read_integrations_gif sysconsole_read_integrations_integration_management sysconsole_read_plugins sysconsole_read_products_boards sysconsole_read_reporting_server_logs sysconsole_read_reporting_site_statistics sysconsole_read_reporting_team_statistics sysconsole_read_site_announcement_banner sysconsole_read_site_customization sysconsole_read_site_emoji sysconsole_read_site_file_sharing_and_downloads sysconsole_read_site_localization sysconsole_read_site_notices sysconsole_read_site_notifications sysconsole_read_site_posts sysconsole_read_site_public_links sysconsole_read_site_users_and_teams sysconsole_read_user_management_channels sysconsole_read_user_management_groups sysconsole_read_user_management_permissions sysconsole_read_user_management_teams sysconsole_write_environment_database sysconsole_write_environment_developer sysconsole_write_environment_elasticsearch sysconsole_write_environment_file_storage sysconsole_write_environment_high_availability sysconsole_write_environment_image_proxy sysconsole_write_environment_logging sysconsole_write_environment_performance_monitoring sysconsole_write_environment_push_notification_server sysconsole_write_environment_rate_limiting sysconsole_write_environment_session_lengths sysconsole_write_environment_smtp sysconsole_write_environment_web_server sysconsole_write_integrations_bot_accounts sysconsole_write_integrations_cors sysconsole_write_integrations_gif sysconsole_write_integrations_integration_management sysconsole_write_products_boards sysconsole_write_site_announcement_banner sysconsole_write_site_customization sysconsole_write_site_emoji sysconsole_write_site_file_sharing_and_downloads sysconsole_write_site_localization sysconsole_write_site_notices sysconsole_write_site_notifications sysconsole_write_site_posts sysconsole_write_site_public_links sysconsole_write_site_users_and_teams sysconsole_write_user_management_channels sysconsole_write_user_management_groups sysconsole_write_user_management_permissions sysconsole_write_user_management_teams test_elasticsearch test_email test_ldap test_s3 test_site_url view_team', diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 897984134be1..edb6d7143092 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -906,4 +906,23 @@ const defaultServerConfig: AdminConfig = { LLMServiceID: '', }, }, + AIRecapSettings: { + Enable: true, + DefaultLimits: { + MaxRecapsPerDay: 10, + MaxScheduledRecaps: 5, + MaxChannelsPerRecap: -1, + MaxPostsPerRecap: 500, + MaxTokensPerRecap: 100000, + MaxPostsPerDay: 5000, + CooldownMinutes: 60, + }, + EnforceRecapsPerDay: true, + EnforceScheduledRecaps: true, + EnforceChannelsPerRecap: true, + EnforcePostsPerRecap: true, + EnforceTokensPerRecap: true, + EnforcePostsPerDay: true, + EnforceCooldown: true, + }, }; diff --git a/e2e-tests/playwright/lib/src/ui/pages/recaps.ts b/e2e-tests/playwright/lib/src/ui/pages/recaps.ts index 1ca1eb84704c..de9046f8c274 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/recaps.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/recaps.ts @@ -47,6 +47,19 @@ class CreateRecapModal { await expect(this.container).not.toBeVisible({timeout: duration.ten_sec}); } + async createSchedule() { + await this.container.getByRole('button', {name: 'Create schedule'}).click(); + await expect(this.container).not.toBeVisible({timeout: duration.ten_sec}); + } + + async enableRunOnce() { + const runOnce = this.container.locator('#run-once-toggle'); + await expect(runOnce).toBeVisible(); + if ((await runOnce.getAttribute('aria-pressed')) !== 'true') { + await runOnce.click(); + } + } + async expectChannelSelectorVisible() { await expect(this.channelSearchInput).toBeVisible(); } @@ -78,6 +91,26 @@ class CreateRecapModal { } } + async expectScheduleConfigurationVisible() { + // The schedule step's submit button is "Create schedule" when creating but "Save changes" when + // editing, so assert on the stable step heading rather than the button label. + await expect(this.container.getByText('On which days should your recap run?')).toBeVisible(); + } + + async selectScheduleDay(dayLabel: string) { + // Day toggle buttons expose the full day name as their accessible name (aria-label), + // so match on the visible short label text instead of the accessible name. + await this.container + .locator('.day-button') + .filter({hasText: new RegExp(`^${escapeRegExp(dayLabel)}$`)}) + .click(); + } + + async saveChanges() { + await this.container.getByRole('button', {name: 'Save changes'}).click(); + await expect(this.container).not.toBeVisible({timeout: duration.ten_sec}); + } + async selectAgent(agentName: string) { await this.container.getByLabel('Agent selector').click(); await this.page @@ -192,10 +225,74 @@ class RecapItem { } } +class ScheduledRecapItem { + readonly menuButton: Locator; + readonly schedulePattern: Locator; + readonly toggleButton: Locator; + + constructor( + private readonly page: Page, + readonly container: Locator, + ) { + this.menuButton = container.getByRole('button', {name: /Options for /}); + this.schedulePattern = container.locator('.schedule-pattern'); + this.toggleButton = container.locator('.scheduled-recap-toggle button'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async expectText(text: string | RegExp) { + await expect(this.container).toContainText(text); + } + + async expectSchedulePattern(text: string | RegExp) { + await expect(this.schedulePattern).toBeVisible(); + await expect(this.schedulePattern).toContainText(text); + } + + async expectActive() { + await expect(this.toggleButton).toHaveAttribute('aria-pressed', 'true'); + await expect(this.toggleButton).toHaveAccessibleName('Active - click to pause'); + } + + async expectPaused() { + await expect(this.toggleButton).toHaveAttribute('aria-pressed', 'false'); + await expect(this.toggleButton).toHaveAccessibleName('Paused - click to resume'); + } + + async pause() { + await this.expectActive(); + await this.toggleButton.click(); + await this.expectPaused(); + } + + async resume() { + await this.expectPaused(); + await this.toggleButton.click(); + await this.expectActive(); + } + + async openMenuAction(actionName: string) { + await this.menuButton.click(); + await this.page.getByRole('menuitem', {name: actionName}).click(); + } + + async editViaMenu() { + await this.openMenuAction('Edit'); + } + + async deleteViaMenu() { + await this.openMenuAction('Delete'); + } +} + export default class RecapsPage { readonly heading: Locator; readonly unreadTab: Locator; readonly readTab: Locator; + readonly scheduledTab: Locator; readonly addRecapButton: Locator; readonly createRecapModal: CreateRecapModal; @@ -203,6 +300,7 @@ export default class RecapsPage { this.heading = page.getByRole('heading', {name: 'Recaps'}); this.unreadTab = page.getByRole('button', {name: 'Unread', exact: true}); this.readTab = page.getByRole('button', {name: 'Read', exact: true}); + this.scheduledTab = page.getByRole('button', {name: 'Scheduled', exact: true}); this.addRecapButton = page.getByRole('button', {name: 'Add a recap'}); this.createRecapModal = new CreateRecapModal(page); } @@ -225,7 +323,12 @@ export default class RecapsPage { } async openCreateRecap() { - await this.addRecapButton.click(); + // A user with existing recaps opens the modal from the header "Add a recap" button; a user with + // no recaps uses the empty-state "Create a recap" button (the header button only renders while + // the recaps list is still loading). Both open the same modal, so click whichever is present. + const openButton = this.page.getByRole('button', {name: /^(Add a recap|Create a recap)$/}); + await expect(openButton.first()).toBeVisible({timeout: duration.one_min}); + await openButton.first().click(); await this.createRecapModal.toBeVisible(); return this.createRecapModal; } @@ -240,6 +343,11 @@ export default class RecapsPage { await expect(this.readTab).toHaveClass(/active/); } + async switchToScheduled() { + await this.scheduledTab.click(); + await expect(this.scheduledTab).toHaveClass(/active/); + } + async expectSetupPlaceholder() { await expect(this.page.getByRole('heading', {name: 'Set up your recap'})).toBeVisible(); await expect( @@ -251,13 +359,39 @@ export default class RecapsPage { } async expectCaughtUpEmptyState() { - await expect(this.page.getByRole('heading', {name: "You're all caught up"})).toBeVisible(); - await expect(this.page.getByText("You don't have any recaps yet. Create one to get started.")).toBeVisible(); + // A user with no recaps at all settles on the "Set up your recap" placeholder, while "You're all + // caught up" is the per-tab empty state shown during loading or when recaps live in other tabs. + // Accept either so the assertion is stable regardless of recaps-list load timing. + const caughtUp = this.page.getByRole('heading', {name: "You're all caught up"}); + const setup = this.page.getByRole('heading', {name: 'Set up your recap'}); + await expect(caughtUp.or(setup).first()).toBeVisible({timeout: duration.one_min}); + } + + async expectScheduledEmptyState() { + await expect(this.page.getByRole('heading', {name: 'Set up your first recap'})).toBeVisible(); + await expect( + this.page.getByText( + 'Copilot recaps help you get caught up quickly on discussions that are most important to you with a summarized report.', + ), + ).toBeVisible(); + await expect( + this.page.locator('.scheduled-recaps-empty-state').getByRole('button', {name: 'Create a recap'}), + ).toBeVisible(); + } + + async openCreateRecapFromScheduledEmptyState() { + await this.page.locator('.scheduled-recaps-empty-state').getByRole('button', {name: 'Create a recap'}).click(); + await this.createRecapModal.toBeVisible(); + return this.createRecapModal; } async expectAddRecapDisabled(reason: string) { - await expect(this.addRecapButton).toBeDisabled(); - await expect(this.addRecapButton).toHaveAttribute('title', reason); + // Depending on whether the user has recaps, the create affordance is either the header + // "Add a recap" button or the empty-state "Create a recap" button; both carry the disabled + // state and reason tooltip when the bridge is unavailable. + const button = this.page.getByRole('button', {name: /^(Add a recap|Create a recap)$/}).first(); + await expect(button).toBeDisabled(); + await expect(button).toHaveAttribute('title', reason); } async confirmDelete() { @@ -280,9 +414,25 @@ export default class RecapsPage { ); } + getScheduledRecap(title: string) { + return new ScheduledRecapItem( + this.page, + this.page + .locator('.scheduled-recap-item') + .filter({ + has: this.page.getByRole('heading', {name: title, exact: true}), + }) + .first(), + ); + } + async expectRecapNotVisible(title: string) { await expect(this.page.getByRole('heading', {name: title, exact: true})).not.toBeVisible(); } + + async expectScheduledRecapNotVisible(title: string) { + await expect(this.page.getByRole('heading', {name: title, exact: true})).not.toBeVisible(); + } } function escapeRegExp(value: string) { diff --git a/e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts b/e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts index 84377c792602..65384b73efd3 100644 --- a/e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts @@ -1,11 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {Client4} from '@mattermost/client'; -import type {Channel} from '@mattermost/types/channels'; - import {expect, test} from '@mattermost/playwright-lib'; -import type {PlaywrightExtended} from '@mattermost/playwright-lib'; + +import { + createChannelWithManyPosts, + createRecapAndWaitForStatus, + createUnreadChannelFixture, + markAllCurrentChannelsRead, + setupRecapBridge, + waitForRecapStatus, + waitForRecordedRequestCount, +} from './recaps_helpers'; /** * @objective Verify a user can create a selected-channels AI recap and receive the mocked summary without reloading the page @@ -50,6 +56,7 @@ test('creates selected-channels recap and auto-renders mocked summary', {tag: '@ const createRecapModal = await recapsPage.openCreateRecap(); await createRecapModal.fillTitle(recapTitle); await createRecapModal.selectSelectedChannels(); + await createRecapModal.enableRunOnce(); await createRecapModal.clickNext(); await createRecapModal.expectChannelSelectorVisible(); await createRecapModal.searchChannel(channel.display_name); @@ -137,6 +144,7 @@ test('creates all-unreads recap for only unread channels', {tag: '@ai_recaps'}, await createRecapModal.fillTitle(recapTitle); await createRecapModal.selectAgent(agent.displayName); await createRecapModal.selectAllUnreads(); + await createRecapModal.enableRunOnce(); await createRecapModal.clickNext(); // * Verify the all-unreads flow skips the channel selector and includes only the unread channels in the summary. @@ -283,13 +291,13 @@ test('deletes a recap from the recaps page', {tag: '@ai_recaps'}, async ({pw}) = await recap.clickDelete(); await recapsPage.confirmDelete(); - // * Verify the recap disappears from the list and the page returns to the setup placeholder. + // * Verify the recap disappears from the list and the page returns to the caught-up empty state. await recapsPage.expectRecapNotVisible(recapTitle); - await recapsPage.expectSetupPlaceholder(); + await recapsPage.expectCaughtUpEmptyState(); }); /** - * @objective Verify regenerating a recap returns it to processing and replaces the rendered summary with the latest mocked response + * @objective Verify regenerating a recap sends a new summary request and replaces the rendered summary with the latest mocked response */ test('regenerates a recap with a new mocked summary', {tag: '@ai_recaps'}, async ({pw}) => { const recapTitle = `Regenerated recap ${pw.random.id()}`; @@ -315,38 +323,48 @@ test('regenerates a recap with a new mocked summary', {tag: '@ai_recaps'}, async }), ], }); - - const channel = await createUnreadChannelFixture( - pw, - adminClient, - adminUser.id, - user.id, - team.id, - 'Regenerate recap channel', - sourceMessage, - ); - await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed'); - - // # Open the recap, confirm the original summary is visible, and trigger regeneration from the recap menu. - const {recapsPage} = await pw.testBrowser.login(user); - await recapsPage.goto(team.name); - await recapsPage.toBeVisible(); - - const recap = recapsPage.getRecap(recapTitle); - await recap.expand(); - await recap.expectText(firstHighlight); - await recap.openMenuAction('Regenerate this recap'); - - // * Verify the recap returns to the processing state and then renders the regenerated summary. - await recap.expectProcessing(); - await expect(recap.container).toContainText(secondHighlight, {timeout: pw.duration.one_min}); - await expect(recap.container).not.toContainText(firstHighlight); - - // * Verify two recap_summary requests were recorded for the original generation and the regeneration. - await waitForRecordedRequestCount(pw, adminClient, 2); - const bridgeState = await pw.getAIBridgeMock(adminClient); - const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary'); - expect(recapRequests).toHaveLength(2); + const originalConfig = await adminClient.getConfig(); + + try { + await adminClient.patchConfig({ + AIRecapSettings: { + EnforceCooldown: false, + }, + }); + + const channel = await createUnreadChannelFixture( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'Regenerate recap channel', + sourceMessage, + ); + await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed'); + + // # Open the recap, confirm the original summary is visible, and trigger regeneration from the recap menu. + const {recapsPage} = await pw.testBrowser.login(user); + await recapsPage.goto(team.name); + await recapsPage.toBeVisible(); + + const recap = recapsPage.getRecap(recapTitle); + await recap.expand(); + await recap.expectText(firstHighlight); + await recap.openMenuAction('Regenerate this recap'); + + // * Verify the regeneration request is recorded and then renders the regenerated summary. + await waitForRecordedRequestCount(pw, adminClient, 2); + await expect(recap.container).toContainText(secondHighlight, {timeout: pw.duration.one_min}); + await expect(recap.container).not.toContainText(firstHighlight); + + // * Verify two recap_summary requests were recorded for the original generation and the regeneration. + const bridgeState = await pw.getAIBridgeMock(adminClient); + const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary'); + expect(recapRequests).toHaveLength(2); + } finally { + await adminClient.updateConfig(originalConfig as any); + } }); /** @@ -466,129 +484,235 @@ test('executes recap channel card actions', {tag: '@ai_recaps'}, async ({pw}) => await expect(page).toHaveURL(new RegExp(`/${team.name}/channels/${channel.name}$`)); }); -async function setupRecapBridge( - pw: PlaywrightExtended, - adminClient: Client4, - { - available = true, - completions, - }: { - available?: boolean; - completions: Array<{completion?: string; error?: string; status_code?: number}>; - }, -) { - await pw.enableAIBridgeTestMode(adminClient, {enableRecaps: true}); - await pw.resetAIBridgeMock(adminClient); - - const {agent, service} = await pw.createMockAIAgent(adminClient, { - agent: { - id: `recap-agent-${pw.random.id()}`, - displayName: 'Recap Summary Agent', - username: `recap.summary.${pw.random.id()}`, - is_default: true, - }, - service: { - id: `recap-service-${pw.random.id()}`, - name: 'Recap Summary Service', - type: 'anthropic', - }, - }); - - await pw.configureAIBridgeMock(adminClient, { - status: {available}, - agents: [agent], - services: [service], - agent_completions: { - recap_summary: completions, - }, - record_requests: true, - }); - - return {agent, service}; -} - -async function createUnreadChannelFixture( - pw: PlaywrightExtended, - adminClient: Client4, - adminUserId: string, - userId: string, - teamId: string, - displayName: string, - sourceMessage: string, -) { - const channel = await adminClient.createChannel( - pw.random.channel({ - teamId, - name: `recap${pw.random.id()}`, - displayName, - unique: false, - }), - ); +/** + * @objective Verify the server enforces MaxTokensPerRecap by trimming source posts before summarizing, + * and includes all posts when token enforcement is disabled + */ +test('enforces the per-recap token limit on the immediate recap path', {tag: '@ai_recaps'}, async ({pw}) => { + const limitedTitle = `Token limited recap ${pw.random.id()}`; + const unlimitedTitle = `Token unlimited recap ${pw.random.id()}`; + const highlight = `Token limit highlight ${pw.random.id()}`; - await adminClient.addToChannel(userId, channel.id); - await adminClient.createPost({ - channel_id: channel.id, - user_id: adminUserId, - message: sourceMessage, - }); + // # Initialize the test server state and queue one recap completion per immediate recap run. + const {adminClient, adminUser, team, user, userClient} = await pw.initSetup(); + if (!adminUser) { + throw new Error('Failed to create admin user'); + } - return channel; -} - -async function createRecapAndWaitForStatus( - pw: PlaywrightExtended, - userClient: Client4, - recapTitle: string, - channelIds: string[], - agentId: string, - expectedStatus: string, -) { - const recap = await userClient.createRecap({ - title: recapTitle, - channel_ids: channelIds, - agent_id: agentId, + const {agent} = await setupRecapBridge(pw, adminClient, { + completions: [ + pw.recapCompletion({highlights: [highlight], actionItems: []}), + pw.recapCompletion({highlights: [highlight], actionItems: []}), + ], }); - await pw.waitUntil( - async () => { - const currentRecap = await userClient.getRecap(recap.id); - return currentRecap.status === expectedStatus; - }, - {timeout: pw.duration.one_min}, - ); + const originalConfig = await adminClient.getConfig(); + + // Each post is padded to ~400 characters (~100 estimated tokens at 4 chars/token), so a 150-token + // budget admits only the single newest post once enforcement is on. + const postCount = 10; + const messageLength = 400; + + try { + // # Enforce a small per-recap token budget and disable the cooldown so two recaps can run back to back. + await adminClient.patchConfig({ + AIRecapSettings: { + EnforceCooldown: false, + EnforceTokensPerRecap: true, + DefaultLimits: { + MaxTokensPerRecap: 150, + }, + }, + }); + + const {channel, markers} = await createChannelWithManyPosts( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'Token limit channel', + postCount, + messageLength, + ); + + // # Run an immediate recap with token enforcement ON. + const limitedRecap = await createRecapAndWaitForStatus( + pw, + userClient, + limitedTitle, + [channel.id], + agent.id, + 'completed', + ); + + // * Verify the server trimmed the source posts: fewer than were created are recorded on the recap channel. + const limitedChannel = limitedRecap.channels?.find((recapChannel) => recapChannel.channel_id === channel.id); + expect(limitedChannel).toBeDefined(); + const limitedCount = limitedChannel?.source_post_ids.length ?? 0; + expect(limitedCount).toBeGreaterThan(0); + expect(limitedCount).toBeLessThan(postCount); + + // # Disable token enforcement and run a second immediate recap over the same channel. + await adminClient.patchConfig({ + AIRecapSettings: { + EnforceCooldown: false, + EnforceTokensPerRecap: false, + }, + }); + + const unlimitedRecap = await createRecapAndWaitForStatus( + pw, + userClient, + unlimitedTitle, + [channel.id], + agent.id, + 'completed', + ); + + // * Verify all created posts are recorded when enforcement is off, and that it exceeds the trimmed count. + const unlimitedChannel = unlimitedRecap.channels?.find( + (recapChannel) => recapChannel.channel_id === channel.id, + ); + expect(unlimitedChannel).toBeDefined(); + const unlimitedCount = unlimitedChannel?.source_post_ids.length ?? 0; + expect(unlimitedCount).toBeGreaterThanOrEqual(postCount); + expect(limitedCount).toBeLessThan(unlimitedCount); + + // * Verify the recorded LLM payloads corroborate the trimming: fewer seeded posts reached the bridge + // for the enforced recap than for the unenforced one. + await waitForRecordedRequestCount(pw, adminClient, 2); + const bridgeState = await pw.getAIBridgeMock(adminClient); + const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary'); + expect(recapRequests).toHaveLength(2); + + const countMarkersInRequest = (request: (typeof recapRequests)[number]) => { + const text = request.messages.map((message) => message.message).join('\n'); + return markers.filter((marker) => text.includes(marker)).length; + }; + + const limitedMarkers = countMarkersInRequest(recapRequests[0]); + const unlimitedMarkers = countMarkersInRequest(recapRequests[1]); + expect(limitedMarkers).toBeGreaterThan(0); + expect(limitedMarkers).toBeLessThan(postCount); + expect(unlimitedMarkers).toBe(postCount); + expect(limitedMarkers).toBeLessThan(unlimitedMarkers); + } finally { + await adminClient.updateConfig(originalConfig as any); + } +}); - return userClient.getRecap(recap.id); -} +/** + * @objective Verify MaxTokensPerRecap is enforced as an independent per-channel cap: a single recap + * spanning two channels trims each channel's source posts to its own budget rather than sharing one + * cross-channel budget + */ +test('enforces the per-recap token limit independently per channel', {tag: '@ai_recaps'}, async ({pw}) => { + const recapTitle = `Per-channel token recap ${pw.random.id()}`; + const highlight = `Per-channel token highlight ${pw.random.id()}`; -async function waitForRecapStatus( - pw: PlaywrightExtended, - userClient: Client4, - recapTitle: string, - expectedStatus: string, -) { - await pw.waitUntil( - async () => { - const recaps = await userClient.getRecaps(0, 60); - return recaps.some((recap) => recap.title === recapTitle && recap.status === expectedStatus); - }, - {timeout: pw.duration.one_min}, - ); -} + // # Initialize the test server state and queue one recap completion per channel summarized. + const {adminClient, adminUser, team, user, userClient} = await pw.initSetup(); + if (!adminUser) { + throw new Error('Failed to create admin user'); + } -async function waitForRecordedRequestCount(pw: PlaywrightExtended, adminClient: Client4, requestCount: number) { - await pw.waitUntil( - async () => { - const bridgeState = await pw.getAIBridgeMock(adminClient); - return ( - bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary').length === - requestCount - ); - }, - {timeout: pw.duration.one_min}, - ); -} + const {agent} = await setupRecapBridge(pw, adminClient, { + completions: [ + pw.recapCompletion({highlights: [highlight], actionItems: []}), + pw.recapCompletion({highlights: [highlight], actionItems: []}), + ], + }); -async function markAllCurrentChannelsRead(userClient: Client4, teamId: string) { - const currentChannels = await userClient.getMyChannels(teamId); - await userClient.readMultipleChannels(currentChannels.map((channel: Channel) => channel.id)); -} + const originalConfig = await adminClient.getConfig(); + + // Each post is padded to ~400 characters (~100 estimated tokens), so a 150-token per-channel + // budget admits only the single newest post in each channel once enforcement is on. + const postCount = 6; + const messageLength = 400; + + try { + // # Enforce a small per-channel token budget; disable cooldown so the immediate recap runs cleanly. + await adminClient.patchConfig({ + AIRecapSettings: { + EnforceCooldown: false, + EnforceTokensPerRecap: true, + DefaultLimits: { + MaxTokensPerRecap: 150, + }, + }, + }); + + const first = await createChannelWithManyPosts( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'Per-channel token channel one', + postCount, + messageLength, + ); + const second = await createChannelWithManyPosts( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'Per-channel token channel two', + postCount, + messageLength, + ); + + // # Run a single immediate recap covering both seeded channels. + const recap = await createRecapAndWaitForStatus( + pw, + userClient, + recapTitle, + [first.channel.id, second.channel.id], + agent.id, + 'completed', + ); + + // * Verify each channel was trimmed independently to its own per-channel budget. + const firstChannel = recap.channels?.find((recapChannel) => recapChannel.channel_id === first.channel.id); + const secondChannel = recap.channels?.find((recapChannel) => recapChannel.channel_id === second.channel.id); + expect(firstChannel).toBeDefined(); + expect(secondChannel).toBeDefined(); + + const firstCount = firstChannel?.source_post_ids.length ?? 0; + const secondCount = secondChannel?.source_post_ids.length ?? 0; + + expect(firstCount).toBeGreaterThan(0); + expect(firstCount).toBeLessThan(postCount); + expect(secondCount).toBeGreaterThan(0); + expect(secondCount).toBeLessThan(postCount); + + // * Verify the cap is per channel, not a shared cross-channel budget: a shared 150-token budget + // could only retain posts in one channel (exhausting it before the second), so both channels + // retaining posts means the combined kept count exceeds any single channel's budget. + expect(firstCount + secondCount).toBeGreaterThan(firstCount); + expect(firstCount + secondCount).toBeGreaterThan(secondCount); + + // * Verify the recorded LLM payloads corroborate per-channel trimming: one request per channel, + // each carrying only its own trimmed subset of seeded markers. + await waitForRecordedRequestCount(pw, adminClient, 2); + const bridgeState = await pw.getAIBridgeMock(adminClient); + const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary'); + expect(recapRequests).toHaveLength(2); + + const countMarkers = (markers: string[]) => { + const text = recapRequests + .flatMap((request) => request.messages.map((message) => message.message)) + .join('\n'); + return markers.filter((marker) => text.includes(marker)).length; + }; + + const firstMarkers = countMarkers(first.markers); + const secondMarkers = countMarkers(second.markers); + expect(firstMarkers).toBe(firstCount); + expect(secondMarkers).toBe(secondCount); + } finally { + await adminClient.updateConfig(originalConfig as any); + } +}); diff --git a/e2e-tests/playwright/specs/functional/channels/ai/recaps_helpers.ts b/e2e-tests/playwright/specs/functional/channels/ai/recaps_helpers.ts new file mode 100644 index 000000000000..16cff526eee9 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/ai/recaps_helpers.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; +import type {Channel} from '@mattermost/types/channels'; + +import type {PlaywrightExtended} from '@mattermost/playwright-lib'; + +type RecapBridgeSetup = { + agent: { + id: string; + displayName: string; + username: string; + }; + service: { + id: string; + name: string; + type: string; + }; +}; + +export async function setupRecapBridge( + pw: PlaywrightExtended, + adminClient: Client4, + { + available = true, + completions, + }: { + available?: boolean; + completions: Array<{completion?: string; error?: string; status_code?: number}>; + }, +): Promise { + await pw.enableAIBridgeTestMode(adminClient, {enableRecaps: true}); + await pw.resetAIBridgeMock(adminClient); + + const {agent, service} = await pw.createMockAIAgent(adminClient, { + agent: { + id: `recap-agent-${pw.random.id()}`, + displayName: 'Recap Summary Agent', + username: `recap.summary.${pw.random.id()}`, + is_default: true, + }, + service: { + id: `recap-service-${pw.random.id()}`, + name: 'Recap Summary Service', + type: 'anthropic', + }, + }); + + await pw.configureAIBridgeMock(adminClient, { + status: {available}, + agents: [agent], + services: [service], + agent_completions: { + recap_summary: completions, + }, + record_requests: true, + }); + + return {agent, service}; +} + +export async function createUnreadChannelFixture( + pw: PlaywrightExtended, + adminClient: Client4, + adminUserId: string, + userId: string, + teamId: string, + displayName: string, + sourceMessage: string, +) { + const channel = await adminClient.createChannel( + pw.random.channel({ + teamId, + name: `recap${pw.random.id()}`, + displayName, + unique: false, + }), + ); + + await adminClient.addToChannel(userId, channel.id); + await adminClient.createPost({ + channel_id: channel.id, + user_id: adminUserId, + message: sourceMessage, + }); + + return channel; +} + +// createChannelWithManyPosts seeds a channel (with the user added as a member) and posts `postCount` +// admin messages, each padded to roughly `messageLength` characters and tagged with a unique marker so +// tests can detect exactly which posts reached the recap. Posts are returned in creation order +// (oldest first); markers[i] corresponds to posts[i]. +export async function createChannelWithManyPosts( + pw: PlaywrightExtended, + adminClient: Client4, + adminUserId: string, + userId: string, + teamId: string, + displayName: string, + postCount: number, + messageLength: number, +) { + const channel = await adminClient.createChannel( + pw.random.channel({ + teamId, + name: `recap${pw.random.id()}`, + displayName, + unique: false, + }), + ); + + await adminClient.addToChannel(userId, channel.id); + + const runId = pw.random.id(); + const posts = []; + const markers: string[] = []; + for (let index = 0; index < postCount; index++) { + const marker = `RTM-${runId}-${index}`; + const padding = 'x'.repeat(Math.max(0, messageLength - marker.length - 1)); + const message = `${marker} ${padding}`; + const post = await adminClient.createPost({ + channel_id: channel.id, + user_id: adminUserId, + message, + }); + posts.push(post); + markers.push(marker); + } + + return {channel, posts, markers}; +} + +export async function createRecapAndWaitForStatus( + pw: PlaywrightExtended, + userClient: Client4, + recapTitle: string, + channelIds: string[], + agentId: string, + expectedStatus: string, +) { + const recap = await userClient.createRecap({ + title: recapTitle, + channel_ids: channelIds, + agent_id: agentId, + }); + + await pw.waitUntil( + async () => { + const currentRecap = await userClient.getRecap(recap.id); + return currentRecap.status === expectedStatus; + }, + {timeout: pw.duration.one_min}, + ); + + return userClient.getRecap(recap.id); +} + +export async function waitForRecapStatus( + pw: PlaywrightExtended, + userClient: Client4, + recapTitle: string, + expectedStatus: string, +) { + await pw.waitUntil( + async () => { + const recaps = await userClient.getRecaps(0, 60); + return recaps.some((recap) => recap.title === recapTitle && recap.status === expectedStatus); + }, + {timeout: pw.duration.one_min}, + ); +} + +export async function waitForRecordedRequestCount(pw: PlaywrightExtended, adminClient: Client4, requestCount: number) { + await pw.waitUntil( + async () => { + const bridgeState = await pw.getAIBridgeMock(adminClient); + return ( + bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary').length === + requestCount + ); + }, + {timeout: pw.duration.one_min}, + ); +} + +export async function markAllCurrentChannelsRead(userClient: Client4, teamId: string) { + const currentChannels = await userClient.getMyChannels(teamId); + await userClient.readMultipleChannels(currentChannels.map((channel: Channel) => channel.id)); +} diff --git a/e2e-tests/playwright/specs/functional/channels/ai/recaps_scheduled.spec.ts b/e2e-tests/playwright/specs/functional/channels/ai/recaps_scheduled.spec.ts new file mode 100644 index 000000000000..fe8d31d6c31f --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/ai/recaps_scheduled.spec.ts @@ -0,0 +1,401 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +import {createUnreadChannelFixture, setupRecapBridge} from './recaps_helpers'; + +const MONDAY = 1 << 1; +const WEDNESDAY = 1 << 3; + +/** + * @objective Verify a user can create, list, pause, and resume a scheduled AI recap + */ +test('creates and manages a scheduled recap', {tag: '@ai_recaps'}, async ({pw}) => { + const recapTitle = `Scheduled recap ${pw.random.id()}`; + const sourceMessage = `Scheduled recap source ${pw.random.id()}`; + + // # Initialize the test server state, configure a deterministic recap agent, and seed a channel. + const {adminClient, adminUser, team, user, userClient} = await pw.initSetup(); + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + const {agent} = await setupRecapBridge(pw, adminClient, { + completions: [], + }); + + const channel = await createUnreadChannelFixture( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'Scheduled recap channel', + sourceMessage, + ); + + // # Create a selected-channel scheduled recap through the modal flow. + const {recapsPage} = await pw.testBrowser.login(user); + await recapsPage.goto(team.name); + await recapsPage.toBeVisible(); + + const createRecapModal = await recapsPage.openCreateRecap(); + await createRecapModal.fillTitle(recapTitle); + await createRecapModal.selectSelectedChannels(); + await createRecapModal.clickNext(); + await createRecapModal.expectChannelSelectorVisible(); + await createRecapModal.searchChannel(channel.display_name); + await createRecapModal.selectChannel(channel.display_name); + await createRecapModal.clickNext(); + await createRecapModal.expectScheduleConfigurationVisible(); + await createRecapModal.selectScheduleDay('M'); + await createRecapModal.createSchedule(); + + // * Verify the scheduled recap is listed with stable schedule structure and active state. + await recapsPage.toBeVisible(); + await recapsPage.switchToScheduled(); + + const scheduledRecap = recapsPage.getScheduledRecap(recapTitle); + await scheduledRecap.toBeVisible(); + await scheduledRecap.expectSchedulePattern(/Mon|Monday/); + await scheduledRecap.expectSchedulePattern(/ at /); + await scheduledRecap.expectActive(); + + // * Verify pause and resume update both the UI and the stored scheduled recap. + await scheduledRecap.pause(); + await scheduledRecap.resume(); + + const scheduledRecaps = await userClient.getScheduledRecaps(0, 60); + const createdRecap = scheduledRecaps.find((recap) => recap.title === recapTitle); + expect(createdRecap).toBeDefined(); + expect(createdRecap?.agent_id).toBe(agent.id); + expect(createdRecap?.channel_ids).toContain(channel.id); + expect(createdRecap?.days_of_week).toBe(MONDAY); + expect(createdRecap?.enabled).toBe(true); +}); + +/** + * @objective Verify the scheduled recap limit rejects creating more active scheduled recaps than configured + */ +test('rejects scheduled recap creation after the active schedule limit', {tag: '@ai_recaps'}, async ({pw}) => { + const titlePrefix = `Limited scheduled recap ${pw.random.id()}`; + const sourceMessage = `Scheduled limit source ${pw.random.id()}`; + + // # Initialize an isolated user, configure the recap bridge, and set a one-scheduled-recap limit. + const {adminClient, adminUser, team, user, userClient} = await pw.initSetup(); + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + const {agent} = await setupRecapBridge(pw, adminClient, { + completions: [], + }); + const originalConfig = await adminClient.getConfig(); + + try { + await adminClient.patchConfig({ + AIRecapSettings: { + EnforceScheduledRecaps: true, + DefaultLimits: { + MaxScheduledRecaps: 1, + }, + }, + }); + + const channel = await createUnreadChannelFixture( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'Scheduled limit recap channel', + sourceMessage, + ); + const scheduledRecapInput = { + days_of_week: WEDNESDAY, + time_of_day: '09:00', + timezone: 'UTC', + time_period: 'last_24h' as const, + channel_mode: 'specific' as const, + channel_ids: [channel.id], + agent_id: agent.id, + is_recurring: true, + }; + + const allowedRecap = await userClient.createScheduledRecap({ + ...scheduledRecapInput, + title: `${titlePrefix} allowed`, + }); + + let blockedError: {status_code?: number; message?: string} | undefined; + try { + await userClient.createScheduledRecap({ + ...scheduledRecapInput, + title: `${titlePrefix} blocked`, + }); + } catch (error) { + blockedError = error as {status_code?: number; message?: string}; + } + + // * Verify the API rejects the over-limit schedule without creating the second recap. + expect(blockedError?.status_code).toBe(400); + expect(blockedError?.message).toContain('scheduled recaps'); + + const scheduledRecaps = await userClient.getScheduledRecaps(0, 60); + expect(scheduledRecaps.some((recap) => recap.id === allowedRecap.id)).toBe(true); + expect(scheduledRecaps.some((recap) => recap.title === `${titlePrefix} blocked`)).toBe(false); + } finally { + await adminClient.updateConfig(originalConfig as any); + } +}); + +/** + * @objective Verify a user can edit a scheduled recap's title and schedule days through the Options menu + */ +test('edits a scheduled recap through the options menu', {tag: '@ai_recaps'}, async ({pw}) => { + const originalTitle = `Editable scheduled recap ${pw.random.id()}`; + const updatedTitle = `Edited scheduled recap ${pw.random.id()}`; + const sourceMessage = `Edit scheduled source ${pw.random.id()}`; + + // # Initialize the test server state, configure the recap bridge, and seed a channel. + const {adminClient, adminUser, team, user, userClient} = await pw.initSetup(); + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + const {agent} = await setupRecapBridge(pw, adminClient, { + completions: [], + }); + + const channel = await createUnreadChannelFixture( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'Edit scheduled recap channel', + sourceMessage, + ); + + // # Create a Monday-only scheduled recap through the API so the edit flow starts from a known state. + const createdRecap = await userClient.createScheduledRecap({ + title: originalTitle, + days_of_week: MONDAY, + time_of_day: '09:00', + timezone: 'UTC', + time_period: 'last_24h', + channel_mode: 'specific', + channel_ids: [channel.id], + agent_id: agent.id, + is_recurring: true, + }); + + // # Open the Scheduled tab and launch the edit modal from the recap's Options menu. + const {recapsPage} = await pw.testBrowser.login(user); + await recapsPage.goto(team.name); + await recapsPage.toBeVisible(); + await recapsPage.switchToScheduled(); + + const scheduledRecap = recapsPage.getScheduledRecap(originalTitle); + await scheduledRecap.toBeVisible(); + await scheduledRecap.editViaMenu(); + + // # Update the title on the first step and add Wednesday on the schedule step of the pre-filled modal. + const editModal = recapsPage.createRecapModal; + await editModal.toBeVisible(); + await editModal.fillTitle(updatedTitle); + await editModal.clickNext(); + await editModal.expectChannelSelectorVisible(); + await editModal.clickNext(); + await editModal.expectScheduleConfigurationVisible(); + await editModal.selectScheduleDay('W'); + await editModal.saveChanges(); + + // * Verify the list reflects the new title and the dropped original title, and shows the added day. + await recapsPage.toBeVisible(); + const updatedRecap = recapsPage.getScheduledRecap(updatedTitle); + await updatedRecap.toBeVisible(); + await updatedRecap.expectSchedulePattern(/Wed|Wednesday/); + await recapsPage.expectScheduledRecapNotVisible(originalTitle); + + // * Verify the persisted scheduled recap carries the updated title and the Monday+Wednesday schedule. + await pw.waitUntil( + async () => { + const scheduledRecaps = await userClient.getScheduledRecaps(0, 60); + const persisted = scheduledRecaps.find((recap) => recap.title === updatedTitle); + return Boolean(persisted) && persisted?.days_of_week === (MONDAY | WEDNESDAY); + }, + {timeout: pw.duration.one_min}, + ); + + const scheduledRecaps = await userClient.getScheduledRecaps(0, 60); + expect(scheduledRecaps.some((recap) => recap.title === originalTitle)).toBe(false); + const persisted = scheduledRecaps.find((recap) => recap.title === updatedTitle); + expect(persisted).toBeDefined(); + expect(persisted?.days_of_week).toBe(MONDAY | WEDNESDAY); + expect(persisted?.channel_ids).toContain(channel.id); + + // * Verify the edit updated the existing recap in place (same id) rather than recreating it, and + // that fields untouched by the edit (time_of_day) survived. + expect(persisted?.id).toBe(createdRecap.id); + expect(persisted?.time_of_day).toBe('09:00'); +}); + +/** + * @objective Verify a user can delete a scheduled recap through the Options menu and confirm dialog + */ +test('deletes a scheduled recap through the options menu', {tag: '@ai_recaps'}, async ({pw}) => { + const recapTitle = `Deletable scheduled recap ${pw.random.id()}`; + const sourceMessage = `Delete scheduled source ${pw.random.id()}`; + + // # Initialize the test server state, configure the recap bridge, and seed a channel. + const {adminClient, adminUser, team, user, userClient} = await pw.initSetup(); + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + const {agent} = await setupRecapBridge(pw, adminClient, { + completions: [], + }); + + const channel = await createUnreadChannelFixture( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'Delete scheduled recap channel', + sourceMessage, + ); + + // # Create a scheduled recap through the API so the UI delete flow has something to remove. + const createdRecap = await userClient.createScheduledRecap({ + title: recapTitle, + days_of_week: MONDAY, + time_of_day: '09:00', + timezone: 'UTC', + time_period: 'last_24h', + channel_mode: 'specific', + channel_ids: [channel.id], + agent_id: agent.id, + is_recurring: true, + }); + + // # Open the Scheduled tab, trigger Delete from the Options menu, and confirm the dialog. + const {recapsPage} = await pw.testBrowser.login(user); + await recapsPage.goto(team.name); + await recapsPage.toBeVisible(); + await recapsPage.switchToScheduled(); + + const scheduledRecap = recapsPage.getScheduledRecap(recapTitle); + await scheduledRecap.toBeVisible(); + await scheduledRecap.deleteViaMenu(); + await recapsPage.confirmDelete(); + + // * Verify the recap disappears from the list and the empty state returns. + await recapsPage.expectScheduledRecapNotVisible(recapTitle); + await recapsPage.expectScheduledEmptyState(); + + // * Verify the scheduled recap is no longer returned by the API. + await pw.waitUntil( + async () => { + const scheduledRecaps = await userClient.getScheduledRecaps(0, 60); + return !scheduledRecaps.some((recap) => recap.id === createdRecap.id); + }, + {timeout: pw.duration.one_min}, + ); +}); + +/** + * @objective Verify a fresh user with no scheduled recaps sees the scheduled empty-state call to action + */ +test('shows the scheduled empty state for a user with no scheduled recaps', {tag: '@ai_recaps'}, async ({pw}) => { + // # Initialize the test server state and configure the recap bridge so the page renders fully. + const {adminClient, team, user} = await pw.initSetup(); + await setupRecapBridge(pw, adminClient, { + completions: [], + }); + + // # Open the recaps page and switch to the Scheduled tab. + const {recapsPage} = await pw.testBrowser.login(user); + await recapsPage.goto(team.name); + await recapsPage.toBeVisible(); + await recapsPage.switchToScheduled(); + + // * Verify the scheduled empty state heading, description, and create CTA are shown. + await recapsPage.expectScheduledEmptyState(); + + // * Verify the empty-state CTA is wired up: clicking it opens the create recap modal. + const createRecapModal = await recapsPage.openCreateRecapFromScheduledEmptyState(); + await createRecapModal.toBeVisible(); +}); + +/** + * @objective Verify the all-unreads scheduled flow skips channel selection and stores the all_unreads mode + */ +test('creates an all-unreads scheduled recap through the modal', {tag: '@ai_recaps'}, async ({pw}) => { + const recapTitle = `All unreads scheduled recap ${pw.random.id()}`; + const sourceMessage = `All unreads scheduled source ${pw.random.id()}`; + + // # Initialize the test server state, configure the recap bridge, and seed an unread channel. + const {adminClient, adminUser, team, user, userClient} = await pw.initSetup(); + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + const {agent} = await setupRecapBridge(pw, adminClient, { + completions: [], + }); + + await createUnreadChannelFixture( + pw, + adminClient, + adminUser.id, + user.id, + team.id, + 'All unreads scheduled channel', + sourceMessage, + ); + + // # Open the modal, choose all-unreads mode, and advance directly to the schedule step. + const {recapsPage} = await pw.testBrowser.login(user); + await recapsPage.goto(team.name); + await recapsPage.toBeVisible(); + + const createRecapModal = await recapsPage.openCreateRecap(); + await createRecapModal.fillTitle(recapTitle); + await createRecapModal.selectAllUnreads(); + await createRecapModal.clickNext(); + + // * Verify the all-unreads flow skips the channel selector and lands on schedule configuration. + await createRecapModal.expectChannelSelectorHidden(); + await createRecapModal.expectScheduleConfigurationVisible(); + await createRecapModal.selectScheduleDay('M'); + await createRecapModal.createSchedule(); + + // * Verify the scheduled recap is listed. + await recapsPage.toBeVisible(); + await recapsPage.switchToScheduled(); + await recapsPage.getScheduledRecap(recapTitle).toBeVisible(); + + // * Verify the persisted scheduled recap uses the all_unreads channel mode and is active. + await pw.waitUntil( + async () => { + const scheduledRecaps = await userClient.getScheduledRecaps(0, 60); + return scheduledRecaps.some((recap) => recap.title === recapTitle); + }, + {timeout: pw.duration.one_min}, + ); + + const scheduledRecaps = await userClient.getScheduledRecaps(0, 60); + const persisted = scheduledRecaps.find((recap) => recap.title === recapTitle); + expect(persisted).toBeDefined(); + expect(persisted?.channel_mode).toBe('all_unreads'); + expect(persisted?.agent_id).toBe(agent.id); + expect(persisted?.days_of_week).toBe(MONDAY); + expect(persisted?.enabled).toBe(true); + + // * Verify the all_unreads contract is fully pinned: no specific channels are persisted. + expect(persisted?.channel_ids ?? []).toHaveLength(0); +}); diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 2b053a0a854e..dff1e99090a8 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -106,6 +106,9 @@ type Routes struct { Recaps *mux.Router // 'api/v4/recaps' + ScheduledRecaps *mux.Router // 'api/v4/scheduled_recaps' + ScheduledRecap *mux.Router // 'api/v4/scheduled_recaps/{scheduled_recap_id:[A-Za-z0-9]+}' + Preferences *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/preferences' License *mux.Router // 'api/v4/license' @@ -274,6 +277,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Reactions = api.BaseRoutes.APIRoot.PathPrefix("/reactions").Subrouter() api.BaseRoutes.Jobs = api.BaseRoutes.APIRoot.PathPrefix("/jobs").Subrouter() api.BaseRoutes.Recaps = api.BaseRoutes.APIRoot.PathPrefix("/recaps").Subrouter() + api.BaseRoutes.ScheduledRecaps = api.BaseRoutes.APIRoot.PathPrefix("/scheduled_recaps").Subrouter() + api.BaseRoutes.ScheduledRecap = api.BaseRoutes.ScheduledRecaps.PathPrefix("/{scheduled_recap_id:[A-Za-z0-9]+}").Subrouter() api.BaseRoutes.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter() api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter() @@ -366,6 +371,7 @@ func Init(srv *app.Server) (*API, error) { api.InitBrand() api.InitJob() api.InitRecap() + api.InitScheduledRecap() api.InitCommand() api.InitStatus() api.InitWebSocket() diff --git a/server/channels/api4/recap.go b/server/channels/api4/recap.go index dda803bf8e39..a5367736d3e0 100644 --- a/server/channels/api4/recap.go +++ b/server/channels/api4/recap.go @@ -15,6 +15,7 @@ import ( func (api *API) InitRecap() { api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(createRecap)).Methods(http.MethodPost) api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(getRecaps)).Methods(http.MethodGet) + api.BaseRoutes.Recaps.Handle("/limit_status", api.APISessionRequired(getRecapLimitStatus)).Methods(http.MethodGet) api.BaseRoutes.Recaps.Handle("/mark_viewed", api.APISessionRequired(markRecapsAsViewed)).Methods(http.MethodPost) api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(getRecap)).Methods(http.MethodGet) api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/read", api.APISessionRequired(markRecapAsRead)).Methods(http.MethodPost) @@ -23,7 +24,7 @@ func (api *API) InitRecap() { } func requireRecapsEnabled(c *Context) { - if !c.App.Config().FeatureFlags.EnableAIRecaps { + if !c.App.AIRecapsEnabled() { c.Err = model.NewAppError("requireRecapsEnabled", "api.recap.disabled.app_error", nil, "", http.StatusNotImplemented) return } @@ -42,6 +43,25 @@ func addRecapChannelIDsToAuditRec(auditRec *model.AuditRecord, recap *model.Reca model.AddEventParameterToAuditRec(auditRec, "channel_ids", channelIDs) } +func getRecapLimitStatus(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + userID := c.AppContext.Session().UserId + + status, appErr := c.App.GetRecapLimitStatus(userID) + if appErr != nil { + c.Err = appErr + return + } + + if err := json.NewEncoder(w).Encode(status); err != nil { + c.Logger.Warn("Error writing response", mlog.Err(err)) + } +} + func createRecap(c *Context, w http.ResponseWriter, r *http.Request) { requireRecapsEnabled(c) if c.Err != nil { diff --git a/server/channels/api4/scheduled_recap.go b/server/channels/api4/scheduled_recap.go new file mode 100644 index 000000000000..39dc00a14d03 --- /dev/null +++ b/server/channels/api4/scheduled_recap.go @@ -0,0 +1,316 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/app" +) + +// requireScheduledRecapOwnership fetches a scheduled recap and verifies the current user owns it. +// Returns the recap on success, or nil if c.Err was set. +func requireScheduledRecapOwnership(c *Context) *model.ScheduledRecap { + recap, err := c.App.GetScheduledRecap(c.AppContext, c.Params.ScheduledRecapId) + if err != nil { + c.Err = err + return nil + } + + if recap.UserId != c.AppContext.Session().UserId { + c.Err = model.NewAppError("requireScheduledRecapOwnership", "api.scheduled_recap.permission_denied", nil, "", http.StatusForbidden) + return nil + } + + return recap +} + +func (api *API) InitScheduledRecap() { + api.BaseRoutes.ScheduledRecaps.Handle("", api.APISessionRequired(createScheduledRecap)).Methods(http.MethodPost) + api.BaseRoutes.ScheduledRecaps.Handle("", api.APISessionRequired(getScheduledRecaps)).Methods(http.MethodGet) + api.BaseRoutes.ScheduledRecap.Handle("", api.APISessionRequired(getScheduledRecap)).Methods(http.MethodGet) + api.BaseRoutes.ScheduledRecap.Handle("", api.APISessionRequired(updateScheduledRecap)).Methods(http.MethodPut) + api.BaseRoutes.ScheduledRecap.Handle("", api.APISessionRequired(deleteScheduledRecap)).Methods(http.MethodDelete) + api.BaseRoutes.ScheduledRecap.Handle("/pause", api.APISessionRequired(pauseScheduledRecap)).Methods(http.MethodPost) + api.BaseRoutes.ScheduledRecap.Handle("/resume", api.APISessionRequired(resumeScheduledRecap)).Methods(http.MethodPost) +} + +func createScheduledRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + var recap model.ScheduledRecap + if err := json.NewDecoder(r.Body).Decode(&recap); err != nil { + c.SetInvalidParamWithErr("body", err) + return + } + + // Validate required fields + if recap.Title == "" { + c.SetInvalidParam("title") + return + } + if recap.DaysOfWeek == 0 { + c.SetInvalidParam("days_of_week") + return + } + if recap.TimeOfDay == "" { + c.SetInvalidParam("time_of_day") + return + } + if recap.Timezone == "" { + c.SetInvalidParam("timezone") + return + } + if recap.TimePeriod == "" { + c.SetInvalidParam("time_period") + return + } + if recap.ChannelMode == "" { + c.SetInvalidParam("channel_mode") + return + } + if recap.AgentId == "" { + c.SetInvalidParam("agent_id") + return + } + recap.Enabled = true + + auditRec := c.MakeAuditRecord(model.AuditEventCreateScheduledRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("scheduled_recap") + model.AddEventParameterToAuditRec(auditRec, "title", recap.Title) + model.AddEventParameterToAuditRec(auditRec, "agent_id", recap.AgentId) + model.AddEventParameterToAuditRec(auditRec, "channel_mode", recap.ChannelMode) + + savedRecap, err := c.App.CreateScheduledRecap(c.AppContext, &recap) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + auditRec.AddEventResultState(savedRecap) + + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(savedRecap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func getScheduledRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireScheduledRecapId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventGetScheduledRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("scheduled_recap") + model.AddEventParameterToAuditRec(auditRec, "scheduled_recap_id", c.Params.ScheduledRecapId) + + recap := requireScheduledRecapOwnership(c) + if c.Err != nil { + return + } + + auditRec.Success() + auditRec.AddEventResultState(recap) + + if err := json.NewEncoder(w).Encode(recap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func getScheduledRecaps(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventGetScheduledRecaps, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelAPI) + model.AddEventParameterToAuditRec(auditRec, "page", c.Params.Page) + model.AddEventParameterToAuditRec(auditRec, "per_page", c.Params.PerPage) + + recaps, err := c.App.GetScheduledRecapsForUser(c.AppContext, c.Params.Page, c.Params.PerPage) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + if len(recaps) > 0 { + auditRec.AddMeta("scheduled_recap_count", len(recaps)) + } + + if err := json.NewEncoder(w).Encode(recaps); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func updateScheduledRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireScheduledRecapId() + if c.Err != nil { + return + } + + var recap model.ScheduledRecap + if err := json.NewDecoder(r.Body).Decode(&recap); err != nil { + c.SetInvalidParamWithErr("body", err) + return + } + + // Ensure ID matches URL param + recap.Id = c.Params.ScheduledRecapId + + existingRecap := requireScheduledRecapOwnership(c) + if c.Err != nil { + return + } + + // Preserve fields that shouldn't be changed via update + recap.UserId = existingRecap.UserId + recap.CreateAt = existingRecap.CreateAt + recap.LastRunAt = existingRecap.LastRunAt + recap.RunCount = existingRecap.RunCount + recap.Enabled = existingRecap.Enabled + + auditRec := c.MakeAuditRecord(model.AuditEventUpdateScheduledRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("scheduled_recap") + model.AddEventParameterToAuditRec(auditRec, "scheduled_recap_id", c.Params.ScheduledRecapId) + auditRec.AddEventPriorState(existingRecap) + + updatedRecap, err := c.App.UpdateScheduledRecap(c.AppContext, &recap) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + auditRec.AddEventResultState(updatedRecap) + + if err := json.NewEncoder(w).Encode(updatedRecap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func deleteScheduledRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireScheduledRecapId() + if c.Err != nil { + return + } + + existingRecap := requireScheduledRecapOwnership(c) + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventDeleteScheduledRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("scheduled_recap") + model.AddEventParameterToAuditRec(auditRec, "scheduled_recap_id", c.Params.ScheduledRecapId) + auditRec.AddEventPriorState(existingRecap) + + if err := c.App.DeleteScheduledRecap(c.AppContext, c.Params.ScheduledRecapId); err != nil { + c.Err = err + return + } + + auditRec.Success() + ReturnStatusOK(w) +} + +func pauseScheduledRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireScheduledRecapId() + if c.Err != nil { + return + } + + existingRecap := requireScheduledRecapOwnership(c) + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventPauseScheduledRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("scheduled_recap") + model.AddEventParameterToAuditRec(auditRec, "scheduled_recap_id", c.Params.ScheduledRecapId) + auditRec.AddEventPriorState(existingRecap) + + pausedRecap, err := c.App.PauseScheduledRecap(c.AppContext, c.Params.ScheduledRecapId) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + auditRec.AddEventResultState(pausedRecap) + + if err := json.NewEncoder(w).Encode(pausedRecap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func resumeScheduledRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireScheduledRecapId() + if c.Err != nil { + return + } + + existingRecap := requireScheduledRecapOwnership(c) + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventResumeScheduledRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("scheduled_recap") + model.AddEventParameterToAuditRec(auditRec, "scheduled_recap_id", c.Params.ScheduledRecapId) + auditRec.AddEventPriorState(existingRecap) + + resumedRecap, err := c.App.ResumeScheduledRecap(c.AppContext, c.Params.ScheduledRecapId) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + auditRec.AddEventResultState(resumedRecap) + + if err := json.NewEncoder(w).Encode(resumedRecap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} diff --git a/server/channels/app/recap.go b/server/channels/app/recap.go index 6277add027d6..aae5866f4c1a 100644 --- a/server/channels/app/recap.go +++ b/server/channels/app/recap.go @@ -4,22 +4,42 @@ package app import ( + "errors" "net/http" "strings" + "time" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" ) // CreateRecap creates a new recap job for the specified channels func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, agentID string) (*model.Recap, *model.AppError) { + if appErr := a.requireAIRecapsEnabled("CreateRecap"); appErr != nil { + return nil, appErr + } + userID := rctx.Session().UserId - // Validate user is member of all channels - for _, channelID := range channelIDs { - if ok, _ := a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionReadChannel); !ok { - return nil, model.NewAppError("CreateRecap", "app.recap.permission_denied", nil, "", http.StatusForbidden) - } + limits, err := a.GetEffectiveLimits() + if err != nil { + return nil, err + } + + // The channel count check runs first so it bounds the permission check below. + if model.IsLimitEnabled(limits.MaxChannelsPerRecap) && len(channelIDs) > limits.MaxChannelsPerRecap { + return nil, recapMaxChannelsExceededError("CreateRecap", limits.MaxChannelsPerRecap, len(channelIDs)) + } + + // Validate user can read all channels + if appErr := a.validateRecapChannelPermissions(rctx, channelIDs, "CreateRecap"); appErr != nil { + return nil, appErr + } + + if appErr := a.checkManualRecapCooldown(userID, limits, "CreateRecap"); appErr != nil { + return nil, appErr } timeNow := model.GetMillis() @@ -38,9 +58,29 @@ func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, a BotID: agentID, } - savedRecap, err := a.Srv().Store().Recap().SaveRecap(recap) - if err != nil { - return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + var ( + savedRecap *model.Recap + storeErr error + ) + if model.IsLimitEnabled(limits.MaxRecapsPerDay) { + startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID) + if dayErr != nil { + return nil, dayErr + } + + savedRecap, storeErr = a.Srv().Store().Recap().SaveRecapIfUnderDailyLimit(recap, startOfDayMillis, limits.MaxRecapsPerDay) + if storeErr != nil { + var limitErr *store.ErrLimitExceeded + if errors.As(storeErr, &limitErr) { + return nil, recapMaxRecapsReachedError("CreateRecap", limits.MaxRecapsPerDay) + } + return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + } else { + savedRecap, storeErr = a.Srv().Store().Recap().SaveRecap(recap) + if storeErr != nil { + return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } } // Create background job @@ -57,6 +97,14 @@ func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, a }) if jobErr != nil { + // The recap row is already committed but its job never enqueued, so flag it + // skipped to free the daily-limit slot for a recap that will never run. + if skipErr := a.Srv().Store().Recap().MarkRecapSkipped(savedRecap.Id, model.SkipReasonJobCreationFailed); skipErr != nil { + rctx.Logger().Warn("Failed to mark orphaned recap as skipped after job creation failure", + mlog.String("recap_id", savedRecap.Id), + mlog.Err(skipErr), + ) + } return nil, jobErr } @@ -136,6 +184,10 @@ func (a *App) MarkRecapsAsViewed(rctx request.CTX) ([]string, *model.AppError) { // RegenerateRecap regenerates an existing recap func (a *App) RegenerateRecap(rctx request.CTX, userID string, recap *model.Recap) (*model.Recap, *model.AppError) { + if appErr := a.requireAIRecapsEnabled("RegenerateRecap"); appErr != nil { + return nil, appErr + } + recapID := recap.Id // Get existing recap channels to extract channel IDs @@ -150,6 +202,33 @@ func (a *App) RegenerateRecap(rctx request.CTX, userID string, recap *model.Reca channelIDs[i] = channel.ChannelId } + limits, limitsErr := a.GetEffectiveLimits() + if limitsErr != nil { + return nil, limitsErr + } + + if model.IsLimitEnabled(limits.MaxChannelsPerRecap) && len(channelIDs) > limits.MaxChannelsPerRecap { + return nil, recapMaxChannelsExceededError("RegenerateRecap", limits.MaxChannelsPerRecap, len(channelIDs)) + } + if appErr := a.checkManualRecapCooldown(userID, limits, "RegenerateRecap"); appErr != nil { + return nil, appErr + } + + if model.IsLimitEnabled(limits.MaxRecapsPerDay) { + startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID) + if dayErr != nil { + return nil, dayErr + } + + count, countErr := a.Srv().Store().Recap().CountForUserSince(userID, startOfDayMillis) + if countErr != nil { + return nil, model.NewAppError("RegenerateRecap", "app.recap.get_daily_count.app_error", nil, "", http.StatusInternalServerError).Wrap(countErr) + } + if count >= int64(limits.MaxRecapsPerDay) { + return nil, recapMaxRecapsReachedError("RegenerateRecap", limits.MaxRecapsPerDay) + } + } + // Delete existing recap channels if deleteErr := a.Srv().Store().Recap().DeleteRecapChannels(recapID); deleteErr != nil { return nil, model.NewAppError("RegenerateRecap", "app.recap.delete_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr) @@ -203,14 +282,25 @@ func (a *App) DeleteRecap(rctx request.CTX, recapID string) *model.AppError { return nil } -// ProcessRecapChannel processes a single channel for a recap, fetching posts, summarizing them, -// and saving the recap channel record. Returns the number of messages processed. +// ProcessRecapChannel processes a single channel for a recap using default manual recap options. func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) { + return a.ProcessRecapChannelWithOptions(rctx, recapID, channelID, userID, agentID, model.RecapProcessingOptions{}) +} + +// ProcessRecapChannelWithOptions processes a single channel for a recap, fetching posts, +// summarizing them, and saving the recap channel record. Returns the number of messages processed. +func (a *App) ProcessRecapChannelWithOptions(rctx request.CTX, recapID, channelID, userID, agentID string, options model.RecapProcessingOptions) (*model.RecapChannelResult, *model.AppError) { result := &model.RecapChannelResult{ ChannelID: channelID, Success: false, } + // Re-verify read access at execution time. Scheduled recaps run long after + // creation, so a user's channel access may have been revoked since then. + if ok, _ := a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionReadChannel); !ok { + return result, model.NewAppError("ProcessRecapChannel", "app.recap.permission_denied", nil, "", http.StatusForbidden) + } + // Get channel info channel, err := a.GetChannel(rctx, channelID) if err != nil { @@ -222,13 +312,42 @@ func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, if lastViewedErr != nil { return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_last_viewed.app_error", nil, "", http.StatusInternalServerError).Wrap(lastViewedErr) } + fetchSince, allowRecentFallback := recapFetchStartAt(options.TimePeriod, lastViewedAt, time.Now()) + + remainingPosts, limitErr := a.getRemainingPostsForRecap(userID, recapID) + if limitErr != nil { + return result, limitErr + } + if remainingPosts == 0 { + if appErr := a.saveRecapChannelRecord(recapID, channel.Id, channel.DisplayName, nil, nil, nil); appErr != nil { + return result, appErr + } + result.Success = true + return result, nil + } // Fetch posts for recap - posts, postsErr := a.fetchPostsForRecap(rctx, channelID, lastViewedAt, 100) + posts, postsErr := a.fetchPostsForRecapWithFallback(rctx, channelID, fetchSince, remainingPosts, allowRecentFallback) if postsErr != nil { return result, postsErr } + // Enforce MaxTokensPerRecap as a per-channel cap. RecapChannel records persist only post + // IDs (not message content), so a true cross-channel token budget would require re-fetching + // every already-processed channel's posts; capping per channel bounds each LLM payload cheaply. + remainingTokens, tokensErr := a.getRemainingTokensForRecap(userID) + if tokensErr != nil { + return result, tokensErr + } + if model.IsLimitEnabled(remainingTokens) { + if trimmed, wasTrimmed := trimPostsToTokenLimit(posts, remainingTokens); wasTrimmed { + posts = trimmed + rctx.Logger().Debug("Recap posts trimmed to token limit", + mlog.Int("max_tokens", remainingTokens), + mlog.String("channel_id", channelID)) + } + } + sourcePostIDs := extractPostIDs(posts) // No posts to summarize - return success with 0 messages @@ -247,7 +366,7 @@ func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, } // Summarize posts - summary, err := a.SummarizePosts(rctx, userID, posts, channel.DisplayName, team.Name, agentID) + summary, err := a.SummarizePostsWithInstructions(rctx, userID, posts, channel.DisplayName, team.Name, agentID, options.CustomInstructions) if err != nil { if saveErr := a.saveRecapChannelRecord(recapID, channel.Id, channel.DisplayName, nil, nil, sourcePostIDs); saveErr != nil { return result, saveErr @@ -283,12 +402,11 @@ func (a *App) saveRecapChannelRecord(recapID, channelID, channelName string, hig return nil } -// fetchPostsForRecap fetches posts for a channel after the given timestamp and enriches them with user information -func (a *App) fetchPostsForRecap(rctx request.CTX, channelID string, lastViewedAt int64, limit int) ([]*model.Post, *model.AppError) { +func (a *App) fetchPostsForRecapWithFallback(rctx request.CTX, channelID string, since int64, limit int, allowRecentFallback bool) ([]*model.Post, *model.AppError) { // Get posts after lastViewedAt options := model.GetPostsSinceOptions{ ChannelId: channelID, - Time: lastViewedAt, + Time: since, } postList, err := a.GetPostsSince(rctx, options) @@ -296,7 +414,7 @@ func (a *App) fetchPostsForRecap(rctx request.CTX, channelID string, lastViewedA return nil, err } - if len(postList.Posts) == 0 { + if allowRecentFallback && len(postList.Posts) == 0 { // If there are no unread posts, get the most recent 15 posts to include in the recap postList, err = a.GetPosts(rctx, channelID, 0, 20) if err != nil { @@ -329,6 +447,19 @@ func (a *App) fetchPostsForRecap(rctx request.CTX, channelID string, lastViewedA return posts, nil } +func recapFetchStartAt(timePeriod string, lastViewedAt int64, now time.Time) (int64, bool) { + switch timePeriod { + case model.TimePeriodLast24h: + return now.Add(-24 * time.Hour).UnixMilli(), false + case model.TimePeriodLastWeek: + return now.Add(-7 * 24 * time.Hour).UnixMilli(), false + case "", model.TimePeriodSinceLastRead: + return lastViewedAt, true + default: + return lastViewedAt, true + } +} + // extractPostIDs extracts post IDs from a slice of posts func extractPostIDs(posts []*model.Post) []string { ids := make([]string, len(posts)) @@ -337,3 +468,140 @@ func extractPostIDs(posts []*model.Post) []string { } return ids } + +func recapMaxChannelsExceededError(where string, limit int, requested int) *model.AppError { + return model.NewAppError(where, + "app.recap.max_channels_exceeded.app_error", + map[string]any{ + "Limit": limit, + "Requested": requested, + }, + "", http.StatusBadRequest) +} + +func recapMaxRecapsReachedError(where string, limit int) *model.AppError { + return model.NewAppError(where, + "app.recap.max_recaps_reached.app_error", + map[string]any{"Limit": limit}, + "", http.StatusBadRequest) +} + +func (a *App) checkManualRecapCooldown(userID string, limits *model.EffectiveRecapLimits, where string) *model.AppError { + if !model.IsLimitEnabled(limits.CooldownMinutes) || limits.CooldownMinutes <= 0 { + return nil + } + + lastManualRecap, storeErr := a.Srv().Store().Recap().GetLastCompletedManualRecap(userID) + if storeErr != nil { + return model.NewAppError(where, + "app.recap.cooldown_check_failed.app_error", + nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + if lastManualRecap == nil { + return nil + } + + cooldownEndTime := lastManualRecap.CreateAt + int64(limits.CooldownMinutes)*60*1000 + now := model.GetMillis() + if now >= cooldownEndTime { + return nil + } + + remainingMs := cooldownEndTime - now + remainingMinutes := int((remainingMs + 60000 - 1) / 60000) + return model.NewAppError(where, + "app.recap.cooldown_active.app_error", + map[string]any{ + "CooldownMinutes": limits.CooldownMinutes, + "RetryAfterMinutes": remainingMinutes, + }, + "", http.StatusTooManyRequests) +} + +func (a *App) getRemainingPostsForRecap(userID string, recapID string) (int, *model.AppError) { + const defaultFetchLimit = 100 + + limits, limitsErr := a.GetEffectiveLimits() + if limitsErr != nil { + return 0, limitsErr + } + + remaining := defaultFetchLimit + currentRecapPosts, appErr := a.countCurrentRecapSourcePosts(recapID) + if appErr != nil { + return 0, appErr + } + + if model.IsLimitEnabled(limits.MaxPostsPerRecap) { + remaining = min(remaining, limits.MaxPostsPerRecap-currentRecapPosts) + } + + if model.IsLimitEnabled(limits.MaxPostsPerDay) { + startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID) + if dayErr != nil { + return 0, dayErr + } + + usedToday, storeErr := a.Srv().Store().Recap().SumTotalMessageCountForUserSince(userID, startOfDayMillis) + if storeErr != nil { + return 0, model.NewAppError("ProcessRecapChannel", "app.recap.sum_daily_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + remaining = min(remaining, limits.MaxPostsPerDay-int(usedToday)-currentRecapPosts) + } + + if remaining < 0 { + return 0, nil + } + return remaining, nil +} + +// getRemainingTokensForRecap returns the per-channel token cap for a recap, or +// model.UnlimitedValue (-1) when token enforcement is disabled. It mirrors +// getRemainingPostsForRecap but is per-channel (see ProcessRecapChannelWithOptions). +func (a *App) getRemainingTokensForRecap(userID string) (int, *model.AppError) { + limits, limitsErr := a.GetEffectiveLimits() + if limitsErr != nil { + return 0, limitsErr + } + return limits.MaxTokensPerRecap, nil +} + +func (a *App) countCurrentRecapSourcePosts(recapID string) (int, *model.AppError) { + recapChannels, storeErr := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID) + if storeErr != nil { + return 0, model.NewAppError("ProcessRecapChannel", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + + count := 0 + for _, recapChannel := range recapChannels { + count += len(recapChannel.SourcePostIds) + } + return count, nil +} + +// estimateTokens estimates token count for text using conservative 4 chars/token heuristic. +// This is approximate - actual LLM tokenization varies by model. +func estimateTokens(text string) int { + // Conservative estimate: 4 characters per token for English + // This tends to overestimate, which is safer for context limits + return (len(text) + 3) / 4 // Ceiling division +} + +// estimatePostTokens estimates tokens for a single post +func estimatePostTokens(post *model.Post) int { + return estimateTokens(post.Message) +} + +// trimPostsToTokenLimit keeps the newest posts (front of the newest-first slice) whose +// cumulative estimated token count stays at or below maxTokens, dropping the rest. +// Returns the trimmed posts and whether any were dropped. +func trimPostsToTokenLimit(posts []*model.Post, maxTokens int) ([]*model.Post, bool) { + total := 0 + for i, post := range posts { + total += estimatePostTokens(post) + if total > maxTokens { + return posts[:i], true + } + } + return posts, false +} diff --git a/server/channels/app/recap_limits.go b/server/channels/app/recap_limits.go new file mode 100644 index 000000000000..874d0fe06d10 --- /dev/null +++ b/server/channels/app/recap_limits.go @@ -0,0 +1,159 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "time" + + "github.com/mattermost/mattermost/server/public/model" +) + +func (a *App) getStartOfUserDay(userID string) (time.Time, *model.AppError) { + user, appErr := a.GetUser(userID) + if appErr != nil { + return time.Time{}, appErr + } + + loc := user.GetTimezoneLocation() + now := time.Now().In(loc) + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + return startOfDay, nil +} + +func (a *App) getStartOfUserDayMillis(userID string) (int64, *model.AppError) { + startOfDay, appErr := a.getStartOfUserDay(userID) + if appErr != nil { + return 0, appErr + } + + return startOfDay.UnixMilli(), nil +} + +func (a *App) AIRecapsEnabled() bool { + return a.Config().AIRecapsEnabled() +} + +func (a *App) requireAIRecapsEnabled(where string) *model.AppError { + if !a.AIRecapsEnabled() { + return model.NewAppError(where, "api.recap.disabled.app_error", nil, "", http.StatusNotImplemented) + } + return nil +} + +// GetRecapLimitStatus returns the current user's limit status for UI display +func (a *App) GetRecapLimitStatus(userID string) (*model.RecapLimitStatus, *model.AppError) { + // Get effective limits + limits, appErr := a.GetEffectiveLimits() + if appErr != nil { + return nil, appErr + } + + startOfDay, appErr := a.getStartOfUserDay(userID) + if appErr != nil { + return nil, appErr + } + now := time.Now().In(startOfDay.Location()) + startOfNextDay := startOfDay.AddDate(0, 0, 1) + + // Count daily usage (excluding skipped) + dailyCount, err := a.Srv().Store().Recap().CountForUserSince(userID, startOfDay.UnixMilli()) + if err != nil { + return nil, model.NewAppError("GetRecapLimitStatus", "app.recap.get_daily_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // Calculate cooldown status + var cooldown model.CooldownStatus + if limits.CooldownMinutes > 0 { + lastRecap, err := a.Srv().Store().Recap().GetLastCompletedManualRecap(userID) + if err != nil { + return nil, model.NewAppError("GetRecapLimitStatus", "app.recap.cooldown_check_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + if lastRecap != nil { + cooldownEnd := lastRecap.CreateAt + int64(limits.CooldownMinutes)*60*1000 + if cooldownEnd > now.UnixMilli() { + cooldown.IsActive = true + cooldown.AvailableAt = cooldownEnd + cooldown.RetryAfterSeconds = int((cooldownEnd - now.UnixMilli()) / 1000) + } + } + } + + return &model.RecapLimitStatus{ + EffectiveLimits: *limits, + Daily: model.DailyUsageStatus{ + Used: int(dailyCount), + Limit: limits.MaxRecapsPerDay, + ResetAt: startOfNextDay.UnixMilli(), + }, + Cooldown: cooldown, + }, nil +} + +// GetEffectiveLimits returns the system-wide resolved recap limits. +func (a *App) GetEffectiveLimits() (*model.EffectiveRecapLimits, *model.AppError) { + if appErr := a.requireAIRecapsEnabled("GetEffectiveLimits"); appErr != nil { + return nil, appErr + } + + config := a.Config() + settings := &config.AIRecapSettings + + // Start with system defaults + limits := &model.EffectiveRecapLimits{ + Source: model.LimitSourceSystem, + SourceID: "", + } + + // Apply system defaults from config (SetDefaults guarantees DefaultLimits is non-nil) + limits.MaxRecapsPerDay = getValueOrDefault(settings.DefaultLimits.MaxRecapsPerDay, 10) + limits.MaxScheduledRecaps = getValueOrDefault(settings.DefaultLimits.MaxScheduledRecaps, 5) + limits.MaxChannelsPerRecap = getValueOrDefault(settings.DefaultLimits.MaxChannelsPerRecap, -1) + limits.MaxPostsPerRecap = getValueOrDefault(settings.DefaultLimits.MaxPostsPerRecap, 500) + limits.MaxTokensPerRecap = getValueOrDefault(settings.DefaultLimits.MaxTokensPerRecap, 100000) + limits.MaxPostsPerDay = getValueOrDefault(settings.DefaultLimits.MaxPostsPerDay, 5000) + limits.CooldownMinutes = getValueOrDefault(settings.DefaultLimits.CooldownMinutes, 60) + + // Apply per-limit enforcement toggles + // When a toggle is disabled, set limit to -1 (unlimited) + if !getBoolOrDefault(settings.EnforceRecapsPerDay, true) { + limits.MaxRecapsPerDay = model.UnlimitedValue + } + if !getBoolOrDefault(settings.EnforceScheduledRecaps, true) { + limits.MaxScheduledRecaps = model.UnlimitedValue + } + if !getBoolOrDefault(settings.EnforceChannelsPerRecap, true) { + limits.MaxChannelsPerRecap = model.UnlimitedValue + } + if !getBoolOrDefault(settings.EnforcePostsPerRecap, true) { + limits.MaxPostsPerRecap = model.UnlimitedValue + } + if !getBoolOrDefault(settings.EnforceTokensPerRecap, true) { + limits.MaxTokensPerRecap = model.UnlimitedValue + } + if !getBoolOrDefault(settings.EnforcePostsPerDay, true) { + limits.MaxPostsPerDay = model.UnlimitedValue + } + if !getBoolOrDefault(settings.EnforceCooldown, true) { + limits.CooldownMinutes = model.UnlimitedValue + } + + return limits, nil +} + +// getValueOrDefault returns the dereferenced pointer value, or the default if nil +func getValueOrDefault(ptr *int, defaultVal int) int { + if ptr == nil { + return defaultVal + } + return *ptr +} + +// getBoolOrDefault returns the dereferenced pointer value, or the default if nil +func getBoolOrDefault(ptr *bool, defaultVal bool) bool { + if ptr == nil { + return defaultVal + } + return *ptr +} diff --git a/server/channels/app/recap_limits_test.go b/server/channels/app/recap_limits_test.go new file mode 100644 index 000000000000..90a64dd67c75 --- /dev/null +++ b/server/channels/app/recap_limits_test.go @@ -0,0 +1,188 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestGetEffectiveLimitsDefaults(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true }) + + // Ensure defaults are set (they should be by default) + limits, appErr := th.App.GetEffectiveLimits() + require.Nil(t, appErr) + require.NotNil(t, limits) + + // Verify system defaults + require.Equal(t, 10, limits.MaxRecapsPerDay, "default MaxRecapsPerDay") + require.Equal(t, 5, limits.MaxScheduledRecaps, "default MaxScheduledRecaps") + require.Equal(t, -1, limits.MaxChannelsPerRecap, "default MaxChannelsPerRecap (unlimited)") + require.Equal(t, 500, limits.MaxPostsPerRecap, "default MaxPostsPerRecap") + require.Equal(t, 100000, limits.MaxTokensPerRecap, "default MaxTokensPerRecap") + require.Equal(t, 5000, limits.MaxPostsPerDay, "default MaxPostsPerDay") + require.Equal(t, 60, limits.CooldownMinutes, "default CooldownMinutes") + + // Verify source tracking + require.Equal(t, model.LimitSourceSystem, limits.Source) + require.Equal(t, "", limits.SourceID) +} + +func TestGetEffectiveLimitsWithDisabledToggle(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true }) + + // Disable the EnforceRecapsPerDay toggle + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(false) + }) + + limits, appErr := th.App.GetEffectiveLimits() + require.Nil(t, appErr) + require.NotNil(t, limits) + + // MaxRecapsPerDay should be -1 (unlimited) because toggle is disabled + require.Equal(t, -1, limits.MaxRecapsPerDay, "MaxRecapsPerDay should be unlimited when toggle disabled") + + // Other limits should remain at defaults + require.Equal(t, 5, limits.MaxScheduledRecaps, "MaxScheduledRecaps should remain default") + require.Equal(t, -1, limits.MaxChannelsPerRecap, "MaxChannelsPerRecap should remain default") + require.Equal(t, 500, limits.MaxPostsPerRecap, "MaxPostsPerRecap should remain default") + require.Equal(t, 100000, limits.MaxTokensPerRecap, "MaxTokensPerRecap should remain default") + require.Equal(t, 5000, limits.MaxPostsPerDay, "MaxPostsPerDay should remain default") + require.Equal(t, 60, limits.CooldownMinutes, "CooldownMinutes should remain default") +} + +func TestGetEffectiveLimitsWithCustomDefaults(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true }) + + // Set custom default limits + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(20) + cfg.AIRecapSettings.DefaultLimits.MaxScheduledRecaps = model.NewPointer(15) + cfg.AIRecapSettings.DefaultLimits.MaxPostsPerRecap = model.NewPointer(1000) + }) + + limits, appErr := th.App.GetEffectiveLimits() + require.Nil(t, appErr) + require.NotNil(t, limits) + + // Verify custom values are honored + require.Equal(t, 20, limits.MaxRecapsPerDay, "custom MaxRecapsPerDay should be honored") + require.Equal(t, 15, limits.MaxScheduledRecaps, "custom MaxScheduledRecaps should be honored") + require.Equal(t, 1000, limits.MaxPostsPerRecap, "custom MaxPostsPerRecap should be honored") + + // Unchanged limits should remain at defaults + require.Equal(t, -1, limits.MaxChannelsPerRecap, "MaxChannelsPerRecap should remain default") + require.Equal(t, 100000, limits.MaxTokensPerRecap, "MaxTokensPerRecap should remain default") + require.Equal(t, 5000, limits.MaxPostsPerDay, "MaxPostsPerDay should remain default") + require.Equal(t, 60, limits.CooldownMinutes, "CooldownMinutes should remain default") +} + +func TestGetEffectiveLimitsAllTogglesDisabled(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true }) + + // Disable all enforcement toggles + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(false) + cfg.AIRecapSettings.EnforceScheduledRecaps = model.NewPointer(false) + cfg.AIRecapSettings.EnforceChannelsPerRecap = model.NewPointer(false) + cfg.AIRecapSettings.EnforcePostsPerRecap = model.NewPointer(false) + cfg.AIRecapSettings.EnforceTokensPerRecap = model.NewPointer(false) + cfg.AIRecapSettings.EnforcePostsPerDay = model.NewPointer(false) + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(false) + }) + + limits, appErr := th.App.GetEffectiveLimits() + require.Nil(t, appErr) + require.NotNil(t, limits) + + // All limits should be -1 (unlimited) + require.Equal(t, -1, limits.MaxRecapsPerDay, "MaxRecapsPerDay should be unlimited") + require.Equal(t, -1, limits.MaxScheduledRecaps, "MaxScheduledRecaps should be unlimited") + require.Equal(t, -1, limits.MaxChannelsPerRecap, "MaxChannelsPerRecap should be unlimited") + require.Equal(t, -1, limits.MaxPostsPerRecap, "MaxPostsPerRecap should be unlimited") + require.Equal(t, -1, limits.MaxTokensPerRecap, "MaxTokensPerRecap should be unlimited") + require.Equal(t, -1, limits.MaxPostsPerDay, "MaxPostsPerDay should be unlimited") + require.Equal(t, -1, limits.CooldownMinutes, "CooldownMinutes should be unlimited") +} + +func TestGetEffectiveLimitsMasterToggleDisabled(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableAIRecaps = true + cfg.AIRecapSettings.Enable = model.NewPointer(false) + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(true) + cfg.AIRecapSettings.EnforceScheduledRecaps = model.NewPointer(true) + cfg.AIRecapSettings.EnforceChannelsPerRecap = model.NewPointer(true) + cfg.AIRecapSettings.EnforcePostsPerRecap = model.NewPointer(true) + cfg.AIRecapSettings.EnforceTokensPerRecap = model.NewPointer(true) + cfg.AIRecapSettings.EnforcePostsPerDay = model.NewPointer(true) + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true) + }) + + limits, appErr := th.App.GetEffectiveLimits() + require.NotNil(t, appErr) + require.Nil(t, limits) + require.Equal(t, "api.recap.disabled.app_error", appErr.Id) +} + +func TestGetEffectiveLimitsFeatureFlagDisabled(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "false") + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableAIRecaps = false + cfg.AIRecapSettings.Enable = model.NewPointer(true) + }) + + limits, appErr := th.App.GetEffectiveLimits() + require.NotNil(t, appErr) + require.Nil(t, limits) + require.Equal(t, "api.recap.disabled.app_error", appErr.Id) +} + +func TestGetEffectiveLimitsUnlimitedConfigValue(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true }) + + // Set MaxRecapsPerDay to unlimited (-1) in config + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(-1) + }) + + limits, appErr := th.App.GetEffectiveLimits() + require.Nil(t, appErr) + require.NotNil(t, limits) + + // Should return -1 from config (unlimited) + require.Equal(t, -1, limits.MaxRecapsPerDay, "unlimited config value should be honored") + + // Other limits should remain at defaults + require.Equal(t, 5, limits.MaxScheduledRecaps) +} + +func TestIsLimitEnabled(t *testing.T) { + mainHelper.Parallel(t) + + // Test that IsLimitEnabled correctly identifies enabled vs disabled limits + require.True(t, model.IsLimitEnabled(10), "positive value should be enabled") + require.True(t, model.IsLimitEnabled(1), "1 should be enabled") + require.True(t, model.IsLimitEnabled(0), "0 should be enabled (0 cooldown is valid)") + require.False(t, model.IsLimitEnabled(-1), "-1 (UnlimitedValue) should not be enabled") + require.True(t, model.IsLimitEnabled(-2), "-2 should be enabled (only -1 is special)") +} diff --git a/server/channels/app/recap_test.go b/server/channels/app/recap_test.go index 390be1cf682b..20f84cbb902a 100644 --- a/server/channels/app/recap_test.go +++ b/server/channels/app/recap_test.go @@ -4,16 +4,21 @@ package app import ( + "os" + "strings" "testing" "time" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/i18n" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateRecap(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + th := Setup(t).InitBasic(t) // Enable AI Recaps feature flag @@ -48,6 +53,160 @@ func TestCreateRecap(t *testing.T) { assert.Nil(t, recap) assert.Equal(t, "app.recap.permission_denied", err.Id) }) + + t.Run("cooldown error rounds up remaining minutes", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(2) + }) + + // Place the last recap 1s ago so the remaining cooldown sits near the top of the + // 2-minute band (~119s). This still exercises ceiling rounding while leaving ~59s of + // slack, so a slow/loaded CI run can't flip the rounded value down to "1 minute". + lastCreateAt := model.GetMillis() - int64(1*1000) + lastManualRecap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Completed Recap", + CreateAt: lastCreateAt, + UpdateAt: lastCreateAt, + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 1, + Status: model.RecapStatusCompleted, + BotID: "test-agent-id", + } + _, saveErr := th.App.Srv().Store().Recap().SaveRecap(lastManualRecap) + require.NoError(t, saveErr) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap, err := th.App.CreateRecap(ctx, "Cooldown Recap", []string{th.BasicChannel.Id}, "test-agent-id") + require.NotNil(t, err) + require.Nil(t, recap) + assert.Equal(t, "app.recap.cooldown_active.app_error", err.Id) + assert.Contains(t, err.SystemMessage(i18n.GetUserTranslations("en")), "another recap in 2 minutes") + }) + + t.Run("cooldown still applies after soft deleting last recap", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(2) + }) + + lastCreateAt := model.GetMillis() - int64(30*1000) + lastManualRecap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Soft Deleted Completed Recap", + CreateAt: lastCreateAt, + UpdateAt: lastCreateAt, + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 1, + Status: model.RecapStatusCompleted, + BotID: "test-agent-id", + } + _, saveErr := th.App.Srv().Store().Recap().SaveRecap(lastManualRecap) + require.NoError(t, saveErr) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + deleteErr := th.App.DeleteRecap(ctx, lastManualRecap.Id) + require.Nil(t, deleteErr) + + recap, err := th.App.CreateRecap(ctx, "Cooldown Recap", []string{th.BasicChannel.Id}, "test-agent-id") + require.NotNil(t, err) + require.Nil(t, recap) + assert.Equal(t, "app.recap.cooldown_active.app_error", err.Id) + }) + + t.Run("create recap blocked by max channels per recap", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceChannelsPerRecap = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxChannelsPerRecap = model.NewPointer(1) + }) + + channel2 := th.CreateChannel(t, th.BasicTeam) + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap, err := th.App.CreateRecap(ctx, "Too Many Channels", []string{th.BasicChannel.Id, channel2.Id}, "test-agent-id") + require.NotNil(t, err) + require.Nil(t, recap) + assert.Equal(t, "app.recap.max_channels_exceeded.app_error", err.Id) + }) + + t.Run("create recap blocked by max recaps per day", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(1) + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(false) + }) + + existingRecap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Existing Today", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 1, + Status: model.RecapStatusCompleted, + BotID: "test-agent-id", + } + _, saveErr := th.App.Srv().Store().Recap().SaveRecap(existingRecap) + require.NoError(t, saveErr) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap, err := th.App.CreateRecap(ctx, "Daily Limit Recap", []string{th.BasicChannel.Id}, "test-agent-id") + require.NotNil(t, err) + require.Nil(t, recap) + assert.Equal(t, "app.recap.max_recaps_reached.app_error", err.Id) + }) +} + +func TestCreateRecapMasterToggleDisabledBlocksCreation(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableAIRecaps = true + cfg.AIRecapSettings.Enable = model.NewPointer(false) + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(1) + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(60) + }) + + existingRecap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Existing Today", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + TotalMessageCount: 1, + Status: model.RecapStatusCompleted, + BotID: "test-agent-id", + } + _, saveErr := th.App.Srv().Store().Recap().SaveRecap(existingRecap) + require.NoError(t, saveErr) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap, appErr := th.App.CreateRecap(ctx, "Blocked Recap", []string{th.BasicChannel.Id}, "test-agent-id") + require.NotNil(t, appErr) + require.Nil(t, recap) + assert.Equal(t, "api.recap.disabled.app_error", appErr.Id) +} + +func TestCreateRecapFeatureFlagDisabledBlocksCreation(t *testing.T) { + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableAIRecaps = false + cfg.AIRecapSettings.Enable = model.NewPointer(true) + }) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap, appErr := th.App.CreateRecap(ctx, "Blocked Recap", []string{th.BasicChannel.Id}, "test-agent-id") + require.NotNil(t, appErr) + require.Nil(t, recap) + assert.Equal(t, "api.recap.disabled.app_error", appErr.Id) } func TestGetRecap(t *testing.T) { @@ -245,6 +404,97 @@ func TestMarkRecapAsRead(t *testing.T) { }) } +func TestRegenerateRecapLimitEnforcement(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + t.Run("regenerate recap blocked by max recaps per day", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(1) + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(false) + }) + + recap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Existing Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + BotID: "test-agent-id", + } + _, err := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, err) + + recapChannel := &model.RecapChannel{ + Id: model.NewId(), + RecapId: recap.Id, + ChannelId: th.BasicChannel.Id, + ChannelName: th.BasicChannel.DisplayName, + Highlights: []string{"highlight"}, + ActionItems: []string{"action"}, + SourcePostIds: []string{model.NewId()}, + CreateAt: model.GetMillis(), + } + err = th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel) + require.NoError(t, err) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + regenerated, appErr := th.App.RegenerateRecap(ctx, th.BasicUser.Id, recap) + require.NotNil(t, appErr) + require.Nil(t, regenerated) + assert.Equal(t, "app.recap.max_recaps_reached.app_error", appErr.Id) + }) + + t.Run("regenerate recap blocked by cooldown", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(false) + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(60) + }) + + recap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Cooldown Existing Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + BotID: "test-agent-id", + } + _, err := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, err) + + recapChannel := &model.RecapChannel{ + Id: model.NewId(), + RecapId: recap.Id, + ChannelId: th.BasicChannel.Id, + ChannelName: th.BasicChannel.DisplayName, + Highlights: []string{"highlight"}, + ActionItems: []string{"action"}, + SourcePostIds: []string{model.NewId()}, + CreateAt: model.GetMillis(), + } + err = th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel) + require.NoError(t, err) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + regenerated, appErr := th.App.RegenerateRecap(ctx, th.BasicUser.Id, recap) + require.NotNil(t, appErr) + require.Nil(t, regenerated) + assert.Equal(t, "app.recap.cooldown_active.app_error", appErr.Id) + }) +} + func TestMarkRecapsAsViewed(t *testing.T) { th := Setup(t).InitBasic(t) @@ -326,6 +576,8 @@ func TestMarkRecapsAsViewed(t *testing.T) { } func TestProcessRecapChannel(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + t.Run("process empty channel", func(t *testing.T) { th := Setup(t).InitBasic(t) @@ -452,6 +704,202 @@ func TestProcessRecapChannel(t *testing.T) { assert.Empty(t, recapChannels[0].ActionItems) assert.Len(t, recapChannels[0].SourcePostIds, 1) }) + + t.Run("denies channel when user lacks read permission", func(t *testing.T) { + bridge := &testAgentsBridge{ + completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) { + require.Fail(t, "bridge should not be called when user lacks channel access") + return "", nil + }, + } + + th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true }) + + // Private channel owned by another user; BasicUser is not a member. + channel := th.CreatePrivateChannel(t, th.BasicTeam, func(c *model.Channel) { + c.CreatorId = th.BasicUser2.Id + }) + th.CreatePost(t, channel, func(p *model.Post) { p.UserId = th.BasicUser2.Id }) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recapID := model.NewId() + agentID := "test-agent" + _, storeErr := th.App.Srv().Store().Recap().SaveRecap(&model.Recap{ + Id: recapID, + UserId: th.BasicUser.Id, + Title: "Unauthorized recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + Status: model.RecapStatusProcessing, + BotID: agentID, + }) + require.NoError(t, storeErr) + + result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID) + require.NotNil(t, err) + assert.Equal(t, "app.recap.permission_denied", err.Id) + require.NotNil(t, result) + assert.False(t, result.Success) + assert.Empty(t, bridge.completeCalls) + + recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID) + require.NoError(t, storeErr) + assert.Empty(t, recapChannels) + }) + + t.Run("max posts per day prevents additional post processing", func(t *testing.T) { + bridge := &testAgentsBridge{ + completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) { + require.Fail(t, "bridge should not be called when post usage is exhausted") + return "", nil + }, + } + + th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableAIRecaps = true + cfg.AIRecapSettings.EnforcePostsPerDay = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxPostsPerDay = model.NewPointer(1) + }) + + existingRecap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Existing usage", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + TotalMessageCount: 1, + Status: model.RecapStatusCompleted, + BotID: "test-agent", + } + _, storeErr := th.App.Srv().Store().Recap().SaveRecap(existingRecap) + require.NoError(t, storeErr) + + channel := th.CreateChannel(t, th.BasicTeam) + th.CreatePost(t, channel) + + recapID := model.NewId() + agentID := "test-agent" + _, storeErr = th.App.Srv().Store().Recap().SaveRecap(&model.Recap{ + Id: recapID, + UserId: th.BasicUser.Id, + Title: "Limited recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + Status: model.RecapStatusProcessing, + BotID: agentID, + }) + require.NoError(t, storeErr) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID) + require.Nil(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, 0, result.MessageCount) + assert.Empty(t, bridge.completeCalls) + + recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID) + require.NoError(t, storeErr) + require.Len(t, recapChannels, 1) + assert.Empty(t, recapChannels[0].SourcePostIds) + }) +} + +func TestProcessRecapChannelTokenLimit(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + + // 400 chars => ~100 estimated tokens per post; 5 posts => ~500 tokens. + longMessage := strings.Repeat("x", 400) + const postCount = 5 + + tests := []struct { + name string + enforceTokens bool + maxTokens int + wantMessageCount int + }{ + { + name: "token limit reduces posts sent to LLM", + enforceTokens: true, + maxTokens: 150, // room for a single ~100-token post + wantMessageCount: 1, + }, + { + name: "no enforcement keeps every post", + enforceTokens: false, + maxTokens: 150, + wantMessageCount: postCount, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + bridge := &testAgentsBridge{ + completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) { + return `{"highlights":["h"],"action_items":["a"]}`, nil + }, + } + + th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableAIRecaps = true + cfg.AIRecapSettings.EnforceTokensPerRecap = model.NewPointer(tc.enforceTokens) + cfg.AIRecapSettings.DefaultLimits.MaxTokensPerRecap = model.NewPointer(tc.maxTokens) + }) + + channel := th.CreateChannel(t, th.BasicTeam) + for range postCount { + th.CreateMessagePost(t, channel, longMessage) + } + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recapID := model.NewId() + agentID := "test-agent" + _, storeErr := th.App.Srv().Store().Recap().SaveRecap(&model.Recap{ + Id: recapID, + UserId: th.BasicUser.Id, + Title: "Token limit recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + Status: model.RecapStatusProcessing, + BotID: agentID, + }) + require.NoError(t, storeErr) + + result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID) + require.Nil(t, err) + require.True(t, result.Success) + assert.Equal(t, tc.wantMessageCount, result.MessageCount) + + recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID) + require.NoError(t, storeErr) + require.Len(t, recapChannels, 1) + assert.Len(t, recapChannels[0].SourcePostIds, tc.wantMessageCount) + }) + } +} + +func TestRecapFetchStartAt(t *testing.T) { + now := time.Date(2026, time.April, 28, 12, 0, 0, 0, time.UTC) + lastViewedAt := now.Add(-2 * time.Hour).UnixMilli() + + startAt, allowFallback := recapFetchStartAt("", lastViewedAt, now) + assert.Equal(t, lastViewedAt, startAt) + assert.True(t, allowFallback) + + startAt, allowFallback = recapFetchStartAt(model.TimePeriodSinceLastRead, lastViewedAt, now) + assert.Equal(t, lastViewedAt, startAt) + assert.True(t, allowFallback) + + startAt, allowFallback = recapFetchStartAt(model.TimePeriodLast24h, lastViewedAt, now) + assert.Equal(t, now.Add(-24*time.Hour).UnixMilli(), startAt) + assert.False(t, allowFallback) + + startAt, allowFallback = recapFetchStartAt(model.TimePeriodLastWeek, lastViewedAt, now) + assert.Equal(t, now.Add(-7*24*time.Hour).UnixMilli(), startAt) + assert.False(t, allowFallback) } func TestExtractPostIDs(t *testing.T) { @@ -475,3 +923,91 @@ func TestExtractPostIDs(t *testing.T) { assert.Len(t, ids, 0) }) } + +func TestEstimateTokens(t *testing.T) { + t.Run("empty string", func(t *testing.T) { + tokens := estimateTokens("") + assert.Equal(t, 0, tokens) + }) + + t.Run("short text", func(t *testing.T) { + // 4 chars = 1 token (ceiling) + tokens := estimateTokens("test") + assert.Equal(t, 1, tokens) + }) + + t.Run("longer text", func(t *testing.T) { + // 20 chars -> (20+3)/4 = 5 tokens (ceiling division) + tokens := estimateTokens("12345678901234567890") + assert.Equal(t, 5, tokens) + }) + + t.Run("conservative ceiling division", func(t *testing.T) { + // 5 chars -> (5+3)/4 = 2 tokens + tokens := estimateTokens("hello") + assert.Equal(t, 2, tokens) + }) +} + +func TestEstimatePostTokens(t *testing.T) { + t.Run("estimates tokens from post message", func(t *testing.T) { + post := &model.Post{Message: "Hello world from Mattermost"} // 27 chars + tokens := estimatePostTokens(post) + // (27+3)/4 = 7 tokens + assert.Equal(t, 7, tokens) + }) +} + +func TestTrimPostsToTokenLimit(t *testing.T) { + // 40 chars => (40+3)/4 = 10 tokens per post. + msg40 := strings.Repeat("x", 40) + makePosts := func(ids ...string) []*model.Post { + posts := make([]*model.Post, len(ids)) + for i, id := range ids { + posts[i] = &model.Post{Id: id, Message: msg40} + } + return posts + } + + tests := []struct { + name string + posts []*model.Post + maxTokens int + wantIDs []string + wantTrimmed bool + }{ + { + name: "no trim when under limit", + posts: makePosts("a", "b"), + maxTokens: 1000, + wantIDs: []string{"a", "b"}, + wantTrimmed: false, + }, + { + name: "keeps newest posts that fit", + posts: makePosts("newest", "middle", "oldest"), + maxTokens: 25, // room for 2 posts (20 tokens), not 3 (30 tokens) + wantIDs: []string{"newest", "middle"}, + wantTrimmed: true, + }, + { + name: "drops all when first post exceeds limit", + posts: makePosts("a", "b"), + maxTokens: 5, // single post is 10 tokens + wantIDs: []string{}, + wantTrimmed: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, trimmed := trimPostsToTokenLimit(tc.posts, tc.maxTokens) + assert.Equal(t, tc.wantTrimmed, trimmed) + gotIDs := make([]string, len(got)) + for i, p := range got { + gotIDs[i] = p.Id + } + assert.Equal(t, tc.wantIDs, gotIDs) + }) + } +} diff --git a/server/channels/app/scheduled_recap.go b/server/channels/app/scheduled_recap.go new file mode 100644 index 000000000000..4ccb41b9bf7a --- /dev/null +++ b/server/channels/app/scheduled_recap.go @@ -0,0 +1,370 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +// CreateScheduledRecap creates a new scheduled recap with validated inputs. +// It sets the user ID from the session, validates the recap configuration, +// computes the initial NextRunAt, and saves to the store. +func (a *App) CreateScheduledRecap(rctx request.CTX, recap *model.ScheduledRecap) (*model.ScheduledRecap, *model.AppError) { + if appErr := a.requireAIRecapsEnabled("CreateScheduledRecap"); appErr != nil { + return nil, appErr + } + + // Set user ID from session + recap.UserId = rctx.Session().UserId + recap.Enabled = true + + // Prepare for save (generates ID, timestamps) + recap.PreSave() + + // Validate configuration + if err := recap.IsValid(); err != nil { + return nil, err + } + + // Limit enforcement: Check user's limits before allowing creation. + // The channel count check runs first so it bounds the permission check below. + limits, limitsErr := a.GetEffectiveLimits() + if limitsErr != nil { + return nil, limitsErr + } + + // Check max channels per recap limit + if model.IsLimitEnabled(limits.MaxChannelsPerRecap) { + if len(recap.ChannelIds) > limits.MaxChannelsPerRecap { + return nil, model.NewAppError("CreateScheduledRecap", + "app.scheduled_recap.max_channels_exceeded.app_error", + map[string]any{ + "Limit": limits.MaxChannelsPerRecap, + "Requested": len(recap.ChannelIds), + }, + "", http.StatusBadRequest) + } + } + + if appErr := a.validateRecapChannelPermissions(rctx, recap.ChannelIds, "CreateScheduledRecap"); appErr != nil { + return nil, appErr + } + + // Compute NextRunAt before saving + nextRunAt, err := recap.ComputeNextRunAt(time.Now()) + if err != nil { + return nil, model.NewAppError("CreateScheduledRecap", "app.scheduled_recap.compute_next_run.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } + recap.NextRunAt = nextRunAt + + // Save to store + var ( + savedRecap *model.ScheduledRecap + storeErr error + ) + if model.IsLimitEnabled(limits.MaxScheduledRecaps) { + savedRecap, storeErr = a.Srv().Store().ScheduledRecap().SaveIfUnderLimit(recap, limits.MaxScheduledRecaps) + if storeErr != nil { + var limitErr *store.ErrLimitExceeded + if errors.As(storeErr, &limitErr) { + return nil, model.NewAppError("CreateScheduledRecap", + "app.scheduled_recap.max_scheduled_reached.app_error", + map[string]any{"Limit": limits.MaxScheduledRecaps}, + "", http.StatusBadRequest) + } + return nil, model.NewAppError("CreateScheduledRecap", "app.scheduled_recap.create.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + } else { + savedRecap, storeErr = a.Srv().Store().ScheduledRecap().Save(recap) + if storeErr != nil { + return nil, model.NewAppError("CreateScheduledRecap", "app.scheduled_recap.create.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + } + + return savedRecap, nil +} + +// GetScheduledRecap retrieves a scheduled recap by ID. +func (a *App) GetScheduledRecap(rctx request.CTX, id string) (*model.ScheduledRecap, *model.AppError) { + recap, err := a.Srv().Store().ScheduledRecap().Get(id) + if err != nil { + var nfErr *store.ErrNotFound + if errors.As(err, &nfErr) { + return nil, model.NewAppError("GetScheduledRecap", "app.scheduled_recap.get.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, model.NewAppError("GetScheduledRecap", "app.scheduled_recap.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return recap, nil +} + +// GetScheduledRecapsForUser retrieves all scheduled recaps for the current user. +func (a *App) GetScheduledRecapsForUser(rctx request.CTX, page, perPage int) ([]*model.ScheduledRecap, *model.AppError) { + userId := rctx.Session().UserId + + recaps, err := a.Srv().Store().ScheduledRecap().GetForUser(userId, page, perPage) + if err != nil { + return nil, model.NewAppError("GetScheduledRecapsForUser", "app.scheduled_recap.list.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return recaps, nil +} + +// UpdateScheduledRecap updates an existing scheduled recap. +// If the recap is enabled, it recomputes NextRunAt. +func (a *App) UpdateScheduledRecap(rctx request.CTX, recap *model.ScheduledRecap) (*model.ScheduledRecap, *model.AppError) { + existingRecap, getErr := a.Srv().Store().ScheduledRecap().Get(recap.Id) + if getErr != nil { + var nfErr *store.ErrNotFound + if errors.As(getErr, &nfErr) { + return nil, model.NewAppError("UpdateScheduledRecap", "app.scheduled_recap.get.app_error", nil, "", http.StatusNotFound).Wrap(getErr) + } + return nil, model.NewAppError("UpdateScheduledRecap", "app.scheduled_recap.get.app_error", nil, "", http.StatusInternalServerError).Wrap(getErr) + } + + sessionUserID := rctx.Session().UserId + if existingRecap.UserId != sessionUserID { + return nil, model.NewAppError("UpdateScheduledRecap", "app.recap.permission_denied", nil, "", http.StatusForbidden) + } + + recap.UserId = existingRecap.UserId + recap.CreateAt = existingRecap.CreateAt + recap.LastRunAt = existingRecap.LastRunAt + recap.RunCount = existingRecap.RunCount + recap.Enabled = existingRecap.Enabled + + // Prepare for update (sets UpdateAt) + recap.PreUpdate() + + // Validate configuration + if err := recap.IsValid(); err != nil { + return nil, err + } + + limits, limitsErr := a.GetEffectiveLimits() + if limitsErr != nil { + return nil, limitsErr + } + if model.IsLimitEnabled(limits.MaxChannelsPerRecap) && len(recap.ChannelIds) > limits.MaxChannelsPerRecap { + return nil, model.NewAppError("UpdateScheduledRecap", + "app.scheduled_recap.max_channels_exceeded.app_error", + map[string]any{ + "Limit": limits.MaxChannelsPerRecap, + "Requested": len(recap.ChannelIds), + }, + "", http.StatusBadRequest) + } + + if appErr := a.validateRecapChannelPermissions(rctx, recap.ChannelIds, "UpdateScheduledRecap"); appErr != nil { + return nil, appErr + } + + // If enabled, recompute NextRunAt + if recap.Enabled { + nextRunAt, err := recap.ComputeNextRunAt(time.Now()) + if err != nil { + return nil, model.NewAppError("UpdateScheduledRecap", "app.scheduled_recap.compute_next_run.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } + recap.NextRunAt = nextRunAt + } + + // Update in store + updatedRecap, storeErr := a.Srv().Store().ScheduledRecap().Update(recap) + if storeErr != nil { + return nil, model.NewAppError("UpdateScheduledRecap", "app.scheduled_recap.update.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + + return updatedRecap, nil +} + +// CreateRecapFromSchedule creates a Recap from a ScheduledRecap configuration. +// This is called by the scheduled recap worker when executing a scheduled recap. +// NOTE: This method does NOT use CreateRecap because that method relies on +// rctx.Session().UserId which is not available in a job worker context. +func (a *App) CreateRecapFromSchedule(rctx request.CTX, sr *model.ScheduledRecap) (*model.Recap, *model.AppError) { + if appErr := a.requireAIRecapsEnabled("CreateRecapFromSchedule"); appErr != nil { + return nil, appErr + } + + channelIDs, resolveErr := a.resolveScheduledRecapChannelIDs(sr) + if resolveErr != nil { + return nil, resolveErr + } + if len(channelIDs) == 0 { + return nil, model.NewAppError("CreateRecapFromSchedule", "app.scheduled_recap.no_channels.app_error", nil, "", http.StatusBadRequest) + } + + timeNow := model.GetMillis() + + // Create recap record directly (not using CreateRecap which requires session) + recap := &model.Recap{ + Id: model.NewId(), + UserId: sr.UserId, // Use UserId from ScheduledRecap, not session + Title: sr.Title, + CreateAt: timeNow, + UpdateAt: timeNow, + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 0, + Status: model.RecapStatusPending, + BotID: sr.AgentId, + ScheduledRecapId: sr.Id, + } + + limits, limitsErr := a.GetEffectiveLimits() + if limitsErr != nil { + return nil, limitsErr + } + if model.IsLimitEnabled(limits.MaxChannelsPerRecap) && len(channelIDs) > limits.MaxChannelsPerRecap { + return nil, recapMaxChannelsExceededError("CreateRecapFromSchedule", limits.MaxChannelsPerRecap, len(channelIDs)) + } + + var ( + savedRecap *model.Recap + err error + ) + if model.IsLimitEnabled(limits.MaxRecapsPerDay) { + startOfDayMillis, dayErr := a.getStartOfUserDayMillis(sr.UserId) + if dayErr != nil { + return nil, dayErr + } + + savedRecap, err = a.Srv().Store().Recap().SaveRecapIfUnderDailyLimit(recap, startOfDayMillis, limits.MaxRecapsPerDay) + if err != nil { + var limitErr *store.ErrLimitExceeded + if errors.As(err, &limitErr) { + return nil, recapMaxRecapsReachedError("CreateRecapFromSchedule", limits.MaxRecapsPerDay) + } + return nil, model.NewAppError("CreateRecapFromSchedule", "app.scheduled_recap.save_recap.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } else { + savedRecap, err = a.Srv().Store().Recap().SaveRecap(recap) + if err != nil { + return nil, model.NewAppError("CreateRecapFromSchedule", "app.scheduled_recap.save_recap.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + // Create recap job to trigger processing + jobData := map[string]string{ + "recap_id": savedRecap.Id, + "user_id": sr.UserId, + "channel_ids": strings.Join(channelIDs, ","), + "agent_id": sr.AgentId, + "time_period": sr.TimePeriod, + "custom_instructions": sr.CustomInstructions, + } + + _, jobErr := a.CreateJob(rctx, &model.Job{ + Type: model.JobTypeRecap, + Data: jobData, + }) + if jobErr != nil { + // The recap row is already committed but its job never enqueued, so flag it + // skipped to free the daily-limit slot for a recap that will never run. + if skipErr := a.Srv().Store().Recap().MarkRecapSkipped(savedRecap.Id, model.SkipReasonJobCreationFailed); skipErr != nil { + rctx.Logger().Warn("Failed to mark orphaned recap as skipped after job creation failure", + mlog.String("recap_id", savedRecap.Id), + mlog.Err(skipErr), + ) + } + return nil, jobErr + } + + return savedRecap, nil +} + +func (a *App) resolveScheduledRecapChannelIDs(sr *model.ScheduledRecap) ([]string, *model.AppError) { + if sr.ChannelMode == model.ChannelModeSpecific { + return sr.ChannelIds, nil + } + + unreads, err := a.Srv().Store().Team().GetChannelUnreadsForAllTeams("", sr.UserId) + if err != nil { + return nil, model.NewAppError("CreateRecapFromSchedule", "app.scheduled_recap.get_unreads.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + channelIDs := make([]string, 0, len(unreads)) + for _, unread := range unreads { + if unread.MsgCount > 0 || unread.MsgCountRoot > 0 { + channelIDs = append(channelIDs, unread.ChannelId) + } + } + + return channelIDs, nil +} + +// DeleteScheduledRecap performs a soft delete of a scheduled recap. +func (a *App) DeleteScheduledRecap(rctx request.CTX, id string) *model.AppError { + if err := a.Srv().Store().ScheduledRecap().Delete(id); err != nil { + return model.NewAppError("DeleteScheduledRecap", "app.scheduled_recap.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return nil +} + +// PauseScheduledRecap disables a scheduled recap without deleting it. +func (a *App) PauseScheduledRecap(rctx request.CTX, id string) (*model.ScheduledRecap, *model.AppError) { + // Disable the recap + if err := a.Srv().Store().ScheduledRecap().SetEnabled(id, false); err != nil { + return nil, model.NewAppError("PauseScheduledRecap", "app.scheduled_recap.pause.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // Fetch and return updated recap + updatedRecap, err := a.Srv().Store().ScheduledRecap().Get(id) + if err != nil { + var nfErr *store.ErrNotFound + if errors.As(err, &nfErr) { + return nil, model.NewAppError("PauseScheduledRecap", "app.scheduled_recap.get.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, model.NewAppError("PauseScheduledRecap", "app.scheduled_recap.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return updatedRecap, nil +} + +// ResumeScheduledRecap enables a paused scheduled recap. +// It recomputes NextRunAt before enabling to ensure the next run is in the future. +func (a *App) ResumeScheduledRecap(rctx request.CTX, id string) (*model.ScheduledRecap, *model.AppError) { + // Get existing recap to compute next run + recap, err := a.Srv().Store().ScheduledRecap().Get(id) + if err != nil { + var nfErr *store.ErrNotFound + if errors.As(err, &nfErr) { + return nil, model.NewAppError("ResumeScheduledRecap", "app.scheduled_recap.get.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, model.NewAppError("ResumeScheduledRecap", "app.scheduled_recap.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // Compute new NextRunAt + nextRunAt, computeErr := recap.ComputeNextRunAt(time.Now()) + if computeErr != nil { + return nil, model.NewAppError("ResumeScheduledRecap", "app.scheduled_recap.compute_next_run.app_error", nil, "", http.StatusBadRequest).Wrap(computeErr) + } + + // Update NextRunAt, enable, and return in one update + recap.NextRunAt = nextRunAt + recap.Enabled = true + updatedRecap, updateErr := a.Srv().Store().ScheduledRecap().Update(recap) + if updateErr != nil { + return nil, model.NewAppError("ResumeScheduledRecap", "app.scheduled_recap.resume.app_error", nil, "", http.StatusInternalServerError).Wrap(updateErr) + } + + return updatedRecap, nil +} + +func (a *App) validateRecapChannelPermissions(rctx request.CTX, channelIDs []string, where string) *model.AppError { + if !a.SessionHasPermissionToChannels(rctx, *rctx.Session(), channelIDs, model.PermissionReadChannel) { + return model.NewAppError(where, "app.recap.permission_denied", nil, "", http.StatusForbidden) + } + + return nil +} diff --git a/server/channels/app/scheduled_recap_test.go b/server/channels/app/scheduled_recap_test.go new file mode 100644 index 000000000000..3ae77eb3c2dc --- /dev/null +++ b/server/channels/app/scheduled_recap_test.go @@ -0,0 +1,571 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "os" + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestScheduledRecap_OverLimitCanManageExisting verifies ENF-07: Users over limit +// can still manage their existing scheduled recaps. Limits only block creation, +// not view/edit/delete operations. This is "grandfathering" behavior. +func TestScheduledRecap_OverLimitCanManageExisting(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + // Set a very restrictive limit (1 scheduled recap max) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceScheduledRecaps = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxScheduledRecaps = model.NewPointer(1) + }) + + // Create a scheduled recap directly in the store (bypassing API limits) + // to simulate user who already has scheduled recaps + scheduledRecap := &model.ScheduledRecap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Existing Recap", + DaysOfWeek: model.EveryDay, // Run every day + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + } + + // Compute initial NextRunAt + nextRunAt, err := scheduledRecap.ComputeNextRunAt(time.Now()) + require.NoError(t, err) + scheduledRecap.NextRunAt = nextRunAt + + savedRecap, saveErr := th.App.Srv().Store().ScheduledRecap().Save(scheduledRecap) + require.NoError(t, saveErr) + require.NotNil(t, savedRecap) + + // Create a second scheduled recap to put user at/over limit + secondRecap := &model.ScheduledRecap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Second Recap", + DaysOfWeek: model.Monday, // Run on Mondays + TimeOfDay: "10:30", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + } + nextRunAt2, err := secondRecap.ComputeNextRunAt(time.Now()) + require.NoError(t, err) + secondRecap.NextRunAt = nextRunAt2 + + _, saveErr = th.App.Srv().Store().ScheduledRecap().Save(secondRecap) + require.NoError(t, saveErr) + + // User now has 2 scheduled recaps but limit is 1 - they are "over limit" + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + + t.Run("over-limit user can view existing scheduled recap", func(t *testing.T) { + // ENF-07: Get operations should NOT check limits + fetchedRecap, getErr := th.App.GetScheduledRecap(ctx, savedRecap.Id) + require.Nil(t, getErr, "GetScheduledRecap should succeed regardless of limits") + require.NotNil(t, fetchedRecap) + assert.Equal(t, savedRecap.Id, fetchedRecap.Id) + assert.Equal(t, savedRecap.Title, fetchedRecap.Title) + }) + + t.Run("over-limit user can list existing scheduled recaps", func(t *testing.T) { + // ENF-07: List operations should NOT check limits + recaps, listErr := th.App.GetScheduledRecapsForUser(ctx, 0, 10) + require.Nil(t, listErr, "GetScheduledRecapsForUser should succeed regardless of limits") + require.NotNil(t, recaps) + assert.Len(t, recaps, 2, "Should return all user's scheduled recaps") + }) + + t.Run("over-limit user can update existing scheduled recap", func(t *testing.T) { + // ENF-07: Update operations should NOT check limits + savedRecap.Title = "Updated Title By Over-Limit User" + savedRecap.TimeOfDay = "14:00" + + updatedRecap, updateErr := th.App.UpdateScheduledRecap(ctx, savedRecap) + require.Nil(t, updateErr, "UpdateScheduledRecap should succeed regardless of limits") + require.NotNil(t, updatedRecap) + assert.Equal(t, "Updated Title By Over-Limit User", updatedRecap.Title) + assert.Equal(t, "14:00", updatedRecap.TimeOfDay) + }) + + t.Run("over-limit user can pause existing scheduled recap", func(t *testing.T) { + // ENF-07: Pause operations should NOT check limits + pausedRecap, pauseErr := th.App.PauseScheduledRecap(ctx, savedRecap.Id) + require.Nil(t, pauseErr, "PauseScheduledRecap should succeed regardless of limits") + require.NotNil(t, pausedRecap) + assert.False(t, pausedRecap.Enabled) + }) + + t.Run("over-limit user can resume existing scheduled recap", func(t *testing.T) { + // ENF-07: Resume operations should NOT check limits + resumedRecap, resumeErr := th.App.ResumeScheduledRecap(ctx, savedRecap.Id) + require.Nil(t, resumeErr, "ResumeScheduledRecap should succeed regardless of limits") + require.NotNil(t, resumedRecap) + assert.True(t, resumedRecap.Enabled) + }) + + t.Run("over-limit user can delete existing scheduled recap", func(t *testing.T) { + // ENF-07: Delete operations should NOT check limits + // Deleting allows user to get back under limit + deleteErr := th.App.DeleteScheduledRecap(ctx, savedRecap.Id) + require.Nil(t, deleteErr, "DeleteScheduledRecap should succeed regardless of limits") + // Note: Soft delete - record still exists but has DeleteAt set + }) +} + +// TestRecap_OverLimitCanManageExisting verifies ENF-07: Users over limit +// can still manage their existing recaps. Limits only block creation, +// not view/list/delete operations. +func TestRecap_OverLimitCanManageExisting(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + // Set restrictive limits (doesn't matter - management ops ignore limits) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(1) + cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(999) + }) + + // Create recap directly in store (bypassing API limits) + recap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Existing Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 25, + Status: model.RecapStatusCompleted, + } + + savedRecap, saveErr := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, saveErr) + require.NotNil(t, savedRecap) + + // Create recap channel for complete data + recapChannel := &model.RecapChannel{ + Id: model.NewId(), + RecapId: recap.Id, + ChannelId: th.BasicChannel.Id, + ChannelName: th.BasicChannel.DisplayName, + Highlights: []string{"Test highlight 1", "Test highlight 2"}, + ActionItems: []string{"Action item 1"}, + SourcePostIds: []string{model.NewId(), model.NewId()}, + CreateAt: model.GetMillis(), + } + err := th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel) + require.NoError(t, err) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + + t.Run("over-limit user can view existing recap", func(t *testing.T) { + // ENF-07: Get operations should NOT check limits + fetchedRecap, getErr := th.App.GetRecap(ctx, recap.Id) + require.Nil(t, getErr, "GetRecap should succeed regardless of limits") + require.NotNil(t, fetchedRecap) + assert.Equal(t, recap.Id, fetchedRecap.Id) + assert.Equal(t, recap.Title, fetchedRecap.Title) + assert.Len(t, fetchedRecap.Channels, 1) + }) + + t.Run("over-limit user can list existing recaps", func(t *testing.T) { + // ENF-07: List operations should NOT check limits + recaps, listErr := th.App.GetRecapsForUser(ctx, 0, 10) + require.Nil(t, listErr, "GetRecapsForUser should succeed regardless of limits") + require.NotNil(t, recaps) + assert.GreaterOrEqual(t, len(recaps), 1) + }) + + t.Run("over-limit user can mark recap as read", func(t *testing.T) { + // ENF-07: Mark read operations should NOT check limits + readRecap, markErr := th.App.MarkRecapAsRead(ctx, savedRecap) + require.Nil(t, markErr, "MarkRecapAsRead should succeed regardless of limits") + require.NotNil(t, readRecap) + assert.Greater(t, readRecap.ReadAt, int64(0)) + }) + + t.Run("over-limit user can delete existing recap", func(t *testing.T) { + // ENF-07: Delete operations should NOT check limits + // Deleting allows user to get back under limit + deleteErr := th.App.DeleteRecap(ctx, recap.Id) + require.Nil(t, deleteErr, "DeleteRecap should succeed regardless of limits") + }) +} + +// TestScheduledRecap_CreateBlockedWhenOverLimit verifies that while management +// operations succeed for over-limit users, creation IS still blocked. +// This confirms limits work correctly for creation while allowing management. +func TestScheduledRecap_CreateBlockedWhenOverLimit(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + // Set limit to 1 scheduled recap + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceScheduledRecaps = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxScheduledRecaps = model.NewPointer(1) + }) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + + // Create first scheduled recap (should succeed) + firstRecap := &model.ScheduledRecap{ + Title: "First Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + } + + createdRecap, createErr := th.App.CreateScheduledRecap(ctx, firstRecap) + require.Nil(t, createErr, "First scheduled recap should be created successfully") + require.NotNil(t, createdRecap) + + // Try to create second scheduled recap (should fail - over limit) + secondRecap := &model.ScheduledRecap{ + Title: "Second Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "10:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + } + + _, createErr = th.App.CreateScheduledRecap(ctx, secondRecap) + require.NotNil(t, createErr, "Second scheduled recap should be blocked by limit") + assert.Equal(t, "app.scheduled_recap.max_scheduled_reached.app_error", createErr.Id) + + // But user can still update and delete their existing recap + createdRecap.Title = "Updated Title" + updatedRecap, updateErr := th.App.UpdateScheduledRecap(ctx, createdRecap) + require.Nil(t, updateErr, "Update should succeed for over-limit user") + assert.Equal(t, "Updated Title", updatedRecap.Title) +} + +func TestScheduledRecapCreateAndUpdateState(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + + recap := &model.ScheduledRecap{ + Title: "Default Enabled Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + } + + createdRecap, createErr := th.App.CreateScheduledRecap(ctx, recap) + require.Nil(t, createErr) + require.NotNil(t, createdRecap) + assert.True(t, createdRecap.Enabled) + + lastRunAt := model.GetMillis() + nextRunAt := lastRunAt + int64(time.Hour/time.Millisecond) + require.NoError(t, th.App.Srv().Store().ScheduledRecap().MarkExecuted(createdRecap.Id, lastRunAt, nextRunAt)) + + staleUpdate := &model.ScheduledRecap{ + Id: createdRecap.Id, + Title: "Updated Without State Fields", + DaysOfWeek: model.Monday, + TimeOfDay: "10:00", + TimePeriod: model.TimePeriodLastWeek, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + } + + updatedRecap, updateErr := th.App.UpdateScheduledRecap(ctx, staleUpdate) + require.Nil(t, updateErr) + require.NotNil(t, updatedRecap) + assert.Equal(t, "Updated Without State Fields", updatedRecap.Title) + assert.True(t, updatedRecap.Enabled) + assert.Equal(t, lastRunAt, updatedRecap.LastRunAt) + assert.Equal(t, 1, updatedRecap.RunCount) +} + +func TestCreateRecapFromScheduleAllUnreads(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(false) + cfg.AIRecapSettings.EnforceChannelsPerRecap = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxChannelsPerRecap = model.NewPointer(10) + }) + + th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel) + post := &model.Post{ + UserId: th.BasicUser2.Id, + ChannelId: th.BasicChannel.Id, + Message: "unread for scheduled recap", + CreateAt: model.GetMillis(), + } + _, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + require.Nil(t, appErr) + + scheduledRecap := &model.ScheduledRecap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "All Unreads", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLastWeek, + ChannelMode: model.ChannelModeAllUnreads, + CustomInstructions: "Focus on launch risks", + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + } + + recap, createErr := th.App.CreateRecapFromSchedule(th.Context, scheduledRecap) + require.Nil(t, createErr) + require.NotNil(t, recap) + assert.Equal(t, scheduledRecap.Id, recap.ScheduledRecapId) + assert.Equal(t, th.BasicUser.Id, recap.UserId) + + jobs, err := th.App.Srv().Store().Job().GetAllByTypeAndStatus(th.Context, model.JobTypeRecap, model.JobStatusPending) + require.NoError(t, err) + + var recapJob *model.Job + for _, job := range jobs { + if job.Data["recap_id"] == recap.Id { + recapJob = job + break + } + } + require.NotNil(t, recapJob) + assert.Equal(t, model.TimePeriodLastWeek, recapJob.Data["time_period"]) + assert.Equal(t, "Focus on launch risks", recapJob.Data["custom_instructions"]) +} + +func TestCreateScheduledRecapMasterToggleDisabled(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableAIRecaps = true + cfg.AIRecapSettings.Enable = model.NewPointer(false) + }) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap := &model.ScheduledRecap{ + Title: "Disabled Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + } + + createdRecap, createErr := th.App.CreateScheduledRecap(ctx, recap) + require.NotNil(t, createErr) + require.Nil(t, createdRecap) + assert.Equal(t, "api.recap.disabled.app_error", createErr.Id) + + scheduledRecap := &model.ScheduledRecap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Disabled Execution", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + } + createdFromSchedule, appErr := th.App.CreateRecapFromSchedule(th.Context, scheduledRecap) + require.NotNil(t, appErr) + require.Nil(t, createdFromSchedule) + assert.Equal(t, "api.recap.disabled.app_error", appErr.Id) +} + +func TestCreateScheduledRecapFeatureFlagDisabled(t *testing.T) { + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableAIRecaps = false + cfg.AIRecapSettings.Enable = model.NewPointer(true) + }) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap := &model.ScheduledRecap{ + Title: "Disabled Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + } + + createdRecap, createErr := th.App.CreateScheduledRecap(ctx, recap) + require.NotNil(t, createErr) + require.Nil(t, createdRecap) + assert.Equal(t, "api.recap.disabled.app_error", createErr.Id) + + scheduledRecap := &model.ScheduledRecap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Disabled Execution", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + } + createdFromSchedule, appErr := th.App.CreateRecapFromSchedule(th.Context, scheduledRecap) + require.NotNil(t, appErr) + require.Nil(t, createdFromSchedule) + assert.Equal(t, "api.recap.disabled.app_error", appErr.Id) +} + +func TestScheduledRecapChannelValidationAndDeduplication(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + + t.Run("create rejects channel without read permission", func(t *testing.T) { + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + _ = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, "", privateChannel) + th.AddUserToChannel(t, th.BasicUser2, privateChannel) + + recap := &model.ScheduledRecap{ + Title: "Restricted Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{privateChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + } + + createdRecap, createErr := th.App.CreateScheduledRecap(ctx, recap) + require.NotNil(t, createErr) + require.Nil(t, createdRecap) + assert.Equal(t, "app.recap.permission_denied", createErr.Id) + }) + + t.Run("update rejects channel without read permission", func(t *testing.T) { + recap := &model.ScheduledRecap{ + Title: "Valid Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + } + + createdRecap, createErr := th.App.CreateScheduledRecap(ctx, recap) + require.Nil(t, createErr) + require.NotNil(t, createdRecap) + + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + _ = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, "", privateChannel) + th.AddUserToChannel(t, th.BasicUser2, privateChannel) + + createdRecap.ChannelIds = []string{privateChannel.Id} + updatedRecap, updateErr := th.App.UpdateScheduledRecap(ctx, createdRecap) + require.NotNil(t, updateErr) + require.Nil(t, updatedRecap) + assert.Equal(t, "app.recap.permission_denied", updateErr.Id) + }) + + t.Run("create deduplicates repeated channel ids before limit checks", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AIRecapSettings.EnforceChannelsPerRecap = model.NewPointer(true) + cfg.AIRecapSettings.DefaultLimits.MaxChannelsPerRecap = model.NewPointer(1) + }) + + recap := &model.ScheduledRecap{ + Title: "Deduped Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{th.BasicChannel.Id, th.BasicChannel.Id}, + AgentId: "test-agent", + Timezone: "America/New_York", + IsRecurring: true, + Enabled: true, + } + + createdRecap, createErr := th.App.CreateScheduledRecap(ctx, recap) + require.Nil(t, createErr) + require.NotNil(t, createdRecap) + assert.Equal(t, model.StringArray{th.BasicChannel.Id}, createdRecap.ChannelIds) + }) +} diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 47d9174c43bf..f2e7a7b67bfb 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -67,6 +67,7 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/jobs/refresh_materialized_views" "github.com/mattermost/mattermost/server/v8/channels/jobs/resend_invitation_email" "github.com/mattermost/mattermost/server/v8/channels/jobs/s3_path_migration" + "github.com/mattermost/mattermost/server/v8/channels/jobs/scheduled_recap" "github.com/mattermost/mattermost/server/v8/channels/store" "github.com/mattermost/mattermost/server/v8/channels/utils" "github.com/mattermost/mattermost/server/v8/config" @@ -1791,6 +1792,12 @@ func (s *Server) initJobs() { nil, ) + s.Jobs.RegisterJobType( + model.JobTypeScheduledRecap, + scheduled_recap.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))), + scheduled_recap.MakeScheduler(s.Jobs, s.Store()), + ) + s.Jobs.RegisterJobType( model.JobTypeDeleteExpiredPosts, delete_expired_posts.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))), diff --git a/server/channels/app/summarization.go b/server/channels/app/summarization.go index 72a60c0783ab..be6fdb5b03b0 100644 --- a/server/channels/app/summarization.go +++ b/server/channels/app/summarization.go @@ -37,6 +37,11 @@ var summarizePostsJSONSchema = map[string]any{ // SummarizePosts generates an AI summary of posts with highlights and action items func (a *App) SummarizePosts(rctx request.CTX, userID string, posts []*model.Post, channelName, teamName string, agentID string) (*model.AIRecapSummaryResponse, *model.AppError) { + return a.SummarizePostsWithInstructions(rctx, userID, posts, channelName, teamName, agentID, "") +} + +// SummarizePostsWithInstructions generates an AI summary and includes optional user-provided instructions. +func (a *App) SummarizePostsWithInstructions(rctx request.CTX, userID string, posts []*model.Post, channelName, teamName string, agentID string, customInstructions string) (*model.AIRecapSummaryResponse, *model.AppError) { if len(posts) == 0 { return &model.AIRecapSummaryResponse{Highlights: []string{}, ActionItems: []string{}}, nil } @@ -48,6 +53,10 @@ func (a *App) SummarizePosts(rctx request.CTX, userID string, posts []*model.Pos conversationText, postIDs := buildConversationTextWithIDs(posts) systemPrompt := "You are an expert at analyzing team conversations and extracting key information. Your task is to summarize a conversation from a Mattermost channel, identifying the most important highlights and any actionable items. Return ONLY valid JSON with 'highlights' and 'action_items' keys, each containing an array of strings. If there are no highlights or action items, return empty arrays. Do not make up information - only include items explicitly mentioned in the conversation." + customInstructionsBlock := "" + if customInstructions = strings.TrimSpace(customInstructions); customInstructions != "" { + customInstructionsBlock = fmt.Sprintf("\nAdditional user instructions:\n%s\n", customInstructions) + } userPrompt := fmt.Sprintf(`Analyze the following conversation from the "%s" channel and provide a summary. @@ -62,6 +71,7 @@ Available Post IDs: %s Return a JSON object with: - "highlights": array of key discussion points, decisions, or important information - "action_items": array of tasks, todos, or action items mentioned +%s IMPORTANT INSTRUCTIONS: 1. When your summary includes a user's username, prepend an @ symbol to the username. For example if you return a highlight with text ' sent an update about project xyz', where is 'john.smith', you should phrase is as '@john.smith sent an update about project xyz'. @@ -70,7 +80,7 @@ IMPORTANT INSTRUCTIONS: Example format: "Team decided to migrate to microservices architecture [PERMALINK:%s/%s/pl/abc123xyz]" -Your response must be compacted valid JSON only, with no additional text, formatting, nor code blocks.`, channelName, siteURL, teamName, conversationText, strings.Join(postIDs, ", "), siteURL, teamName, siteURL, teamName) +Your response must be compacted valid JSON only, with no additional text, formatting, nor code blocks.`, channelName, siteURL, teamName, conversationText, strings.Join(postIDs, ", "), customInstructionsBlock, siteURL, teamName, siteURL, teamName) // Create bridge client sessionUserID := "" diff --git a/server/channels/app/summarization_test.go b/server/channels/app/summarization_test.go index 9b7b1efd6514..4eecce5f3500 100644 --- a/server/channels/app/summarization_test.go +++ b/server/channels/app/summarization_test.go @@ -187,4 +187,32 @@ func TestSummarizePosts(t *testing.T) { assert.Empty(t, summary.ActionItems) assert.Len(t, bridge.completeCalls, 0) }) + + t.Run("custom instructions are included in prompt", func(t *testing.T) { + bridge := &testAgentsBridge{ + completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) { + return `{"highlights":["Highlight 1"],"action_items":[]}`, nil + }, + } + + th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t) + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + posts := []*model.Post{{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "Important update", + CreateAt: model.GetMillis(), + Props: model.StringInterface{ + "username": th.BasicUser.Username, + }, + }} + + _, appErr := th.App.SummarizePostsWithInstructions(ctx, th.BasicUser.Id, posts, th.BasicChannel.DisplayName, th.BasicTeam.Name, model.NewId(), "Focus on launch risks") + require.Nil(t, appErr) + require.Len(t, bridge.completeCalls, 1) + require.Len(t, bridge.completeCalls[0].request.Messages, 2) + assert.Contains(t, bridge.completeCalls[0].request.Messages[1].Message, "Additional user instructions:") + assert.Contains(t, bridge.completeCalls[0].request.Messages[1].Message, "Focus on launch risks") + }) } diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index bfc9a4b12748..6465c5c72743 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -403,3 +403,17 @@ channels/db/migrations/postgres/000203_add_lastnotifiedat_to_user_access_tokens. channels/db/migrations/postgres/000203_add_lastnotifiedat_to_user_access_tokens.up.sql channels/db/migrations/postgres/000204_add_channel_type_space_enum.down.sql channels/db/migrations/postgres/000204_add_channel_type_space_enum.up.sql +channels/db/migrations/postgres/000205_create_scheduled_recaps.down.sql +channels/db/migrations/postgres/000205_create_scheduled_recaps.up.sql +channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.down.sql +channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.up.sql +channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.down.sql +channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.up.sql +channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.down.sql +channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.up.sql +channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.down.sql +channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.up.sql +channels/db/migrations/postgres/000210_add_recap_skip_fields.down.sql +channels/db/migrations/postgres/000210_add_recap_skip_fields.up.sql +channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.down.sql +channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.up.sql diff --git a/server/channels/db/migrations/postgres/000205_create_scheduled_recaps.down.sql b/server/channels/db/migrations/postgres/000205_create_scheduled_recaps.down.sql new file mode 100644 index 000000000000..62764a99bc82 --- /dev/null +++ b/server/channels/db/migrations/postgres/000205_create_scheduled_recaps.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ScheduledRecaps; diff --git a/server/channels/db/migrations/postgres/000205_create_scheduled_recaps.up.sql b/server/channels/db/migrations/postgres/000205_create_scheduled_recaps.up.sql new file mode 100644 index 000000000000..215c474506fe --- /dev/null +++ b/server/channels/db/migrations/postgres/000205_create_scheduled_recaps.up.sql @@ -0,0 +1,34 @@ +-- ScheduledRecaps table: stores scheduled recap configuration +CREATE TABLE IF NOT EXISTS ScheduledRecaps ( + Id VARCHAR(26) PRIMARY KEY, + UserId VARCHAR(26) NOT NULL, + Title VARCHAR(255) NOT NULL, + + -- Schedule configuration (user intent) + DaysOfWeek INT NOT NULL, + TimeOfDay VARCHAR(5) NOT NULL, + Timezone VARCHAR(64) NOT NULL, + TimePeriod VARCHAR(32) NOT NULL, + + -- Schedule state (computed) + NextRunAt BIGINT NOT NULL, + LastRunAt BIGINT DEFAULT 0 NOT NULL, + RunCount INT DEFAULT 0 NOT NULL, + + -- Channel configuration + ChannelMode VARCHAR(32) NOT NULL, + ChannelIds jsonb NOT NULL DEFAULT '[]'::jsonb, + + -- AI configuration + CustomInstructions TEXT, + AgentId VARCHAR(26) DEFAULT '' NOT NULL, + + -- Schedule type and state + IsRecurring BOOLEAN DEFAULT true NOT NULL, + Enabled BOOLEAN DEFAULT true NOT NULL, + + -- Standard timestamps + CreateAt BIGINT NOT NULL, + UpdateAt BIGINT NOT NULL, + DeleteAt BIGINT DEFAULT 0 NOT NULL +); diff --git a/server/channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.down.sql b/server/channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.down.sql new file mode 100644 index 000000000000..3f5a6cf12966 --- /dev/null +++ b/server/channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.down.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_user_id; diff --git a/server/channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.up.sql b/server/channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.up.sql new file mode 100644 index 000000000000..e94255fbcfed --- /dev/null +++ b/server/channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.up.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_user_id ON ScheduledRecaps(UserId); diff --git a/server/channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.down.sql b/server/channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.down.sql new file mode 100644 index 000000000000..54bcfd8becdd --- /dev/null +++ b/server/channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.down.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_next_run_at; diff --git a/server/channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.up.sql b/server/channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.up.sql new file mode 100644 index 000000000000..b093d44822a8 --- /dev/null +++ b/server/channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.up.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_next_run_at ON ScheduledRecaps(NextRunAt); diff --git a/server/channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.down.sql b/server/channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.down.sql new file mode 100644 index 000000000000..1ae451412e41 --- /dev/null +++ b/server/channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.down.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_enabled_next_run; diff --git a/server/channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.up.sql b/server/channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.up.sql new file mode 100644 index 000000000000..b3afe001e882 --- /dev/null +++ b/server/channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.up.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_enabled_next_run ON ScheduledRecaps(Enabled, DeleteAt, NextRunAt); diff --git a/server/channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.down.sql b/server/channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.down.sql new file mode 100644 index 000000000000..2c4d99988c62 --- /dev/null +++ b/server/channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.down.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_user_delete; diff --git a/server/channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.up.sql b/server/channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.up.sql new file mode 100644 index 000000000000..eeab45422564 --- /dev/null +++ b/server/channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.up.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_user_delete ON ScheduledRecaps(UserId, DeleteAt); diff --git a/server/channels/db/migrations/postgres/000210_add_recap_skip_fields.down.sql b/server/channels/db/migrations/postgres/000210_add_recap_skip_fields.down.sql new file mode 100644 index 000000000000..ceb9024abc8a --- /dev/null +++ b/server/channels/db/migrations/postgres/000210_add_recap_skip_fields.down.sql @@ -0,0 +1,3 @@ +-- Remove ScheduledRecapId and SkipReason columns from Recaps table +ALTER TABLE Recaps DROP COLUMN IF EXISTS SkipReason; +ALTER TABLE Recaps DROP COLUMN IF EXISTS ScheduledRecapId; diff --git a/server/channels/db/migrations/postgres/000210_add_recap_skip_fields.up.sql b/server/channels/db/migrations/postgres/000210_add_recap_skip_fields.up.sql new file mode 100644 index 000000000000..0ad27b0e4f64 --- /dev/null +++ b/server/channels/db/migrations/postgres/000210_add_recap_skip_fields.up.sql @@ -0,0 +1,5 @@ +-- Add ScheduledRecapId and SkipReason columns to Recaps table +-- These support linking recaps to their scheduled source and tracking skip reasons + +ALTER TABLE Recaps ADD COLUMN IF NOT EXISTS ScheduledRecapId VARCHAR(26) DEFAULT ''; +ALTER TABLE Recaps ADD COLUMN IF NOT EXISTS SkipReason VARCHAR(64) DEFAULT ''; diff --git a/server/channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.down.sql b/server/channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.down.sql new file mode 100644 index 000000000000..69b5ed2003e3 --- /dev/null +++ b/server/channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.down.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_recaps_scheduled_recap_id; diff --git a/server/channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.up.sql b/server/channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.up.sql new file mode 100644 index 000000000000..7882bcc0ab40 --- /dev/null +++ b/server/channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.up.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_recaps_scheduled_recap_id ON Recaps(ScheduledRecapId); diff --git a/server/channels/jobs/jobs.go b/server/channels/jobs/jobs.go index 2ec570c6dfdc..a60b79f119d5 100644 --- a/server/channels/jobs/jobs.go +++ b/server/channels/jobs/jobs.go @@ -61,6 +61,20 @@ func (srv *JobServer) CreateJobOnce(rctx request.CTX, jobType string, jobData ma return job, nil } +func (srv *JobServer) CreateJobOnceByTypeAndData(rctx request.CTX, jobType string, jobData map[string]string, data map[string]string) (*model.Job, *model.AppError) { + job, appErr := srv._createJob(rctx, jobType, jobData) + if appErr != nil { + return nil, appErr + } + + savedJob, err := srv.Store.Job().SaveOnceByTypeAndData(job, data) + if err != nil { + return nil, model.NewAppError("CreateJob", "app.job.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return savedJob, nil +} + func (srv *JobServer) _createJob(rctx request.CTX, jobType string, jobData map[string]string) (*model.Job, *model.AppError) { job := model.Job{ Id: model.NewId(), diff --git a/server/channels/jobs/recap/worker.go b/server/channels/jobs/recap/worker.go index d638124d07ca..11be3dcbf472 100644 --- a/server/channels/jobs/recap/worker.go +++ b/server/channels/jobs/recap/worker.go @@ -15,13 +15,13 @@ import ( ) type AppIface interface { - ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) + ProcessRecapChannelWithOptions(rctx request.CTX, recapID, channelID, userID, agentID string, options model.RecapProcessingOptions) (*model.RecapChannelResult, *model.AppError) Publish(message *model.WebSocketEvent) } func MakeWorker(jobServer *jobs.JobServer, storeInstance store.Store, appInstance AppIface) *jobs.SimpleWorker { isEnabled := func(cfg *model.Config) bool { - return cfg.FeatureFlags.EnableAIRecaps + return cfg.AIRecapsEnabled() } execute := func(logger mlog.LoggerIFace, job *model.Job) error { @@ -39,6 +39,10 @@ func processRecapJob(logger mlog.LoggerIFace, job *model.Job, storeInstance stor userID := job.Data["user_id"] channelIDs := strings.Split(job.Data["channel_ids"], ",") agentID := job.Data["agent_id"] + options := model.RecapProcessingOptions{ + TimePeriod: job.Data["time_period"], + CustomInstructions: job.Data["custom_instructions"], + } logger.Info("Starting recap job", mlog.String("recap_id", recapID), @@ -63,7 +67,7 @@ func processRecapJob(logger mlog.LoggerIFace, job *model.Job, storeInstance stor // Process the channel - use a context with the user's session so that // session-dependent code (e.g. auto-translation supplements) works correctly. rctx := request.EmptyContext(logger).WithSession(&model.Session{UserId: userID}) - result, err := appInstance.ProcessRecapChannel(rctx, recapID, channelID, userID, agentID) + result, err := appInstance.ProcessRecapChannelWithOptions(rctx, recapID, channelID, userID, agentID, options) if err != nil { logger.Warn("Failed to process channel", mlog.String("channel_id", channelID), @@ -83,7 +87,13 @@ func processRecapJob(logger mlog.LoggerIFace, job *model.Job, storeInstance stor } // Update recap with final data (title is already set by user in CreateRecap) - recap, _ := storeInstance.Recap().GetRecap(recapID) + recap, err := storeInstance.Recap().GetRecap(recapID) + if err != nil || recap == nil { + logger.Warn("Recap no longer available while finalizing job", + mlog.String("recap_id", recapID), + mlog.Err(err)) + return nil + } recap.TotalMessageCount = totalMessages recap.UpdateAt = model.GetMillis() diff --git a/server/channels/jobs/recap/worker_test.go b/server/channels/jobs/recap/worker_test.go index 35916641b53b..a2d1bf799e24 100644 --- a/server/channels/jobs/recap/worker_test.go +++ b/server/channels/jobs/recap/worker_test.go @@ -18,8 +18,8 @@ type MockAppIface struct { mock.Mock } -func (m *MockAppIface) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) { - args := m.Called(rctx, recapID, channelID, userID, agentID) +func (m *MockAppIface) ProcessRecapChannelWithOptions(rctx request.CTX, recapID, channelID, userID, agentID string, options model.RecapProcessingOptions) (*model.RecapChannelResult, *model.AppError) { + args := m.Called(rctx, recapID, channelID, userID, agentID, options) if args.Get(0) == nil { return nil, args.Get(1).(*model.AppError) } @@ -52,13 +52,13 @@ func TestProcessRecapJob(t *testing.T) { mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil) mockApp.On("Publish", mock.Anything).Return() - mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(&model.RecapChannelResult{ + mockApp.On("ProcessRecapChannelWithOptions", mock.Anything, "recap1", "channel1", "user1", "agent1", model.RecapProcessingOptions{}).Return(&model.RecapChannelResult{ ChannelID: "channel1", Success: true, MessageCount: 10, }, nil) - mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(&model.RecapChannelResult{ + mockApp.On("ProcessRecapChannelWithOptions", mock.Anything, "recap1", "channel2", "user1", "agent1", model.RecapProcessingOptions{}).Return(&model.RecapChannelResult{ ChannelID: "channel2", Success: true, MessageCount: 5, @@ -84,13 +84,13 @@ func TestProcessRecapJob(t *testing.T) { mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil) mockApp.On("Publish", mock.Anything).Return() - mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(&model.RecapChannelResult{ + mockApp.On("ProcessRecapChannelWithOptions", mock.Anything, "recap1", "channel1", "user1", "agent1", model.RecapProcessingOptions{}).Return(&model.RecapChannelResult{ ChannelID: "channel1", Success: true, MessageCount: 10, }, nil) - mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) + mockApp.On("ProcessRecapChannelWithOptions", mock.Anything, "recap1", "channel2", "user1", "agent1", model.RecapProcessingOptions{}).Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) recap := &model.Recap{Id: "recap1"} mockRecapStore.On("GetRecap", "recap1").Return(recap, nil) @@ -112,8 +112,8 @@ func TestProcessRecapJob(t *testing.T) { mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil) mockApp.On("Publish", mock.Anything).Return() - mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) - mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) + mockApp.On("ProcessRecapChannelWithOptions", mock.Anything, "recap1", "channel1", "user1", "agent1", model.RecapProcessingOptions{}).Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) + mockApp.On("ProcessRecapChannelWithOptions", mock.Anything, "recap1", "channel2", "user1", "agent1", model.RecapProcessingOptions{}).Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) recap := &model.Recap{Id: "recap1"} mockRecapStore.On("GetRecap", "recap1").Return(recap, nil) @@ -125,4 +125,43 @@ func TestProcessRecapJob(t *testing.T) { require.Error(t, err) require.Equal(t, "all channels failed to process", err.Error()) }) + + t.Run("passes scheduled options to channel processing", func(t *testing.T) { + jobWithOptions := &model.Job{ + Data: map[string]string{ + "recap_id": "recap1", + "user_id": "user1", + "channel_ids": "channel1", + "agent_id": "agent1", + "time_period": model.TimePeriodLastWeek, + "custom_instructions": "Focus on blockers", + }, + } + + mockStore := &mocks.Store{} + mockRecapStore := &mocks.RecapStore{} + mockStore.On("Recap").Return(mockRecapStore) + + mockApp := &MockAppIface{} + mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil) + mockApp.On("Publish", mock.Anything).Return() + mockApp.On("ProcessRecapChannelWithOptions", mock.Anything, "recap1", "channel1", "user1", "agent1", model.RecapProcessingOptions{ + TimePeriod: model.TimePeriodLastWeek, + CustomInstructions: "Focus on blockers", + }).Return(&model.RecapChannelResult{ + ChannelID: "channel1", + Success: true, + MessageCount: 3, + }, nil) + + recap := &model.Recap{Id: "recap1"} + mockRecapStore.On("GetRecap", "recap1").Return(recap, nil) + mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool { + return r.TotalMessageCount == 3 && r.Status == model.RecapStatusCompleted + })).Return(recap, nil) + + err := processRecapJob(logger, jobWithOptions, mockStore, mockApp, nil) + require.NoError(t, err) + mockApp.AssertExpectations(t) + }) } diff --git a/server/channels/jobs/scheduled_recap/scheduler.go b/server/channels/jobs/scheduled_recap/scheduler.go new file mode 100644 index 000000000000..0600b975423b --- /dev/null +++ b/server/channels/jobs/scheduled_recap/scheduler.go @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package scheduled_recap + +import ( + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/jobs" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +// SchedulerPollingInterval defines how often the scheduler polls for due scheduled recaps. +const SchedulerPollingInterval = 1 * time.Minute + +// Scheduler polls for due scheduled recaps and creates jobs for them. +type Scheduler struct { + *jobs.PeriodicScheduler + store store.Store + jobServer *jobs.JobServer +} + +// MakeScheduler creates a new scheduler for scheduled recaps. +func MakeScheduler(jobServer *jobs.JobServer, storeInstance store.Store) *Scheduler { + isEnabled := func(cfg *model.Config) bool { + return cfg.AIRecapsEnabled() + } + return &Scheduler{ + PeriodicScheduler: jobs.NewPeriodicScheduler( + jobServer, + model.JobTypeScheduledRecap, + SchedulerPollingInterval, + isEnabled, + ), + store: storeInstance, + jobServer: jobServer, + } +} + +// NextScheduleTime overrides to use tight polling interval. +func (s *Scheduler) NextScheduleTime(cfg *model.Config, now time.Time, pendingJobs bool, lastJob *model.Job) *time.Time { + next := now.Add(SchedulerPollingInterval) + return &next +} + +// ScheduleJob polls for due scheduled recaps and creates jobs for each. +func (s *Scheduler) ScheduleJob(rctx request.CTX, cfg *model.Config, pendingJobs bool, lastJob *model.Job) (*model.Job, *model.AppError) { + now := model.GetMillis() + dueRecaps, err := s.store.ScheduledRecap().GetDueBefore(now, 100) + if err != nil { + mlog.Error("Failed to get due scheduled recaps", mlog.Err(err)) + return nil, nil + } + + for _, sr := range dueRecaps { + // The worker re-fetches the full row by ID, so the job only needs the ID. + jobData := model.StringMap{ + "scheduled_recap_id": sr.Id, + } + + job, jobErr := s.jobServer.CreateJobOnceByTypeAndData( + rctx, + model.JobTypeScheduledRecap, + jobData, + map[string]string{"scheduled_recap_id": sr.Id}, + ) + if jobErr != nil { + mlog.Warn("Scheduled recap job creation failed", + mlog.String("scheduled_recap_id", sr.Id), + mlog.Err(jobErr)) + continue + } + if job == nil { + mlog.Debug("Scheduled recap job already queued", + mlog.String("scheduled_recap_id", sr.Id)) + } + } + + return nil, nil +} diff --git a/server/channels/jobs/scheduled_recap/scheduler_test.go b/server/channels/jobs/scheduled_recap/scheduler_test.go new file mode 100644 index 000000000000..1b2c4dc49c15 --- /dev/null +++ b/server/channels/jobs/scheduled_recap/scheduler_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package scheduled_recap + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/jobs" + "github.com/mattermost/mattermost/server/v8/channels/store/storetest" + "github.com/mattermost/mattermost/server/v8/channels/utils/testutils" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestScheduleJobEnqueuesEachDueRecapAndSkipsDuplicateAtomically(t *testing.T) { + cfg := &model.Config{} + cfg.SetDefaults() + cfg.FeatureFlags.EnableAIRecaps = true + + mockStore := &storetest.Store{} + t.Cleanup(func() { + mockStore.AssertExpectations(t) + }) + + jobServer := jobs.NewJobServer(&testutils.StaticConfigService{Cfg: cfg}, mockStore, nil, mlog.CreateConsoleTestLogger(t), nil) + jobServer.RegisterJobType(model.JobTypeScheduledRecap, jobs.NewSimpleWorker( + model.JobTypeScheduledRecap, + jobServer, + func(logger mlog.LoggerIFace, job *model.Job) error { return nil }, + func(cfg *model.Config) bool { return true }, + ), nil) + + dueRecap1 := testScheduledRecap(true) + dueRecap2 := testScheduledRecap(true) + duplicateRecap := *dueRecap1 + dueRecaps := []*model.ScheduledRecap{dueRecap1, dueRecap2, &duplicateRecap} + + mockStore.ScheduledRecapStore. + On("GetDueBefore", mock.AnythingOfType("int64"), 100). + Return(dueRecaps, nil) + + for _, sr := range []*model.ScheduledRecap{dueRecap1, dueRecap2} { + mockStore.JobStore. + On("SaveOnceByTypeAndData", mock.MatchedBy(func(job *model.Job) bool { + return job.Type == model.JobTypeScheduledRecap && + len(job.Data) == 1 && + job.Data["scheduled_recap_id"] == sr.Id + }), map[string]string{"scheduled_recap_id": sr.Id}). + Return(func(job *model.Job, data map[string]string) *model.Job { return job }, nil). + Once() + } + + mockStore.JobStore. + On("SaveOnceByTypeAndData", mock.MatchedBy(func(job *model.Job) bool { + return job.Type == model.JobTypeScheduledRecap && + job.Data["scheduled_recap_id"] == dueRecap1.Id + }), map[string]string{"scheduled_recap_id": dueRecap1.Id}). + Return(nil, nil). + Once() + + scheduler := MakeScheduler(jobServer, mockStore) + job, appErr := scheduler.ScheduleJob(request.EmptyContext(mlog.CreateConsoleTestLogger(t)), cfg, false, nil) + require.Nil(t, appErr) + require.Nil(t, job) +} diff --git a/server/channels/jobs/scheduled_recap/worker.go b/server/channels/jobs/scheduled_recap/worker.go new file mode 100644 index 000000000000..d8d9c200926f --- /dev/null +++ b/server/channels/jobs/scheduled_recap/worker.go @@ -0,0 +1,138 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package scheduled_recap + +import ( + "fmt" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/jobs" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +// AppIface defines the app methods required by the scheduled recap worker. +// This interface will be implemented by the App layer in 03-02. +type AppIface interface { + CreateRecapFromSchedule(rctx request.CTX, scheduledRecap *model.ScheduledRecap) (*model.Recap, *model.AppError) +} + +// MakeWorker creates a new worker for processing scheduled recap jobs. +func MakeWorker(jobServer *jobs.JobServer, storeInstance store.Store, app AppIface) *jobs.SimpleWorker { + const workerName = "ScheduledRecap" + + isEnabled := func(cfg *model.Config) bool { + return cfg.AIRecapsEnabled() + } + + execute := func(logger mlog.LoggerIFace, job *model.Job) error { + defer jobServer.HandleJobPanic(logger, job) + return processScheduledRecapJob(logger, job, storeInstance, app) + } + + return jobs.NewSimpleWorker(workerName, jobServer, execute, isEnabled) +} + +func processScheduledRecapJob(logger mlog.LoggerIFace, job *model.Job, storeInstance store.Store, app AppIface) error { + scheduledRecapID := job.Data["scheduled_recap_id"] + + // Get the scheduled recap + sr, err := storeInstance.ScheduledRecap().Get(scheduledRecapID) + if err != nil { + return fmt.Errorf("scheduled recap not found: %w", err) + } + + // Verify still enabled + if !sr.Enabled { + logger.Info("Scheduled recap is disabled, skipping", + mlog.String("scheduled_recap_id", scheduledRecapID)) + return nil + } + + // CreateRecapFromSchedule performs the atomic daily-limit check; an over-limit + // run surfaces as the max_recaps_reached error, which we treat as a skip. + rctx := request.EmptyContext(logger) + _, appErr := app.CreateRecapFromSchedule(rctx, sr) + if appErr != nil { + if appErr.Id == "app.recap.max_recaps_reached.app_error" { + if saveErr := saveSkippedRecap(storeInstance, sr); saveErr != nil { + logger.Error("Failed to save skipped recap", mlog.Err(saveErr)) + return fmt.Errorf("failed to save skipped recap: %w", saveErr) + } + logger.Info("Scheduled recap skipped due to daily limit", + mlog.String("scheduled_recap_id", scheduledRecapID), + mlog.String("user_id", sr.UserId)) + return finalizeSchedule(logger, storeInstance, sr) + } + return fmt.Errorf("failed to create recap from schedule: %w", appErr) + } + + logger.Info("Scheduled recap executed successfully", + mlog.String("scheduled_recap_id", scheduledRecapID)) + + return finalizeSchedule(logger, storeInstance, sr) +} + +// finalizeSchedule advances the schedule to its next run and, for non-recurring +// recaps, disables it last so a one-shot recap is never left enabled. +func finalizeSchedule(logger mlog.LoggerIFace, storeInstance store.Store, sr *model.ScheduledRecap) error { + if err := advanceSchedule(logger, storeInstance, sr); err != nil { + return err + } + + if !sr.IsRecurring { + logger.Info("Disabling non-recurring scheduled recap", + mlog.String("scheduled_recap_id", sr.Id)) + if setErr := storeInstance.ScheduledRecap().SetEnabled(sr.Id, false); setErr != nil { + return fmt.Errorf("failed to disable non-recurring scheduled recap: %w", setErr) + } + } + + return nil +} + +// advanceSchedule computes the next run time and marks the scheduled recap as executed. +// If the next run time can't be computed, the recap is disabled. +func advanceSchedule(logger mlog.LoggerIFace, storeInstance store.Store, sr *model.ScheduledRecap) error { + nextRunAt, computeErr := sr.ComputeNextRunAt(time.Now()) + if computeErr != nil { + logger.Error("Failed to compute next run time", + mlog.String("scheduled_recap_id", sr.Id), + mlog.Err(computeErr)) + if setErr := storeInstance.ScheduledRecap().SetEnabled(sr.Id, false); setErr != nil { + return fmt.Errorf("failed to disable scheduled recap after next-run computation failure: %w", setErr) + } + return fmt.Errorf("failed to compute next run time: %w", computeErr) + } + + if markErr := storeInstance.ScheduledRecap().MarkExecuted(sr.Id, model.GetMillis(), nextRunAt); markErr != nil { + logger.Error("Failed to mark as executed", + mlog.String("scheduled_recap_id", sr.Id), + mlog.Err(markErr)) + return fmt.Errorf("failed to mark scheduled recap as executed: %w", markErr) + } + + return nil +} + +func saveSkippedRecap(storeInstance store.Store, sr *model.ScheduledRecap) error { + skippedRecap := &model.Recap{ + Id: model.NewId(), + UserId: sr.UserId, + Title: sr.Title, + Status: model.RecapStatusSkipped, + SkipReason: model.SkipReasonDailyLimit, + ScheduledRecapId: sr.Id, + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + } + + if _, saveErr := storeInstance.Recap().SaveRecap(skippedRecap); saveErr != nil { + return saveErr + } + + return nil +} diff --git a/server/channels/jobs/scheduled_recap/worker_test.go b/server/channels/jobs/scheduled_recap/worker_test.go new file mode 100644 index 000000000000..fed4bd0e191a --- /dev/null +++ b/server/channels/jobs/scheduled_recap/worker_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package scheduled_recap + +import ( + "errors" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockScheduledRecapApp struct { + mock.Mock +} + +func (m *mockScheduledRecapApp) CreateRecapFromSchedule(rctx request.CTX, scheduledRecap *model.ScheduledRecap) (*model.Recap, *model.AppError) { + args := m.Called(rctx, scheduledRecap) + if args.Get(0) == nil { + return nil, args.Get(1).(*model.AppError) + } + return args.Get(0).(*model.Recap), nil +} + +func TestProcessScheduledRecapJobReturnsPersistenceErrors(t *testing.T) { + logger := mlog.CreateConsoleTestLogger(t) + + t.Run("mark executed failure", func(t *testing.T) { + scheduledRecap := testScheduledRecap(true) + job := &model.Job{Data: map[string]string{"scheduled_recap_id": scheduledRecap.Id}} + + mockStore := &mocks.Store{} + mockScheduledStore := &mocks.ScheduledRecapStore{} + mockStore.On("ScheduledRecap").Return(mockScheduledStore) + mockScheduledStore.On("Get", scheduledRecap.Id).Return(scheduledRecap, nil) + mockScheduledStore.On("MarkExecuted", scheduledRecap.Id, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(errors.New("mark failed")) + + mockApp := &mockScheduledRecapApp{} + mockApp.On("CreateRecapFromSchedule", mock.Anything, scheduledRecap).Return(&model.Recap{Id: model.NewId()}, nil) + + err := processScheduledRecapJob(logger, job, mockStore, mockApp) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to mark scheduled recap as executed") + mockScheduledStore.AssertExpectations(t) + mockApp.AssertExpectations(t) + }) + + t.Run("non recurring disable failure", func(t *testing.T) { + scheduledRecap := testScheduledRecap(false) + job := &model.Job{Data: map[string]string{"scheduled_recap_id": scheduledRecap.Id}} + + mockStore := &mocks.Store{} + mockScheduledStore := &mocks.ScheduledRecapStore{} + mockStore.On("ScheduledRecap").Return(mockScheduledStore) + mockScheduledStore.On("Get", scheduledRecap.Id).Return(scheduledRecap, nil) + mockScheduledStore.On("MarkExecuted", scheduledRecap.Id, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(nil) + mockScheduledStore.On("SetEnabled", scheduledRecap.Id, false).Return(errors.New("disable failed")) + + mockApp := &mockScheduledRecapApp{} + mockApp.On("CreateRecapFromSchedule", mock.Anything, scheduledRecap).Return(&model.Recap{Id: model.NewId()}, nil) + + err := processScheduledRecapJob(logger, job, mockStore, mockApp) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to disable non-recurring scheduled recap") + mockScheduledStore.AssertExpectations(t) + mockApp.AssertExpectations(t) + }) +} + +func TestProcessScheduledRecapJobDailyLimitSkip(t *testing.T) { + logger := mlog.CreateConsoleTestLogger(t) + limitErr := model.NewAppError("CreateRecapFromSchedule", "app.recap.max_recaps_reached.app_error", nil, "", 429) + + t.Run("recurring schedule advances but stays enabled", func(t *testing.T) { + scheduledRecap := testScheduledRecap(true) + job := &model.Job{Data: map[string]string{"scheduled_recap_id": scheduledRecap.Id}} + + mockStore := &mocks.Store{} + mockScheduledStore := &mocks.ScheduledRecapStore{} + mockRecapStore := &mocks.RecapStore{} + mockStore.On("ScheduledRecap").Return(mockScheduledStore) + mockStore.On("Recap").Return(mockRecapStore) + mockScheduledStore.On("Get", scheduledRecap.Id).Return(scheduledRecap, nil) + mockScheduledStore.On("MarkExecuted", scheduledRecap.Id, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(nil) + mockRecapStore.On("SaveRecap", mock.MatchedBy(func(r *model.Recap) bool { + return r.Status == model.RecapStatusSkipped && r.ScheduledRecapId == scheduledRecap.Id + })).Return(&model.Recap{}, nil) + + mockApp := &mockScheduledRecapApp{} + mockApp.On("CreateRecapFromSchedule", mock.Anything, scheduledRecap).Return(nil, limitErr) + + err := processScheduledRecapJob(logger, job, mockStore, mockApp) + require.NoError(t, err) + mockScheduledStore.AssertExpectations(t) + mockRecapStore.AssertExpectations(t) + mockApp.AssertExpectations(t) + // A recurring schedule must not be disabled on the skip path. + mockScheduledStore.AssertNotCalled(t, "SetEnabled", mock.Anything, mock.Anything) + }) + + t.Run("non recurring schedule is disabled on skip", func(t *testing.T) { + scheduledRecap := testScheduledRecap(false) + job := &model.Job{Data: map[string]string{"scheduled_recap_id": scheduledRecap.Id}} + + mockStore := &mocks.Store{} + mockScheduledStore := &mocks.ScheduledRecapStore{} + mockRecapStore := &mocks.RecapStore{} + mockStore.On("ScheduledRecap").Return(mockScheduledStore) + mockStore.On("Recap").Return(mockRecapStore) + mockScheduledStore.On("Get", scheduledRecap.Id).Return(scheduledRecap, nil) + mockScheduledStore.On("MarkExecuted", scheduledRecap.Id, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(nil) + mockScheduledStore.On("SetEnabled", scheduledRecap.Id, false).Return(nil) + mockRecapStore.On("SaveRecap", mock.MatchedBy(func(r *model.Recap) bool { + return r.Status == model.RecapStatusSkipped && r.ScheduledRecapId == scheduledRecap.Id + })).Return(&model.Recap{}, nil) + + mockApp := &mockScheduledRecapApp{} + mockApp.On("CreateRecapFromSchedule", mock.Anything, scheduledRecap).Return(nil, limitErr) + + err := processScheduledRecapJob(logger, job, mockStore, mockApp) + require.NoError(t, err) + mockScheduledStore.AssertExpectations(t) + mockRecapStore.AssertExpectations(t) + mockApp.AssertExpectations(t) + }) +} + +func testScheduledRecap(isRecurring bool) *model.ScheduledRecap { + return &model.ScheduledRecap{ + Id: model.NewId(), + UserId: model.NewId(), + Title: "Scheduled Recap", + DaysOfWeek: model.EveryDay, + TimeOfDay: "09:00", + Timezone: "America/New_York", + TimePeriod: model.TimePeriodLast24h, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{model.NewId()}, + AgentId: "test-agent", + IsRecurring: isRecurring, + Enabled: true, + } +} diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index f8d7d108c586..6db38579b3e6 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -63,6 +63,7 @@ type RetryLayer struct { RetentionPolicyStore store.RetentionPolicyStore RoleStore store.RoleStore ScheduledPostStore store.ScheduledPostStore + ScheduledRecapStore store.ScheduledRecapStore SchemeStore store.SchemeStore SessionStore store.SessionStore SessionAttributeStore store.SessionAttributeStore @@ -254,6 +255,10 @@ func (s *RetryLayer) ScheduledPost() store.ScheduledPostStore { return s.ScheduledPostStore } +func (s *RetryLayer) ScheduledRecap() store.ScheduledRecapStore { + return s.ScheduledRecapStore +} + func (s *RetryLayer) Scheme() store.SchemeStore { return s.SchemeStore } @@ -537,6 +542,11 @@ type RetryLayerScheduledPostStore struct { Root *RetryLayer } +type RetryLayerScheduledRecapStore struct { + store.ScheduledRecapStore + Root *RetryLayer +} + type RetryLayerSchemeStore struct { store.SchemeStore Root *RetryLayer @@ -7482,6 +7492,27 @@ func (s *RetryLayerJobStore) SaveOnce(job *model.Job) (*model.Job, error) { } +func (s *RetryLayerJobStore) SaveOnceByTypeAndData(job *model.Job, data map[string]string) (*model.Job, error) { + + tries := 0 + for { + result, err := s.JobStore.SaveOnceByTypeAndData(job, data) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerJobStore) UpdateOptimistically(job *model.Job, currentStatus string) (*model.Job, error) { tries := 0 @@ -11418,6 +11449,27 @@ func (s *RetryLayerReadReceiptStore) Update(rctx request.CTX, receipt *model.Rea } +func (s *RetryLayerRecapStore) CountForUserSince(userId string, since int64) (int64, error) { + + tries := 0 + for { + result, err := s.RecapStore.CountForUserSince(userId, since) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerRecapStore) DeleteRecap(id string) error { tries := 0 @@ -11460,6 +11512,27 @@ func (s *RetryLayerRecapStore) DeleteRecapChannels(recapId string) error { } +func (s *RetryLayerRecapStore) GetLastCompletedManualRecap(userId string) (*model.Recap, error) { + + tries := 0 + for { + result, err := s.RecapStore.GetLastCompletedManualRecap(userId) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerRecapStore) GetRecap(id string) (*model.Recap, error) { tries := 0 @@ -11544,6 +11617,27 @@ func (s *RetryLayerRecapStore) MarkRecapAsRead(id string) error { } +func (s *RetryLayerRecapStore) MarkRecapSkipped(id string, reason string) error { + + tries := 0 + for { + err := s.RecapStore.MarkRecapSkipped(id, reason) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerRecapStore) MarkRecapsAsViewed(userId string, statuses []string) ([]string, error) { tries := 0 @@ -11607,6 +11701,48 @@ func (s *RetryLayerRecapStore) SaveRecapChannel(recapChannel *model.RecapChannel } +func (s *RetryLayerRecapStore) SaveRecapIfUnderDailyLimit(recap *model.Recap, since int64, limit int) (*model.Recap, error) { + + tries := 0 + for { + result, err := s.RecapStore.SaveRecapIfUnderDailyLimit(recap, since, limit) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerRecapStore) SumTotalMessageCountForUserSince(userId string, since int64) (int64, error) { + + tries := 0 + for { + result, err := s.RecapStore.SumTotalMessageCountForUserSince(userId, since) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerRecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) { tries := 0 @@ -12705,6 +12841,237 @@ func (s *RetryLayerScheduledPostStore) UpdatedScheduledPost(scheduledPost *model } +func (s *RetryLayerScheduledRecapStore) CountForUser(userId string) (int64, error) { + + tries := 0 + for { + result, err := s.ScheduledRecapStore.CountForUser(userId) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) Delete(id string) error { + + tries := 0 + for { + err := s.ScheduledRecapStore.Delete(id) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) Get(id string) (*model.ScheduledRecap, error) { + + tries := 0 + for { + result, err := s.ScheduledRecapStore.Get(id) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) GetDueBefore(timestamp int64, limit int) ([]*model.ScheduledRecap, error) { + + tries := 0 + for { + result, err := s.ScheduledRecapStore.GetDueBefore(timestamp, limit) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) GetForUser(userId string, page int, perPage int) ([]*model.ScheduledRecap, error) { + + tries := 0 + for { + result, err := s.ScheduledRecapStore.GetForUser(userId, page, perPage) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) MarkExecuted(id string, lastRunAt int64, nextRunAt int64) error { + + tries := 0 + for { + err := s.ScheduledRecapStore.MarkExecuted(id, lastRunAt, nextRunAt) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) Save(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) { + + tries := 0 + for { + result, err := s.ScheduledRecapStore.Save(scheduledRecap) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) SaveIfUnderLimit(scheduledRecap *model.ScheduledRecap, limit int) (*model.ScheduledRecap, error) { + + tries := 0 + for { + result, err := s.ScheduledRecapStore.SaveIfUnderLimit(scheduledRecap, limit) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) SetEnabled(id string, enabled bool) error { + + tries := 0 + for { + err := s.ScheduledRecapStore.SetEnabled(id, enabled) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) Update(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) { + + tries := 0 + for { + result, err := s.ScheduledRecapStore.Update(scheduledRecap) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerScheduledRecapStore) UpdateNextRunAt(id string, nextRunAt int64) error { + + tries := 0 + for { + err := s.ScheduledRecapStore.UpdateNextRunAt(id, nextRunAt) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerSchemeStore) CountByScope(scope string) (int64, error) { tries := 0 @@ -19310,6 +19677,7 @@ func New(childStore store.Store) *RetryLayer { newStore.RetentionPolicyStore = &RetryLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore} newStore.RoleStore = &RetryLayerRoleStore{RoleStore: childStore.Role(), Root: &newStore} newStore.ScheduledPostStore = &RetryLayerScheduledPostStore{ScheduledPostStore: childStore.ScheduledPost(), Root: &newStore} + newStore.ScheduledRecapStore = &RetryLayerScheduledRecapStore{ScheduledRecapStore: childStore.ScheduledRecap(), Root: &newStore} newStore.SchemeStore = &RetryLayerSchemeStore{SchemeStore: childStore.Scheme(), Root: &newStore} newStore.SessionStore = &RetryLayerSessionStore{SessionStore: childStore.Session(), Root: &newStore} newStore.SessionAttributeStore = &RetryLayerSessionAttributeStore{SessionAttributeStore: childStore.SessionAttribute(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 447dfc686d9d..31683df6b792 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -74,6 +74,7 @@ func genStore() *mocks.Store { mock.On("ContentFlagging").Return(&mocks.ContentFlaggingStore{}) mock.On("ReadReceipt").Return(&mocks.ReadReceiptStore{}) mock.On("Recap").Return(&mocks.RecapStore{}) + mock.On("ScheduledRecap").Return(&mocks.ScheduledRecapStore{}) mock.On("TemporaryPost").Return(&mocks.TemporaryPostStore{}) mock.On("View").Return(&mocks.ViewStore{}) mock.On("ChannelJoinRequest").Return(&mocks.ChannelJoinRequestStore{}) diff --git a/server/channels/store/sqlstore/job_store.go b/server/channels/store/sqlstore/job_store.go index 1b026331b30b..b505526417f6 100644 --- a/server/channels/store/sqlstore/job_store.go +++ b/server/channels/store/sqlstore/job_store.go @@ -141,6 +141,78 @@ func (jss SqlJobStore) SaveOnce(job *model.Job) (*model.Job, error) { return job, nil } +// SaveOnceByTypeAndData inserts the job only when no pending or in-progress job already exists +// with the same type and matching data filter. Unlike SaveOnce (which dedupes per type), this +// allows many concurrent jobs of the same type while keeping at most one queued per entity +// identified by the data filter (e.g. one job per scheduled_recap_id). Returns (nil, nil) when a +// matching job already exists. +func (jss SqlJobStore) SaveOnceByTypeAndData(job *model.Job, data map[string]string) (*model.Job, error) { + if len(data) == 0 { + return nil, errors.New("data filter cannot be empty") + } + + jsonData, err := json.Marshal(job.Data) + if err != nil { + return nil, errors.Wrap(err, "failed marshalling job data") + } + if jss.IsBinaryParamEnabled() { + jsonData = AppendBinaryFlag(jsonData) + } + + tx, err := jss.GetMaster().BeginWithIsolation(&sql.TxOptions{ + Isolation: sql.LevelSerializable, + }) + if err != nil { + return nil, errors.Wrap(err, "begin_transaction") + } + defer finalizeTransactionX(tx, &err) + + query, args, err := jss.jobTypeAndDataQuery( + jss.getQueryBuilder().Select("COUNT(*)").From("Jobs"), + job.Type, + data, + model.JobStatusPending, + model.JobStatusInProgress, + ).ToSql() + if err != nil { + return nil, errors.Wrap(err, "job_tosql") + } + + var count int64 + err = tx.Get(&count, query, args...) + if err != nil { + return nil, errors.Wrapf(err, "failed to count pending and in-progress jobs with type=%s and data filter", job.Type) + } + + if count > 0 { + return nil, nil + } + + query, args, err = jss.getQueryBuilder(). + Insert("Jobs"). + Columns("Id", "Type", "Priority", "CreateAt", "StartAt", "LastActivityAt", "Status", "Progress", "Data"). + Values(job.Id, job.Type, job.Priority, job.CreateAt, job.StartAt, job.LastActivityAt, job.Status, job.Progress, jsonData).ToSql() + if err != nil { + return nil, errors.Wrap(err, "failed to generate sqlquery") + } + + if _, err = tx.Exec(query, args...); err != nil { + if isRepeatableError(err) { + return nil, nil + } + return nil, errors.Wrap(err, "failed to save Job") + } + + if err = tx.Commit(); err != nil { + if isRepeatableError(err) { + return nil, nil + } + return nil, errors.Wrap(err, "commit_transaction") + } + + return job, nil +} + // UpdateOptimistically updates the job only if its current status matches currentStatus. // Returns the updated job on success, or nil if no row was matched (status mismatch or job not // found). A nil return with a nil error is not an error — it means the precondition was not met. @@ -388,9 +460,8 @@ func (jss SqlJobStore) GetCountByStatusAndType(status string, jobType string) (i return count, nil } -func (jss SqlJobStore) GetByTypeAndData(rctx request.CTX, jobType string, data map[string]string, useMaster bool, statuses ...string) ([]*model.Job, error) { - query := jss.jobQuery.Where(sq.Eq{"Type": jobType}) - +func (jss SqlJobStore) jobTypeAndDataQuery(query sq.SelectBuilder, jobType string, data map[string]string, statuses ...string) sq.SelectBuilder { + query = query.Where(sq.Eq{"Type": jobType}) // Add status filtering if provided - enables full usage of idx_jobs_status_type index if len(statuses) > 0 { query = query.Where(sq.Eq{"Status": statuses}) @@ -401,6 +472,12 @@ func (jss SqlJobStore) GetByTypeAndData(rctx request.CTX, jobType string, data m query = query.Where(sq.Expr("Data->? = ?", key, fmt.Sprintf(`"%s"`, value))) } + return query +} + +func (jss SqlJobStore) GetByTypeAndData(rctx request.CTX, jobType string, data map[string]string, useMaster bool, statuses ...string) ([]*model.Job, error) { + query := jss.jobTypeAndDataQuery(jss.jobQuery, jobType, data, statuses...) + queryString, args, err := query.ToSql() if err != nil { return nil, errors.Wrap(err, "get_by_type_and_data_tosql") diff --git a/server/channels/store/sqlstore/recap_store.go b/server/channels/store/sqlstore/recap_store.go index ad8c2b59f743..161701f20074 100644 --- a/server/channels/store/sqlstore/recap_store.go +++ b/server/channels/store/sqlstore/recap_store.go @@ -27,6 +27,8 @@ var ( "TotalMessageCount", "Status", "BotID", + "ScheduledRecapId", + "SkipReason", } recapChannelColumns = []string{ @@ -77,6 +79,8 @@ func (s *SqlRecapStore) recapToMap(recap *model.Recap) map[string]any { "TotalMessageCount": recap.TotalMessageCount, "Status": recap.Status, "BotID": recap.BotID, + "ScheduledRecapId": recap.ScheduledRecapId, + "SkipReason": recap.SkipReason, } } @@ -109,20 +113,56 @@ func (s *SqlRecapStore) recapChannelToMap(rc *model.RecapChannel) (map[string]an } func (s *SqlRecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) { + if err := s.saveRecapWithExecutor(s.GetMaster(), recap); err != nil { + return nil, err + } + + return recap, nil +} + +func (s *SqlRecapStore) SaveRecapIfUnderDailyLimit(recap *model.Recap, since int64, limit int) (*model.Recap, error) { + // SERIALIZABLE prevents the COUNT/INSERT check from racing under READ COMMITTED; + // the retry layer retries the serialization failures this can surface. + tx, err := s.GetMaster().BeginWithIsolation(&sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return nil, errors.Wrap(err, "failed to begin transaction for SaveRecapIfUnderDailyLimit") + } + defer finalizeTransactionX(tx, &err) + + count, err := s.countForUserSinceWithExecutor(tx, recap.UserId, since) + if err != nil { + return nil, err + } + if count >= int64(limit) { + return nil, store.NewErrLimitExceeded("recaps_per_day", int(count), fmt.Sprintf("userId=%s limit=%d", recap.UserId, limit)) + } + + if err = s.saveRecapWithExecutor(tx, recap); err != nil { + return nil, err + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "failed to commit transaction for SaveRecapIfUnderDailyLimit") + } + + return recap, nil +} + +func (s *SqlRecapStore) saveRecapWithExecutor(executor sqlxExecutor, recap *model.Recap) error { query := s.getQueryBuilder(). Insert("Recaps"). SetMap(s.recapToMap(recap)) - if _, err := s.GetMaster().ExecBuilder(query); err != nil { - return nil, errors.Wrap(err, "failed to save Recap") + if _, err := executor.ExecBuilder(query); err != nil { + return errors.Wrap(err, "failed to save Recap") } - return recap, nil + return nil } func (s *SqlRecapStore) GetRecap(id string) (*model.Recap, error) { var recap model.Recap - query := s.recapSelectQuery.Where(sq.Eq{"Id": id}) + query := s.recapSelectQuery.Where(sq.Eq{"Id": id, "DeleteAt": 0}) if err := s.GetReplica().GetBuilder(&recap, query); err != nil { if err == sql.ErrNoRows { @@ -140,6 +180,7 @@ func (s *SqlRecapStore) GetRecapsForUser(userId string, page, perPage int) ([]*m query := s.recapSelectQuery. Where(sq.Eq{"UserId": userId, "DeleteAt": 0}). + Where(sq.NotEq{"Status": model.RecapStatusSkipped}). // Skipped recaps are internal audit records, not client-facing. OrderBy("CreateAt DESC"). Limit(uint64(perPage)). Offset(uint64(offset)) @@ -189,6 +230,25 @@ func (s *SqlRecapStore) UpdateRecapStatus(id, status string) error { return nil } +// MarkRecapSkipped flips a still-pending recap to skipped. Scoped to pending so a +// recap a worker has already started processing is never clobbered. +func (s *SqlRecapStore) MarkRecapSkipped(id, reason string) error { + query := s.getQueryBuilder(). + Update("Recaps"). + SetMap(map[string]any{ + "Status": model.RecapStatusSkipped, + "SkipReason": reason, + "UpdateAt": model.GetMillis(), + }). + Where(sq.Eq{"Id": id, "Status": model.RecapStatusPending}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to mark Recap as skipped for id=%s", id) + } + + return nil +} + func (s *SqlRecapStore) MarkRecapAsRead(id string) error { now := model.GetMillis() @@ -329,3 +389,65 @@ func (s *SqlRecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.Reca return recapChannels, nil } + +// CountForUserSince returns count of recaps created by user since given timestamp. +// Excludes skipped recaps from the count, but still counts soft-deleted recaps +// because they already consumed AI usage. +func (s *SqlRecapStore) CountForUserSince(userId string, since int64) (int64, error) { + return s.countForUserSinceWithExecutor(s.GetReplica(), userId, since) +} + +func (s *SqlRecapStore) countForUserSinceWithExecutor(executor sqlxExecutor, userId string, since int64) (int64, error) { + query := s.getQueryBuilder(). + Select("COUNT(*)"). + From("Recaps"). + Where(sq.Eq{"UserId": userId}). + Where(sq.GtOrEq{"CreateAt": since}). + Where(sq.NotEq{"Status": model.RecapStatusSkipped}) // Don't count skipped recaps + + var count int64 + err := executor.GetBuilder(&count, query) + if err != nil { + return 0, errors.Wrap(err, "failed to count recaps for user since timestamp") + } + return count, nil +} + +func (s *SqlRecapStore) SumTotalMessageCountForUserSince(userId string, since int64) (int64, error) { + query := s.getQueryBuilder(). + Select("COALESCE(SUM(TotalMessageCount), 0)"). + From("Recaps"). + Where(sq.Eq{"UserId": userId}). + Where(sq.GtOrEq{"CreateAt": since}). + Where(sq.NotEq{"Status": model.RecapStatusSkipped}) + + var total int64 + err := s.GetReplica().GetBuilder(&total, query) + if err != nil { + return 0, errors.Wrap(err, "failed to sum recap message count for user since timestamp") + } + return total, nil +} + +// GetLastCompletedManualRecap returns the most recent completed manual recap for user. +// Manual recap = ScheduledRecapId is empty. Used for cooldown checking, including +// soft-deleted recaps because deleting a recap should not bypass cooldown. +// Returns nil, nil if no manual recap exists. +func (s *SqlRecapStore) GetLastCompletedManualRecap(userId string) (*model.Recap, error) { + var recap model.Recap + query := s.recapSelectQuery. + Where(sq.Eq{"UserId": userId}). + Where(sq.Eq{"Status": model.RecapStatusCompleted}). + Where(sq.Or{sq.Eq{"ScheduledRecapId": ""}, sq.Expr("ScheduledRecapId IS NULL")}). // Manual = no scheduled recap ID (NULL for pre-migration rows) + OrderBy("CreateAt DESC"). + Limit(1) + + err := s.GetReplica().GetBuilder(&recap, query) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil // No manual recap found - not an error + } + return nil, errors.Wrap(err, "failed to get last completed manual recap") + } + return &recap, nil +} diff --git a/server/channels/store/sqlstore/recap_store_test.go b/server/channels/store/sqlstore/recap_store_test.go index 1dae88a75634..b67321d2603c 100644 --- a/server/channels/store/sqlstore/recap_store_test.go +++ b/server/channels/store/sqlstore/recap_store_test.go @@ -72,6 +72,40 @@ func TestRecapStore(t *testing.T) { assert.Len(t, recaps, 3) }) + t.Run("GetRecapsForUserExcludesSkipped", func(t *testing.T) { + userId := model.NewId() + + save := func(status string) string { + r := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + TotalMessageCount: 1, + Status: status, + BotID: "test-bot-id", + } + _, err := ss.Recap().SaveRecap(r) + require.NoError(t, err) + return r.Id + } + + completed := save(model.RecapStatusCompleted) + failed := save(model.RecapStatusFailed) + skipped := save(model.RecapStatusSkipped) + + recaps, err := ss.Recap().GetRecapsForUser(userId, 0, 10) + require.NoError(t, err) + + ids := make([]string, 0, len(recaps)) + for _, r := range recaps { + ids = append(ids, r.Id) + } + assert.ElementsMatch(t, []string{completed, failed}, ids) + assert.NotContains(t, ids, skipped) + }) + t.Run("UpdateRecapStatus", func(t *testing.T) { recap := &model.Recap{ Id: model.NewId(), @@ -166,6 +200,194 @@ func TestRecapStore(t *testing.T) { } }) + t.Run("CountForUserSinceIncludesSoftDeletedRecaps", func(t *testing.T) { + userId := model.NewId() + since := model.GetMillis() - 1000 + + completedRecap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Completed Recap", + CreateAt: since + 1, + UpdateAt: since + 1, + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + BotID: "test-bot-id", + } + _, err := ss.Recap().SaveRecap(completedRecap) + require.NoError(t, err) + + skippedRecap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Skipped Recap", + CreateAt: since + 2, + UpdateAt: since + 2, + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 0, + Status: model.RecapStatusSkipped, + BotID: "test-bot-id", + } + _, err = ss.Recap().SaveRecap(skippedRecap) + require.NoError(t, err) + + err = ss.Recap().DeleteRecap(completedRecap.Id) + require.NoError(t, err) + err = ss.Recap().DeleteRecap(skippedRecap.Id) + require.NoError(t, err) + + count, err := ss.Recap().CountForUserSince(userId, since) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + totalMessages, err := ss.Recap().SumTotalMessageCountForUserSince(userId, since) + require.NoError(t, err) + assert.Equal(t, int64(10), totalMessages) + }) + + t.Run("SaveRecapIfUnderDailyLimit", func(t *testing.T) { + since := model.GetMillis() - 1000 + + user, err := ss.User().Save(rctx, &model.User{ + Username: model.NewUsername(), + Email: model.NewId() + "@example.com", + }) + require.NoError(t, err) + userId := user.Id + + recap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "First Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusPending, + BotID: "test-bot-id", + } + _, err = ss.Recap().SaveRecapIfUnderDailyLimit(recap, since, 1) + require.NoError(t, err) + + overLimitRecap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Second Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusPending, + BotID: "test-bot-id", + } + _, err = ss.Recap().SaveRecapIfUnderDailyLimit(overLimitRecap, since, 1) + require.Error(t, err) + + var limitErr *store.ErrLimitExceeded + require.ErrorAs(t, err, &limitErr) + }) + + t.Run("MarkRecapSkippedFreesDailyCount", func(t *testing.T) { + userId := model.NewId() + since := model.GetMillis() - 1000 + + recap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Orphan Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + Status: model.RecapStatusPending, + BotID: "test-bot-id", + } + _, err := ss.Recap().SaveRecap(recap) + require.NoError(t, err) + + count, err := ss.Recap().CountForUserSince(userId, since) + require.NoError(t, err) + assert.Equal(t, int64(1), count, "pending recap should count toward the daily limit") + + err = ss.Recap().MarkRecapSkipped(recap.Id, model.SkipReasonJobCreationFailed) + require.NoError(t, err) + + updated, err := ss.Recap().GetRecap(recap.Id) + require.NoError(t, err) + assert.Equal(t, model.RecapStatusSkipped, updated.Status) + assert.Equal(t, model.SkipReasonJobCreationFailed, updated.SkipReason) + + count, err = ss.Recap().CountForUserSince(userId, since) + require.NoError(t, err) + assert.Equal(t, int64(0), count, "skipped recap should no longer consume a daily slot") + + // The pending-only guard must not clobber a recap a worker has already started. + completed := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Completed Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + Status: model.RecapStatusCompleted, + BotID: "test-bot-id", + } + _, err = ss.Recap().SaveRecap(completed) + require.NoError(t, err) + + err = ss.Recap().MarkRecapSkipped(completed.Id, model.SkipReasonJobCreationFailed) + require.NoError(t, err) + + unchanged, err := ss.Recap().GetRecap(completed.Id) + require.NoError(t, err) + assert.Equal(t, model.RecapStatusCompleted, unchanged.Status, "non-pending recap must not be flipped to skipped") + }) + + t.Run("GetLastCompletedManualRecapIncludesSoftDeletedRecaps", func(t *testing.T) { + userId := model.NewId() + baseTime := model.GetMillis() + + olderRecap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Older Recap", + CreateAt: baseTime - 60000, + UpdateAt: baseTime - 60000, + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + BotID: "test-bot-id", + } + _, err := ss.Recap().SaveRecap(olderRecap) + require.NoError(t, err) + + newerRecap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Newer Recap", + CreateAt: baseTime - 30000, + UpdateAt: baseTime - 30000, + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + BotID: "test-bot-id", + } + _, err = ss.Recap().SaveRecap(newerRecap) + require.NoError(t, err) + + err = ss.Recap().DeleteRecap(newerRecap.Id) + require.NoError(t, err) + + lastRecap, err := ss.Recap().GetLastCompletedManualRecap(userId) + require.NoError(t, err) + require.NotNil(t, lastRecap) + assert.Equal(t, newerRecap.Id, lastRecap.Id) + }) + t.Run("MarkRecapsAsViewed", func(t *testing.T) { userId := model.NewId() otherUserId := model.NewId() @@ -285,6 +507,12 @@ func TestRecapStore(t *testing.T) { recaps, err := ss.Recap().GetRecapsForUser(recap.UserId, 0, 10) require.NoError(t, err) assert.Len(t, recaps, 0) + + _, err = ss.Recap().GetRecap(recap.Id) + require.Error(t, err) + + var nfErr *store.ErrNotFound + require.ErrorAs(t, err, &nfErr) }) }) } diff --git a/server/channels/store/sqlstore/scheduled_recap_store.go b/server/channels/store/sqlstore/scheduled_recap_store.go new file mode 100644 index 000000000000..39b8510749a5 --- /dev/null +++ b/server/channels/store/sqlstore/scheduled_recap_store.go @@ -0,0 +1,303 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/store" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +var scheduledRecapColumns = []string{ + "Id", "UserId", "Title", + "DaysOfWeek", "TimeOfDay", "Timezone", "TimePeriod", + "NextRunAt", "LastRunAt", "RunCount", + "ChannelMode", "ChannelIds", + "CustomInstructions", "AgentId", + "IsRecurring", "Enabled", + "CreateAt", "UpdateAt", "DeleteAt", +} + +type SqlScheduledRecapStore struct { + *SqlStore + selectQuery sq.SelectBuilder +} + +func newSqlScheduledRecapStore(sqlStore *SqlStore) store.ScheduledRecapStore { + s := &SqlScheduledRecapStore{ + SqlStore: sqlStore, + } + s.selectQuery = s.getQueryBuilder(). + Select(scheduledRecapColumns...). + From("ScheduledRecaps") + return s +} + +// toMap converts a ScheduledRecap to a map for INSERT/UPDATE operations. +// ChannelIds is a Postgres jsonb column; model.StringArray serializes it via +// driver.Valuer, so no manual JSON marshaling is needed here. +func (s *SqlScheduledRecapStore) toMap(sr *model.ScheduledRecap) map[string]any { + return map[string]any{ + "Id": sr.Id, + "UserId": sr.UserId, + "Title": sr.Title, + "DaysOfWeek": sr.DaysOfWeek, + "TimeOfDay": sr.TimeOfDay, + "Timezone": sr.Timezone, + "TimePeriod": sr.TimePeriod, + "NextRunAt": sr.NextRunAt, + "LastRunAt": sr.LastRunAt, + "RunCount": sr.RunCount, + "ChannelMode": sr.ChannelMode, + "ChannelIds": sr.ChannelIds, + "CustomInstructions": sr.CustomInstructions, + "AgentId": sr.AgentId, + "IsRecurring": sr.IsRecurring, + "Enabled": sr.Enabled, + "CreateAt": sr.CreateAt, + "UpdateAt": sr.UpdateAt, + "DeleteAt": sr.DeleteAt, + } +} + +// Save inserts a new ScheduledRecap into the database. +func (s *SqlScheduledRecapStore) Save(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) { + if err := s.saveWithExecutor(s.GetMaster(), scheduledRecap); err != nil { + return nil, err + } + + return scheduledRecap, nil +} + +func (s *SqlScheduledRecapStore) SaveIfUnderLimit(scheduledRecap *model.ScheduledRecap, limit int) (*model.ScheduledRecap, error) { + // SERIALIZABLE prevents the COUNT/INSERT check from racing under READ COMMITTED; + // the retry layer retries the serialization failures this can surface. + tx, err := s.GetMaster().BeginWithIsolation(&sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return nil, errors.Wrap(err, "failed to begin transaction for SaveIfUnderLimit") + } + defer finalizeTransactionX(tx, &err) + + count, err := s.countForUserWithExecutor(tx, scheduledRecap.UserId) + if err != nil { + return nil, err + } + if count >= int64(limit) { + return nil, store.NewErrLimitExceeded("scheduled_recaps_per_user", int(count), "userId="+scheduledRecap.UserId) + } + + if err = s.saveWithExecutor(tx, scheduledRecap); err != nil { + return nil, err + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "failed to commit transaction for SaveIfUnderLimit") + } + + return scheduledRecap, nil +} + +func (s *SqlScheduledRecapStore) saveWithExecutor(executor sqlxExecutor, scheduledRecap *model.ScheduledRecap) error { + query := s.getQueryBuilder(). + Insert("ScheduledRecaps"). + SetMap(s.toMap(scheduledRecap)) + + if _, err := executor.ExecBuilder(query); err != nil { + return errors.Wrap(err, "failed to save ScheduledRecap") + } + + return nil +} + +// Get retrieves a ScheduledRecap by ID. +func (s *SqlScheduledRecapStore) Get(id string) (*model.ScheduledRecap, error) { + var sr model.ScheduledRecap + query := s.selectQuery.Where(sq.Eq{"Id": id, "DeleteAt": 0}) + + if err := s.GetReplica().GetBuilder(&sr, query); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("ScheduledRecap", id) + } + return nil, errors.Wrapf(err, "failed to get ScheduledRecap with id=%s", id) + } + + return &sr, nil +} + +// Update updates an existing ScheduledRecap. +func (s *SqlScheduledRecapStore) Update(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) { + scheduledRecap.PreUpdate() + + query := s.getQueryBuilder(). + Update("ScheduledRecaps"). + SetMap(map[string]any{ + "Title": scheduledRecap.Title, + "DaysOfWeek": scheduledRecap.DaysOfWeek, + "TimeOfDay": scheduledRecap.TimeOfDay, + "Timezone": scheduledRecap.Timezone, + "TimePeriod": scheduledRecap.TimePeriod, + "NextRunAt": scheduledRecap.NextRunAt, + "LastRunAt": scheduledRecap.LastRunAt, + "RunCount": scheduledRecap.RunCount, + "ChannelMode": scheduledRecap.ChannelMode, + "ChannelIds": scheduledRecap.ChannelIds, + "CustomInstructions": scheduledRecap.CustomInstructions, + "AgentId": scheduledRecap.AgentId, + "IsRecurring": scheduledRecap.IsRecurring, + "Enabled": scheduledRecap.Enabled, + "UpdateAt": scheduledRecap.UpdateAt, + }). + Where(sq.Eq{"Id": scheduledRecap.Id, "DeleteAt": 0}) + + result, err := s.GetMaster().ExecBuilder(query) + if err != nil { + return nil, errors.Wrapf(err, "failed to update ScheduledRecap with id=%s", scheduledRecap.Id) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return nil, errors.Wrap(err, "failed to retrieve affected rows for ScheduledRecap update") + } + if rowsAffected == 0 { + return nil, store.NewErrNotFound("ScheduledRecap", scheduledRecap.Id) + } + + return scheduledRecap, nil +} + +// Delete performs a soft delete by setting DeleteAt. +func (s *SqlScheduledRecapStore) Delete(id string) error { + deleteAt := model.GetMillis() + + query := s.getQueryBuilder(). + Update("ScheduledRecaps"). + SetMap(map[string]any{ + "DeleteAt": deleteAt, + "UpdateAt": deleteAt, + }). + Where(sq.Eq{"Id": id}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to delete ScheduledRecap with id=%s", id) + } + + return nil +} + +// GetForUser retrieves paginated ScheduledRecaps for a user (excluding soft-deleted). +func (s *SqlScheduledRecapStore) GetForUser(userId string, page, perPage int) ([]*model.ScheduledRecap, error) { + offset := page * perPage + recaps := []*model.ScheduledRecap{} + + query := s.selectQuery. + Where(sq.Eq{"UserId": userId, "DeleteAt": 0}). + OrderBy("CreateAt DESC"). + Limit(uint64(perPage)). + Offset(uint64(offset)) + + if err := s.GetReplica().SelectBuilder(&recaps, query); err != nil { + return nil, errors.Wrapf(err, "failed to get ScheduledRecaps for userId=%s", userId) + } + + return recaps, nil +} + +// GetDueBefore retrieves enabled, non-deleted ScheduledRecaps that are due before the given timestamp. +// It reads from master so the scheduler does not enqueue from replica-lagged NextRunAt values. +// Results are ordered by NextRunAt ASC to process oldest first. +func (s *SqlScheduledRecapStore) GetDueBefore(timestamp int64, limit int) ([]*model.ScheduledRecap, error) { + recaps := []*model.ScheduledRecap{} + + query := s.selectQuery. + Where(sq.Eq{"Enabled": true, "DeleteAt": 0}). + Where(sq.LtOrEq{"NextRunAt": timestamp}). + OrderBy("NextRunAt ASC"). + Limit(uint64(limit)) + + if err := s.GetMaster().SelectBuilder(&recaps, query); err != nil { + return nil, errors.Wrapf(err, "failed to get due ScheduledRecaps before timestamp=%d", timestamp) + } + + return recaps, nil +} + +// UpdateNextRunAt updates only the NextRunAt field (and UpdateAt). +func (s *SqlScheduledRecapStore) UpdateNextRunAt(id string, nextRunAt int64) error { + updateAt := model.GetMillis() + + query := s.getQueryBuilder(). + Update("ScheduledRecaps"). + SetMap(map[string]any{ + "NextRunAt": nextRunAt, + "UpdateAt": updateAt, + }). + Where(sq.Eq{"Id": id}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to update NextRunAt for ScheduledRecap id=%s", id) + } + + return nil +} + +// MarkExecuted updates LastRunAt, NextRunAt, increments RunCount, and sets UpdateAt. +func (s *SqlScheduledRecapStore) MarkExecuted(id string, lastRunAt int64, nextRunAt int64) error { + updateAt := model.GetMillis() + + query := s.getQueryBuilder(). + Update("ScheduledRecaps"). + Set("LastRunAt", lastRunAt). + Set("NextRunAt", nextRunAt). + Set("RunCount", sq.Expr("RunCount + 1")). + Set("UpdateAt", updateAt). + Where(sq.Eq{"Id": id}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to mark ScheduledRecap as executed for id=%s", id) + } + + return nil +} + +// CountForUser returns count of active (non-deleted, enabled) scheduled recaps for a user. +func (s *SqlScheduledRecapStore) CountForUser(userId string) (int64, error) { + return s.countForUserWithExecutor(s.GetReplica(), userId) +} + +func (s *SqlScheduledRecapStore) countForUserWithExecutor(executor sqlxExecutor, userId string) (int64, error) { + query := s.getQueryBuilder(). + Select("COUNT(*)"). + From("ScheduledRecaps"). + Where(sq.Eq{"UserId": userId}). + Where(sq.Eq{"DeleteAt": 0}). + Where(sq.Eq{"Enabled": true}) + + var count int64 + err := executor.GetBuilder(&count, query) + if err != nil { + return 0, errors.Wrap(err, "failed to count scheduled recaps for user") + } + return count, nil +} + +// SetEnabled updates only the Enabled field (and UpdateAt). +func (s *SqlScheduledRecapStore) SetEnabled(id string, enabled bool) error { + updateAt := model.GetMillis() + + query := s.getQueryBuilder(). + Update("ScheduledRecaps"). + SetMap(map[string]any{ + "Enabled": enabled, + "UpdateAt": updateAt, + }). + Where(sq.Eq{"Id": id}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to set Enabled=%v for ScheduledRecap id=%s", enabled, id) + } + + return nil +} diff --git a/server/channels/store/sqlstore/scheduled_recap_store_test.go b/server/channels/store/sqlstore/scheduled_recap_store_test.go new file mode 100644 index 000000000000..2307e405baf4 --- /dev/null +++ b/server/channels/store/sqlstore/scheduled_recap_store_test.go @@ -0,0 +1,419 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestScheduledRecap creates a valid ScheduledRecap for testing +func createTestScheduledRecap(userId string) *model.ScheduledRecap { + return &model.ScheduledRecap{ + Id: model.NewId(), + UserId: userId, + Title: "Test Scheduled Recap", + DaysOfWeek: model.Weekdays, + TimeOfDay: "09:00", + Timezone: "America/New_York", + TimePeriod: model.TimePeriodLast24h, + NextRunAt: model.GetMillis() + 3600000, // 1 hour from now + LastRunAt: 0, + RunCount: 0, + ChannelMode: model.ChannelModeSpecific, + ChannelIds: []string{model.NewId(), model.NewId()}, + AgentId: "test-agent", + IsRecurring: true, + Enabled: true, + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + } +} + +func scheduledRecapIDs(recaps []*model.ScheduledRecap) []string { + ids := make([]string, 0, len(recaps)) + for _, recap := range recaps { + ids = append(ids, recap.Id) + } + return ids +} + +func TestScheduledRecapStore(t *testing.T) { + StoreTest(t, func(t *testing.T, rctx request.CTX, ss store.Store) { + t.Run("SaveAndGet", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + + savedSR, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + assert.Equal(t, sr.Id, savedSR.Id) + assert.Equal(t, sr.UserId, savedSR.UserId) + assert.Equal(t, sr.Title, savedSR.Title) + assert.NotZero(t, savedSR.CreateAt) + assert.NotZero(t, savedSR.UpdateAt) + + retrievedSR, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.Equal(t, sr.Id, retrievedSR.Id) + assert.Equal(t, sr.UserId, retrievedSR.UserId) + assert.Equal(t, sr.Title, retrievedSR.Title) + assert.Equal(t, sr.DaysOfWeek, retrievedSR.DaysOfWeek) + assert.Equal(t, sr.TimeOfDay, retrievedSR.TimeOfDay) + assert.Equal(t, sr.Timezone, retrievedSR.Timezone) + assert.Equal(t, sr.TimePeriod, retrievedSR.TimePeriod) + assert.Equal(t, sr.ChannelMode, retrievedSR.ChannelMode) + assert.Equal(t, sr.ChannelIds, retrievedSR.ChannelIds) + assert.Equal(t, sr.AgentId, retrievedSR.AgentId) + assert.Equal(t, sr.IsRecurring, retrievedSR.IsRecurring) + assert.Equal(t, sr.Enabled, retrievedSR.Enabled) + }) + + t.Run("GetNotFound", func(t *testing.T) { + _, err := ss.ScheduledRecap().Get(model.NewId()) + require.Error(t, err) + var nfErr *store.ErrNotFound + require.ErrorAs(t, err, &nfErr) + }) + + t.Run("GetForUser", func(t *testing.T) { + userId := model.NewId() + otherUserId := model.NewId() + + // Create 3 scheduled recaps for same user + for i := range 3 { + sr := createTestScheduledRecap(userId) + sr.Id = model.NewId() + sr.Title = "Recap " + string(rune('A'+i)) + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + } + + // Create 1 for different user + otherSR := createTestScheduledRecap(otherUserId) + _, err := ss.ScheduledRecap().Save(otherSR) + require.NoError(t, err) + + // Should only return 3 for first user + recaps, err := ss.ScheduledRecap().GetForUser(userId, 0, 10) + require.NoError(t, err) + assert.Len(t, recaps, 3) + + // Test pagination - page 0, perPage 2 should return 2 + recapsPage, err := ss.ScheduledRecap().GetForUser(userId, 0, 2) + require.NoError(t, err) + assert.Len(t, recapsPage, 2) + }) + + t.Run("SaveIfUnderLimit", func(t *testing.T) { + user, err := ss.User().Save(rctx, &model.User{ + Username: model.NewUsername(), + Email: model.NewId() + "@example.com", + }) + require.NoError(t, err) + userId := user.Id + + firstRecap := createTestScheduledRecap(userId) + _, err = ss.ScheduledRecap().SaveIfUnderLimit(firstRecap, 1) + require.NoError(t, err) + + secondRecap := createTestScheduledRecap(userId) + secondRecap.Id = model.NewId() + _, err = ss.ScheduledRecap().SaveIfUnderLimit(secondRecap, 1) + require.Error(t, err) + + var limitErr *store.ErrLimitExceeded + require.ErrorAs(t, err, &limitErr) + }) + + t.Run("Update", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + // Modify fields + sr.Title = "Updated Title" + sr.DaysOfWeek = model.Weekend + sr.TimeOfDay = "14:30" + sr.ChannelIds = []string{model.NewId()} + + updatedSR, err := ss.ScheduledRecap().Update(sr) + require.NoError(t, err) + assert.Equal(t, "Updated Title", updatedSR.Title) + + // Verify persisted + retrievedSR, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.Equal(t, "Updated Title", retrievedSR.Title) + assert.Equal(t, model.Weekend, retrievedSR.DaysOfWeek) + assert.Equal(t, "14:30", retrievedSR.TimeOfDay) + assert.Len(t, retrievedSR.ChannelIds, 1) + }) + + t.Run("UpdateNotFound", func(t *testing.T) { + sr := createTestScheduledRecap(model.NewId()) + + _, err := ss.ScheduledRecap().Update(sr) + require.Error(t, err) + + var nfErr *store.ErrNotFound + require.ErrorAs(t, err, &nfErr) + }) + + t.Run("UpdateSoftDeletedReturnsNotFound", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + err = ss.ScheduledRecap().Delete(sr.Id) + require.NoError(t, err) + + sr.Title = "Should Not Update" + _, err = ss.ScheduledRecap().Update(sr) + require.Error(t, err) + + var nfErr *store.ErrNotFound + require.ErrorAs(t, err, &nfErr) + }) + + t.Run("DeleteSoftDelete", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + // Delete (soft) + err = ss.ScheduledRecap().Delete(sr.Id) + require.NoError(t, err) + + // GetForUser should return 0 (soft deleted) + recaps, err := ss.ScheduledRecap().GetForUser(userId, 0, 10) + require.NoError(t, err) + assert.Len(t, recaps, 0) + + // Direct Get should not return soft-deleted records + _, err = ss.ScheduledRecap().Get(sr.Id) + require.Error(t, err) + var nfErr *store.ErrNotFound + require.ErrorAs(t, err, &nfErr) + }) + + t.Run("GetDueBefore", func(t *testing.T) { + now := model.GetMillis() + userId := model.NewId() + + // Create one due in past (should be returned) + pastSR := createTestScheduledRecap(userId) + pastSR.Id = model.NewId() + pastSR.NextRunAt = now - 3600000 // 1 hour ago + pastSR.Enabled = true + _, err := ss.ScheduledRecap().Save(pastSR) + require.NoError(t, err) + + // Create one due now (should be returned) + nowSR := createTestScheduledRecap(userId) + nowSR.Id = model.NewId() + nowSR.NextRunAt = now + nowSR.Enabled = true + _, err = ss.ScheduledRecap().Save(nowSR) + require.NoError(t, err) + + // Create one due in future (should NOT be returned) + futureSR := createTestScheduledRecap(userId) + futureSR.Id = model.NewId() + futureSR.NextRunAt = now + 3600000 // 1 hour from now + futureSR.Enabled = true + _, err = ss.ScheduledRecap().Save(futureSR) + require.NoError(t, err) + + // Create one that's disabled (should NOT be returned) + disabledSR := createTestScheduledRecap(userId) + disabledSR.Id = model.NewId() + disabledSR.NextRunAt = now - 3600000 + disabledSR.Enabled = false + _, err = ss.ScheduledRecap().Save(disabledSR) + require.NoError(t, err) + + // Create one that's deleted (should NOT be returned) + deletedSR := createTestScheduledRecap(userId) + deletedSR.Id = model.NewId() + deletedSR.NextRunAt = now - 3600000 + deletedSR.Enabled = true + _, err = ss.ScheduledRecap().Save(deletedSR) + require.NoError(t, err) + err = ss.ScheduledRecap().Delete(deletedSR.Id) + require.NoError(t, err) + + // Query for due recaps + dueRecaps, err := ss.ScheduledRecap().GetDueBefore(now, 10) + require.NoError(t, err) + + // Should have 2: pastSR and nowSR + assert.Len(t, dueRecaps, 2) + + // Verify ordered by NextRunAt ASC (oldest first) + if len(dueRecaps) >= 2 { + assert.True(t, dueRecaps[0].NextRunAt <= dueRecaps[1].NextRunAt) + } + + // Verify we got the right IDs (past and now) + ids := make(map[string]bool) + for _, r := range dueRecaps { + ids[r.Id] = true + } + assert.True(t, ids[pastSR.Id], "past recap should be returned") + assert.True(t, ids[nowSR.Id], "now recap should be returned") + assert.False(t, ids[futureSR.Id], "future recap should NOT be returned") + assert.False(t, ids[disabledSR.Id], "disabled recap should NOT be returned") + assert.False(t, ids[deletedSR.Id], "deleted recap should NOT be returned") + }) + + t.Run("GetDueBeforeReflectsMarkExecuted", func(t *testing.T) { + userId := model.NewId() + now := model.GetMillis() + + sr := createTestScheduledRecap(userId) + sr.NextRunAt = now - 3600000 + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + dueRecaps, err := ss.ScheduledRecap().GetDueBefore(now, 10) + require.NoError(t, err) + require.Contains(t, scheduledRecapIDs(dueRecaps), sr.Id) + + err = ss.ScheduledRecap().MarkExecuted(sr.Id, now, now+3600000) + require.NoError(t, err) + + dueRecaps, err = ss.ScheduledRecap().GetDueBefore(now, 10) + require.NoError(t, err) + require.NotContains(t, scheduledRecapIDs(dueRecaps), sr.Id) + }) + + t.Run("UpdateNextRunAt", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + originalNextRunAt := sr.NextRunAt + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + // Update NextRunAt + newNextRunAt := originalNextRunAt + 86400000 // +1 day + err = ss.ScheduledRecap().UpdateNextRunAt(sr.Id, newNextRunAt) + require.NoError(t, err) + + // Verify + retrievedSR, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.Equal(t, newNextRunAt, retrievedSR.NextRunAt) + assert.True(t, retrievedSR.UpdateAt >= sr.UpdateAt) + }) + + t.Run("MarkExecuted", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + sr.RunCount = 0 + sr.LastRunAt = 0 + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + now := model.GetMillis() + nextRun := now + 86400000 // +1 day + + // First execution + err = ss.ScheduledRecap().MarkExecuted(sr.Id, now, nextRun) + require.NoError(t, err) + + retrievedSR, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.Equal(t, now, retrievedSR.LastRunAt) + assert.Equal(t, nextRun, retrievedSR.NextRunAt) + assert.Equal(t, 1, retrievedSR.RunCount) + + // Second execution + time.Sleep(10 * time.Millisecond) // ensure different timestamp + now2 := model.GetMillis() + nextRun2 := now2 + 86400000 + + err = ss.ScheduledRecap().MarkExecuted(sr.Id, now2, nextRun2) + require.NoError(t, err) + + retrievedSR2, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.Equal(t, now2, retrievedSR2.LastRunAt) + assert.Equal(t, nextRun2, retrievedSR2.NextRunAt) + assert.Equal(t, 2, retrievedSR2.RunCount) + }) + + t.Run("SetEnabled", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + sr.Enabled = true + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + // Disable + err = ss.ScheduledRecap().SetEnabled(sr.Id, false) + require.NoError(t, err) + + retrievedSR, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.False(t, retrievedSR.Enabled) + + // Re-enable + err = ss.ScheduledRecap().SetEnabled(sr.Id, true) + require.NoError(t, err) + + retrievedSR2, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.True(t, retrievedSR2.Enabled) + }) + + t.Run("ChannelIdsJsonSerialization", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + sr.ChannelIds = []string{"ch1", "ch2", "ch3"} + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + retrievedSR, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.Equal(t, model.StringArray{"ch1", "ch2", "ch3"}, retrievedSR.ChannelIds) + }) + + t.Run("EmptyChannelIds", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + sr.ChannelMode = model.ChannelModeAllUnreads + sr.ChannelIds = []string{} + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + retrievedSR, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + assert.Empty(t, retrievedSR.ChannelIds) + }) + + t.Run("NilChannelIds", func(t *testing.T) { + userId := model.NewId() + sr := createTestScheduledRecap(userId) + sr.ChannelMode = model.ChannelModeAllUnreads + sr.ChannelIds = nil + _, err := ss.ScheduledRecap().Save(sr) + require.NoError(t, err) + + retrievedSR, err := ss.ScheduledRecap().Get(sr.Id) + require.NoError(t, err) + // nil is serialized as "null", should unmarshal back to nil + assert.Nil(t, retrievedSR.ChannelIds) + }) + }) +} diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index b31e54478bc3..18e16928f96e 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -117,6 +117,7 @@ type SqlStoreStores struct { autotranslation store.AutoTranslationStore ContentFlagging store.ContentFlaggingStore recap store.RecapStore + scheduledRecap store.ScheduledRecapStore readReceipt store.ReadReceiptStore temporaryPost store.TemporaryPostStore channelJoinRequest store.ChannelJoinRequestStore @@ -311,6 +312,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface store.stores.autotranslation = newSqlAutoTranslationStore(store) store.stores.ContentFlagging = newContentFlaggingStore(store) store.stores.recap = newSqlRecapStore(store) + store.stores.scheduledRecap = newSqlScheduledRecapStore(store) store.stores.readReceipt = newSqlReadReceiptStore(store, metrics) store.stores.temporaryPost = newSqlTemporaryPostStore(store, metrics) store.stores.channelJoinRequest = newSqlChannelJoinRequestStore(store) @@ -961,6 +963,10 @@ func (ss *SqlStore) Recap() store.RecapStore { return ss.stores.recap } +func (ss *SqlStore) ScheduledRecap() store.ScheduledRecapStore { + return ss.stores.scheduledRecap +} + func (ss *SqlStore) ReadReceipt() store.ReadReceiptStore { return ss.stores.readReceipt } diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 79874e970648..24d34d3efad6 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -103,6 +103,7 @@ type Store interface { GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error) ContentFlagging() ContentFlaggingStore Recap() RecapStore + ScheduledRecap() ScheduledRecapStore ReadReceipt() ReadReceiptStore TemporaryPost() TemporaryPostStore ChannelJoinRequest() ChannelJoinRequestStore @@ -828,6 +829,9 @@ type JobStore interface { // If this method is called concurrently with another job of the same type, // then nil, nil is returned. SaveOnce(job *model.Job) (*model.Job, error) + // SaveOnceByTypeAndData will only insert the job when there is no pending or + // in-progress job with the same type matching the data filter. + SaveOnceByTypeAndData(job *model.Job, data map[string]string) (*model.Job, error) UpdateOptimistically(job *model.Job, currentStatus string) (*model.Job, error) UpdateStatus(id string, status string) (*model.Job, error) UpdateStatusOptimistically(id string, currentStatus string, newStatus string) (*model.Job, error) @@ -1410,14 +1414,58 @@ type ChannelJoinRequestStore interface { type RecapStore interface { SaveRecap(recap *model.Recap) (*model.Recap, error) + SaveRecapIfUnderDailyLimit(recap *model.Recap, since int64, limit int) (*model.Recap, error) UpdateRecap(recap *model.Recap) (*model.Recap, error) GetRecap(id string) (*model.Recap, error) GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error) UpdateRecapStatus(id, status string) error + // MarkRecapSkipped flips a recap to the skipped status with the given reason. + // Skipped recaps are excluded from the daily-limit count, so this frees the slot + // for a recap that never ran (e.g. its processing job failed to enqueue). + MarkRecapSkipped(id, reason string) error MarkRecapAsRead(id string) error MarkRecapsAsViewed(userId string, statuses []string) ([]string, error) DeleteRecap(id string) error DeleteRecapChannels(recapId string) error SaveRecapChannel(recapChannel *model.RecapChannel) error GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) + + // CountForUserSince returns count of recaps created by user since given timestamp. + // Used for daily limit enforcement (pass midnight timestamp in user timezone). + // Excludes skipped recaps, but includes soft-deleted recaps because they still + // consumed AI usage. + CountForUserSince(userId string, since int64) (int64, error) + + // SumTotalMessageCountForUserSince returns the total number of posts processed + // by recaps created by user since given timestamp. Excludes skipped recaps, but + // includes soft-deleted recaps because they still consumed AI usage. + SumTotalMessageCountForUserSince(userId string, since int64) (int64, error) + + // GetLastCompletedManualRecap returns the most recent completed manual recap for user. + // Manual recap = ScheduledRecapId is empty. Used for cooldown checking, including + // soft-deleted recaps so deleting a recap does not bypass cooldown. + // Returns nil, nil if no manual recap exists. + GetLastCompletedManualRecap(userId string) (*model.Recap, error) +} + +type ScheduledRecapStore interface { + // CRUD operations + Save(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) + SaveIfUnderLimit(scheduledRecap *model.ScheduledRecap, limit int) (*model.ScheduledRecap, error) + Get(id string) (*model.ScheduledRecap, error) + Update(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) + Delete(id string) error // Soft delete (sets DeleteAt) + + // Query operations + GetForUser(userId string, page, perPage int) ([]*model.ScheduledRecap, error) + GetDueBefore(timestamp int64, limit int) ([]*model.ScheduledRecap, error) + + // CountForUser returns count of active (non-deleted, enabled) scheduled recaps for a user + // Used for max scheduled recaps limit enforcement + CountForUser(userId string) (int64, error) + + // State updates (efficient single-field updates) + UpdateNextRunAt(id string, nextRunAt int64) error + MarkExecuted(id string, lastRunAt int64, nextRunAt int64) error + SetEnabled(id string, enabled bool) error } diff --git a/server/channels/store/storetest/job_store.go b/server/channels/store/storetest/job_store.go index 837c03130206..796c7bf9419f 100644 --- a/server/channels/store/storetest/job_store.go +++ b/server/channels/store/storetest/job_store.go @@ -20,6 +20,7 @@ import ( func TestJobStore(t *testing.T, rctx request.CTX, ss store.Store) { t.Run("JobSaveGet", func(t *testing.T) { testJobSaveGet(t, rctx, ss) }) t.Run("JobSaveOnce", func(t *testing.T) { testJobSaveOnce(t, rctx, ss) }) + t.Run("JobSaveOnceByTypeAndData", func(t *testing.T) { testJobSaveOnceByTypeAndData(t, rctx, ss) }) t.Run("JobGetAllByType", func(t *testing.T) { testJobGetAllByType(t, rctx, ss) }) t.Run("JobGetAllByTypeAndStatus", func(t *testing.T) { testJobGetAllByTypeAndStatus(t, rctx, ss) }) t.Run("JobGetAllByTypePage", func(t *testing.T) { testJobGetAllByTypePage(t, rctx, ss) }) @@ -105,6 +106,89 @@ func testJobSaveOnce(t *testing.T, rctx request.CTX, ss store.Store) { } } +func testJobSaveOnceByTypeAndData(t *testing.T, rctx request.CTX, ss store.Store) { + jobType := model.NewId() + dedupeID1 := model.NewId() + dedupeID2 := model.NewId() + dataFilter1 := map[string]string{"scheduled_recap_id": dedupeID1} + dataFilter2 := map[string]string{"scheduled_recap_id": dedupeID2} + + job1 := &model.Job{ + Id: model.NewId(), + Type: jobType, + Status: model.JobStatusPending, + Data: map[string]string{ + "scheduled_recap_id": dedupeID1, + "payload": model.NewId(), + }, + } + savedJob1, err := ss.Job().SaveOnceByTypeAndData(job1, dataFilter1) + require.NoError(t, err) + require.NotNil(t, savedJob1) + defer func() { _, _ = ss.Job().Delete(savedJob1.Id) }() + + job2 := &model.Job{ + Id: model.NewId(), + Type: jobType, + Status: model.JobStatusPending, + Data: map[string]string{ + "scheduled_recap_id": dedupeID2, + "payload": model.NewId(), + }, + } + savedJob2, err := ss.Job().SaveOnceByTypeAndData(job2, dataFilter2) + require.NoError(t, err) + require.NotNil(t, savedJob2) + defer func() { _, _ = ss.Job().Delete(savedJob2.Id) }() + + jobs, err := ss.Job().GetByTypeAndData(rctx, jobType, dataFilter1, true, model.JobStatusPending, model.JobStatusInProgress) + require.NoError(t, err) + require.Len(t, jobs, 1) + jobs, err = ss.Job().GetByTypeAndData(rctx, jobType, dataFilter2, true, model.JobStatusPending, model.JobStatusInProgress) + require.NoError(t, err) + require.Len(t, jobs, 1) + + var wg sync.WaitGroup + var mu sync.Mutex + duplicateIDs := make([]string, 0, 2) + duplicateDedupeID := model.NewId() + start := make(chan struct{}) + for i := range 2 { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + + job := &model.Job{ + Id: model.NewId(), + Type: jobType, + Status: model.JobStatusPending, + Data: map[string]string{ + "scheduled_recap_id": duplicateDedupeID, + "payload": model.NewId(), + }, + } + + savedJob, saveErr := ss.Job().SaveOnceByTypeAndData(job, map[string]string{"scheduled_recap_id": duplicateDedupeID}) + require.NoError(t, saveErr) + if savedJob != nil { + mu.Lock() + duplicateIDs = append(duplicateIDs, savedJob.Id) + mu.Unlock() + } + }(i) + } + close(start) + wg.Wait() + for _, id := range duplicateIDs { + defer func(id string) { _, _ = ss.Job().Delete(id) }(id) + } + + jobs, err = ss.Job().GetByTypeAndData(rctx, jobType, map[string]string{"scheduled_recap_id": duplicateDedupeID}, true, model.JobStatusPending, model.JobStatusInProgress) + require.NoError(t, err) + require.Len(t, jobs, 1) +} + func testJobGetAllByType(t *testing.T, rctx request.CTX, ss store.Store) { jobType := model.NewId() diff --git a/server/channels/store/storetest/mocks/JobStore.go b/server/channels/store/storetest/mocks/JobStore.go index f4ebc739b876..6a9fb063d3a4 100644 --- a/server/channels/store/storetest/mocks/JobStore.go +++ b/server/channels/store/storetest/mocks/JobStore.go @@ -456,6 +456,36 @@ func (_m *JobStore) SaveOnce(job *model.Job) (*model.Job, error) { return r0, r1 } +// SaveOnceByTypeAndData provides a mock function with given fields: job, data +func (_m *JobStore) SaveOnceByTypeAndData(job *model.Job, data map[string]string) (*model.Job, error) { + ret := _m.Called(job, data) + + if len(ret) == 0 { + panic("no return value specified for SaveOnceByTypeAndData") + } + + var r0 *model.Job + var r1 error + if rf, ok := ret.Get(0).(func(*model.Job, map[string]string) (*model.Job, error)); ok { + return rf(job, data) + } + if rf, ok := ret.Get(0).(func(*model.Job, map[string]string) *model.Job); ok { + r0 = rf(job, data) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Job) + } + } + + if rf, ok := ret.Get(1).(func(*model.Job, map[string]string) error); ok { + r1 = rf(job, data) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // UpdateOptimistically provides a mock function with given fields: job, currentStatus func (_m *JobStore) UpdateOptimistically(job *model.Job, currentStatus string) (*model.Job, error) { ret := _m.Called(job, currentStatus) diff --git a/server/channels/store/storetest/mocks/RecapStore.go b/server/channels/store/storetest/mocks/RecapStore.go index de356d2f9172..8a328729b803 100644 --- a/server/channels/store/storetest/mocks/RecapStore.go +++ b/server/channels/store/storetest/mocks/RecapStore.go @@ -14,6 +14,34 @@ type RecapStore struct { mock.Mock } +// CountForUserSince provides a mock function with given fields: userId, since +func (_m *RecapStore) CountForUserSince(userId string, since int64) (int64, error) { + ret := _m.Called(userId, since) + + if len(ret) == 0 { + panic("no return value specified for CountForUserSince") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(string, int64) (int64, error)); ok { + return rf(userId, since) + } + if rf, ok := ret.Get(0).(func(string, int64) int64); ok { + r0 = rf(userId, since) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(string, int64) error); ok { + r1 = rf(userId, since) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // DeleteRecap provides a mock function with given fields: id func (_m *RecapStore) DeleteRecap(id string) error { ret := _m.Called(id) @@ -50,6 +78,36 @@ func (_m *RecapStore) DeleteRecapChannels(recapId string) error { return r0 } +// GetLastCompletedManualRecap provides a mock function with given fields: userId +func (_m *RecapStore) GetLastCompletedManualRecap(userId string) (*model.Recap, error) { + ret := _m.Called(userId) + + if len(ret) == 0 { + panic("no return value specified for GetLastCompletedManualRecap") + } + + var r0 *model.Recap + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.Recap, error)); ok { + return rf(userId) + } + if rf, ok := ret.Get(0).(func(string) *model.Recap); ok { + r0 = rf(userId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Recap) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(userId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetRecap provides a mock function with given fields: id func (_m *RecapStore) GetRecap(id string) (*model.Recap, error) { ret := _m.Called(id) @@ -158,6 +216,24 @@ func (_m *RecapStore) MarkRecapAsRead(id string) error { return r0 } +// MarkRecapSkipped provides a mock function with given fields: id, reason +func (_m *RecapStore) MarkRecapSkipped(id string, reason string) error { + ret := _m.Called(id, reason) + + if len(ret) == 0 { + panic("no return value specified for MarkRecapSkipped") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(id, reason) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // MarkRecapsAsViewed provides a mock function with given fields: userId, statuses func (_m *RecapStore) MarkRecapsAsViewed(userId string, statuses []string) ([]string, error) { ret := _m.Called(userId, statuses) @@ -236,6 +312,64 @@ func (_m *RecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error { return r0 } +// SaveRecapIfUnderDailyLimit provides a mock function with given fields: recap, since, limit +func (_m *RecapStore) SaveRecapIfUnderDailyLimit(recap *model.Recap, since int64, limit int) (*model.Recap, error) { + ret := _m.Called(recap, since, limit) + + if len(ret) == 0 { + panic("no return value specified for SaveRecapIfUnderDailyLimit") + } + + var r0 *model.Recap + var r1 error + if rf, ok := ret.Get(0).(func(*model.Recap, int64, int) (*model.Recap, error)); ok { + return rf(recap, since, limit) + } + if rf, ok := ret.Get(0).(func(*model.Recap, int64, int) *model.Recap); ok { + r0 = rf(recap, since, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Recap) + } + } + + if rf, ok := ret.Get(1).(func(*model.Recap, int64, int) error); ok { + r1 = rf(recap, since, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SumTotalMessageCountForUserSince provides a mock function with given fields: userId, since +func (_m *RecapStore) SumTotalMessageCountForUserSince(userId string, since int64) (int64, error) { + ret := _m.Called(userId, since) + + if len(ret) == 0 { + panic("no return value specified for SumTotalMessageCountForUserSince") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(string, int64) (int64, error)); ok { + return rf(userId, since) + } + if rf, ok := ret.Get(0).(func(string, int64) int64); ok { + r0 = rf(userId, since) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(string, int64) error); ok { + r1 = rf(userId, since) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // UpdateRecap provides a mock function with given fields: recap func (_m *RecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) { ret := _m.Called(recap) diff --git a/server/channels/store/storetest/mocks/ScheduledRecapStore.go b/server/channels/store/storetest/mocks/ScheduledRecapStore.go new file mode 100644 index 000000000000..e050ada66c7b --- /dev/null +++ b/server/channels/store/storetest/mocks/ScheduledRecapStore.go @@ -0,0 +1,309 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// ScheduledRecapStore is an autogenerated mock type for the ScheduledRecapStore type +type ScheduledRecapStore struct { + mock.Mock +} + +// CountForUser provides a mock function with given fields: userId +func (_m *ScheduledRecapStore) CountForUser(userId string) (int64, error) { + ret := _m.Called(userId) + + if len(ret) == 0 { + panic("no return value specified for CountForUser") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(string) (int64, error)); ok { + return rf(userId) + } + if rf, ok := ret.Get(0).(func(string) int64); ok { + r0 = rf(userId) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(userId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: id +func (_m *ScheduledRecapStore) Delete(id string) error { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: id +func (_m *ScheduledRecapStore) Get(id string) (*model.ScheduledRecap, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *model.ScheduledRecap + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.ScheduledRecap, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *model.ScheduledRecap); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ScheduledRecap) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetDueBefore provides a mock function with given fields: timestamp, limit +func (_m *ScheduledRecapStore) GetDueBefore(timestamp int64, limit int) ([]*model.ScheduledRecap, error) { + ret := _m.Called(timestamp, limit) + + if len(ret) == 0 { + panic("no return value specified for GetDueBefore") + } + + var r0 []*model.ScheduledRecap + var r1 error + if rf, ok := ret.Get(0).(func(int64, int) ([]*model.ScheduledRecap, error)); ok { + return rf(timestamp, limit) + } + if rf, ok := ret.Get(0).(func(int64, int) []*model.ScheduledRecap); ok { + r0 = rf(timestamp, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ScheduledRecap) + } + } + + if rf, ok := ret.Get(1).(func(int64, int) error); ok { + r1 = rf(timestamp, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForUser provides a mock function with given fields: userId, page, perPage +func (_m *ScheduledRecapStore) GetForUser(userId string, page int, perPage int) ([]*model.ScheduledRecap, error) { + ret := _m.Called(userId, page, perPage) + + if len(ret) == 0 { + panic("no return value specified for GetForUser") + } + + var r0 []*model.ScheduledRecap + var r1 error + if rf, ok := ret.Get(0).(func(string, int, int) ([]*model.ScheduledRecap, error)); ok { + return rf(userId, page, perPage) + } + if rf, ok := ret.Get(0).(func(string, int, int) []*model.ScheduledRecap); ok { + r0 = rf(userId, page, perPage) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ScheduledRecap) + } + } + + if rf, ok := ret.Get(1).(func(string, int, int) error); ok { + r1 = rf(userId, page, perPage) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MarkExecuted provides a mock function with given fields: id, lastRunAt, nextRunAt +func (_m *ScheduledRecapStore) MarkExecuted(id string, lastRunAt int64, nextRunAt int64) error { + ret := _m.Called(id, lastRunAt, nextRunAt) + + if len(ret) == 0 { + panic("no return value specified for MarkExecuted") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, int64, int64) error); ok { + r0 = rf(id, lastRunAt, nextRunAt) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Save provides a mock function with given fields: scheduledRecap +func (_m *ScheduledRecapStore) Save(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) { + ret := _m.Called(scheduledRecap) + + if len(ret) == 0 { + panic("no return value specified for Save") + } + + var r0 *model.ScheduledRecap + var r1 error + if rf, ok := ret.Get(0).(func(*model.ScheduledRecap) (*model.ScheduledRecap, error)); ok { + return rf(scheduledRecap) + } + if rf, ok := ret.Get(0).(func(*model.ScheduledRecap) *model.ScheduledRecap); ok { + r0 = rf(scheduledRecap) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ScheduledRecap) + } + } + + if rf, ok := ret.Get(1).(func(*model.ScheduledRecap) error); ok { + r1 = rf(scheduledRecap) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SaveIfUnderLimit provides a mock function with given fields: scheduledRecap, limit +func (_m *ScheduledRecapStore) SaveIfUnderLimit(scheduledRecap *model.ScheduledRecap, limit int) (*model.ScheduledRecap, error) { + ret := _m.Called(scheduledRecap, limit) + + if len(ret) == 0 { + panic("no return value specified for SaveIfUnderLimit") + } + + var r0 *model.ScheduledRecap + var r1 error + if rf, ok := ret.Get(0).(func(*model.ScheduledRecap, int) (*model.ScheduledRecap, error)); ok { + return rf(scheduledRecap, limit) + } + if rf, ok := ret.Get(0).(func(*model.ScheduledRecap, int) *model.ScheduledRecap); ok { + r0 = rf(scheduledRecap, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ScheduledRecap) + } + } + + if rf, ok := ret.Get(1).(func(*model.ScheduledRecap, int) error); ok { + r1 = rf(scheduledRecap, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetEnabled provides a mock function with given fields: id, enabled +func (_m *ScheduledRecapStore) SetEnabled(id string, enabled bool) error { + ret := _m.Called(id, enabled) + + if len(ret) == 0 { + panic("no return value specified for SetEnabled") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(id, enabled) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Update provides a mock function with given fields: scheduledRecap +func (_m *ScheduledRecapStore) Update(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) { + ret := _m.Called(scheduledRecap) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 *model.ScheduledRecap + var r1 error + if rf, ok := ret.Get(0).(func(*model.ScheduledRecap) (*model.ScheduledRecap, error)); ok { + return rf(scheduledRecap) + } + if rf, ok := ret.Get(0).(func(*model.ScheduledRecap) *model.ScheduledRecap); ok { + r0 = rf(scheduledRecap) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ScheduledRecap) + } + } + + if rf, ok := ret.Get(1).(func(*model.ScheduledRecap) error); ok { + r1 = rf(scheduledRecap) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UpdateNextRunAt provides a mock function with given fields: id, nextRunAt +func (_m *ScheduledRecapStore) UpdateNextRunAt(id string, nextRunAt int64) error { + ret := _m.Called(id, nextRunAt) + + if len(ret) == 0 { + panic("no return value specified for UpdateNextRunAt") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, int64) error); ok { + r0 = rf(id, nextRunAt) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewScheduledRecapStore creates a new instance of ScheduledRecapStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewScheduledRecapStore(t interface { + mock.TestingT + Cleanup(func()) +}) *ScheduledRecapStore { + mock := &ScheduledRecapStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index f3f3630a721d..4a68ee640b99 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -1195,6 +1195,26 @@ func (_m *Store) ScheduledPost() store.ScheduledPostStore { return r0 } +// ScheduledRecap provides a mock function with no fields +func (_m *Store) ScheduledRecap() store.ScheduledRecapStore { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ScheduledRecap") + } + + var r0 store.ScheduledRecapStore + if rf, ok := ret.Get(0).(func() store.ScheduledRecapStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.ScheduledRecapStore) + } + } + + return r0 +} + // Scheme provides a mock function with no fields func (_m *Store) Scheme() store.SchemeStore { ret := _m.Called() diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 78ba6eb31eb6..dbc4f5cfd9e4 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -75,6 +75,7 @@ type Store struct { AutoTranslationStore mocks.AutoTranslationStore ContentFlaggingStore mocks.ContentFlaggingStore RecapStore mocks.RecapStore + ScheduledRecapStore mocks.ScheduledRecapStore ReadReceiptStore mocks.ReadReceiptStore TemporaryPostStore mocks.TemporaryPostStore ViewStore mocks.ViewStore @@ -182,6 +183,9 @@ func (s *Store) ContentFlagging() store.ContentFlaggingStore { func (s *Store) Recap() store.RecapStore { return &s.RecapStore } +func (s *Store) ScheduledRecap() store.ScheduledRecapStore { + return &s.ScheduledRecapStore +} func (s *Store) ReadReceipt() store.ReadReceiptStore { return &s.ReadReceiptStore } @@ -253,6 +257,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.AutoTranslationStore, &s.ContentFlaggingStore, &s.RecapStore, + &s.ScheduledRecapStore, &s.ReadReceiptStore, &s.TemporaryPostStore, &s.ViewStore, diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 1aabf85929e7..ba5c8aa7fb6f 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -62,6 +62,7 @@ type TimerLayer struct { RetentionPolicyStore store.RetentionPolicyStore RoleStore store.RoleStore ScheduledPostStore store.ScheduledPostStore + ScheduledRecapStore store.ScheduledRecapStore SchemeStore store.SchemeStore SessionStore store.SessionStore SessionAttributeStore store.SessionAttributeStore @@ -253,6 +254,10 @@ func (s *TimerLayer) ScheduledPost() store.ScheduledPostStore { return s.ScheduledPostStore } +func (s *TimerLayer) ScheduledRecap() store.ScheduledRecapStore { + return s.ScheduledRecapStore +} + func (s *TimerLayer) Scheme() store.SchemeStore { return s.SchemeStore } @@ -536,6 +541,11 @@ type TimerLayerScheduledPostStore struct { Root *TimerLayer } +type TimerLayerScheduledRecapStore struct { + store.ScheduledRecapStore + Root *TimerLayer +} + type TimerLayerSchemeStore struct { store.SchemeStore Root *TimerLayer @@ -6028,6 +6038,22 @@ func (s *TimerLayerJobStore) SaveOnce(job *model.Job) (*model.Job, error) { return result, err } +func (s *TimerLayerJobStore) SaveOnceByTypeAndData(job *model.Job, data map[string]string) (*model.Job, error) { + start := time.Now() + + result, err := s.JobStore.SaveOnceByTypeAndData(job, data) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("JobStore.SaveOnceByTypeAndData", success, elapsed) + } + return result, err +} + func (s *TimerLayerJobStore) UpdateOptimistically(job *model.Job, currentStatus string) (*model.Job, error) { start := time.Now() @@ -9081,6 +9107,22 @@ func (s *TimerLayerReadReceiptStore) Update(rctx request.CTX, receipt *model.Rea return result, err } +func (s *TimerLayerRecapStore) CountForUserSince(userId string, since int64) (int64, error) { + start := time.Now() + + result, err := s.RecapStore.CountForUserSince(userId, since) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("RecapStore.CountForUserSince", success, elapsed) + } + return result, err +} + func (s *TimerLayerRecapStore) DeleteRecap(id string) error { start := time.Now() @@ -9113,6 +9155,22 @@ func (s *TimerLayerRecapStore) DeleteRecapChannels(recapId string) error { return err } +func (s *TimerLayerRecapStore) GetLastCompletedManualRecap(userId string) (*model.Recap, error) { + start := time.Now() + + result, err := s.RecapStore.GetLastCompletedManualRecap(userId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("RecapStore.GetLastCompletedManualRecap", success, elapsed) + } + return result, err +} + func (s *TimerLayerRecapStore) GetRecap(id string) (*model.Recap, error) { start := time.Now() @@ -9177,6 +9235,22 @@ func (s *TimerLayerRecapStore) MarkRecapAsRead(id string) error { return err } +func (s *TimerLayerRecapStore) MarkRecapSkipped(id string, reason string) error { + start := time.Now() + + err := s.RecapStore.MarkRecapSkipped(id, reason) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("RecapStore.MarkRecapSkipped", success, elapsed) + } + return err +} + func (s *TimerLayerRecapStore) MarkRecapsAsViewed(userId string, statuses []string) ([]string, error) { start := time.Now() @@ -9225,6 +9299,38 @@ func (s *TimerLayerRecapStore) SaveRecapChannel(recapChannel *model.RecapChannel return err } +func (s *TimerLayerRecapStore) SaveRecapIfUnderDailyLimit(recap *model.Recap, since int64, limit int) (*model.Recap, error) { + start := time.Now() + + result, err := s.RecapStore.SaveRecapIfUnderDailyLimit(recap, since, limit) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("RecapStore.SaveRecapIfUnderDailyLimit", success, elapsed) + } + return result, err +} + +func (s *TimerLayerRecapStore) SumTotalMessageCountForUserSince(userId string, since int64) (int64, error) { + start := time.Now() + + result, err := s.RecapStore.SumTotalMessageCountForUserSince(userId, since) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("RecapStore.SumTotalMessageCountForUserSince", success, elapsed) + } + return result, err +} + func (s *TimerLayerRecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) { start := time.Now() @@ -10073,6 +10179,182 @@ func (s *TimerLayerScheduledPostStore) UpdatedScheduledPost(scheduledPost *model return err } +func (s *TimerLayerScheduledRecapStore) CountForUser(userId string) (int64, error) { + start := time.Now() + + result, err := s.ScheduledRecapStore.CountForUser(userId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.CountForUser", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledRecapStore) Delete(id string) error { + start := time.Now() + + err := s.ScheduledRecapStore.Delete(id) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerScheduledRecapStore) Get(id string) (*model.ScheduledRecap, error) { + start := time.Now() + + result, err := s.ScheduledRecapStore.Get(id) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledRecapStore) GetDueBefore(timestamp int64, limit int) ([]*model.ScheduledRecap, error) { + start := time.Now() + + result, err := s.ScheduledRecapStore.GetDueBefore(timestamp, limit) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.GetDueBefore", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledRecapStore) GetForUser(userId string, page int, perPage int) ([]*model.ScheduledRecap, error) { + start := time.Now() + + result, err := s.ScheduledRecapStore.GetForUser(userId, page, perPage) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.GetForUser", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledRecapStore) MarkExecuted(id string, lastRunAt int64, nextRunAt int64) error { + start := time.Now() + + err := s.ScheduledRecapStore.MarkExecuted(id, lastRunAt, nextRunAt) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.MarkExecuted", success, elapsed) + } + return err +} + +func (s *TimerLayerScheduledRecapStore) Save(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) { + start := time.Now() + + result, err := s.ScheduledRecapStore.Save(scheduledRecap) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.Save", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledRecapStore) SaveIfUnderLimit(scheduledRecap *model.ScheduledRecap, limit int) (*model.ScheduledRecap, error) { + start := time.Now() + + result, err := s.ScheduledRecapStore.SaveIfUnderLimit(scheduledRecap, limit) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.SaveIfUnderLimit", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledRecapStore) SetEnabled(id string, enabled bool) error { + start := time.Now() + + err := s.ScheduledRecapStore.SetEnabled(id, enabled) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.SetEnabled", success, elapsed) + } + return err +} + +func (s *TimerLayerScheduledRecapStore) Update(scheduledRecap *model.ScheduledRecap) (*model.ScheduledRecap, error) { + start := time.Now() + + result, err := s.ScheduledRecapStore.Update(scheduledRecap) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.Update", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledRecapStore) UpdateNextRunAt(id string, nextRunAt int64) error { + start := time.Now() + + err := s.ScheduledRecapStore.UpdateNextRunAt(id, nextRunAt) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledRecapStore.UpdateNextRunAt", success, elapsed) + } + return err +} + func (s *TimerLayerSchemeStore) CountByScope(scope string) (int64, error) { start := time.Now() @@ -15264,6 +15546,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.RetentionPolicyStore = &TimerLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore} newStore.RoleStore = &TimerLayerRoleStore{RoleStore: childStore.Role(), Root: &newStore} newStore.ScheduledPostStore = &TimerLayerScheduledPostStore{ScheduledPostStore: childStore.ScheduledPost(), Root: &newStore} + newStore.ScheduledRecapStore = &TimerLayerScheduledRecapStore{ScheduledRecapStore: childStore.ScheduledRecap(), Root: &newStore} newStore.SchemeStore = &TimerLayerSchemeStore{SchemeStore: childStore.Scheme(), Root: &newStore} newStore.SessionStore = &TimerLayerSessionStore{SessionStore: childStore.Session(), Root: &newStore} newStore.SessionAttributeStore = &TimerLayerSessionAttributeStore{SessionAttributeStore: childStore.SessionAttribute(), Root: &newStore} diff --git a/server/channels/web/context.go b/server/channels/web/context.go index cc36894567bf..e0632cf3a610 100644 --- a/server/channels/web/context.go +++ b/server/channels/web/context.go @@ -812,6 +812,17 @@ func (c *Context) RequireRecapId() *Context { return c } +func (c *Context) RequireScheduledRecapId() *Context { + if c.Err != nil { + return c + } + + if !model.IsValidId(c.Params.ScheduledRecapId) { + c.SetInvalidURLParam("scheduled_recap_id") + } + return c +} + func (c *Context) RequireViewId() *Context { if c.Err != nil { return c diff --git a/server/channels/web/params.go b/server/channels/web/params.go index 6cb97cbb33f0..69d0b530e0f7 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -56,6 +56,7 @@ type Params struct { JobId string JobType string RecapId string + ScheduledRecapId string ActionId string RoleId string RoleName string @@ -183,6 +184,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.JobId = props["job_id"] params.JobType = props["job_type"] params.RecapId = props["recap_id"] + params.ScheduledRecapId = props["scheduled_recap_id"] params.ActionId = props["action_id"] params.RoleId = props["role_id"] params.RoleName = props["role_name"] diff --git a/server/i18n/en.json b/server/i18n/en.json index 798cb61ab6ef..e98666349974 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -3558,6 +3558,10 @@ "id": "api.scheduled_posts.license_error", "translation": "Scheduled posts feature requires a license" }, + { + "id": "api.scheduled_recap.permission_denied", + "translation": "You do not have permission to access this scheduled recap." + }, { "id": "api.scheme.create_scheme.license.error", "translation": "Your license does not support creating permissions schemes." @@ -8756,6 +8760,14 @@ "id": "app.reaction.save.save.too_many_reactions", "translation": "Reaction limit has been reached for this post." }, + { + "id": "app.recap.cooldown_active.app_error", + "translation": "Your organization's policy requires {{.CooldownMinutes}} minutes between recaps. You can create another recap in {{.RetryAfterMinutes}} minutes." + }, + { + "id": "app.recap.cooldown_check_failed.app_error", + "translation": "Failed to check cooldown status." + }, { "id": "app.recap.delete.app_error", "translation": "Failed to delete recap." @@ -8776,6 +8788,10 @@ "id": "app.recap.get_channels.app_error", "translation": "Failed to get recap channels." }, + { + "id": "app.recap.get_daily_count.app_error", + "translation": "Failed to check recap limits." + }, { "id": "app.recap.get_last_viewed.app_error", "translation": "Failed to get last viewed timestamp." @@ -8796,6 +8812,14 @@ "id": "app.recap.mark_viewed.app_error", "translation": "Failed to mark recaps as viewed." }, + { + "id": "app.recap.max_channels_exceeded.app_error", + "translation": "Your organization's policy limits recaps to {{.Limit}} channels. You requested {{.Requested}} channels." + }, + { + "id": "app.recap.max_recaps_reached.app_error", + "translation": "Your organization's policy limits you to {{.Limit}} recaps per day. Please try again tomorrow." + }, { "id": "app.recap.permission_denied", "translation": "No permission for recap." @@ -8808,6 +8832,10 @@ "id": "app.recap.save_channel.app_error", "translation": "Failed to save recap channel." }, + { + "id": "app.recap.sum_daily_posts.app_error", + "translation": "Failed to check recap post usage." + }, { "id": "app.recap.update.app_error", "translation": "Failed to update recap." @@ -9050,6 +9078,58 @@ "id": "app.scheduled_post.update.rejected_by_plugin", "translation": "Scheduled post update rejected by plugin: {{.Reason}}" }, + { + "id": "app.scheduled_recap.compute_next_run.app_error", + "translation": "Unable to compute the next scheduled recap run." + }, + { + "id": "app.scheduled_recap.create.app_error", + "translation": "Unable to create scheduled recap." + }, + { + "id": "app.scheduled_recap.delete.app_error", + "translation": "Unable to delete scheduled recap." + }, + { + "id": "app.scheduled_recap.get.app_error", + "translation": "Unable to get scheduled recap." + }, + { + "id": "app.scheduled_recap.get_unreads.app_error", + "translation": "Unable to get unread channels for scheduled recap." + }, + { + "id": "app.scheduled_recap.list.app_error", + "translation": "Unable to get scheduled recaps." + }, + { + "id": "app.scheduled_recap.max_channels_exceeded.app_error", + "translation": "Your organization's policy limits recaps to {{.Limit}} channels. You requested {{.Requested}} channels." + }, + { + "id": "app.scheduled_recap.max_scheduled_reached.app_error", + "translation": "Your organization's policy limits you to {{.Limit}} scheduled recaps. Please delete an existing scheduled recap before creating a new one." + }, + { + "id": "app.scheduled_recap.no_channels.app_error", + "translation": "Scheduled recap must include at least one channel." + }, + { + "id": "app.scheduled_recap.pause.app_error", + "translation": "Unable to pause scheduled recap." + }, + { + "id": "app.scheduled_recap.resume.app_error", + "translation": "Unable to resume scheduled recap." + }, + { + "id": "app.scheduled_recap.save_recap.app_error", + "translation": "Unable to save recap for scheduled recap." + }, + { + "id": "app.scheduled_recap.update.app_error", + "translation": "Unable to update scheduled recap." + }, { "id": "app.scheme.delete.app_error", "translation": "Unable to delete this scheme." @@ -11654,6 +11734,34 @@ "id": "model.compliance.is_valid.start_end_at.app_error", "translation": "To must be greater than From." }, + { + "id": "model.config.is_valid.ai_recap.cooldown_minutes.app_error", + "translation": "Invalid AI recap cooldown minutes. Must be greater than or equal to 0." + }, + { + "id": "model.config.is_valid.ai_recap.max_channels_per_recap.app_error", + "translation": "Invalid AI recap max channels per recap. Must be greater than or equal to 1, or -1 for unlimited." + }, + { + "id": "model.config.is_valid.ai_recap.max_posts_per_day.app_error", + "translation": "Invalid AI recap max posts per day. Must be greater than or equal to 1, or -1 for unlimited." + }, + { + "id": "model.config.is_valid.ai_recap.max_posts_per_recap.app_error", + "translation": "Invalid AI recap max posts per recap. Must be greater than or equal to 1, or -1 for unlimited." + }, + { + "id": "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", + "translation": "Invalid AI recap max recaps per day. Must be greater than or equal to 1, or -1 for unlimited." + }, + { + "id": "model.config.is_valid.ai_recap.max_scheduled_recaps.app_error", + "translation": "Invalid AI recap max scheduled recaps. Must be greater than or equal to 1, or -1 for unlimited." + }, + { + "id": "model.config.is_valid.ai_recap.max_tokens_per_recap.app_error", + "translation": "Invalid AI recap max tokens per recap. Must be greater than or equal to 1, or -1 for unlimited." + }, { "id": "model.config.is_valid.allow_cookies_for_subdomains.app_error", "translation": "Allowing cookies for subdomains requires SiteURL to be set." @@ -13070,6 +13178,74 @@ "id": "model.scheduled_post.is_valid.scheduled_at.app_error", "translation": "Invalid scheduled at time." }, + { + "id": "model.scheduled_recap.compute_next_run.days_of_week.app_error", + "translation": "Invalid scheduled recap days of week." + }, + { + "id": "model.scheduled_recap.compute_next_run.no_valid_day.app_error", + "translation": "No valid day found for the scheduled recap." + }, + { + "id": "model.scheduled_recap.compute_next_run.time_format.app_error", + "translation": "Invalid scheduled recap time format." + }, + { + "id": "model.scheduled_recap.compute_next_run.timezone.app_error", + "translation": "Invalid scheduled recap timezone." + }, + { + "id": "model.scheduled_recap.is_valid.agent_id.app_error", + "translation": "Scheduled recap must have an agent ID." + }, + { + "id": "model.scheduled_recap.is_valid.channel_id.app_error", + "translation": "Invalid scheduled recap channel ID." + }, + { + "id": "model.scheduled_recap.is_valid.channel_ids_empty.app_error", + "translation": "Scheduled recap must include at least one channel when using specific channels." + }, + { + "id": "model.scheduled_recap.is_valid.channel_mode.app_error", + "translation": "Invalid scheduled recap channel mode." + }, + { + "id": "model.scheduled_recap.is_valid.custom_instructions_length.app_error", + "translation": "Scheduled recap custom instructions exceed the maximum length." + }, + { + "id": "model.scheduled_recap.is_valid.days_of_week.app_error", + "translation": "Invalid scheduled recap days of week." + }, + { + "id": "model.scheduled_recap.is_valid.id.app_error", + "translation": "Scheduled recap must have an ID." + }, + { + "id": "model.scheduled_recap.is_valid.time_of_day.app_error", + "translation": "Invalid scheduled recap time of day." + }, + { + "id": "model.scheduled_recap.is_valid.time_period.app_error", + "translation": "Invalid scheduled recap time period." + }, + { + "id": "model.scheduled_recap.is_valid.timezone.app_error", + "translation": "Invalid scheduled recap timezone." + }, + { + "id": "model.scheduled_recap.is_valid.title_empty.app_error", + "translation": "Scheduled recap title cannot be empty." + }, + { + "id": "model.scheduled_recap.is_valid.title_length.app_error", + "translation": "Scheduled recap title exceeds the maximum length." + }, + { + "id": "model.scheduled_recap.is_valid.user_id.app_error", + "translation": "Scheduled recap must have a user ID." + }, { "id": "model.scheme.is_valid.app_error", "translation": "Invalid scheme." diff --git a/server/public/model/ai_recap_limits.go b/server/public/model/ai_recap_limits.go new file mode 100644 index 000000000000..b1ee19363e78 --- /dev/null +++ b/server/public/model/ai_recap_limits.go @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +// UnlimitedValue is the sentinel value indicating a limit is disabled/unlimited +const UnlimitedValue = -1 + +// LimitSource indicates where the effective limits originated from +type LimitSource string + +const ( + LimitSourceSystem LimitSource = "system" + LimitSourceGroup LimitSource = "group" + LimitSourceUser LimitSource = "user" +) + +// EffectiveRecapLimits contains resolved limit values for a user. +// These are non-pointer fields because resolution has already happened. +// A value of -1 (UnlimitedValue) means the limit is disabled/unlimited. +type EffectiveRecapLimits struct { + // Resolved limit values (-1 = unlimited/disabled) + MaxRecapsPerDay int `json:"max_recaps_per_day"` + MaxScheduledRecaps int `json:"max_scheduled_recaps"` + MaxChannelsPerRecap int `json:"max_channels_per_recap"` + MaxPostsPerRecap int `json:"max_posts_per_recap"` + MaxTokensPerRecap int `json:"max_tokens_per_recap"` + MaxPostsPerDay int `json:"max_posts_per_day"` + CooldownMinutes int `json:"cooldown_minutes"` + + // Source tracking (for debugging/UI display) + Source LimitSource `json:"source"` // Where limits came from + SourceID string `json:"source_id"` // Group ID or User ID if overridden, empty for system +} + +// RecapLimitStatus contains the current user's limit status for UI display +type RecapLimitStatus struct { + EffectiveLimits EffectiveRecapLimits `json:"effective_limits"` + Daily DailyUsageStatus `json:"daily"` + Cooldown CooldownStatus `json:"cooldown"` +} + +// DailyUsageStatus tracks daily recap usage +type DailyUsageStatus struct { + Used int `json:"used"` + Limit int `json:"limit"` + ResetAt int64 `json:"reset_at"` // Unix timestamp ms for midnight in user timezone +} + +// CooldownStatus tracks cooldown state +type CooldownStatus struct { + IsActive bool `json:"is_active"` + AvailableAt int64 `json:"available_at"` // Unix timestamp ms when cooldown ends + RetryAfterSeconds int `json:"retry_after_seconds"` // Seconds until available +} + +// IsLimitEnabled returns true if the given limit value is enabled (not unlimited). +// Useful for enforcement code to check if a limit should be enforced. +func IsLimitEnabled(limitValue int) bool { + return limitValue != UnlimitedValue +} diff --git a/server/public/model/ai_recap_settings.go b/server/public/model/ai_recap_settings.go new file mode 100644 index 000000000000..cfb638dfdd10 --- /dev/null +++ b/server/public/model/ai_recap_settings.go @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/http" +) + +// RecapLimitSettings configures the limits for AI Recaps +type RecapLimitSettings struct { + MaxRecapsPerDay *int `access:"ai_recaps"` // Default: 10, -1 = unlimited + MaxScheduledRecaps *int `access:"ai_recaps"` // Default: 5, -1 = unlimited + MaxChannelsPerRecap *int `access:"ai_recaps"` // Default: -1 (unlimited) + MaxPostsPerRecap *int `access:"ai_recaps"` // Default: 500, -1 = unlimited + MaxTokensPerRecap *int `access:"ai_recaps"` // Default: 100000, -1 = unlimited + MaxPostsPerDay *int `access:"ai_recaps"` // Default: 5000, -1 = unlimited + CooldownMinutes *int `access:"ai_recaps"` // Default: 60, 0 = no cooldown +} + +// SetDefaults sets the default values for RecapLimitSettings +func (s *RecapLimitSettings) SetDefaults() { + if s.MaxRecapsPerDay == nil { + s.MaxRecapsPerDay = NewPointer(10) + } + if s.MaxScheduledRecaps == nil { + s.MaxScheduledRecaps = NewPointer(5) + } + if s.MaxChannelsPerRecap == nil { + s.MaxChannelsPerRecap = NewPointer(-1) // unlimited by default + } + if s.MaxPostsPerRecap == nil { + s.MaxPostsPerRecap = NewPointer(500) + } + if s.MaxTokensPerRecap == nil { + s.MaxTokensPerRecap = NewPointer(100000) + } + if s.MaxPostsPerDay == nil { + s.MaxPostsPerDay = NewPointer(5000) + } + if s.CooldownMinutes == nil { + s.CooldownMinutes = NewPointer(60) + } +} + +// isValid validates the RecapLimitSettings +func (s *RecapLimitSettings) isValid() *AppError { + // MaxRecapsPerDay: must be >= 1 OR == -1 (unlimited) + if s.MaxRecapsPerDay != nil && *s.MaxRecapsPerDay != -1 && *s.MaxRecapsPerDay < 1 { + return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", nil, "", http.StatusBadRequest) + } + + // MaxScheduledRecaps: must be >= 1 OR == -1 (unlimited) + if s.MaxScheduledRecaps != nil && *s.MaxScheduledRecaps != -1 && *s.MaxScheduledRecaps < 1 { + return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_scheduled_recaps.app_error", nil, "", http.StatusBadRequest) + } + + // MaxChannelsPerRecap: must be >= 1 OR == -1 (unlimited) + if s.MaxChannelsPerRecap != nil && *s.MaxChannelsPerRecap != -1 && *s.MaxChannelsPerRecap < 1 { + return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_channels_per_recap.app_error", nil, "", http.StatusBadRequest) + } + + // MaxPostsPerRecap: must be >= 1 OR == -1 (unlimited) + if s.MaxPostsPerRecap != nil && *s.MaxPostsPerRecap != -1 && *s.MaxPostsPerRecap < 1 { + return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_posts_per_recap.app_error", nil, "", http.StatusBadRequest) + } + + // MaxTokensPerRecap: must be >= 1 OR == -1 (unlimited) + if s.MaxTokensPerRecap != nil && *s.MaxTokensPerRecap != -1 && *s.MaxTokensPerRecap < 1 { + return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_tokens_per_recap.app_error", nil, "", http.StatusBadRequest) + } + + // MaxPostsPerDay: must be >= 1 OR == -1 (unlimited) + if s.MaxPostsPerDay != nil && *s.MaxPostsPerDay != -1 && *s.MaxPostsPerDay < 1 { + return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_posts_per_day.app_error", nil, "", http.StatusBadRequest) + } + + // CooldownMinutes: must be >= 0 (0 = no cooldown) + if s.CooldownMinutes != nil && *s.CooldownMinutes < 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.cooldown_minutes.app_error", nil, "", http.StatusBadRequest) + } + + return nil +} + +// AIRecapSettings configures the AI Recap feature limits +type AIRecapSettings struct { + Enable *bool `access:"ai_recaps"` // Master toggle, default: true + + // System-wide default limits + DefaultLimits *RecapLimitSettings `access:"ai_recaps"` + + // Per-limit enforcement toggles (all default to true) + EnforceRecapsPerDay *bool `access:"ai_recaps"` + EnforceScheduledRecaps *bool `access:"ai_recaps"` + EnforceChannelsPerRecap *bool `access:"ai_recaps"` + EnforcePostsPerRecap *bool `access:"ai_recaps"` + EnforceTokensPerRecap *bool `access:"ai_recaps"` + EnforcePostsPerDay *bool `access:"ai_recaps"` + EnforceCooldown *bool `access:"ai_recaps"` +} + +// SetDefaults sets the default values for AIRecapSettings +func (s *AIRecapSettings) SetDefaults() { + if s.Enable == nil { + s.Enable = NewPointer(true) + } + + if s.DefaultLimits == nil { + s.DefaultLimits = &RecapLimitSettings{} + } + s.DefaultLimits.SetDefaults() + + if s.EnforceRecapsPerDay == nil { + s.EnforceRecapsPerDay = NewPointer(true) + } + if s.EnforceScheduledRecaps == nil { + s.EnforceScheduledRecaps = NewPointer(true) + } + if s.EnforceChannelsPerRecap == nil { + s.EnforceChannelsPerRecap = NewPointer(true) + } + if s.EnforcePostsPerRecap == nil { + s.EnforcePostsPerRecap = NewPointer(true) + } + if s.EnforceTokensPerRecap == nil { + s.EnforceTokensPerRecap = NewPointer(true) + } + if s.EnforcePostsPerDay == nil { + s.EnforcePostsPerDay = NewPointer(true) + } + if s.EnforceCooldown == nil { + s.EnforceCooldown = NewPointer(true) + } +} + +// IsEnabled reports whether the admin AI Recaps master toggle is enabled. +func (s *AIRecapSettings) IsEnabled() bool { + return s == nil || s.Enable == nil || *s.Enable +} + +// AIRecapsEnabled reports whether AI Recaps are enabled by both feature flag and admin config. +func (o *Config) AIRecapsEnabled() bool { + return o != nil && o.FeatureFlags.EnableAIRecaps && o.AIRecapSettings.IsEnabled() +} + +// IsValid validates the AIRecapSettings +func (s *AIRecapSettings) IsValid() *AppError { + if s.DefaultLimits != nil { + if appErr := s.DefaultLimits.isValid(); appErr != nil { + return appErr + } + } + return nil +} diff --git a/server/public/model/ai_recap_settings_test.go b/server/public/model/ai_recap_settings_test.go new file mode 100644 index 000000000000..752bfd88517d --- /dev/null +++ b/server/public/model/ai_recap_settings_test.go @@ -0,0 +1,236 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAIRecapSettingsSetDefaults(t *testing.T) { + t.Run("sets all defaults on empty struct", func(t *testing.T) { + s := &AIRecapSettings{} + s.SetDefaults() + + // Master toggle + require.NotNil(t, s.Enable) + assert.True(t, *s.Enable) + + // Enforcement toggles - all should be true + require.NotNil(t, s.EnforceRecapsPerDay) + assert.True(t, *s.EnforceRecapsPerDay) + require.NotNil(t, s.EnforceScheduledRecaps) + assert.True(t, *s.EnforceScheduledRecaps) + require.NotNil(t, s.EnforceChannelsPerRecap) + assert.True(t, *s.EnforceChannelsPerRecap) + require.NotNil(t, s.EnforcePostsPerRecap) + assert.True(t, *s.EnforcePostsPerRecap) + require.NotNil(t, s.EnforceTokensPerRecap) + assert.True(t, *s.EnforceTokensPerRecap) + require.NotNil(t, s.EnforcePostsPerDay) + assert.True(t, *s.EnforcePostsPerDay) + require.NotNil(t, s.EnforceCooldown) + assert.True(t, *s.EnforceCooldown) + + // Default limits + require.NotNil(t, s.DefaultLimits) + require.NotNil(t, s.DefaultLimits.MaxRecapsPerDay) + assert.Equal(t, 10, *s.DefaultLimits.MaxRecapsPerDay) + require.NotNil(t, s.DefaultLimits.MaxScheduledRecaps) + assert.Equal(t, 5, *s.DefaultLimits.MaxScheduledRecaps) + require.NotNil(t, s.DefaultLimits.MaxChannelsPerRecap) + assert.Equal(t, -1, *s.DefaultLimits.MaxChannelsPerRecap) // unlimited + require.NotNil(t, s.DefaultLimits.MaxPostsPerRecap) + assert.Equal(t, 500, *s.DefaultLimits.MaxPostsPerRecap) + require.NotNil(t, s.DefaultLimits.MaxTokensPerRecap) + assert.Equal(t, 100000, *s.DefaultLimits.MaxTokensPerRecap) + require.NotNil(t, s.DefaultLimits.MaxPostsPerDay) + assert.Equal(t, 5000, *s.DefaultLimits.MaxPostsPerDay) + require.NotNil(t, s.DefaultLimits.CooldownMinutes) + assert.Equal(t, 60, *s.DefaultLimits.CooldownMinutes) + }) +} + +func TestRecapLimitSettingsValidation(t *testing.T) { + t.Run("valid positive limits", func(t *testing.T) { + s := &RecapLimitSettings{ + MaxRecapsPerDay: NewPointer(10), + MaxScheduledRecaps: NewPointer(5), + MaxChannelsPerRecap: NewPointer(3), + MaxPostsPerRecap: NewPointer(500), + MaxTokensPerRecap: NewPointer(100000), + MaxPostsPerDay: NewPointer(5000), + CooldownMinutes: NewPointer(60), + } + + assert.Nil(t, s.isValid()) + }) + + t.Run("valid unlimited (-1) values", func(t *testing.T) { + s := &RecapLimitSettings{ + MaxRecapsPerDay: NewPointer(-1), + MaxScheduledRecaps: NewPointer(-1), + MaxChannelsPerRecap: NewPointer(-1), + MaxPostsPerRecap: NewPointer(-1), + MaxTokensPerRecap: NewPointer(-1), + MaxPostsPerDay: NewPointer(-1), + CooldownMinutes: NewPointer(0), // 0 = no cooldown + } + + assert.Nil(t, s.isValid()) + }) + + t.Run("valid cooldown of 0", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.CooldownMinutes = NewPointer(0) + + assert.Nil(t, s.isValid()) + }) + + t.Run("invalid MaxRecapsPerDay = 0", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.MaxRecapsPerDay = NewPointer(0) + + err := s.isValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", err.Id) + }) + + t.Run("invalid MaxRecapsPerDay = -2 (only -1 allowed for unlimited)", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.MaxRecapsPerDay = NewPointer(-2) + + err := s.isValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", err.Id) + }) + + t.Run("invalid MaxScheduledRecaps = 0", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.MaxScheduledRecaps = NewPointer(0) + + err := s.isValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.max_scheduled_recaps.app_error", err.Id) + }) + + t.Run("invalid MaxChannelsPerRecap = 0", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.MaxChannelsPerRecap = NewPointer(0) + + err := s.isValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.max_channels_per_recap.app_error", err.Id) + }) + + t.Run("invalid MaxPostsPerRecap = 0", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.MaxPostsPerRecap = NewPointer(0) + + err := s.isValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.max_posts_per_recap.app_error", err.Id) + }) + + t.Run("invalid MaxTokensPerRecap = 0", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.MaxTokensPerRecap = NewPointer(0) + + err := s.isValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.max_tokens_per_recap.app_error", err.Id) + }) + + t.Run("invalid MaxPostsPerDay = 0", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.MaxPostsPerDay = NewPointer(0) + + err := s.isValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.max_posts_per_day.app_error", err.Id) + }) + + t.Run("invalid CooldownMinutes negative", func(t *testing.T) { + s := &RecapLimitSettings{} + s.SetDefaults() + s.CooldownMinutes = NewPointer(-1) + + err := s.isValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.cooldown_minutes.app_error", err.Id) + }) +} + +func TestAIRecapSettingsPreservesExistingValues(t *testing.T) { + t.Run("preserves existing MaxRecapsPerDay", func(t *testing.T) { + s := &AIRecapSettings{ + DefaultLimits: &RecapLimitSettings{ + MaxRecapsPerDay: NewPointer(20), + }, + } + s.SetDefaults() + + assert.Equal(t, 20, *s.DefaultLimits.MaxRecapsPerDay) + }) + + t.Run("preserves existing Enable value", func(t *testing.T) { + s := &AIRecapSettings{ + Enable: NewPointer(false), + } + s.SetDefaults() + + assert.False(t, *s.Enable) + }) + + t.Run("preserves existing enforcement toggle", func(t *testing.T) { + s := &AIRecapSettings{ + EnforceRecapsPerDay: NewPointer(false), + } + s.SetDefaults() + + assert.False(t, *s.EnforceRecapsPerDay) + // Other toggles should be set to true (default) + assert.True(t, *s.EnforceScheduledRecaps) + }) +} + +func TestAIRecapSettingsIsValid(t *testing.T) { + t.Run("valid with defaults", func(t *testing.T) { + s := &AIRecapSettings{} + s.SetDefaults() + + assert.Nil(t, s.IsValid()) + }) + + t.Run("valid with nil DefaultLimits", func(t *testing.T) { + s := &AIRecapSettings{ + Enable: NewPointer(true), + } + + assert.Nil(t, s.IsValid()) + }) + + t.Run("invalid if DefaultLimits are invalid", func(t *testing.T) { + s := &AIRecapSettings{ + Enable: NewPointer(true), + DefaultLimits: &RecapLimitSettings{ + MaxRecapsPerDay: NewPointer(0), // invalid + }, + } + + err := s.IsValid() + require.NotNil(t, err) + assert.Equal(t, "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", err.Id) + }) +} diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 7455fbcbe6a2..d2ac5f6afc39 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -337,6 +337,17 @@ const ( AuditEventDeleteRecap = "deleteRecap" // delete recap ) +// Scheduled Recaps +const ( + AuditEventCreateScheduledRecap = "createScheduledRecap" // create scheduled recap configuration + AuditEventGetScheduledRecap = "getScheduledRecap" // view a single scheduled recap + AuditEventGetScheduledRecaps = "getScheduledRecaps" // list user's scheduled recaps + AuditEventUpdateScheduledRecap = "updateScheduledRecap" // update scheduled recap configuration + AuditEventDeleteScheduledRecap = "deleteScheduledRecap" // delete scheduled recap + AuditEventPauseScheduledRecap = "pauseScheduledRecap" // pause scheduled recap execution + AuditEventResumeScheduledRecap = "resumeScheduledRecap" // resume paused scheduled recap +) + // Preferences const ( AuditEventDeletePreferences = "deletePreferences" // delete user preferences diff --git a/server/public/model/config.go b/server/public/model/config.go index e72049e065be..7fcf6566949c 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -4231,6 +4231,7 @@ type Config struct { AccessControlSettings AccessControlSettings ContentFlaggingSettings ContentFlaggingSettings AutoTranslationSettings AutoTranslationSettings + AIRecapSettings AIRecapSettings } func (o *Config) Auditable() map[string]any { @@ -4351,6 +4352,7 @@ func (o *Config) SetDefaults() { o.ConnectedWorkspacesSettings.SetDefaults(isUpdate, o.ExperimentalSettings) o.AccessControlSettings.SetDefaults() o.ContentFlaggingSettings.SetDefaults() + o.AIRecapSettings.SetDefaults() } func (o *Config) IsValid() *AppError { @@ -4511,6 +4513,10 @@ func (o *Config) IsValid() *AppError { return appErr } + if appErr := o.AIRecapSettings.IsValid(); appErr != nil { + return appErr + } + if appErr := o.MobileEphemeralModeSettings.isValid(); appErr != nil { return appErr } diff --git a/server/public/model/job.go b/server/public/model/job.go index 567a33912534..ce7f6fadc171 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -47,6 +47,7 @@ const ( JobTypeAccessControlTeamSync = "access_control_team_sync" JobTypePushProxyAuth = "push_proxy_auth" JobTypeRecap = "recap" + JobTypeScheduledRecap = "scheduled_recap" JobTypeDeleteExpiredPosts = "delete_expired_posts" JobTypeAutoTranslationRecovery = "autotranslation_recovery" JobTypeCleanupExpiredAccessTokens = "cleanup_expired_access_tokens" @@ -85,6 +86,7 @@ var AllJobTypes = [...]string{ JobTypeNotifyExpiringAccessTokens, JobTypeRefreshMaterializedViews, JobTypeMobileSessionMetadata, + JobTypeScheduledRecap, } type Job struct { diff --git a/server/public/model/permission.go b/server/public/model/permission.go index 921d46ff8944..9a89b665473c 100644 --- a/server/public/model/permission.go +++ b/server/public/model/permission.go @@ -376,6 +376,8 @@ var PermissionSysconsoleWriteExperimentalFeatureFlags *Permission var PermissionSysconsoleReadExperimentalBleve *Permission var PermissionSysconsoleWriteExperimentalBleve *Permission +var PermissionSysconsoleReadAiRecaps *Permission +var PermissionSysconsoleWriteAiRecaps *Permission var PermissionPublicPlaybookCreate *Permission var PermissionPublicPlaybookManageProperties *Permission @@ -2191,6 +2193,19 @@ func initializePermissions() { PermissionScopeSystem, } + PermissionSysconsoleReadAiRecaps = &Permission{ + "sysconsole_read_ai_recaps", + "", + "", + PermissionScopeSystem, + } + PermissionSysconsoleWriteAiRecaps = &Permission{ + "sysconsole_write_ai_recaps", + "", + "", + PermissionScopeSystem, + } + PermissionCreateCustomGroup = &Permission{ "create_custom_group", "authentication.permissions.create_custom_group.name", @@ -2429,6 +2444,7 @@ func initializePermissions() { PermissionSysconsoleReadExperimentalFeatureFlags, PermissionSysconsoleReadProductsBoards, PermissionSysconsoleReadIPFilters, + PermissionSysconsoleReadAiRecaps, } SysconsoleWritePermissions = []*Permission{ @@ -2488,6 +2504,7 @@ func initializePermissions() { PermissionSysconsoleWriteExperimentalFeatureFlags, PermissionSysconsoleWriteProductsBoards, PermissionSysconsoleWriteIPFilters, + PermissionSysconsoleWriteAiRecaps, } SystemScopedPermissionsMinusSysconsole := []*Permission{ diff --git a/server/public/model/recap.go b/server/public/model/recap.go index fc7276ec4d44..df00c438075a 100644 --- a/server/public/model/recap.go +++ b/server/public/model/recap.go @@ -15,6 +15,8 @@ type Recap struct { TotalMessageCount int `json:"total_message_count"` Status string `json:"status"` BotID string `json:"bot_id"` + ScheduledRecapId string `json:"scheduled_recap_id,omitempty"` // Set if created from scheduled recap + SkipReason string `json:"skip_reason,omitempty"` // Why the recap was skipped; see SkipReason* constants Channels []*RecapChannel `json:"channels,omitempty"` } @@ -40,6 +42,11 @@ type AIRecapSummaryResponse struct { ActionItems []string `json:"action_items"` } +type RecapProcessingOptions struct { + TimePeriod string + CustomInstructions string +} + // RecapChannelResult represents the result of processing a single channel for a recap type RecapChannelResult struct { ChannelID string @@ -52,6 +59,14 @@ const ( RecapStatusProcessing = "processing" RecapStatusCompleted = "completed" RecapStatusFailed = "failed" + RecapStatusSkipped = "skipped" // Recap skipped due to a limit violation or a non-recoverable creation failure +) + +// Skip reason constants for when a recap is skipped +const ( + SkipReasonDailyLimit = "daily_limit_reached" + SkipReasonCooldown = "cooldown_active" + SkipReasonJobCreationFailed = "job_creation_failed" // Recap row committed but its processing job could not be enqueued ) // Auditable returns safe-to-log fields for audit logging @@ -69,6 +84,8 @@ func (r *Recap) Auditable() map[string]any { "channel_ids": channelIDs, "total_message_count": r.TotalMessageCount, "bot_id": r.BotID, + "scheduled_recap_id": r.ScheduledRecapId, + "skip_reason": r.SkipReason, "create_at": r.CreateAt, "update_at": r.UpdateAt, "read_at": r.ReadAt, diff --git a/server/public/model/scheduled_recap.go b/server/public/model/scheduled_recap.go new file mode 100644 index 000000000000..68977233a7da --- /dev/null +++ b/server/public/model/scheduled_recap.go @@ -0,0 +1,269 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +// Day-of-week bitmask constants following Go's time.Weekday (Sunday=0) +const ( + Sunday = 1 << 0 // 1 + Monday = 1 << 1 // 2 + Tuesday = 1 << 2 // 4 + Wednesday = 1 << 3 // 8 + Thursday = 1 << 4 // 16 + Friday = 1 << 5 // 32 + Saturday = 1 << 6 // 64 + + Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday // 62 + Weekend = Saturday | Sunday // 65 + EveryDay = Weekdays | Weekend // 127 +) + +// Channel mode constants +const ( + ChannelModeSpecific = "specific" + ChannelModeAllUnreads = "all_unreads" +) + +// Time period constants +const ( + TimePeriodLast24h = "last_24h" + TimePeriodLastWeek = "last_week" + TimePeriodSinceLastRead = "since_last_read" +) + +// Validation constants +const ( + ScheduledRecapTitleMaxLength = 255 + ScheduledRecapCustomInstructionsMaxLength = 500 + ScheduledRecapMinDaysOfWeek = 1 + ScheduledRecapMaxDaysOfWeek = 127 +) + +// timeOfDayRegex validates HH:MM format (00:00 to 23:59) +var timeOfDayRegex = regexp.MustCompile(`^([0-1][0-9]|2[0-3]):([0-5][0-9])$`) + +// ScheduledRecap represents a user's scheduled recap configuration +type ScheduledRecap struct { + Id string `json:"id"` + UserId string `json:"user_id"` + Title string `json:"title"` + + // Schedule configuration (user intent) + DaysOfWeek int `json:"days_of_week"` // Bitmask: Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64 + TimeOfDay string `json:"time_of_day"` // HH:MM format (e.g., "09:00") + Timezone string `json:"timezone"` // IANA timezone (e.g., "America/New_York") + TimePeriod string `json:"time_period"` // "last_24h", "last_week", "since_last_read" + + // Schedule state (computed) + NextRunAt int64 `json:"next_run_at"` // UTC milliseconds, computed from schedule + timezone + LastRunAt int64 `json:"last_run_at"` // UTC milliseconds, updated after each run + RunCount int `json:"run_count"` // Number of times this schedule has executed + + // Channel configuration + ChannelMode string `json:"channel_mode"` // "specific" or "all_unreads" + ChannelIds StringArray `json:"channel_ids,omitempty"` // channel IDs (when mode = "specific"); persisted as jsonb via StringArray + + // AI configuration + CustomInstructions string `json:"custom_instructions,omitempty"` // Custom AI instructions + AgentId string `json:"agent_id"` // AI agent to use + + // Schedule type and state + IsRecurring bool `json:"is_recurring"` // false for "run once" schedules + Enabled bool `json:"enabled"` // false when paused + + // Standard timestamps + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` // Soft delete +} + +func deduplicateChannelIDs(channelIDs []string) []string { + if len(channelIDs) < 2 { + return channelIDs + } + + seen := make(map[string]struct{}, len(channelIDs)) + deduplicated := make([]string, 0, len(channelIDs)) + for _, channelID := range channelIDs { + if _, ok := seen[channelID]; ok { + continue + } + + seen[channelID] = struct{}{} + deduplicated = append(deduplicated, channelID) + } + + return deduplicated +} + +// ComputeNextRunAt calculates the next scheduled execution time in UTC milliseconds. +// It uses the user's timezone to determine the correct local time, handling DST automatically. +func (sr *ScheduledRecap) ComputeNextRunAt(fromTime time.Time) (int64, error) { + // Load user's timezone + loc, err := time.LoadLocation(sr.Timezone) + if err != nil { + return 0, NewAppError("ScheduledRecap.ComputeNextRunAt", "model.scheduled_recap.compute_next_run.timezone.app_error", nil, "timezone="+sr.Timezone, http.StatusBadRequest) + } + + // Validate time of day format using regex + if !timeOfDayRegex.MatchString(sr.TimeOfDay) { + return 0, NewAppError("ScheduledRecap.ComputeNextRunAt", "model.scheduled_recap.compute_next_run.time_format.app_error", nil, "time_of_day="+sr.TimeOfDay, http.StatusBadRequest) + } + + // Parse time of day + parts := strings.Split(sr.TimeOfDay, ":") + hour, _ := strconv.Atoi(parts[0]) + minute, _ := strconv.Atoi(parts[1]) + + // Validate days of week + if sr.DaysOfWeek < ScheduledRecapMinDaysOfWeek || sr.DaysOfWeek > ScheduledRecapMaxDaysOfWeek { + return 0, NewAppError("ScheduledRecap.ComputeNextRunAt", "model.scheduled_recap.compute_next_run.days_of_week.app_error", nil, "days_of_week="+strconv.Itoa(sr.DaysOfWeek), http.StatusBadRequest) + } + + // Convert fromTime to user's timezone + localNow := fromTime.In(loc) + + // Start searching from today + candidate := time.Date( + localNow.Year(), localNow.Month(), localNow.Day(), + hour, minute, 0, 0, + loc, + ) + + // If today's time has passed, start from tomorrow + if !candidate.After(localNow) { + candidate = candidate.AddDate(0, 0, 1) + } + + // Find next matching day of week (max 7 iterations) + for range 7 { + weekday := int(candidate.Weekday()) // 0=Sunday + dayBit := 1 << weekday + + if sr.DaysOfWeek&dayBit != 0 { + // Found a matching day - return as UTC milliseconds + return candidate.UnixMilli(), nil + } + + candidate = candidate.AddDate(0, 0, 1) + } + + return 0, NewAppError("ScheduledRecap.ComputeNextRunAt", "model.scheduled_recap.compute_next_run.no_valid_day.app_error", nil, "", http.StatusBadRequest) +} + +// IsValid validates the scheduled recap configuration +func (sr *ScheduledRecap) IsValid() *AppError { + if !IsValidId(sr.Id) { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.id.app_error", nil, "id="+sr.Id, http.StatusBadRequest) + } + + if !IsValidId(sr.UserId) { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.user_id.app_error", nil, "user_id="+sr.UserId, http.StatusBadRequest) + } + + if sr.Title == "" { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.title_empty.app_error", nil, "", http.StatusBadRequest) + } + + if len(sr.Title) > ScheduledRecapTitleMaxLength { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.title_length.app_error", nil, "title_length="+strconv.Itoa(len(sr.Title)), http.StatusBadRequest) + } + + if len(sr.CustomInstructions) > ScheduledRecapCustomInstructionsMaxLength { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.custom_instructions_length.app_error", nil, "custom_instructions_length="+strconv.Itoa(len(sr.CustomInstructions)), http.StatusBadRequest) + } + + if sr.DaysOfWeek < ScheduledRecapMinDaysOfWeek || sr.DaysOfWeek > ScheduledRecapMaxDaysOfWeek { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.days_of_week.app_error", nil, "days_of_week="+strconv.Itoa(sr.DaysOfWeek), http.StatusBadRequest) + } + + if !timeOfDayRegex.MatchString(sr.TimeOfDay) { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.time_of_day.app_error", nil, "time_of_day="+sr.TimeOfDay, http.StatusBadRequest) + } + + // Validate timezone by attempting to load it + if _, err := time.LoadLocation(sr.Timezone); err != nil { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.timezone.app_error", nil, "timezone="+sr.Timezone, http.StatusBadRequest) + } + + if sr.TimePeriod != TimePeriodLast24h && sr.TimePeriod != TimePeriodLastWeek && sr.TimePeriod != TimePeriodSinceLastRead { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.time_period.app_error", nil, "time_period="+sr.TimePeriod, http.StatusBadRequest) + } + + if sr.ChannelMode != ChannelModeSpecific && sr.ChannelMode != ChannelModeAllUnreads { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.channel_mode.app_error", nil, "channel_mode="+sr.ChannelMode, http.StatusBadRequest) + } + + if sr.ChannelMode == ChannelModeSpecific { + if len(sr.ChannelIds) == 0 { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.channel_ids_empty.app_error", nil, "", http.StatusBadRequest) + } + + for _, channelID := range sr.ChannelIds { + if !IsValidId(channelID) { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.channel_id.app_error", nil, "channel_id="+channelID, http.StatusBadRequest) + } + } + } + + if sr.AgentId == "" { + return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.agent_id.app_error", nil, "", http.StatusBadRequest) + } + + return nil +} + +// PreSave prepares the scheduled recap for initial save +func (sr *ScheduledRecap) PreSave() { + sr.ChannelIds = deduplicateChannelIDs(sr.ChannelIds) + + if sr.Id == "" { + sr.Id = NewId() + } + + if sr.CreateAt == 0 { + sr.CreateAt = GetMillis() + } + + if sr.UpdateAt == 0 { + sr.UpdateAt = sr.CreateAt + } +} + +// PreUpdate prepares the scheduled recap for update +func (sr *ScheduledRecap) PreUpdate() { + sr.ChannelIds = deduplicateChannelIDs(sr.ChannelIds) + sr.UpdateAt = GetMillis() +} + +// Auditable returns a map of safe-to-log fields for audit logging +func (sr *ScheduledRecap) Auditable() map[string]any { + return map[string]any{ + "id": sr.Id, + "user_id": sr.UserId, + "title": sr.Title, + "days_of_week": sr.DaysOfWeek, + "time_of_day": sr.TimeOfDay, + "timezone": sr.Timezone, + "time_period": sr.TimePeriod, + "next_run_at": sr.NextRunAt, + "last_run_at": sr.LastRunAt, + "run_count": sr.RunCount, + "channel_mode": sr.ChannelMode, + "channel_ids": sr.ChannelIds, + "agent_id": sr.AgentId, + "is_recurring": sr.IsRecurring, + "enabled": sr.Enabled, + "create_at": sr.CreateAt, + "update_at": sr.UpdateAt, + "delete_at": sr.DeleteAt, + } +} diff --git a/server/public/model/scheduled_recap_test.go b/server/public/model/scheduled_recap_test.go new file mode 100644 index 000000000000..bb7a4d08aecc --- /dev/null +++ b/server/public/model/scheduled_recap_test.go @@ -0,0 +1,548 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScheduledRecapConstants(t *testing.T) { + t.Run("day of week bitmask values", func(t *testing.T) { + assert.Equal(t, 1, Sunday) + assert.Equal(t, 2, Monday) + assert.Equal(t, 4, Tuesday) + assert.Equal(t, 8, Wednesday) + assert.Equal(t, 16, Thursday) + assert.Equal(t, 32, Friday) + assert.Equal(t, 64, Saturday) + }) + + t.Run("weekdays constant", func(t *testing.T) { + expected := Monday | Tuesday | Wednesday | Thursday | Friday + assert.Equal(t, 62, expected) + assert.Equal(t, expected, Weekdays) + }) + + t.Run("weekend constant", func(t *testing.T) { + expected := Saturday | Sunday + assert.Equal(t, 65, expected) + assert.Equal(t, expected, Weekend) + }) + + t.Run("every day constant", func(t *testing.T) { + expected := Weekdays | Weekend + assert.Equal(t, 127, expected) + assert.Equal(t, expected, EveryDay) + }) + + t.Run("channel mode constants", func(t *testing.T) { + assert.Equal(t, "specific", ChannelModeSpecific) + assert.Equal(t, "all_unreads", ChannelModeAllUnreads) + }) + + t.Run("time period constants", func(t *testing.T) { + assert.Equal(t, "last_24h", TimePeriodLast24h) + assert.Equal(t, "last_week", TimePeriodLastWeek) + assert.Equal(t, "since_last_read", TimePeriodSinceLastRead) + }) +} + +func TestScheduledRecapComputeNextRunAt(t *testing.T) { + t.Run("monday only schedule", func(t *testing.T) { + sr := &ScheduledRecap{ + DaysOfWeek: Monday, + TimeOfDay: "09:00", + Timezone: "America/New_York", + } + + // Start from Sunday 2024-01-07 10:00 AM EST + fromTime := time.Date(2024, 1, 7, 10, 0, 0, 0, time.UTC) + + nextRunAt, err := sr.ComputeNextRunAt(fromTime) + require.NoError(t, err) + + // Should be Monday 2024-01-08 09:00 AM EST = 14:00 UTC + result := time.UnixMilli(nextRunAt) + loc, _ := time.LoadLocation("America/New_York") + localResult := result.In(loc) + + assert.Equal(t, time.Monday, localResult.Weekday()) + assert.Equal(t, 9, localResult.Hour()) + assert.Equal(t, 0, localResult.Minute()) + }) + + t.Run("weekday schedule skips weekend", func(t *testing.T) { + sr := &ScheduledRecap{ + DaysOfWeek: Weekdays, + TimeOfDay: "08:00", + Timezone: "America/Los_Angeles", + } + + // Start from Friday 2024-01-05 at 17:00 PST (past 8am) + loc, _ := time.LoadLocation("America/Los_Angeles") + fromTime := time.Date(2024, 1, 5, 17, 0, 0, 0, loc) + + nextRunAt, err := sr.ComputeNextRunAt(fromTime) + require.NoError(t, err) + + result := time.UnixMilli(nextRunAt) + localResult := result.In(loc) + + // Should skip Saturday and Sunday, land on Monday + assert.Equal(t, time.Monday, localResult.Weekday()) + assert.Equal(t, 8, localResult.Hour()) + }) + + t.Run("every day schedule returns next day", func(t *testing.T) { + sr := &ScheduledRecap{ + DaysOfWeek: EveryDay, + TimeOfDay: "06:00", + Timezone: "Europe/London", + } + + // Start from Wednesday 2024-01-10 at 07:00 GMT (past 6am) + loc, _ := time.LoadLocation("Europe/London") + fromTime := time.Date(2024, 1, 10, 7, 0, 0, 0, loc) + + nextRunAt, err := sr.ComputeNextRunAt(fromTime) + require.NoError(t, err) + + result := time.UnixMilli(nextRunAt) + localResult := result.In(loc) + + // Should be Thursday 2024-01-11 at 06:00 + assert.Equal(t, time.Thursday, localResult.Weekday()) + assert.Equal(t, 6, localResult.Hour()) + }) + + t.Run("same day before scheduled time", func(t *testing.T) { + sr := &ScheduledRecap{ + DaysOfWeek: Monday, + TimeOfDay: "15:00", + Timezone: "America/New_York", + } + + // Start from Monday 2024-01-08 at 10:00 AM EST (before 3pm) + loc, _ := time.LoadLocation("America/New_York") + fromTime := time.Date(2024, 1, 8, 10, 0, 0, 0, loc) + + nextRunAt, err := sr.ComputeNextRunAt(fromTime) + require.NoError(t, err) + + result := time.UnixMilli(nextRunAt) + localResult := result.In(loc) + + // Should be same day Monday at 15:00 + assert.Equal(t, 8, localResult.Day()) + assert.Equal(t, time.Monday, localResult.Weekday()) + assert.Equal(t, 15, localResult.Hour()) + }) + + t.Run("different timezones produce different UTC millis", func(t *testing.T) { + srEast := &ScheduledRecap{ + DaysOfWeek: Monday, + TimeOfDay: "09:00", + Timezone: "America/New_York", + } + + srWest := &ScheduledRecap{ + DaysOfWeek: Monday, + TimeOfDay: "09:00", + Timezone: "America/Los_Angeles", + } + + // Start from Sunday before both times + fromTime := time.Date(2024, 1, 7, 0, 0, 0, 0, time.UTC) + + nextRunEast, err := srEast.ComputeNextRunAt(fromTime) + require.NoError(t, err) + + nextRunWest, err := srWest.ComputeNextRunAt(fromTime) + require.NoError(t, err) + + // Pacific is 3 hours behind Eastern, so West should be larger (later) + assert.Greater(t, nextRunWest, nextRunEast) + + // The difference should be 3 hours = 3 * 60 * 60 * 1000 ms + diff := nextRunWest - nextRunEast + assert.Equal(t, int64(3*60*60*1000), diff) + }) + + t.Run("DST spring forward - March 2024", func(t *testing.T) { + // In America/New_York, on March 10, 2024, 2:00 AM becomes 3:00 AM + // A schedule for 2:30 AM doesn't exist on that day + // Go's time.Date returns the equivalent time before DST transition + // i.e., 2:30 AM requested becomes 1:30 AM EST (which is a valid time) + sr := &ScheduledRecap{ + DaysOfWeek: Sunday, + TimeOfDay: "02:30", + Timezone: "America/New_York", + } + + // Start from Saturday March 9, 2024 at noon + loc, _ := time.LoadLocation("America/New_York") + fromTime := time.Date(2024, 3, 9, 12, 0, 0, 0, loc) + + nextRunAt, err := sr.ComputeNextRunAt(fromTime) + require.NoError(t, err) + + result := time.UnixMilli(nextRunAt) + localResult := result.In(loc) + + // Go normalizes non-existent times to the equivalent valid time + // 2:30 AM on March 10 becomes 1:30 AM EST (before DST kicks in) + // This is documented Go behavior for time.Date with non-existent times + assert.Equal(t, time.Sunday, localResult.Weekday()) + assert.Equal(t, 1, localResult.Hour()) + assert.Equal(t, 30, localResult.Minute()) + }) + + t.Run("DST fall back - November 2024", func(t *testing.T) { + // In America/New_York, on November 3, 2024, 1:30 AM occurs twice + // Go's time.Date picks the first occurrence (before DST ends) + sr := &ScheduledRecap{ + DaysOfWeek: Sunday, + TimeOfDay: "01:30", + Timezone: "America/New_York", + } + + // Start from Saturday November 2, 2024 at noon + loc, _ := time.LoadLocation("America/New_York") + fromTime := time.Date(2024, 11, 2, 12, 0, 0, 0, loc) + + nextRunAt, err := sr.ComputeNextRunAt(fromTime) + require.NoError(t, err) + + result := time.UnixMilli(nextRunAt) + localResult := result.In(loc) + + // Should be Sunday at 1:30 AM (first occurrence, still in DST) + assert.Equal(t, time.Sunday, localResult.Weekday()) + assert.Equal(t, 1, localResult.Hour()) + assert.Equal(t, 30, localResult.Minute()) + }) + + t.Run("error on invalid timezone", func(t *testing.T) { + sr := &ScheduledRecap{ + DaysOfWeek: Monday, + TimeOfDay: "09:00", + Timezone: "Invalid/Timezone", + } + + fromTime := time.Now() + _, err := sr.ComputeNextRunAt(fromTime) + require.Error(t, err) + }) + + t.Run("error on invalid time format", func(t *testing.T) { + sr := &ScheduledRecap{ + DaysOfWeek: Monday, + TimeOfDay: "9:00", + Timezone: "America/New_York", + } + + fromTime := time.Now() + _, err := sr.ComputeNextRunAt(fromTime) + require.Error(t, err) + }) + + t.Run("error on zero days of week", func(t *testing.T) { + sr := &ScheduledRecap{ + DaysOfWeek: 0, + TimeOfDay: "09:00", + Timezone: "America/New_York", + } + + fromTime := time.Now() + _, err := sr.ComputeNextRunAt(fromTime) + require.Error(t, err) + }) +} + +func TestScheduledRecapIsValid(t *testing.T) { + validRecap := func() *ScheduledRecap { + return &ScheduledRecap{ + Id: NewId(), + UserId: NewId(), + Title: "Daily Standup Recap", + DaysOfWeek: Weekdays, + TimeOfDay: "09:00", + Timezone: "America/New_York", + TimePeriod: TimePeriodLast24h, + ChannelMode: ChannelModeSpecific, + ChannelIds: []string{NewId()}, + AgentId: "test-agent", + IsRecurring: true, + Enabled: true, + } + } + + t.Run("valid scheduled recap passes", func(t *testing.T) { + sr := validRecap() + assert.Nil(t, sr.IsValid()) + }) + + t.Run("invalid id fails", func(t *testing.T) { + sr := validRecap() + sr.Id = "invalid" + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("invalid user id fails", func(t *testing.T) { + sr := validRecap() + sr.UserId = "" + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("empty title fails", func(t *testing.T) { + sr := validRecap() + sr.Title = "" + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("title too long fails", func(t *testing.T) { + sr := validRecap() + sr.Title = string(make([]byte, ScheduledRecapTitleMaxLength+1)) + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("custom instructions too long fails", func(t *testing.T) { + sr := validRecap() + sr.CustomInstructions = string(make([]byte, ScheduledRecapCustomInstructionsMaxLength+1)) + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("zero days of week fails", func(t *testing.T) { + sr := validRecap() + sr.DaysOfWeek = 0 + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("days of week over 127 fails", func(t *testing.T) { + sr := validRecap() + sr.DaysOfWeek = 128 + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("invalid time format fails", func(t *testing.T) { + testCases := []string{ + "9:00", // single digit hour + "25:00", // invalid hour + "09:60", // invalid minute + "9am", // wrong format + "09:00:00", // too many colons + "", // empty + } + + for _, tc := range testCases { + sr := validRecap() + sr.TimeOfDay = tc + assert.NotNil(t, sr.IsValid(), "expected failure for time: %s", tc) + } + }) + + t.Run("valid time formats pass", func(t *testing.T) { + testCases := []string{ + "00:00", + "09:00", + "12:30", + "23:59", + } + + for _, tc := range testCases { + sr := validRecap() + sr.TimeOfDay = tc + assert.Nil(t, sr.IsValid(), "expected success for time: %s", tc) + } + }) + + t.Run("invalid timezone fails", func(t *testing.T) { + sr := validRecap() + sr.Timezone = "PST" // Abbreviation, not IANA + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("invalid time period fails", func(t *testing.T) { + sr := validRecap() + sr.TimePeriod = "invalid" + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("invalid channel mode fails", func(t *testing.T) { + sr := validRecap() + sr.ChannelMode = "invalid" + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("specific mode with empty channel ids fails", func(t *testing.T) { + sr := validRecap() + sr.ChannelMode = ChannelModeSpecific + sr.ChannelIds = []string{} + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("specific mode with invalid channel id fails", func(t *testing.T) { + sr := validRecap() + sr.ChannelMode = ChannelModeSpecific + sr.ChannelIds = []string{NewId(), "invalid"} + assert.NotNil(t, sr.IsValid()) + }) + + t.Run("all unreads mode with empty channel ids passes", func(t *testing.T) { + sr := validRecap() + sr.ChannelMode = ChannelModeAllUnreads + sr.ChannelIds = []string{} + assert.Nil(t, sr.IsValid()) + }) + + t.Run("empty agent id fails", func(t *testing.T) { + sr := validRecap() + sr.AgentId = "" + assert.NotNil(t, sr.IsValid()) + }) +} + +func TestScheduledRecapPreSave(t *testing.T) { + t.Run("generates id if empty", func(t *testing.T) { + sr := &ScheduledRecap{} + sr.PreSave() + assert.Len(t, sr.Id, 26) + }) + + t.Run("sets create_at and update_at", func(t *testing.T) { + sr := &ScheduledRecap{} + sr.PreSave() + assert.NotZero(t, sr.CreateAt) + assert.Equal(t, sr.CreateAt, sr.UpdateAt) + }) + + t.Run("preserves existing id", func(t *testing.T) { + existingId := NewId() + sr := &ScheduledRecap{Id: existingId} + sr.PreSave() + assert.Equal(t, existingId, sr.Id) + }) + + t.Run("deduplicates channel ids while preserving order", func(t *testing.T) { + channelA := NewId() + channelB := NewId() + sr := &ScheduledRecap{ChannelIds: StringArray{channelA, channelB, channelA, channelB}} + sr.PreSave() + assert.Equal(t, StringArray{channelA, channelB}, sr.ChannelIds) + }) +} + +func TestScheduledRecapPreUpdate(t *testing.T) { + t.Run("updates update_at", func(t *testing.T) { + sr := &ScheduledRecap{ + UpdateAt: 1000, + } + sr.PreUpdate() + assert.Greater(t, sr.UpdateAt, int64(1000)) + }) + + t.Run("deduplicates channel ids while preserving order", func(t *testing.T) { + channelA := NewId() + channelB := NewId() + sr := &ScheduledRecap{ + ChannelIds: StringArray{channelA, channelB, channelA}, + } + sr.PreUpdate() + assert.Equal(t, StringArray{channelA, channelB}, sr.ChannelIds) + }) +} + +func TestScheduledRecapAuditable(t *testing.T) { + t.Run("returns all expected fields", func(t *testing.T) { + sr := &ScheduledRecap{ + Id: NewId(), + UserId: NewId(), + Title: "Test Recap", + DaysOfWeek: Weekdays, + TimeOfDay: "09:00", + Timezone: "America/New_York", + TimePeriod: TimePeriodLast24h, + ChannelMode: ChannelModeSpecific, + ChannelIds: []string{"ch1", "ch2"}, + AgentId: NewId(), + IsRecurring: true, + Enabled: true, + CreateAt: 1000, + UpdateAt: 2000, + DeleteAt: 0, + } + + audit := sr.Auditable() + + assert.Equal(t, sr.Id, audit["id"]) + assert.Equal(t, sr.UserId, audit["user_id"]) + assert.Equal(t, sr.Title, audit["title"]) + assert.Equal(t, sr.DaysOfWeek, audit["days_of_week"]) + assert.Equal(t, sr.TimeOfDay, audit["time_of_day"]) + assert.Equal(t, sr.Timezone, audit["timezone"]) + assert.Equal(t, sr.TimePeriod, audit["time_period"]) + assert.Equal(t, sr.ChannelMode, audit["channel_mode"]) + assert.Equal(t, sr.ChannelIds, audit["channel_ids"]) + assert.Equal(t, sr.AgentId, audit["agent_id"]) + assert.Equal(t, sr.IsRecurring, audit["is_recurring"]) + assert.Equal(t, sr.Enabled, audit["enabled"]) + assert.Equal(t, sr.CreateAt, audit["create_at"]) + assert.Equal(t, sr.UpdateAt, audit["update_at"]) + assert.Equal(t, sr.DeleteAt, audit["delete_at"]) + }) +} + +func TestScheduledRecapDayOfWeekBitmask(t *testing.T) { + t.Run("single day", func(t *testing.T) { + // Monday = 2 + days := Monday + assert.Equal(t, 2, days) + assert.True(t, days&Monday != 0) + assert.False(t, days&Tuesday != 0) + }) + + t.Run("multiple days Mon+Wed+Fri", func(t *testing.T) { + days := Monday | Wednesday | Friday // 2 + 8 + 32 = 42 + assert.Equal(t, 42, days) + + assert.True(t, days&Monday != 0) + assert.False(t, days&Tuesday != 0) + assert.True(t, days&Wednesday != 0) + assert.False(t, days&Thursday != 0) + assert.True(t, days&Friday != 0) + assert.False(t, days&Saturday != 0) + assert.False(t, days&Sunday != 0) + }) + + t.Run("weekdays constant", func(t *testing.T) { + assert.True(t, Weekdays&Monday != 0) + assert.True(t, Weekdays&Tuesday != 0) + assert.True(t, Weekdays&Wednesday != 0) + assert.True(t, Weekdays&Thursday != 0) + assert.True(t, Weekdays&Friday != 0) + assert.False(t, Weekdays&Saturday != 0) + assert.False(t, Weekdays&Sunday != 0) + }) + + t.Run("weekend constant", func(t *testing.T) { + assert.False(t, Weekend&Monday != 0) + assert.False(t, Weekend&Friday != 0) + assert.True(t, Weekend&Saturday != 0) + assert.True(t, Weekend&Sunday != 0) + }) + + t.Run("every day constant includes all days", func(t *testing.T) { + assert.True(t, EveryDay&Sunday != 0) + assert.True(t, EveryDay&Monday != 0) + assert.True(t, EveryDay&Tuesday != 0) + assert.True(t, EveryDay&Wednesday != 0) + assert.True(t, EveryDay&Thursday != 0) + assert.True(t, EveryDay&Friday != 0) + assert.True(t, EveryDay&Saturday != 0) + }) +} diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 233992090fa2..55f0601f73ba 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -134,6 +134,7 @@ import ChannelDetails from './team_channel_settings/channel/details'; import TeamSettings from './team_channel_settings/team'; import TeamDetails from './team_channel_settings/team/details'; import type {AdminDefinition as AdminDefinitionType} from './types'; +import UnlimitedNumberSetting from './unlimited_number_setting'; import ValidationResult from './validation'; import WorkspaceOptimizationDashboard from './workspace-optimization/dashboard'; @@ -4108,6 +4109,129 @@ const AdminDefinition: AdminDefinitionType = { ], }, }, + recaps: { + url: 'site_config/recaps', + title: defineMessage({id: 'admin.sidebar.recaps', defaultMessage: 'Recaps'}), + isHidden: it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + schema: { + id: 'RecapSettings', + name: defineMessage({id: 'admin.site.recaps', defaultMessage: 'Recaps'}), + sections: [ + { + key: 'AIRecapSettings.Global', + title: '', + settings: [ + { + type: 'bool', + key: 'AIRecapSettings.Enable', + label: defineMessage({id: 'admin.recaps.enable.title', defaultMessage: 'Enable AI Recaps:'}), + help_text: defineMessage({id: 'admin.recaps.enable.desc', defaultMessage: 'When enabled, users can create and schedule AI recaps subject to the configured limits.'}), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + }, + ], + }, + { + key: 'AIRecapSettings.QuotaLimits', + title: defineMessage({id: 'admin.recaps.sections.quota.title', defaultMessage: 'Quota Limits'}), + description: defineMessage({id: 'admin.recaps.sections.quota.description', defaultMessage: 'Control how many recaps users can create.'}), + settings: [ + { + type: 'custom', + key: 'AIRecapSettings.DefaultLimits.MaxScheduledRecaps', + component: UnlimitedNumberSetting, + label: defineMessage({id: 'admin.recaps.maxScheduledRecaps.title', defaultMessage: 'Maximum Scheduled Recaps:'}), + help_text: defineMessage({id: 'admin.recaps.maxScheduledRecaps.desc', defaultMessage: 'Maximum number of scheduled recaps a user can have active at once.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + it.stateIsFalse('AIRecapSettings.Enable'), + ), + }, + { + type: 'custom', + key: 'AIRecapSettings.DefaultLimits.MaxRecapsPerDay', + component: UnlimitedNumberSetting, + label: defineMessage({id: 'admin.recaps.maxRecapsPerDay.title', defaultMessage: 'Maximum Recaps Per Day:'}), + help_text: defineMessage({id: 'admin.recaps.maxRecapsPerDay.desc', defaultMessage: 'Maximum number of recaps a user can generate per day. Resets at midnight in the user\'s timezone.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + it.stateIsFalse('AIRecapSettings.Enable'), + ), + }, + { + type: 'custom', + key: 'AIRecapSettings.DefaultLimits.MaxPostsPerDay', + component: UnlimitedNumberSetting, + label: defineMessage({id: 'admin.recaps.maxPostsPerDay.title', defaultMessage: 'Maximum Posts Per Day:'}), + help_text: defineMessage({id: 'admin.recaps.maxPostsPerDay.desc', defaultMessage: 'Maximum total posts that can be processed for recaps per user per day.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + it.stateIsFalse('AIRecapSettings.Enable'), + ), + }, + ], + }, + { + key: 'AIRecapSettings.ContentLimits', + title: defineMessage({id: 'admin.recaps.sections.content.title', defaultMessage: 'Content Limits'}), + description: defineMessage({id: 'admin.recaps.sections.content.description', defaultMessage: 'Control how much content can be included in each recap.'}), + settings: [ + { + type: 'custom', + key: 'AIRecapSettings.DefaultLimits.MaxChannelsPerRecap', + component: UnlimitedNumberSetting, + label: defineMessage({id: 'admin.recaps.maxChannelsPerRecap.title', defaultMessage: 'Maximum Channels Per Recap:'}), + help_text: defineMessage({id: 'admin.recaps.maxChannelsPerRecap.desc', defaultMessage: 'Maximum number of channels that can be included in a single recap.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + it.stateIsFalse('AIRecapSettings.Enable'), + ), + }, + { + type: 'custom', + key: 'AIRecapSettings.DefaultLimits.MaxPostsPerRecap', + component: UnlimitedNumberSetting, + label: defineMessage({id: 'admin.recaps.maxPostsPerRecap.title', defaultMessage: 'Maximum Posts Per Recap:'}), + help_text: defineMessage({id: 'admin.recaps.maxPostsPerRecap.desc', defaultMessage: 'Maximum number of posts to include when generating a recap.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + it.stateIsFalse('AIRecapSettings.Enable'), + ), + }, + { + type: 'custom', + key: 'AIRecapSettings.DefaultLimits.MaxTokensPerRecap', + component: UnlimitedNumberSetting, + label: defineMessage({id: 'admin.recaps.maxTokensPerRecap.title', defaultMessage: 'Maximum Tokens Per Recap:'}), + help_text: defineMessage({id: 'admin.recaps.maxTokensPerRecap.desc', defaultMessage: 'Maximum estimated token count for LLM context when generating a recap.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + it.stateIsFalse('AIRecapSettings.Enable'), + ), + }, + ], + }, + { + key: 'AIRecapSettings.TimeLimits', + title: defineMessage({id: 'admin.recaps.sections.time.title', defaultMessage: 'Time Limits'}), + description: defineMessage({id: 'admin.recaps.sections.time.description', defaultMessage: 'Control timing between recap requests.'}), + settings: [ + { + type: 'number', + key: 'AIRecapSettings.DefaultLimits.CooldownMinutes', + label: defineMessage({id: 'admin.recaps.cooldownMinutes.title', defaultMessage: 'Cooldown Between Recaps (minutes):'}), + help_text: defineMessage({id: 'admin.recaps.cooldownMinutes.desc', defaultMessage: 'Minimum time in minutes between recap generations for each user. Set to 0 to disable cooldown.'}), + placeholder: defineMessage({id: 'admin.recaps.cooldownMinutes.placeholder', defaultMessage: 'E.g.: "60"'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.AI_RECAPS)), + it.stateIsFalse('AIRecapSettings.Enable'), + ), + validate: validators.minValue(0, defineMessage({id: 'admin.recaps.cooldownMinutes.minValue', defaultMessage: 'Cooldown must be 0 or greater'})), + }, + ], + }, + ], + }, + }, content_flagging: { url: 'site_config/data_spillage', title: defineMessage({id: 'admin.sidebar.dataSpillage', defaultMessage: 'Data Spillage Handling'}), diff --git a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap index da3705cf736e..d8abafed4e3a 100644 --- a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap @@ -1331,6 +1331,25 @@ c0,1.1,0.9,2,2,2h16c1.1,0,2-0.9,2-2V4z M20,4v5H4V4H20z M22,15v5c0,1.1-0.9,2-2,2H class="nav nav__sub-menu subsections" /> +