From 25f3a75cb7977e1af675cb1f22b704614ea052b5 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Tue, 21 Jul 2026 13:10:25 -0400 Subject: [PATCH] [MM-67163] Scheduled Recaps (#35495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: map existing codebase with focus on recaps - STACK.md - Technologies and dependencies - ARCHITECTURE.md - System design and patterns - STRUCTURE.md - Directory layout - CONVENTIONS.md - Code style and patterns - TESTING.md - Test structure - INTEGRATIONS.md - External services - CONCERNS.md - Technical debt and issues * docs: initialize project Scheduled AI Recaps — enable recurring scheduled recaps instead of only manual one-off summaries * chore: add project config Mode: yolo Depth: standard Parallelization: enabled * docs: complete domain research for scheduled recaps Research files: - STACK.md: Job server patterns, ScheduledPosts reference implementation - FEATURES.md: Table stakes vs differentiators, anti-features to avoid - ARCHITECTURE.md: Component boundaries, data flow, 5-phase build order - PITFALLS.md: Timezone, cluster, schema critical pitfalls - SUMMARY.md: Synthesized findings and roadmap implications Key findings: - Use ScheduledPosts polling pattern, not JobServer schedulers - Store user intent + pre-computed NextRunAt for efficient polling - Leverage existing recap worker, extend with time_period support - Critical: Get timezone/schema right in Phase 1 to avoid migrations * docs: define v1 requirements 35 requirements across 5 categories: - Scheduling (7) - Management (8) - Backend Infrastructure (10) - Frontend Wizard (7) - Frontend Scheduled Tab (7) Topic-based recaps deferred to v2 * docs: create roadmap (5 phases) * docs(01): capture phase context Phase 01: Database Foundation - OpenCode discretion on implementation patterns - Implicit requirements from downstream phases noted * docs(01-database-foundation): research phase domain Phase 1: Database Foundation - Analyzed existing Recap and ScheduledPost patterns - Documented schema design for recurring schedules - Identified timezone/DST handling approach using IANA zones - Catalogued store interface patterns - Documented common pitfalls and anti-patterns * docs(01): create phase plan for database foundation Phase 01: Database Foundation - 2 plans in 2 waves - Wave 1: Model + migration (01-01-PLAN.md) - Wave 2: Store layer + tests (01-02-PLAN.md) - Ready for execution * feat(01-01): create ScheduledRecap model with DST-aware NextRunAt computation - Add ScheduledRecap struct with all required fields for schedule configuration - Add day-of-week bitmask constants matching Go's time.Weekday (Sunday=0) - Add channel mode constants (specific, all_unreads) - Add time period constants (last_24h, last_week, since_last_read) - Implement ComputeNextRunAt with timezone-aware scheduling using time.LoadLocation - Implement IsValid for input validation - Add PreSave/PreUpdate lifecycle methods - Add Auditable method for audit logging * feat(01-01): create database migration for ScheduledRecaps table - Create ScheduledRecaps table with all required columns - Add index for user queries (idx_scheduled_recaps_user_id) - Add index for scheduler polling (idx_scheduled_recaps_next_run_at) - Add composite index for efficient scheduler query (idx_scheduled_recaps_enabled_next_run) - Add index for user + soft delete queries (idx_scheduled_recaps_user_delete) - Add down migration to drop all indexes and table * test(01-01): add unit tests for ScheduledRecap with DST edge cases - Test day-of-week bitmask constants and operations - Test ComputeNextRunAt for Monday-only, weekday, and every-day schedules - Test timezone handling - different timezones produce different UTC millis - Test DST spring forward edge case (March 2024) - Go normalizes non-existent times - Test DST fall back edge case (November 2024) - Go uses first occurrence - Test error cases: invalid timezone, invalid time format, zero days - Test IsValid method for all validation rules - Test PreSave and PreUpdate lifecycle methods - Test Auditable method returns expected fields - Fix ComputeNextRunAt to validate time format using regex before parsing * docs(01-01): complete ScheduledRecap model and migration plan Tasks completed: 3/3 - Task 1: Create ScheduledRecap model with constants and NextRunAt computation - Task 2: Create database migration for ScheduledRecaps table - Task 3: Add unit tests for ComputeNextRunAt with DST edge cases SUMMARY: .planning/phases/01-database-foundation/01-01-SUMMARY.md * feat(01-02): add ScheduledRecapStore interface to store.go - Add ScheduledRecapStore interface with CRUD operations (Save, Get, Update, Delete) - Add query operations (GetForUser, GetDueBefore) - Add state update methods (UpdateNextRunAt, MarkExecuted, SetEnabled) - Register ScheduledRecap() method in main Store interface * feat(01-02): create SqlScheduledRecapStore implementation - Implement CRUD operations (Save, Get, Update, Delete with soft delete) - Implement GetForUser with pagination for user's scheduled recaps - Implement GetDueBefore for scheduler polling query - Implement efficient state updates (UpdateNextRunAt, MarkExecuted, SetEnabled) - Handle JSON serialization/deserialization of ChannelIds array - Follow existing patterns from recap_store.go * feat(01-02): register ScheduledRecapStore in SqlStore - Add scheduledRecap field to SqlStoreStores struct - Initialize newSqlScheduledRecapStore in NewSqlStore - Add ScheduledRecap() accessor method to SqlStore * test(01-02): add comprehensive tests for ScheduledRecapStore - Test CRUD operations (Save, Get, Update, Delete) - Test GetForUser with pagination - Test GetDueBefore scheduler query filtering - Test state update methods (UpdateNextRunAt, MarkExecuted, SetEnabled) - Test ChannelIds JSON serialization (array, empty, nil) - All 13 test cases pass * docs(01-02): complete ScheduledRecapStore plan Tasks completed: 4/4 - Add ScheduledRecapStore interface to store.go - Create SqlScheduledRecapStore implementation - Register ScheduledRecapStore in SqlStore - Create comprehensive store tests SUMMARY: .planning/phases/01-database-foundation/01-02-SUMMARY.md * fix(01): regenerate store mocks for ScheduledRecapStore * docs(01): complete Database Foundation phase Phase 1: Database Foundation - 2 plans executed (model + store) - 3 requirements complete (INFRA-01, INFRA-02, INFRA-10) - Goal verified ✓ * docs(03): research phase scheduler integration domain Phase 03: Scheduler Integration - Standard stack identified (Mattermost job system) - Architecture patterns documented (Scheduler + Worker pattern) - Cluster-safe execution via leader-only scheduling - Pitfalls catalogued (duplicate jobs, race conditions) - Code examples from existing codebase patterns * docs(02): create phase 2 API layer plans Phase 02: API Layer - 2 plans in 2 waves - Plan 01 (Wave 1): App layer methods for CRUD + pause/resume - Plan 02 (Wave 2): API handlers, routes, params, audit events - Ready for execution * feat(02-01): create App layer CRUD methods for ScheduledRecap - Add CreateScheduledRecap with session-based userId, validation, NextRunAt computation - Add GetScheduledRecap to retrieve by ID - Add GetScheduledRecapsForUser with pagination - Add UpdateScheduledRecap with NextRunAt recomputation when enabled - Add DeleteScheduledRecap for soft delete - Add PauseScheduledRecap to disable without deleting - Add ResumeScheduledRecap with NextRunAt recomputation before enabling - Regenerate store layer files for ScheduledRecapStore interface * docs(02-01): complete App layer CRUD methods plan Tasks completed: 3/3 - Regenerate store mocks (already complete from Phase 1) - Create App layer file with CRUD methods - Verify app layer interfaces (no interface file exists) SUMMARY: .planning/phases/02-api-layer/02-01-SUMMARY.md * feat(02-02): add audit event constants for scheduled recaps - AuditEventCreateScheduledRecap for recap configuration creation - AuditEventGetScheduledRecap for viewing single recap - AuditEventGetScheduledRecaps for listing user recaps - AuditEventUpdateScheduledRecap for configuration updates - AuditEventDeleteScheduledRecap for recap deletion - AuditEventPauseScheduledRecap for pausing execution - AuditEventResumeScheduledRecap for resuming execution * feat(02-02): add ScheduledRecapId to params and context - Add ScheduledRecapId field to Params struct - Parse scheduled_recap_id from URL path variables - Add RequireScheduledRecapId validation method * feat(02-02): add route registration for scheduled recaps - Add ScheduledRecaps and ScheduledRecap routes to Routes struct - Initialize route prefixes in Init function - Add InitScheduledRecap call in initialization * feat(02-02): create API handlers for scheduled recaps - InitScheduledRecap registers all 7 API routes - createScheduledRecap validates required fields and creates recap - getScheduledRecap retrieves with authorization check - getScheduledRecaps lists user's recaps with pagination - updateScheduledRecap updates with ownership verification - deleteScheduledRecap soft deletes with authorization - pauseScheduledRecap disables execution with ownership check - resumeScheduledRecap re-enables with NextRunAt recomputation - All handlers include audit logging and feature flag check * docs(02-02): complete API handlers plan Tasks completed: 4/4 - Add audit event constants for scheduled recaps - Add ScheduledRecapId to params and context - Add route registration in api.go - Create API handler file SUMMARY: .planning/phases/02-api-layer/02-02-SUMMARY.md * docs(02): complete API Layer phase Phase 2: API Layer - 2 plans executed in 2 waves - 16/16 must-haves verified - INFRA-05 through INFRA-09 complete - Ready for Phase 3: Scheduler Integration * docs(03): create phase plan for scheduler integration Phase 03: Scheduler Integration - 2 plans in 2 waves - Wave 1: Job constant, scheduler, worker - Wave 2: Job registration, App method - Ready for execution * fix(03): revise plan 03-02 Task 2 for worker context compatibility - CreateRecapFromSchedule now creates recap directly via store - Uses sr.UserId instead of rctx.Session().UserId (unavailable in worker) - Creates JobTypeRecap job directly instead of delegating to CreateRecap - Updated key_links to reflect store and job linkage * docs(04): capture phase context Phase 04: Scheduled Tab - Implementation decisions documented - Phase boundary established - Figma references captured (123:62940, 123:19772) * feat(03-01): add JobTypeScheduledRecap constant - Add JobTypeScheduledRecap constant with value 'scheduled_recap' - Add to AllJobTypes slice for job type validation * feat(03-01): create ScheduledRecap scheduler - Add Scheduler struct wrapping PeriodicScheduler - 1-minute polling interval (SchedulerPollingInterval constant) - Enabled when cfg.FeatureFlags.EnableAIRecaps is true - ScheduleJob polls GetDueBefore for due recaps - Creates job with CreateJobOnce for deduplication - Job data: scheduled_recap_id, user_id, channel_ids, agent_id * feat(03-01): create ScheduledRecap worker - Define AppIface interface with CreateRecapFromSchedule method - Use SimpleWorker pattern following recap/worker.go pattern - Enabled when cfg.FeatureFlags.EnableAIRecaps is true - Extract job data: scheduled_recap_id, user_id, channel_ids, agent_id - Verify ScheduledRecap exists and is enabled before execution - Call app.CreateRecapFromSchedule to create the actual recap - Compute next run time using sr.ComputeNextRunAt - Call MarkExecuted to update LastRunAt, NextRunAt, RunCount atomically - Disable non-recurring schedules after execution * docs(03-01): complete job system components plan Tasks completed: 3/3 - Add JobTypeScheduledRecap constant - Create scheduler implementation - Create worker implementation SUMMARY: .planning/phases/03-scheduler-integration/03-01-SUMMARY.md * docs(04): create phase 4 plans - Scheduled Tab UI Phase 04: Frontend - Scheduled Tab - 4 plans in 4 waves - Wave 1: TypeScript types + Client4 API methods - Wave 2: Redux layer (action types, actions, reducer, selectors) - Wave 3: ScheduledRecapItem component (card UI with toggle, menu) - Wave 4: Scheduled tab integration with human verification Covers requirements TAB-01 through TAB-07 and MGMT-01 through MGMT-08 * feat(03-02): add ScheduledRecap job registration in initJobs - Register JobTypeScheduledRecap with worker and scheduler - Import scheduled_recap package for job components - Worker uses App interface for CreateRecapFromSchedule - Scheduler polls for due recaps at 1-minute intervals * feat(03-02): implement CreateRecapFromSchedule App method - Create Recap from ScheduledRecap configuration - Use sr.UserId instead of session (worker context has no session) - Create recap record directly via store - Create JobTypeRecap job to trigger processing - Handle both specific channels and all_unreads mode * docs(03-02): complete app integration plan Tasks completed: 3/3 - Add job registration in initJobs - Implement CreateRecapFromSchedule App method - Verify full integration compiles SUMMARY: .planning/phases/03-scheduler-integration/03-02-SUMMARY.md * docs(phase-3): complete scheduler integration phase * feat(04-01): add ScheduledRecap TypeScript types - Add ScheduledRecap type matching Go model fields - Add ScheduledRecapInput type for create/update operations - Types exported via @mattermost/types/recaps * feat(04-01): add Client4 scheduled recap route and imports - Add getScheduledRecapsRoute() method returning /scheduled_recaps endpoint - Import ScheduledRecap and ScheduledRecapInput types * feat(04-01): add Client4 scheduled recap API methods - createScheduledRecap: POST /scheduled_recaps - getScheduledRecaps: GET /scheduled_recaps (paginated) - getScheduledRecap: GET /scheduled_recaps/:id - updateScheduledRecap: PUT /scheduled_recaps/:id - deleteScheduledRecap: DELETE /scheduled_recaps/:id - pauseScheduledRecap: POST /scheduled_recaps/:id/pause - resumeScheduledRecap: POST /scheduled_recaps/:id/resume * docs(04-01): complete TypeScript types and Client4 methods plan Tasks completed: 3/3 - Add ScheduledRecap TypeScript type - Add Client4 scheduled recap route helper - Add Client4 scheduled recap API methods SUMMARY: .planning/phases/04-scheduled-tab/04-01-SUMMARY.md * feat(04-02): add scheduled recap action types - GET_SCHEDULED_RECAPS_REQUEST/SUCCESS/FAILURE - RECEIVED_SCHEDULED_RECAP and RECEIVED_SCHEDULED_RECAPS - PAUSE_SCHEDULED_RECAP_REQUEST/SUCCESS/FAILURE - RESUME_SCHEDULED_RECAP_REQUEST/SUCCESS/FAILURE - DELETE_SCHEDULED_RECAP_REQUEST/SUCCESS/FAILURE * feat(04-02): add scheduled recap Redux actions - getScheduledRecaps: fetches paginated scheduled recaps - pauseScheduledRecap: pauses a scheduled recap - resumeScheduledRecap: resumes a paused scheduled recap - deleteScheduledRecap: deletes a scheduled recap * feat(04-02): add scheduled recaps to reducer - Add scheduledRecaps to RecapsState type - Handle RECEIVED_SCHEDULED_RECAP for single recap - Handle RECEIVED_SCHEDULED_RECAPS for bulk updates - Handle DELETE_SCHEDULED_RECAP_SUCCESS for removal * feat(04-02): add scheduled recap selectors - getScheduledRecapsState: base selector for raw state - getAllScheduledRecaps: returns all scheduled recaps as array - getActiveScheduledRecaps: filters enabled, non-deleted recaps - getPausedScheduledRecaps: filters disabled, non-deleted recaps - getScheduledRecapById: returns single recap by ID * feat(04-02): update GlobalState type for scheduled recaps - Import ScheduledRecap type from recaps - Add scheduledRecaps field to recaps entity state * docs(04-02): complete Redux store and actions plan Tasks completed: 5/5 - Add scheduled recap action types - Add scheduled recap Redux actions - Add scheduled recaps to reducer - Add scheduled recap selectors - Update GlobalState type for scheduled recaps SUMMARY: .planning/phases/04-scheduled-tab/04-02-SUMMARY.md * feat(04-03): add i18n strings for scheduled recap UI - Add scheduled tab label - Add active/paused toggle states - Add run stats strings (last run, run count, never run, next run) - Add toast messages for pause/resume/delete - Add kebab menu labels (edit, delete) - Add delete confirmation modal strings - Add empty state strings (title, description, cta) - Add day formatting strings (weekdays, weekend, everyday, individual days) - Add schedule format string * feat(04-03): create useScheduleDisplay hook for schedule formatting - Add bitmask constants matching Go model (Sun=1, Mon=2, etc.) - formatDaysOfWeek: smart groupings (Every day, Weekdays, Weekends) or comma-separated - formatTimeOfDay: locale-appropriate 12/24hr time from HH:MM - formatSchedule: combines days and time with i18n format string - formatNextRun: smart relative formatting (Today, Tomorrow, Day name, Date) - formatLastRun: formatted date or 'Never run' - formatRunCount: pluralized run count * feat(04-03): create ScheduledRecapItem component - Render card with title and schedule pattern subtitle - Show next run time when schedule is active - Toggle between Active/Paused states with pause/resume actions - Run stats (last run, run count) appear on hover - Kebab menu with Edit and Delete options - Delete confirmation modal with FormattedMessage - Use useScheduleDisplay hook for all formatting * feat(04-03): add ScheduledRecapItem styles - Card with border, radius, and hover state - Flexbox layout with title/subtitle and actions - Title with truncation (ellipsis) for long names - Subtitle with metadata separator styling - Run stats with opacity transition on hover - Toggle button min-width for consistent sizing - Kebab menu button hover state * docs(04-03): complete ScheduledRecapItem component plan Tasks completed: 4/4 - Add i18n strings for scheduled recap UI - Create useScheduleDisplay hook for schedule formatting - Create ScheduledRecapItem component - Add ScheduledRecapItem styles SUMMARY: .planning/phases/04-scheduled-tab/04-03-SUMMARY.md * feat(04-04): create ScheduledRecapsEmptyState component - Empty state displays when no scheduled recaps exist - Shows illustration with icons, title, description - CTA button to create first recap - Supports disabled state when agents bridge is disabled * feat(04-04): create ScheduledRecapsList component - Renders empty state when no scheduled recaps exist - Maps over scheduled recaps to render ScheduledRecapItem - Passes edit and create handlers through to children * feat(04-04): add Scheduled tab to main Recaps component - Add Scheduled tab after Unread and Read tabs - Fetch scheduled recaps on mount with getScheduledRecaps - Display ScheduledRecapsList when on scheduled tab - Wire up edit handler (opens create modal - Phase 5 adds pre-fill) - Import scheduled_recap_item.scss for styling * style(04-04): add SCSS styles for scheduled recaps - Add .scheduled-recaps-list styles (flex column, centered, gap) - Add .scheduled-recaps-empty-state styles (centered, illustration, text) - Consistent with existing recap UI styling patterns * docs(04-04): complete Scheduled tab integration plan * docs(04): update STATE.md for phase 4 completion * docs(04): complete Scheduled Tab phase Phase 4: Scheduled Tab - 4 plans executed across 4 waves - 15/15 requirements verified - Human verified UI works correctly * docs(05): capture phase context Phase 05: Enhanced Wizard - Implementation decisions documented - Phase boundary established * docs(05): add research hints for component discovery * docs(05): research phase domain for enhanced wizard Phase 05: Enhanced Wizard - Frontend Implementation - Standard stack identified (existing codebase components) - Architecture patterns documented (multi-step modal, bitmask days) - Pitfalls catalogued (timezone, validation, edit mode) - Code examples from codebase referenced * docs(05): create phase plan for enhanced wizard Phase 05: Frontend - Enhanced Wizard - 6 plans in 3 waves - Wave 1: Redux actions, DayOfWeekSelector - Wave 2: ScheduleConfiguration, Run once toggle - Wave 3: Modal integration, Edit wiring - Ready for execution * feat(05-01): add action type constants for create/update scheduled recap - CREATE_SCHEDULED_RECAP_REQUEST/SUCCESS/FAILURE - UPDATE_SCHEDULED_RECAP_REQUEST/SUCCESS/FAILURE * feat(05-02): create DayOfWeekSelector component - Bitmask-based day selection matching server model - Monday-first ordering for work schedule intuition - XOR toggle for clean state management - aria-pressed accessibility support * feat(05-01): add createScheduledRecap async action - Takes ScheduledRecapInput parameter - Calls Client4.createScheduledRecap - Dispatches RECEIVED_SCHEDULED_RECAP on success - Follows existing pauseScheduledRecap pattern * feat(05-02): add DayOfWeekSelector styles - Flexbox layout with 8px gap between buttons - 40x40px day buttons with hover states - Selected state uses button-bg color - Error and disabled state styling * feat(05-01): add updateScheduledRecap async action - Takes id and ScheduledRecapInput parameters - Calls Client4.updateScheduledRecap - Dispatches RECEIVED_SCHEDULED_RECAP on success - Follows existing action patterns * docs(05-02): complete DayOfWeekSelector plan Tasks completed: 2/2 - DayOfWeekSelector component with bitmask state - Styled button group with toggle/hover/error states SUMMARY: .planning/phases/05-enhanced-wizard/05-02-SUMMARY.md * docs(05-01): complete Redux actions for scheduled recaps plan Tasks completed: 3/3 - Add action type constants for create/update scheduled recap - Add createScheduledRecap async action - Add updateScheduledRecap async action SUMMARY: .planning/phases/05-enhanced-wizard/05-01-SUMMARY.md * feat(05-04): add run once toggle to RecapConfiguration - Add runOnce, setRunOnce, and isEditMode props to Props type - Import Toggle component - Add run once toggle section at bottom of Step 1 - Toggle hidden when isEditMode is true - Include descriptive text below toggle * feat(05-03): create ScheduleConfiguration component for Step 3 - Add day-of-week selection using DayOfWeekSelector - Add time picker with 30-minute intervals and locale-aware formatting - Add time period dropdown (Previous day, Last 3 days, Last 7 days) - Add custom instructions textarea with 500 char limit - Add next run preview with timezone support - Use getCurrentTimezone selector for user timezone * feat(05-04): add run once toggle styles - Add .run-once-group with top separator border - Style toggle and label with proper alignment - Add description text with left margin for alignment - Use consistent spacing and typography * feat(05-03): add Step 3 schedule configuration styles - Add step-three layout with vertical flex and gap - Add form-group styling with label and error states - Add next-run-preview styling with background and subtle text - Add textarea overrides for custom instructions input * docs(05-04): complete run once toggle plan Tasks completed: 2/2 - Add run once toggle to RecapConfiguration - Add run once toggle styles SUMMARY: .planning/phases/05-enhanced-wizard/05-04-SUMMARY.md * docs(05-03): complete ScheduleConfiguration plan Tasks completed: 2/2 - Create ScheduleConfiguration component - Add Step 3 styles to SCSS SUMMARY: .planning/phases/05-enhanced-wizard/05-03-SUMMARY.md * feat(05-05): add schedule state and edit mode props to modal - Add editScheduledRecap prop for edit mode detection - Add schedule state (daysOfWeek, timeOfDay, timePeriod, customInstructions) - Add runOnce state and validation state (daysError, timeError) - Add useEffect to pre-fill form in edit mode - Import createScheduledRecap, updateScheduledRecap actions - Import ScheduleConfiguration component and getCurrentTimezone selector * feat(05-06): update handleEditScheduledRecap to pass scheduled recap to modal - Find scheduled recap by ID from scheduledRecaps array - Pass editScheduledRecap via dialogProps to CreateRecapModal - Early return if scheduled recap not found * feat(05-05): update step navigation for run once and schedule flows - Update handleNext to clear validation errors on navigation - Update handlePrevious to clear validation errors on navigation - Update getTotalSteps for run once (2-3 steps) vs scheduled (always 3) - Update getActualStep for proper step indicator mapping * feat(05-05): update renderStep for schedule vs run once flows - Pass runOnce, setRunOnce, and isEditMode props to RecapConfiguration - Show ChannelSummary for run once mode at step 3 - Show ScheduleConfiguration for scheduled mode at step 3 - Pass all schedule state props to ScheduleConfiguration component * feat(05-05): update handleSubmit for immediate and scheduled recaps - Add schedule field validation for non-run-once mode - Dispatch createRecap for run once mode (existing behavior) - Dispatch updateScheduledRecap for edit mode - Dispatch createScheduledRecap for new scheduled recaps - Navigate to ?tab=scheduled after creating/editing scheduled recap - Add proper error messages for schedule validation failures * feat(05-05): update modal header and button text for edit mode - Update canProceed to validate schedule fields in step 3 - Add getConfirmButtonText helper for context-aware button text - Show 'Start recap' for run once, 'Save changes' for edit mode - Show 'Create schedule' for new scheduled recaps - Update headerText to show 'Edit your recap' in edit mode * docs(05-05): complete wizard integration plan Tasks completed: 5/5 - Add schedule state and edit mode props - Update step navigation for run once and schedule flows - Update renderStep for schedule vs run once flows - Update handleSubmit for immediate and scheduled recaps - Update modal header and button text for edit mode SUMMARY: .planning/phases/05-enhanced-wizard/05-05-SUMMARY.md * fix(05-06): JSON.stringify body in scheduled recap API calls createScheduledRecap and updateScheduledRecap were passing objects directly to doFetch body, causing '[object Object]' to be sent instead of JSON. Fixed to match createRecap pattern. * fix(05-06): align time period values with server model Frontend was using 'last_3_days' and 'last_7_days' but server expects 'last_24h', 'last_week', and 'since_last_read'. Updated options to match. * fix(05-06): remove duplicate border on custom instructions textarea - GenericModal adds a border to all .form-control elements - Input widget's Input_fieldset already provides a border container - This caused a double-border visual glitch on the textarea - Added border: none to the inner textarea to fix the issue * fix(05-06): reserve space for next run preview to prevent modal height jump - Always render next-run-preview container (previously conditionally rendered) - Use visibility:hidden instead of not rendering when no preview available - Add non-breaking space placeholder to maintain consistent element height - Prevents jarring visual jump when user selects a day of the week * fix(05-06): remove border/background from next-run-preview - Remove padding, border-radius, and background-color from .next-run-preview - Style as plain text with subtle color and smaller font size - Keep margin for appropriate spacing from time selector * fix(05-06): use abbreviated timezone in next recap preview - Use Intl.DateTimeFormat with timeZoneName: 'short' to get timezone abbreviation (e.g., EST, PST, EDT) instead of full label - Remove unused getCurrentTimezoneLabel selector import - Preview now shows 'Monday at 9:00 AM (EST)' instead of 'Monday at 9:00 AM ((UTC-05:00) Eastern Time (US & Canada))' * fix(05-06): prevent modal height jump when next run preview appears - Move next-run-preview inside time-selection-group as helper text - Add min-height: 16px to reserve space when preview is hidden - Use visibility: hidden instead of display: none for consistent height - Reduce step-three gap from 20px to 16px for better spacing - Add margin-bottom: 0 to form-group to override default spacing * fix(05-06): add section titles and fix spacing in schedule configuration - Add section titles per Figma design (Heading 100 style): - 'When would you like your summary sent?' as main header - 'On which days should your recap run?' for days section - 'At what time?' for time section - 'Select a time period for your recap to cover' for time period - 'Additional instructions for {agentName}' for custom instructions - Pass agentName prop from parent to show selected agent name - Fix spacing: reserve space for next-run preview with container to prevent time period section from jumping when preview appears - Update SCSS with schedule-section groups and proper spacing * fix(05-06): remove duplicate title and fix subtitle-dropdown spacing - Remove 'When would you like your summary sent?' duplicate title - Add scoped CSS rule for 12px total spacing between subtitle and dropdown * fix(05-06): use standard Toggle without text labels for active/paused state - Remove onText/offText props from Toggle component - Add ariaLabel for accessibility (describes toggle state and action) - Update SCSS to remove min-width constraint that was for text display - Navigation to scheduled tab after creating scheduled recap already works correctly * fix(05-06): toggle color and tab navigation after creating scheduled recap - Use btn-toggle-primary class for scheduled recap toggle to display proper button-bg color - Add useQuery hook to read tab query parameter from URL - Sync activeTab state with URL tab parameter to enable navigation after modal close * fix(05-06): sync tab state with URL bidirectionally - Add handleTabChange callback that updates both state and URL - Use history.replace() to update URL without polluting browser history - Remove tab param from URL when switching to 'unread' (default tab) - Simplify URL sync useEffect to always update from tabParam - This enables proper navigation after creating scheduled recaps * fix(05-06): fix navigation to scheduled tab after creating scheduled recap - Replace useRouteMatch() with getCurrentRelativeTeamUrl selector - Modal was using route match which returned wrong URL context (modal is rendered at root level) - Use team selector to get correct team URL for navigation - Update test to remove unnecessary useRouteMatch mock * fix(05-06): sort scheduled recaps by newest first - Update getAllScheduledRecaps selector to sort by create_at descending - Follows same pattern as other recap selectors (getUnreadRecaps, getReadRecaps) - Derived selectors (getActiveScheduledRecaps, getPausedScheduledRecaps) inherit sort order * docs(05-06): complete edit wiring and UI polish plan * docs(phase-05): complete Enhanced Wizard phase Phase 5: Enhanced Wizard - 6 plans executed across 3 waves - 13 requirements verified - Multi-step wizard for creating/editing scheduled recaps - Run once and scheduled flows - Full edit mode with pre-fill - Extensive UI polish based on human feedback All 39 requirements complete. Milestone ready for audit. * docs(v1): milestone audit complete - all requirements satisfied - 39/39 requirements verified - 5/5 phases passed - 100% cross-phase integration - 5/5 E2E flows complete - 2 minor tech debt items (non-blocking) * chore: remove .planning from git tracking - Add .planning to .gitignore - Remove .planning files from git index (kept locally) - Planning files are for local development only * feat(06-01): add AIRecapSettings and RecapLimitSettings structs - RecapLimitSettings with 7 limit fields (recaps/day, scheduled, channels, posts, tokens, posts/day, cooldown) - AIRecapSettings with master toggle and per-limit enforcement toggles - SetDefaults methods with sensible defaults (10 recaps/day, 5 scheduled, etc.) - isValid/IsValid validation methods enforcing natural minimums * feat(06-01): integrate AIRecapSettings into Config struct - Add AIRecapSettings field to Config struct - Call AIRecapSettings.SetDefaults() in Config.SetDefaults() - Call AIRecapSettings.IsValid() in Config.IsValid() * test(06-01): add tests for AIRecapSettings and RecapLimitSettings - TestAIRecapSettingsSetDefaults: verifies all defaults match spec - TestRecapLimitSettingsValidation: verifies validation rejects invalid values - TestAIRecapSettingsPreservesExistingValues: verifies SetDefaults preserves existing - TestAIRecapSettingsIsValid: verifies IsValid delegates to DefaultLimits * feat(06-02): create EffectiveRecapLimits struct - Add EffectiveRecapLimits struct with 7 resolved limit fields - Add LimitSource type with system/group/user constants - Add UnlimitedValue constant (-1) for disabled limits - Add IsLimitEnabled helper function for enforcement code * feat(06-02): create GetEffectiveLimits resolution function - Add GetEffectiveLimits(userID) returning resolved limits for any user - Resolve limits from AIRecapSettings.DefaultLimits config - Apply per-limit enforcement toggles (disabled = -1 unlimited) - Add helper functions getValueOrDefault and getBoolOrDefault - Structure for Phase 8 group/user resolution with TODOs * test(06-02): add tests for GetEffectiveLimits function - TestGetEffectiveLimitsDefaults verifies system defaults returned - TestGetEffectiveLimitsWithDisabledToggle verifies -1 returned for disabled limits - TestGetEffectiveLimitsWithCustomDefaults verifies custom config honored - TestGetEffectiveLimitsAllTogglesDisabled verifies all -1 when all disabled - TestGetEffectiveLimitsUnlimitedConfigValue verifies -1 config value honored - TestIsLimitEnabled verifies helper correctly identifies enabled limits * feat(07-01): add RecapStatusSkipped constant and SkipReason field - Add RecapStatusSkipped constant for recaps skipped due to limit violations - Add SkipReasonDailyLimit and SkipReasonCooldown skip reason constants - Add ScheduledRecapId field to Recap struct for tracking scheduled recaps - Add SkipReason field to Recap struct for tracking why recap was skipped - Update Auditable() method to include new fields - Update recapColumns and recapToMap to include new fields * feat(07-01): add store interface methods for limit enforcement - Add CountForUserSince to RecapStore for daily limit enforcement - Add GetLastCompletedManualRecap to RecapStore for cooldown checking - Add CountForUser to ScheduledRecapStore for max scheduled recaps limit - Update mock implementations for both stores * feat(07-01): implement store methods in SQL stores - Implement CountForUser in SqlScheduledRecapStore - Counts active (non-deleted, enabled) scheduled recaps for a user - Implement CountForUserSince in SqlRecapStore - Counts recaps since timestamp, excluding skipped recaps - Implement GetLastCompletedManualRecap in SqlRecapStore - Returns most recent completed manual recap (no ScheduledRecapId) - Returns nil, nil when no manual recap exists * feat(07-03): add daily limit check to scheduled recap worker - Add GetEffectiveLimits and GetUser to AppIface for limit checking - Check MaxRecapsPerDay before executing scheduled recap - Create skipped recap record when daily limit exceeded - Use user's timezone for midnight calculation - Update next run time even when skipping (scheduler moves on) * feat(07-03): add cooldown check to manual recap creation - Check CooldownMinutes before allowing manual recap creation - Return HTTP 429 with retry-after info when cooldown active - Only checks against completed manual recaps (per CONTEXT.md) - Failed recaps don't consume cooldown (checks completed only) * feat(07-03): add i18n messages for cooldown errors - Add cooldown_active error with retry info template - Add cooldown_check_failed error message - Uses "Your organization's policy limits..." pattern * feat(07-02): add limit checks to CreateScheduledRecap - Add max scheduled recaps limit check using CountForUser store method - Add max channels per recap limit check against ChannelIds length - Return HTTP 400 with clear error messages when limits exceeded - Uses GetEffectiveLimits for limit resolution (ENF-01, ENF-02, ENF-08) * feat(07-02): add i18n error messages for scheduled recap limits - Add max_scheduled_reached message with "Your organization's policy limits..." pattern - Add max_channels_exceeded message with limit and requested count - Add count_failed internal error message * test(07-04): add unit tests for post/token truncation - Test proportional post distribution across channels - Test minimum 1 post per channel guarantee - Test empty channel handling - Test token estimation (4 chars/token heuristic) - Test token limit truncation removes from largest channels Verifies ENF-05, ENF-06 truncation implementation. * test(07-05): add ENF-07 permission preservation tests - Add tests verifying over-limit users can view/edit/delete existing recaps - Tests confirm management operations do NOT check limits (grandfathering) - Tests confirm creation IS still blocked when over limit - Add migration 000151 for missing ScheduledRecapId/SkipReason columns ENF-07: Limits only block creation, not management of existing resources * feat(08-01): create UnlimitedNumberSetting component - Number input with Unlimited checkbox for admin console settings - When checked: disables input and sets value to -1 (unlimited) - When unchecked: enables input and sets value to defaultValue - Supports disabled state and setByEnv footer * test(08-01): add unit tests for UnlimitedNumberSetting - Tests rendering with numeric and unlimited values - Tests checkbox toggle behavior (check/uncheck) - Tests number input changes - Tests disabled state and setByEnv footer - Tests custom unlimited label and placeholder - 11 test cases covering core functionality * feat(08-02): add Recaps subsection to admin_definition.tsx - Import UnlimitedNumberSetting component - Add 'recaps' subsection under site configuration section - Include master enable toggle for AI Recap Limits - Add 3 grouped sections: Quota Limits, Content Limits, Time Limits - Configure all 7 limit settings with proper config keys - Add AIRecapSettings and RecapLimitSettings TypeScript types - All settings disabled when master toggle is off * feat(08-02): add i18n strings for Recaps admin section - Add admin.sidebar.recaps for navigation - Add admin.site.recaps for section title - Add admin.recaps.enable.* for master toggle - Add admin.recaps.sections.* for section descriptions - Add admin.recaps.max*.* for all limit field labels/descriptions - Add admin.recaps.cooldownMinutes.* for time limit settings - Add admin.recaps.unlimited for checkbox label - Total: 24 new i18n strings * style(08-02): remove section comments to fix lint errors Remove inline comments that triggered lines-around-comment lint rule * feat(09-01): add RecapLimitStatus model and App layer logic - Add RecapLimitStatus, DailyUsageStatus, CooldownStatus structs - Implement App.GetRecapLimitStatus with daily usage count and cooldown calculation * feat(09-01): add GET /api/v4/recaps/limit_status endpoint - Register route and handler - Return structured limit status - Add error translation * feat(09-01): add TypeScript types for limit status - Export RecapLimitStatus and related types * feat(09-02): add recap limit status redux integration - Add Client4.getRecapLimitStatus method - Add Redux action, reducer, and selector for limit status - Update GlobalState and initial state to include limitStatus * fix(09-02): update CreateRecapModal error handling and tests - Check dispatch result.error instead of try/catch to handle server errors - Display server error message (e.g. policy limits) inline - Fix TypeScript errors: displayName property and missing props in tests * feat(09-03): implement user-facing limit status UI * Verify Phase 9: User-Facing UX * Remove UAT artifact * fix: UI/UX issues (badge, toggle, input, tooltip) * fix: Increase RecapUsageBadge tooltip z-index * blank lines * [MM-67163] checkpoint: scheduled recaps feature working All phases (01-09) complete and verified: - Database foundation with DST-aware scheduling - API layer for CRUD operations - Job scheduler and worker - Scheduled tab UI with list/create/edit/delete - Enhanced wizard with schedule configuration - Config settings and admin console section - Limit enforcement (daily, cooldown, token, post) - User-facing limit status badge Co-Authored-By: Claude Opus 4.6 (1M context) * [MM-67163] simplify: reduce duplication and fix issues across scheduled recaps - Extract requireScheduledRecapOwnership helper for 5 API handlers - Consolidate ResumeScheduledRecap from 4 store calls to 2 - Remove redundant Get() in PauseScheduledRecap - Extract advanceSchedule helper in worker to deduplicate skip/success paths - Remove double PreSave() in store Save method - Remove dead code fallback in GetEffectiveLimits - Deduplicate ScheduledRecapInput construction in create modal - Fix missing fetchRecapLimitStatus import (TS error) - Extract day-of-week bitmask constants to @mattermost/types/recaps - Fix hardcoded English strings in schedule_display formatNextRun Co-Authored-By: Claude Opus 4.6 (1M context) * Address CodeRabbit review feedback on scheduled recaps Server fixes: - Gate getRecapLimitStatus with requireRecapsEnabled guard - Normalize error mapping (404 vs 500) in scheduled recap handlers - Enforce MaxChannelsPerRecap limit in UpdateScheduledRecap - Return explicit error for unsupported all_unreads mode in scheduled recaps - Add compensation logic to clean up orphan recaps on job creation failure - Fix proportional post truncation to strictly enforce maxPosts cap - Add missing CountForUser retry wrapper in RetryLayerScheduledRecapStore - Handle NULL ScheduledRecapId in manual recap cooldown lookup - Exclude soft-deleted rows in scheduled recap Get query Frontend fixes: - Pass isCreationBlocked to ScheduledRecapsList empty state - Fix same-day nextRunAt mislabeled as "Tomorrow" in schedule display - Handle thunk error results in scheduled recap item actions - Replace scheduled recaps map on full refresh instead of merging Made-with: Cursor * Handle thunk error results in scheduled recap toggle handler Check dispatch result for errors in handleToggle to prevent false-success UI flows when pause/resume operations fail. Made-with: Cursor * Fix CI failures: mock store, permissions, migrations, lint, and Playwright config - Add ScheduledRecap() to storetest.Store mock and retrylayer test setup - Register sysconsole_read_ai_recaps / sysconsole_write_ai_recaps permissions - Renumber scheduled_recaps migration from 150→156 and recap_skip_fields from 151→157 to resolve version conflicts - Fix ESLint errors in recap components (operator-linebreak, import order, labels, headers, etc.) - Add AIRecapSettings to Playwright default_config.ts Made-with: Cursor * Address CodeRabbit Round 2 review feedback - Add OpenAPI spec for GET /api/v4/recaps/limit_status with schema definitions for RecapLimitStatus, EffectiveRecapLimits, DailyUsageStatus, and CooldownStatus - Set ScheduledRecapId when creating recaps from schedules to prevent cooldown logic from treating scheduled recaps as manual - Handle past nextRunAt timestamps in schedule display: show "Yesterday" for -1 day and full date for older past dates Made-with: Cursor * Add missing variable declarations for AI recaps permissions Made-with: Cursor * Fix jsx-max-props-per-line lint errors in schedule_configuration.tsx Made-with: Cursor * Fix stylelint property order in recap SCSS files Made-with: Cursor * Update admin sidebar snapshots to include Recaps section Made-with: Cursor * Fix Go lint issues and add OpenAPI specs for scheduled_recap endpoints - Use max/min builtins instead of if-statements (modernize/minmax) - Use range-over-int syntax for for-loops (modernize/rangeint) - Fix tautological Monday&Monday test (staticcheck/SA4000) - Add OpenAPI specs for all 7 scheduled_recap API routes - Add ScheduledRecap model definition to definitions.yaml Made-with: Cursor * Regenerate i18n en.json for recap-related strings Made-with: Cursor * Fix gofmt indentation in recap.go Made-with: Cursor * Fix scheduled recap CI regressions Align the scheduled recap soft-delete store test with the current Get behavior and add the missing scheduled recap i18n strings so server and enterprise checks stay in sync. Made-with: Cursor * Address CodeRabbit review feedback on scheduled recaps - Use session user ID instead of client-controlled recap.UserId for limit enforcement in UpdateScheduledRecap (security hardening) - Add missing i18n entry for app.recap.fetch_posts.app_error - All other review comments were already addressed in prior commits Made-with: Cursor * Fix schedule configuration import order Reorder the moment import so the webapp lint job passes again on the scheduled recap PR. Made-with: Cursor * Allow selecting "all unreads" recap type when no current unreads exist With scheduled recaps, users should be able to select "all unreads" even without current unread channels since unreads will exist when the schedule runs. The "run once" toggle is now disabled when all unreads is selected with no current unreads, preserving the pre-scheduling behavior of preventing an immediate recap with nothing to summarize. Made-with: Cursor * Fix indentation in recap_configuration.tsx to satisfy eslint Co-Authored-By: Claude Opus 4.6 (1M context) * Fix Recaps test mocks for scheduled recap state. Keep the webapp test shard green by mirroring the new selectors and mount-time actions used by the Recaps page. Made-with: Cursor * Update server/channels/app/recap.go * Update server/channels/app/recap.go * Update server/channels/app/recap_limits.go * Update server/channels/app/recap_limits.go * Update server/channels/app/scheduled_recap.go * Update server/channels/app/scheduled_recap.go * Update webapp/channels/src/components/recaps/scheduled_recaps_empty_state.tsx * Update server/channels/app/scheduled_recap.go * Fix gofmt formatting in recap limits Made-with: Cursor * Fix recap limits for soft-deleted recaps Keep deleted recaps in quota and cooldown checks so soft deletion cannot bypass AI usage enforcement. Made-with: Cursor * Fix translation * Fix server check-style: concurrent indexes and lint cleanups - Use CREATE/DROP INDEX CONCURRENTLY in 000168 scheduled recaps migrations (required by mattermost-govet concurrentIndex check). - gofmt validation constants in scheduled_recap.go. - Replace string += loops in recap tests with strings.Repeat for modernize linter. Made-with: Cursor * Fix 000168 migration: run CONCURRENTLY indexes outside transaction PostgreSQL rejects CREATE/DROP INDEX CONCURRENTLY inside a transaction. Morph requires -- morph:nontransactional for these migrations, matching other index migrations in the repo. Made-with: Cursor * Stabilize scheduled Recaps for review Bring the scheduled Recaps work back into a shippable state by tightening backend scheduling and limit semantics, cleaning up the UI flows, and adding focused Recaps E2E coverage. Made-with: Cursor * Fix scheduled recaps lint failures Made-with: Cursor * Fix scheduled recap Go lint Made-with: Cursor * Fix scheduled recaps Playwright check Made-with: Cursor * Sync scheduled recaps i18n catalog Made-with: Cursor * Fix server recaps CI checks Made-with: Cursor * Stabilize recap server CI setup Made-with: Cursor * Recaps: enforce token limit, remove dead truncation code, simplify scheduled worker - Delete unused multi-channel truncation subsystem (FetchAndTruncatePostsForRecap, truncatePostsProportionally) - Enforce MaxTokensPerRecap in the live recap path (previously a silent no-op) - Drop redundant non-atomic daily-limit pre-check in the scheduled worker; rely on the atomic check in CreateRecapFromSchedule - Disable non-recurring schedules on all terminal paths (including daily-limit skips) via finalizeSchedule - Trim scheduled-recap job payload to scheduled_recap_id - Exclude skipped recaps from GetRecapsForUser Co-authored-by: nick.misasi * Recaps webapp: format schedule times in the schedule timezone, static day i18n, typed schedule fields - Display next-run and schedule times using the scheduled recap's timezone (shared schedule_time_format helper) instead of browser-local time - Replace dynamic day-of-week i18n message IDs with static descriptors so strings are extractable/translatable - Add ScheduledRecapTimePeriod/ScheduledRecapChannelMode union types - Use shared Button in the scheduled recaps empty state Co-authored-by: nick.misasi * Recaps e2e: cover scheduled edit/delete/all-unreads/empty-state and token-limit enforcement - Add UI coverage for editing, deleting, and the empty state of scheduled recaps - Add all-unreads scheduled recap modal flow - Add immediate-recap token-limit enforcement tests (single and per-channel), validating MaxTokensPerRecap truncation end-to-end - Extend recaps page object and helpers Co-authored-by: nick.misasi * Recaps: regenerate store layers/mocks and i18n after cleanup; fix lint - Regenerate timerlayer/retrylayer/RecapStore mock to canonical order - Drop orphaned app.recap.fetch_posts.app_error i18n key (removed with dead fetch helper) - Add missing semicolon in UnlimitedNumberSetting props Co-authored-by: nick.misasi * Recaps: regenerate default roles permissions and admin sidebar snapshot - Regenerate Cypress default_roles_permissions fixture to include AI Recaps sysconsole permissions - Update admin sidebar snapshot for the new Recaps section Co-authored-by: nick.misasi * Recaps: stabilize flaky cooldown round-up test The cooldown round-up subtest placed the prior recap 30s before a 2-minute cooldown, leaving only ~30s of slack before the rounded remaining time would flip from 2 to 1 minute. Under heavily loaded CI this could intermittently fail the exact-minute assertion. Move the prior recap to 1s ago so the remaining time sits near the top of the 2-minute band (~59s slack) while still exercising ceiling rounding. Co-authored-by: nick.misasi * Recaps: store ScheduledRecap.ChannelIds as jsonb Postgres is the only supported database, so the prior TEXT+JSON-string workaround for MySQL compatibility is unnecessary. Store ChannelIds in a jsonb column and type the model field as model.StringArray, which removes the bespoke marshal/unmarshal and intermediate scan struct in the store. Co-authored-by: nick.misasi * Recaps: gate recap limit settings on the ai_recaps permission Add access:"ai_recaps" to DefaultLimits and all RecapLimitSettings fields so a delegated admin with sysconsole_write_ai_recaps can save the limit values, instead of falling back to requiring manage_system. Co-authored-by: nick.misasi * Recaps: drop bespoke Users row lock for limit enforcement The recap limit savers were the only SELECT ... FOR UPDATE in the sqlstore. Conform to the prevailing pattern (e.g. channel_bookmark_store.Save): enforce MaxScheduledRecaps / MaxRecapsPerDay with a transactional count + insert and no row lock, accepting the same best-effort behavior under concurrency as channels, team members, and bookmarks. Co-authored-by: nick.misasi * Recaps: document SaveOnceByTypeAndData dedup semantics Co-authored-by: nick.misasi * Recaps: use SERIALIZABLE isolation for limit-check inserts Co-authored-by: nick.misasi * Recaps: mark orphaned recap skipped when job enqueue fails When CreateJob fails after the recap row is committed, flag the recap skipped with reason job_creation_failed instead of leaving it pending. Skipped recaps are excluded from the daily-limit count, so this frees the quota slot for a recap that will never run, and keeps CreateRecap consistent with CreateRecapFromSchedule. Co-authored-by: nick.misasi * ci: retrigger CI (flaky enterprise npm cache EEXIST) Co-authored-by: nick.misasi * ci: retrigger CI (flaky Vet API container init) Co-authored-by: nick.misasi * Recaps: re-check channel read permission at recap execution time Co-authored-by: nick.misasi * fix scheduled recap job server test setup Co-authored-by: nick.misasi * ci: retry flaky webapp test Co-authored-by: nick.misasi * ci: retry documentation impact review Co-authored-by: nick.misasi * MM-67163: Address review feedback: remove unused userID param, return AppError from GetRecapLimitStatus Co-authored-by: nick.misasi * MM-67163: Bulk channel permission check for recap creation, bounded by channel limit Co-authored-by: nick.misasi * chore: rerun CI Co-authored-by: nick.misasi * chore: rerun CI after network failure Co-authored-by: nick.misasi --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Mattermost Build Co-authored-by: Cursor Agent --- .gitignore | 4 + api/Makefile | 1 + api/v4/source/definitions.yaml | 149 +++++ api/v4/source/introduction.yaml | 2 + api/v4/source/recaps.yaml | 27 + api/v4/source/scheduled_recaps.yaml | 424 +++++++++++++ .../support/api/default_roles_permissions.js | 2 +- .../lib/src/server/default_config.ts | 19 + .../playwright/lib/src/ui/pages/recaps.ts | 160 ++++- .../functional/channels/ai/recaps.spec.ts | 438 +++++++++----- .../functional/channels/ai/recaps_helpers.ts | 191 ++++++ .../channels/ai/recaps_scheduled.spec.ts | 401 ++++++++++++ server/channels/api4/api.go | 6 + server/channels/api4/recap.go | 22 +- server/channels/api4/scheduled_recap.go | 316 ++++++++++ server/channels/app/recap.go | 300 ++++++++- server/channels/app/recap_limits.go | 159 +++++ server/channels/app/recap_limits_test.go | 188 ++++++ server/channels/app/recap_test.go | 536 ++++++++++++++++ server/channels/app/scheduled_recap.go | 370 ++++++++++++ server/channels/app/scheduled_recap_test.go | 571 ++++++++++++++++++ server/channels/app/server.go | 7 + server/channels/app/summarization.go | 12 +- server/channels/app/summarization_test.go | 28 + server/channels/db/migrations/migrations.list | 14 + .../000205_create_scheduled_recaps.down.sql | 1 + .../000205_create_scheduled_recaps.up.sql | 34 ++ ...te_scheduled_recaps_user_id_index.down.sql | 2 + ...eate_scheduled_recaps_user_id_index.up.sql | 2 + ...cheduled_recaps_next_run_at_index.down.sql | 2 + ..._scheduled_recaps_next_run_at_index.up.sql | 2 + ...led_recaps_enabled_next_run_index.down.sql | 2 + ...duled_recaps_enabled_next_run_index.up.sql | 2 + ...cheduled_recaps_user_delete_index.down.sql | 2 + ..._scheduled_recaps_user_delete_index.up.sql | 2 + .../000210_add_recap_skip_fields.down.sql | 3 + .../000210_add_recap_skip_fields.up.sql | 5 + ...d_recaps_scheduled_recap_id_index.down.sql | 2 + ...add_recaps_scheduled_recap_id_index.up.sql | 2 + server/channels/jobs/jobs.go | 14 + server/channels/jobs/recap/worker.go | 18 +- server/channels/jobs/recap/worker_test.go | 55 +- .../jobs/scheduled_recap/scheduler.go | 83 +++ .../jobs/scheduled_recap/scheduler_test.go | 69 +++ .../channels/jobs/scheduled_recap/worker.go | 138 +++++ .../jobs/scheduled_recap/worker_test.go | 148 +++++ .../channels/store/retrylayer/retrylayer.go | 368 +++++++++++ .../store/retrylayer/retrylayer_test.go | 1 + server/channels/store/sqlstore/job_store.go | 83 ++- server/channels/store/sqlstore/recap_store.go | 130 +++- .../store/sqlstore/recap_store_test.go | 228 +++++++ .../store/sqlstore/scheduled_recap_store.go | 303 ++++++++++ .../sqlstore/scheduled_recap_store_test.go | 419 +++++++++++++ server/channels/store/sqlstore/store.go | 6 + server/channels/store/store.go | 48 ++ server/channels/store/storetest/job_store.go | 84 +++ .../store/storetest/mocks/JobStore.go | 30 + .../store/storetest/mocks/RecapStore.go | 134 ++++ .../storetest/mocks/ScheduledRecapStore.go | 309 ++++++++++ .../channels/store/storetest/mocks/Store.go | 20 + server/channels/store/storetest/store.go | 5 + .../channels/store/timerlayer/timerlayer.go | 283 +++++++++ server/channels/web/context.go | 11 + server/channels/web/params.go | 2 + server/i18n/en.json | 176 ++++++ server/public/model/ai_recap_limits.go | 61 ++ server/public/model/ai_recap_settings.go | 155 +++++ server/public/model/ai_recap_settings_test.go | 236 ++++++++ server/public/model/audit_events.go | 11 + server/public/model/config.go | 6 + server/public/model/job.go | 2 + server/public/model/permission.go | 17 + server/public/model/recap.go | 17 + server/public/model/scheduled_recap.go | 269 +++++++++ server/public/model/scheduled_recap_test.go | 548 +++++++++++++++++ .../admin_console/admin_definition.tsx | 124 ++++ .../__snapshots__/admin_sidebar.test.tsx.snap | 171 ++++++ .../unlimited_number_setting.test.tsx | 199 ++++++ .../unlimited_number_setting.tsx | 110 ++++ .../create_recap_modal.scss | 182 ++++++ .../create_recap_modal.test.tsx | 103 +++- .../create_recap_modal/create_recap_modal.tsx | 289 ++++++++- .../day_of_week_selector.tsx | 55 ++ .../recap_configuration.test.tsx | 80 ++- .../recap_configuration.tsx | 118 +++- .../schedule_configuration.test.tsx | 80 +++ .../schedule_configuration.tsx | 246 ++++++++ .../src/components/recaps/day_descriptors.ts | 63 ++ .../recaps/recap_usage_badge/index.ts | 4 + .../recap_usage_badge/recap_usage_badge.scss | 117 ++++ .../recap_usage_badge/recap_usage_badge.tsx | 220 +++++++ .../src/components/recaps/recaps.scss | 62 ++ .../src/components/recaps/recaps.test.tsx | 12 + .../channels/src/components/recaps/recaps.tsx | 188 ++++-- .../recaps/schedule_display.test.tsx | 43 ++ .../components/recaps/schedule_display.tsx | 109 ++++ .../components/recaps/schedule_time_format.ts | 91 +++ .../recaps/scheduled_recap_item.scss | 103 ++++ .../recaps/scheduled_recap_item.tsx | 172 ++++++ .../recaps/scheduled_recaps_empty_state.tsx | 49 ++ .../recaps/scheduled_recaps_list.tsx | 41 ++ webapp/channels/src/i18n/en.json | 108 +++- .../src/action_types/recaps.ts | 33 + .../mattermost-redux/src/actions/recaps.ts | 149 ++++- .../src/constants/permissions.ts | 4 + .../src/constants/permissions_sysconsole.ts | 2 + .../src/reducers/entities/recaps.ts | 46 +- .../src/selectors/entities/recaps.ts | 35 +- .../src/store/initial_state.ts | 2 + webapp/platform/client/src/client4.ts | 63 +- webapp/platform/types/src/config.ts | 23 + webapp/platform/types/src/recaps.ts | 108 ++++ webapp/platform/types/src/store.ts | 4 +- 113 files changed, 12168 insertions(+), 337 deletions(-) create mode 100644 api/v4/source/scheduled_recaps.yaml create mode 100644 e2e-tests/playwright/specs/functional/channels/ai/recaps_helpers.ts create mode 100644 e2e-tests/playwright/specs/functional/channels/ai/recaps_scheduled.spec.ts create mode 100644 server/channels/api4/scheduled_recap.go create mode 100644 server/channels/app/recap_limits.go create mode 100644 server/channels/app/recap_limits_test.go create mode 100644 server/channels/app/scheduled_recap.go create mode 100644 server/channels/app/scheduled_recap_test.go create mode 100644 server/channels/db/migrations/postgres/000205_create_scheduled_recaps.down.sql create mode 100644 server/channels/db/migrations/postgres/000205_create_scheduled_recaps.up.sql create mode 100644 server/channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.down.sql create mode 100644 server/channels/db/migrations/postgres/000206_create_scheduled_recaps_user_id_index.up.sql create mode 100644 server/channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.down.sql create mode 100644 server/channels/db/migrations/postgres/000207_create_scheduled_recaps_next_run_at_index.up.sql create mode 100644 server/channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.down.sql create mode 100644 server/channels/db/migrations/postgres/000208_create_scheduled_recaps_enabled_next_run_index.up.sql create mode 100644 server/channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.down.sql create mode 100644 server/channels/db/migrations/postgres/000209_create_scheduled_recaps_user_delete_index.up.sql create mode 100644 server/channels/db/migrations/postgres/000210_add_recap_skip_fields.down.sql create mode 100644 server/channels/db/migrations/postgres/000210_add_recap_skip_fields.up.sql create mode 100644 server/channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.down.sql create mode 100644 server/channels/db/migrations/postgres/000211_add_recaps_scheduled_recap_id_index.up.sql create mode 100644 server/channels/jobs/scheduled_recap/scheduler.go create mode 100644 server/channels/jobs/scheduled_recap/scheduler_test.go create mode 100644 server/channels/jobs/scheduled_recap/worker.go create mode 100644 server/channels/jobs/scheduled_recap/worker_test.go create mode 100644 server/channels/store/sqlstore/scheduled_recap_store.go create mode 100644 server/channels/store/sqlstore/scheduled_recap_store_test.go create mode 100644 server/channels/store/storetest/mocks/ScheduledRecapStore.go create mode 100644 server/public/model/ai_recap_limits.go create mode 100644 server/public/model/ai_recap_settings.go create mode 100644 server/public/model/ai_recap_settings_test.go create mode 100644 server/public/model/scheduled_recap.go create mode 100644 server/public/model/scheduled_recap_test.go create mode 100644 webapp/channels/src/components/admin_console/unlimited_number_setting.test.tsx create mode 100644 webapp/channels/src/components/admin_console/unlimited_number_setting.tsx create mode 100644 webapp/channels/src/components/create_recap_modal/day_of_week_selector.tsx create mode 100644 webapp/channels/src/components/create_recap_modal/schedule_configuration.test.tsx create mode 100644 webapp/channels/src/components/create_recap_modal/schedule_configuration.tsx create mode 100644 webapp/channels/src/components/recaps/day_descriptors.ts create mode 100644 webapp/channels/src/components/recaps/recap_usage_badge/index.ts create mode 100644 webapp/channels/src/components/recaps/recap_usage_badge/recap_usage_badge.scss create mode 100644 webapp/channels/src/components/recaps/recap_usage_badge/recap_usage_badge.tsx create mode 100644 webapp/channels/src/components/recaps/schedule_display.test.tsx create mode 100644 webapp/channels/src/components/recaps/schedule_display.tsx create mode 100644 webapp/channels/src/components/recaps/schedule_time_format.ts create mode 100644 webapp/channels/src/components/recaps/scheduled_recap_item.scss create mode 100644 webapp/channels/src/components/recaps/scheduled_recap_item.tsx create mode 100644 webapp/channels/src/components/recaps/scheduled_recaps_empty_state.tsx create mode 100644 webapp/channels/src/components/recaps/scheduled_recaps_list.tsx 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" /> +