[MM-67163] Scheduled Recaps (#35495)

* 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) <noreply@anthropic.com>

* [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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* Recaps: document SaveOnceByTypeAndData dedup semantics

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Recaps: use SERIALIZABLE isolation for limit-check inserts

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* 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 <nick.misasi@mattermost.com>

* ci: retrigger CI (flaky enterprise npm cache EEXIST)

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ci: retrigger CI (flaky Vet API container init)

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Recaps: re-check channel read permission at recap execution time

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* fix scheduled recap job server test setup

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ci: retry flaky webapp test

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ci: retry documentation impact review

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM-67163: Address review feedback: remove unused userID param, return AppError from GetRecapLimitStatus

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM-67163: Bulk channel permission check for recap creation, bounded by channel limit

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* chore: rerun CI

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* chore: rerun CI after network failure

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Nick Misasi
2026-07-21 13:10:25 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 Mattermost Build Cursor Agent
parent 518b6b2c39
commit 25f3a75cb7
113 changed files with 12161 additions and 330 deletions
+4
View File
@@ -167,6 +167,10 @@ docker-compose.override.yaml
**/CLAUDE.md
.claude
.cursorrules
# Planning files (local only)
.planning
.cursor/*
!.cursor/README.md
!.cursor/cursor.md
+1
View File
@@ -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
+149
View File
@@ -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'
+2
View File
@@ -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:
+27
View File
@@ -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:
+424
View File
@@ -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"
File diff suppressed because one or more lines are too long
@@ -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,
},
};
+155 -5
View File
@@ -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) {
@@ -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 originalConfig = await adminClient.getConfig();
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');
try {
await adminClient.patchConfig({
AIRecapSettings: {
EnforceCooldown: false,
},
});
// # 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 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');
const recap = recapsPage.getRecap(recapTitle);
await recap.expand();
await recap.expectText(firstHighlight);
await recap.openMenuAction('Regenerate this recap');
// # 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();
// * 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);
const recap = recapsPage.getRecap(recapTitle);
await recap.expand();
await recap.expectText(firstHighlight);
await recap.openMenuAction('Regenerate this recap');
// * 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);
// * 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);
/**
* @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()}`;
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',
},
// # 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');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({highlights: [highlight], actionItems: []}),
pw.recapCompletion({highlights: [highlight], actionItems: []}),
],
});
await pw.configureAIBridgeMock(adminClient, {
status: {available},
agents: [agent],
services: [service],
agent_completions: {
recap_summary: completions,
},
record_requests: true,
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);
}
});
/**
* @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()}`;
// # 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');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({highlights: [highlight], actionItems: []}),
pw.recapCompletion({highlights: [highlight], actionItems: []}),
],
});
return {agent, service};
}
const originalConfig = await adminClient.getConfig();
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,
}),
);
// 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;
await adminClient.addToChannel(userId, channel.id);
await adminClient.createPost({
channel_id: channel.id,
user_id: adminUserId,
message: sourceMessage,
});
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,
},
},
});
return channel;
}
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,
);
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,
});
// # 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',
);
await pw.waitUntil(
async () => {
const currentRecap = await userClient.getRecap(recap.id);
return currentRecap.status === expectedStatus;
},
{timeout: pw.duration.one_min},
);
// * 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();
return userClient.getRecap(recap.id);
}
const firstCount = firstChannel?.source_post_ids.length ?? 0;
const secondCount = secondChannel?.source_post_ids.length ?? 0;
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},
);
}
expect(firstCount).toBeGreaterThan(0);
expect(firstCount).toBeLessThan(postCount);
expect(secondCount).toBeGreaterThan(0);
expect(secondCount).toBeLessThan(postCount);
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},
);
}
// * 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);
async function markAllCurrentChannelsRead(userClient: Client4, teamId: string) {
const currentChannels = await userClient.getMyChannels(teamId);
await userClient.readMultipleChannels(currentChannels.map((channel: Channel) => channel.id));
}
// * 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);
}
});
@@ -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<RecapBridgeSetup> {
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));
}
@@ -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);
});
+6
View File
@@ -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()
+21 -1
View File
@@ -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 {
+316
View File
@@ -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))
}
}
+284 -16
View File
@@ -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
}
+159
View File
@@ -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
}
+188
View File
@@ -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)")
}
+536
View File
@@ -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)
})
}
}
+370
View File
@@ -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
}
+571
View File
@@ -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)
})
}
+7
View File
@@ -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()))),
+11 -1
View File
@@ -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 '<username> sent an update about project xyz', where <username> 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 := ""
+28
View File
@@ -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")
})
}
@@ -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
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ScheduledRecaps;
@@ -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
);
@@ -0,0 +1,2 @@
-- morph:nontransactional
DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_user_id;
@@ -0,0 +1,2 @@
-- morph:nontransactional
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_user_id ON ScheduledRecaps(UserId);
@@ -0,0 +1,2 @@
-- morph:nontransactional
DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_next_run_at;
@@ -0,0 +1,2 @@
-- morph:nontransactional
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_next_run_at ON ScheduledRecaps(NextRunAt);
@@ -0,0 +1,2 @@
-- morph:nontransactional
DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_enabled_next_run;
@@ -0,0 +1,2 @@
-- morph:nontransactional
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_enabled_next_run ON ScheduledRecaps(Enabled, DeleteAt, NextRunAt);
@@ -0,0 +1,2 @@
-- morph:nontransactional
DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_user_delete;
@@ -0,0 +1,2 @@
-- morph:nontransactional
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_user_delete ON ScheduledRecaps(UserId, DeleteAt);
@@ -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;
@@ -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 '';
@@ -0,0 +1,2 @@
-- morph:nontransactional
DROP INDEX CONCURRENTLY IF EXISTS idx_recaps_scheduled_recap_id;
@@ -0,0 +1,2 @@
-- morph:nontransactional
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_recaps_scheduled_recap_id ON Recaps(ScheduledRecapId);
+14
View File
@@ -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(),
+14 -4
View File
@@ -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()
+47 -8
View File
@@ -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)
})
}
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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,
}
}
@@ -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}
@@ -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{})
+80 -3
View File
@@ -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")
+129 -7
View File
@@ -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) {
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 := 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 := executor.ExecBuilder(query); err != nil {
return errors.Wrap(err, "failed to save Recap")
}
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
}
@@ -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)
})
})
}
@@ -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
}
@@ -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)
})
})
}
+6
View File
@@ -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
}
+48
View File
@@ -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
}
@@ -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()
@@ -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)
@@ -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)
@@ -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
}
@@ -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()
+5
View File
@@ -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,
@@ -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}
+11
View File
@@ -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
+2
View File
@@ -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"]
+176
View File
@@ -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."
+61
View File
@@ -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
}
+155
View File
@@ -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
}
@@ -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)
})
}
+11
View File
@@ -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
+6
View File
@@ -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
}
+2
View File
@@ -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 {
+17
View File
@@ -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{
+17
View File
@@ -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,
+269
View File
@@ -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,
}
}
+548
View File
@@ -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)
})
}
@@ -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'}),
@@ -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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -3169,6 +3188,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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -5034,6 +5072,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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -6836,6 +6893,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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -8615,6 +8691,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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -10484,6 +10579,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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -12244,6 +12358,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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -14016,6 +14149,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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -15675,6 +15827,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"
/>
</li>
<li
class="sidebar-section"
data-testid="site.recaps"
>
<a
class="sidebar-section-title"
href="/admin_console/site_config/recaps"
id="site_config/recaps"
>
<span
class="sidebar-section-title__text"
>
Recaps
</span>
</a>
<ul
class="nav nav__sub-menu subsections"
/>
</li>
<li
class="sidebar-section"
data-testid="site.file_sharing_downloads"
@@ -0,0 +1,199 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import UnlimitedNumberSetting from './unlimited_number_setting';
describe('components/admin_console/UnlimitedNumberSetting', () => {
const baseProps = {
id: 'test.setting.id',
label: 'Test Label',
helpText: 'Test help text',
value: 10,
onChange: jest.fn(),
disabled: false,
setByEnv: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
test('renders with numeric value - number input shows value, checkbox unchecked', () => {
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={10}
/>,
);
const numberInput = screen.getByTestId('test.setting.idnumber') as HTMLInputElement;
const checkbox = screen.getByTestId('test.setting.idcheckbox') as HTMLInputElement;
expect(numberInput).toHaveValue(10);
expect(numberInput).not.toBeDisabled();
expect(checkbox).not.toBeChecked();
screen.getByText('Test Label');
screen.getByText('Test help text');
});
test('renders with unlimited value (-1) - checkbox checked, number input disabled', () => {
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={-1}
/>,
);
const numberInput = screen.getByTestId('test.setting.idnumber') as HTMLInputElement;
const checkbox = screen.getByTestId('test.setting.idcheckbox') as HTMLInputElement;
expect(checkbox).toBeChecked();
expect(numberInput).toBeDisabled();
expect(numberInput).toHaveValue(null); // Empty when unlimited
});
test('checking Unlimited calls onChange with -1', () => {
const onChange = jest.fn();
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={10}
onChange={onChange}
/>,
);
const checkbox = screen.getByTestId('test.setting.idcheckbox');
fireEvent.click(checkbox);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('test.setting.id', -1);
});
test('unchecking Unlimited calls onChange with default value', () => {
const onChange = jest.fn();
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={-1}
defaultValue={5}
onChange={onChange}
/>,
);
const checkbox = screen.getByTestId('test.setting.idcheckbox');
fireEvent.click(checkbox);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('test.setting.id', 5);
});
test('unchecking Unlimited uses defaultValue of 1 when not specified', () => {
const onChange = jest.fn();
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={-1}
onChange={onChange}
/>,
);
const checkbox = screen.getByTestId('test.setting.idcheckbox');
fireEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith('test.setting.id', 1);
});
test('number input change calls onChange with parsed number', () => {
const onChange = jest.fn();
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={10}
onChange={onChange}
/>,
);
const numberInput = screen.getByTestId('test.setting.idnumber');
fireEvent.change(numberInput, {target: {value: '25'}});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('test.setting.id', 25);
});
test('disabled prop disables both inputs', () => {
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={10}
disabled={true}
/>,
);
const numberInput = screen.getByTestId('test.setting.idnumber') as HTMLInputElement;
const checkbox = screen.getByTestId('test.setting.idcheckbox') as HTMLInputElement;
expect(numberInput).toBeDisabled();
expect(checkbox).toBeDisabled();
});
test('setByEnv shows SetByEnv footer and disables both inputs', () => {
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={10}
setByEnv={true}
/>,
);
const numberInput = screen.getByTestId('test.setting.idnumber') as HTMLInputElement;
const checkbox = screen.getByTestId('test.setting.idcheckbox') as HTMLInputElement;
expect(numberInput).toBeDisabled();
expect(checkbox).toBeDisabled();
// SetByEnv component renders a warning message
expect(screen.getByText(/This setting has been set through an environment variable/)).toBeInTheDocument();
});
test('custom unlimited label is displayed', () => {
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={10}
unlimitedLabel='No Limit'
/>,
);
screen.getByText('No Limit');
});
test('placeholder is shown on number input when not unlimited', () => {
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={10}
placeholder='Enter a number'
/>,
);
const numberInput = screen.getByTestId('test.setting.idnumber') as HTMLInputElement;
expect(numberInput).toHaveAttribute('placeholder', 'Enter a number');
});
test('unlimited label is shown as placeholder when unlimited is checked', () => {
renderWithContext(
<UnlimitedNumberSetting
{...baseProps}
value={-1}
unlimitedLabel='No Limit'
/>,
);
const numberInput = screen.getByTestId('test.setting.idnumber') as HTMLInputElement;
expect(numberInput).toHaveAttribute('placeholder', 'No Limit');
});
});
@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import type {ChangeEvent} from 'react';
import Setting from 'components/widgets/settings/setting';
import SetByEnv from './set_by_env';
type Props = {
id: string;
label: React.ReactNode;
helpText?: React.ReactNode;
value: number;
onChange: (id: string, value: number) => void;
disabled?: boolean;
setByEnv?: boolean;
placeholder?: string;
defaultValue?: number;
unlimitedLabel?: string;
};
/**
* UnlimitedNumberSetting combines a number input with an "Unlimited" checkbox.
* When "Unlimited" is checked, the number input is disabled and the value is set to -1.
* This provides a clean UX for settings that use -1 as a sentinel value for "unlimited".
*/
const UnlimitedNumberSetting: React.FC<Props> = ({
id,
label,
helpText,
value,
onChange,
disabled = false,
setByEnv = false,
placeholder = '',
defaultValue = 1,
unlimitedLabel = 'Unlimited',
}: Props) => {
const isDisabled = disabled || setByEnv;
const isUnlimited = value === -1;
const [inputValue, setInputValue] = useState<string>(isUnlimited ? '' : String(value));
useEffect(() => {
setInputValue(isUnlimited ? '' : String(value));
}, [value, isUnlimited]);
const handleCheckboxChange = (e: ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
if (checked) {
onChange(id, -1);
} else {
onChange(id, defaultValue);
}
};
const handleNumberChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
const parsed = parseInt(newValue, 10);
if (!isNaN(parsed) && parsed >= 1) {
onChange(id, parsed);
}
};
return (
<Setting
label={label}
labelClassName='col-sm-4'
inputClassName='col-sm-8'
helpText={helpText}
inputId={id}
footer={setByEnv ? <SetByEnv/> : undefined}
>
<div className='unlimited-number-setting'>
<input
id={id}
data-testid={`${id}number`}
type='number'
className='form-control'
style={{display: 'inline-block', width: 'auto', marginRight: '16px'}}
value={inputValue}
onChange={handleNumberChange}
disabled={isDisabled || isUnlimited}
placeholder={isUnlimited ? unlimitedLabel : placeholder}
min={1}
/>
<label
className='unlimited-checkbox-label'
style={{display: 'inline-flex', alignItems: 'center', cursor: isDisabled ? 'not-allowed' : 'pointer'}}
>
<input
data-testid={`${id}checkbox`}
type='checkbox'
checked={isUnlimited}
onChange={handleCheckboxChange}
disabled={isDisabled}
style={{marginRight: '8px'}}
/>
{unlimitedLabel}
</label>
</div>
</Setting>
);
};
export default UnlimitedNumberSetting;
@@ -26,6 +26,7 @@
.create-recap-modal-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
}
@@ -276,6 +277,34 @@
}
}
}
// Run once toggle
.run-once-group {
padding-top: 20px;
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
margin-top: 24px;
.run-once-toggle {
display: flex;
align-items: center;
gap: 12px;
.run-once-label {
margin: 0;
color: var(--center-channel-color);
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
}
.run-once-description {
margin-top: 4px;
margin-left: 52px; // Align with label (toggle width + gap)
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
}
}
}
// Step Two Channel Selector Styles
@@ -417,5 +446,158 @@
}
}
}
// Day of Week Selector Styles
.day-of-week-selector {
display: flex;
gap: 8px;
&.error {
.day-button:not(.selected) {
border-color: var(--error-text);
}
}
.day-button {
display: flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background-color: var(--center-channel-bg);
color: var(--center-channel-color);
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.15s ease;
&:hover:not(.disabled) {
border-color: rgba(var(--center-channel-color-rgb), 0.32);
background-color: rgba(var(--center-channel-color-rgb), 0.04);
}
&:focus-visible {
border-color: var(--button-bg);
box-shadow: 0 0 0 2px rgba(var(--button-bg-rgb), 0.12);
outline: none;
}
&.selected {
border-color: var(--button-bg);
background-color: var(--button-bg);
color: var(--button-color);
&:hover:not(.disabled) {
background-color: var(--button-bg-hover, var(--button-bg));
}
}
&.disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
}
// Step Three Schedule Configuration Styles
.step-three {
display: flex;
flex-direction: column;
gap: 28px; // Gap between sections (matches Figma content gap)
// Schedule section groups related form elements
.schedule-section {
display: flex;
flex-direction: column;
gap: 4px; // Tight gap within sections
.section-title {
margin: 0 0 8px;
color: var(--center-channel-color);
font-family: 'Open Sans', sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.section-subtitle {
display: block;
margin-bottom: 8px;
color: var(--center-channel-color);
font-family: 'Open Sans', sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
// When DropdownInput immediately follows section-subtitle, reduce top margin
// to achieve 12px total spacing (8px subtitle margin-bottom + 4px = 12px)
+ .DropdownInput {
margin-top: 4px;
}
}
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 0; // Override default form-group margin
.form-label {
margin-bottom: 8px;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
.form-error {
margin-top: 4px;
color: var(--error-text);
font-size: 12px;
}
// Days group needs spacing below for the time selector
&.days-group {
margin-bottom: 16px;
}
}
// Time selection group with integrated next-run preview
.time-selection-group {
// Container reserves space even when preview is hidden
.next-run-preview-container {
min-height: 24px; // Reserve space for preview (16px line + 8px margin)
}
.next-run-preview {
margin-top: 8px; // Small gap below time selector, matching Figma helper text spacing
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
line-height: 16px;
// Use visibility to reserve space even when hidden, preventing modal height jumping
&.hidden {
visibility: hidden;
}
}
}
// Override Input widget styles for textarea
.Input_container {
.Input_fieldset {
textarea.Input {
min-height: 80px;
resize: vertical;
}
// Remove duplicate border - the Input_fieldset provides the border,
// but GenericModal adds a border to all .form-control elements
.Input_wrapper textarea.form-control {
border: none;
}
}
}
}
}
@@ -11,8 +11,13 @@ import {Preferences} from 'utils/constants';
import CreateRecapModal from './create_recap_modal';
const mockHistoryPush = jest.fn();
jest.mock('mattermost-redux/actions/recaps', () => ({
createRecap: jest.fn(() => ({type: 'CREATE_RECAP'})),
createScheduledRecap: jest.fn(() => ({type: 'CREATE_SCHEDULED_RECAP'})),
updateScheduledRecap: jest.fn(() => ({type: 'UPDATE_SCHEDULED_RECAP'})),
getRecapLimitStatus: jest.fn(() => ({type: 'GET_RECAP_LIMIT_STATUS'})),
}));
jest.mock('mattermost-redux/actions/agents', () => ({
@@ -27,10 +32,7 @@ jest.mock('mattermost-redux/actions/preferences', () => ({
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: jest.fn(),
}),
useRouteMatch: () => ({
url: '/team/test',
push: mockHistoryPush,
}),
}));
@@ -123,6 +125,38 @@ describe('CreateRecapModal', () => {
},
};
const makeRootTeamState = () => ({
...initialState,
entities: {
...initialState.entities,
teams: {
currentTeamId: 'root-team',
teams: {},
myMembers: {},
},
channels: {
...initialState.entities.channels,
channels: {
channel1: {
...initialState.entities.channels.channels.channel1,
team_id: 'root-team',
},
channel2: {
...initialState.entities.channels.channels.channel2,
team_id: 'root-team',
},
},
channelsInTeam: {
'root-team': new Set(['channel1', 'channel2']),
},
},
},
});
beforeEach(() => {
jest.clearAllMocks();
});
test('should render modal with header including AI agent dropdown', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
@@ -217,6 +251,26 @@ describe('CreateRecapModal', () => {
expect(paginationDots.length).toBeGreaterThan(0);
});
test('should show two visible steps for scheduled all-unreads recaps', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
await waitFor(() => {
expect(screen.getByLabelText('Agent selector')).toHaveTextContent('Copilot');
});
await userEvent.type(screen.getByPlaceholderText('Give your recap a name'), 'Test Recap');
await userEvent.click(screen.getByText('Recap all my unreads'));
await userEvent.click(screen.getByRole('button', {name: /next/i}));
await waitFor(() => {
const paginationDots = document.querySelectorAll('.pagination-dot');
expect(paginationDots).toHaveLength(2);
expect(paginationDots[0]).not.toHaveClass('active');
expect(paginationDots[1]).toHaveClass('active');
});
});
test('should show Next button on first step', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
@@ -380,5 +434,46 @@ describe('CreateRecapModal', () => {
// OpenAI should still be selected in the header
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
test('should normalize the run once redirect when the team URL is the root path', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, makeRootTeamState());
await waitFor(() => {
expect(screen.getByLabelText('Agent selector')).toHaveTextContent('Copilot');
});
await userEvent.type(screen.getByPlaceholderText('Give your recap a name'), 'Test Recap');
await userEvent.click(screen.getByText('Recap all my unreads'));
await userEvent.click(screen.getByLabelText('Run once'));
await userEvent.click(screen.getByRole('button', {name: /next/i}));
const startRecapButton = await screen.findByRole('button', {name: /start recap/i});
await userEvent.click(startRecapButton);
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith('/recaps');
});
});
test('should normalize the scheduled recap redirect when the team URL is the root path', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, makeRootTeamState());
await waitFor(() => {
expect(screen.getByLabelText('Agent selector')).toHaveTextContent('Copilot');
});
await userEvent.type(screen.getByPlaceholderText('Give your recap a name'), 'Test Recap');
await userEvent.click(screen.getByText('Recap all my unreads'));
await userEvent.click(screen.getByRole('button', {name: /next/i}));
const createScheduleButton = await screen.findByRole('button', {name: /create schedule/i});
await userEvent.click(screen.getByText('M', {selector: 'button'}));
await waitFor(() => expect(createScheduleButton).not.toBeDisabled());
await userEvent.click(createScheduleButton);
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith('/recaps?tab=scheduled');
});
});
});
@@ -1,86 +1,165 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback, useEffect} from 'react';
import React, {useState, useCallback, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useHistory, useRouteMatch} from 'react-router-dom';
import {useHistory} from 'react-router-dom';
import {ChevronLeftIcon, ChevronRightIcon} from '@mattermost/compass-icons/components';
import {GenericModal} from '@mattermost/components';
import {Button} from '@mattermost/shared/components/button';
import type {Channel} from '@mattermost/types/channels';
import type {ScheduledRecap, ScheduledRecapInput, ScheduledRecapTimePeriod} from '@mattermost/types/recaps';
import {ScheduledRecapChannelModes, ScheduledRecapTimePeriods} from '@mattermost/types/recaps';
import {getAgents} from 'mattermost-redux/actions/agents';
import {createRecap} from 'mattermost-redux/actions/recaps';
import {createRecap, createScheduledRecap, updateScheduledRecap, getRecapLimitStatus as fetchRecapLimitStatus} from 'mattermost-redux/actions/recaps';
import {getAgents as getAgentsSelector, getDefaultAgent} from 'mattermost-redux/selectors/entities/agents';
import {getMyChannels, getUnreadChannelIds} from 'mattermost-redux/selectors/entities/channels';
import {getRecapLimitStatus} from 'mattermost-redux/selectors/entities/recaps';
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {AgentDropdown, useSelectedAgent} from 'components/common/agents';
import PaginationDots from 'components/common/pagination_dots';
import RecapUsageBadge from 'components/recaps/recap_usage_badge';
import ChannelSelector from './channel_selector';
import ChannelSummary from './channel_summary';
import RecapConfiguration from './recap_configuration';
import ScheduleConfiguration from './schedule_configuration';
import './create_recap_modal.scss';
type Props = {
onExited: () => void;
editScheduledRecap?: ScheduledRecap; // When present, modal is in edit mode
};
type RecapType = 'selected' | 'all_unreads';
const CreateRecapModal = ({onExited}: Props) => {
const {formatMessage} = useIntl();
const CreateRecapModal = ({onExited, editScheduledRecap}: Props) => {
const {formatMessage, formatTime} = useIntl();
const dispatch = useDispatch();
const history = useHistory();
const {url} = useRouteMatch();
const teamUrl = useSelector(getCurrentRelativeTeamUrl);
const normalizedTeamUrl = teamUrl === '/' ? '' : teamUrl;
const currentUserId = useSelector(getCurrentUserId);
const myChannels = useSelector(getMyChannels);
const unreadChannelIds = useSelector(getUnreadChannelIds);
const agents = useSelector(getAgentsSelector);
const limitStatus = useSelector(getRecapLimitStatus);
const defaultAgent = useSelector(getDefaultAgent);
const [selectedBotId, setSelectedBotId] = useSelectedAgent(agents);
const [preferredSelectedBotId, setPreferredSelectedBotId] = useSelectedAgent(agents);
const [currentStep, setCurrentStep] = useState(1);
const [recapName, setRecapName] = useState('');
const [recapType, setRecapType] = useState<RecapType | null>(null);
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
const [editSelectedBotId, setEditSelectedBotId] = useState<string | null>(null);
const [isAgentMenuOpen, setIsAgentMenuOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Schedule state
const [runOnce, setRunOnce] = useState(false);
const [daysOfWeek, setDaysOfWeek] = useState<number>(0);
const [timeOfDay, setTimeOfDay] = useState<string>('09:00');
const [timePeriod, setTimePeriod] = useState<ScheduledRecapTimePeriod>(ScheduledRecapTimePeriods.Last24h);
const [customInstructions, setCustomInstructions] = useState<string>('');
// Validation state
const [daysError, setDaysError] = useState(false);
const [timeError, setTimeError] = useState(false);
// Get user timezone
const userTimezone = useSelector(getCurrentTimezone);
// Edit mode detection
const isEditMode = Boolean(editScheduledRecap);
const selectedBotId = editSelectedBotId ?? preferredSelectedBotId;
const manualLimitBlockMessage = useMemo(() => {
if (!limitStatus || !runOnce) {
return '';
}
if (limitStatus.cooldown.is_active) {
return formatMessage(
{id: 'recaps.addRecap.cooldownTooltip', defaultMessage: 'Available again at {time}'},
{time: formatTime(new Date(limitStatus.cooldown.available_at), {hour: 'numeric', minute: '2-digit'})},
);
}
const {daily} = limitStatus;
if (daily.limit !== -1 && daily.used >= daily.limit) {
return formatMessage(
{id: 'recaps.addRecap.limitReachedTooltip', defaultMessage: 'Daily limit reached. Resets at {time}'},
{time: formatTime(new Date(daily.reset_at), {hour: 'numeric', minute: '2-digit'})},
);
}
return '';
}, [formatMessage, formatTime, limitStatus, runOnce]);
const isBlocked = manualLimitBlockMessage.length > 0;
// Fetch AI agents on mount
useEffect(() => {
dispatch(getAgents());
}, [dispatch]);
// Pre-fill form for edit mode
useEffect(() => {
if (editScheduledRecap) {
setRecapName(editScheduledRecap.title);
setRecapType(editScheduledRecap.channel_mode === 'all_unreads' ? 'all_unreads' : 'selected');
setSelectedChannelIds(editScheduledRecap.channel_ids || []);
setDaysOfWeek(editScheduledRecap.days_of_week);
setTimeOfDay(editScheduledRecap.time_of_day);
setTimePeriod(editScheduledRecap.time_period);
setCustomInstructions(editScheduledRecap.custom_instructions || '');
setEditSelectedBotId(editScheduledRecap.agent_id);
// Don't set runOnce in edit mode - it's always a scheduled recap
}
}, [editScheduledRecap]);
// Get unread channels
const unreadChannels = myChannels.filter((channel: Channel) =>
unreadChannelIds.includes(channel.id),
);
const handleNext = useCallback(() => {
// Clear validation errors
setDaysError(false);
setTimeError(false);
if (currentStep === 1) {
if (recapType === 'all_unreads') {
// For all unreads, skip channel selector and go to summary
// For all unreads, set channels and go to step 3
// Run once: summary; Scheduled: schedule configuration
setSelectedChannelIds(unreadChannels.map((c: Channel) => c.id));
setCurrentStep(3); // Go to summary
setCurrentStep(3);
} else {
// For selected channels, go to channel selector
setCurrentStep(2);
}
} else if (currentStep === 2) {
// From channel selector to summary
// From channel selector to step 3 (summary for run once, schedule for scheduled)
setCurrentStep(3);
}
}, [currentStep, recapType, unreadChannels]);
const handlePrevious = useCallback(() => {
// Clear validation errors
setDaysError(false);
setTimeError(false);
if (currentStep === 3 && recapType === 'all_unreads') {
// From summary back to step 1 if all unreads
// From step 3 back to step 1 if all unreads (skipped channel selector)
setCurrentStep(1);
} else if (currentStep > 1) {
setCurrentStep(currentStep - 1);
@@ -88,7 +167,8 @@ const CreateRecapModal = ({onExited}: Props) => {
}, [currentStep, recapType]);
const handleSubmit = useCallback(async () => {
if (selectedChannelIds.length === 0) {
// Validate channel selection for selected type
if (selectedChannelIds.length === 0 && recapType === 'selected') {
setError(formatMessage({id: 'recaps.modal.error.noChannels', defaultMessage: 'Please select at least one channel.'}));
return;
}
@@ -102,18 +182,103 @@ const CreateRecapModal = ({onExited}: Props) => {
return;
}
if (runOnce && isBlocked) {
setError(manualLimitBlockMessage);
return;
}
// For scheduled recaps (not run once), validate schedule fields
if (!runOnce) {
if (daysOfWeek === 0) {
setDaysError(true);
setError(formatMessage({id: 'recaps.modal.error.noDays', defaultMessage: 'Please select at least one day.'}));
return;
}
if (!timeOfDay) {
setTimeError(true);
setError(formatMessage({id: 'recaps.modal.error.noTime', defaultMessage: 'Please select a time.'}));
return;
}
}
setIsSubmitting(true);
setError(null);
if (runOnce) {
try {
// Run once: create immediate recap (existing behavior)
const result = await dispatch(createRecap(recapName, selectedChannelIds, selectedBotId));
if (result.error) {
setError(result.error.message || formatMessage({id: 'recaps.modal.error.createFailed', defaultMessage: 'Failed to create recap. Please try again.'}));
setIsSubmitting(false);
return;
}
// Fetch the new limit status to update the usage badge immediately
dispatch(fetchRecapLimitStatus());
onExited();
history.push(`${normalizedTeamUrl}/recaps`);
} catch {
setError(formatMessage({id: 'recaps.modal.error.createFailed', defaultMessage: 'Failed to create recap. Please try again.'}));
setIsSubmitting(false);
}
return;
}
// Create or update scheduled recap
const input: ScheduledRecapInput = {
title: recapName,
days_of_week: daysOfWeek,
time_of_day: timeOfDay,
timezone: userTimezone || 'UTC',
time_period: timePeriod,
channel_mode: recapType === 'all_unreads' ? ScheduledRecapChannelModes.AllUnreads : ScheduledRecapChannelModes.Specific,
channel_ids: recapType === 'selected' ? selectedChannelIds : undefined,
custom_instructions: customInstructions || undefined,
agent_id: selectedBotId,
is_recurring: true,
};
try {
await dispatch(createRecap(recapName, selectedChannelIds, selectedBotId));
const result = isEditMode && editScheduledRecap ?
await dispatch(updateScheduledRecap(editScheduledRecap.id, input)) :
await dispatch(createScheduledRecap(input));
if (result.error) {
setError(result.error.message || formatMessage({id: 'recaps.modal.error.scheduleFailed', defaultMessage: 'Failed to save scheduled recap. Please try again.'}));
setIsSubmitting(false);
return;
}
onExited();
history.push(`${url}/recaps`);
history.push(`${normalizedTeamUrl}/recaps?tab=scheduled`);
} catch {
setError(formatMessage({id: 'recaps.modal.error.createFailed', defaultMessage: 'Failed to create recap. Please try again.'}));
setError(formatMessage({id: 'recaps.modal.error.scheduleFailed', defaultMessage: 'Failed to save scheduled recap. Please try again.'}));
setIsSubmitting(false);
}
}, [selectedChannelIds, currentUserId, selectedBotId, dispatch, onExited, history, url, formatMessage, recapName]);
}, [
selectedChannelIds,
currentUserId,
selectedBotId,
runOnce,
isEditMode,
editScheduledRecap,
daysOfWeek,
timeOfDay,
timePeriod,
customInstructions,
userTimezone,
recapName,
recapType,
isBlocked,
manualLimitBlockMessage,
dispatch,
onExited,
history,
normalizedTeamUrl,
formatMessage,
]);
const canProceed = () => {
if (currentStep === 1) {
@@ -121,7 +286,18 @@ const CreateRecapModal = ({onExited}: Props) => {
} else if (currentStep === 2) {
return selectedChannelIds.length > 0;
} else if (currentStep === 3) {
return selectedChannelIds.length > 0 && selectedBotId.length > 0;
if (runOnce) {
// On final step, also check limits for run-once
if (isBlocked) {
return false;
}
// Run once summary step
return selectedChannelIds.length > 0 && selectedBotId.length > 0;
}
// Schedule configuration step
return daysOfWeek > 0 && timeOfDay.length > 0 && timePeriod.length > 0;
}
return false;
};
@@ -134,6 +310,7 @@ const CreateRecapModal = ({onExited}: Props) => {
if (recapType === 'all_unreads') {
return currentStep === 1 ? 1 : 2;
}
return currentStep;
};
@@ -147,6 +324,9 @@ const CreateRecapModal = ({onExited}: Props) => {
recapType={recapType}
setRecapType={setRecapType}
unreadChannels={unreadChannels}
runOnce={runOnce}
setRunOnce={setRunOnce}
isEditMode={isEditMode}
/>
);
case 2:
@@ -158,23 +338,68 @@ const CreateRecapModal = ({onExited}: Props) => {
unreadChannels={unreadChannels}
/>
);
case 3:
case 3: {
if (runOnce) {
// Run once: show summary
return (
<ChannelSummary
selectedChannelIds={selectedChannelIds}
myChannels={myChannels}
/>
);
}
// Scheduled: show schedule configuration
// Get the selected agent's display name
const selectedAgent = agents.find((agent) => agent.id === selectedBotId);
const agentName = selectedAgent?.displayName || selectedAgent?.username || 'Copilot';
return (
<ChannelSummary
selectedChannelIds={selectedChannelIds}
myChannels={myChannels}
<ScheduleConfiguration
daysOfWeek={daysOfWeek}
setDaysOfWeek={setDaysOfWeek}
timeOfDay={timeOfDay}
setTimeOfDay={setTimeOfDay}
timePeriod={timePeriod}
setTimePeriod={setTimePeriod}
customInstructions={customInstructions}
setCustomInstructions={setCustomInstructions}
daysError={daysError}
timeError={timeError}
agentName={agentName}
/>
);
}
default:
return null;
}
};
const confirmButtonText = currentStep === 3 ? formatMessage({id: 'recaps.modal.startRecap', defaultMessage: 'Start recap'}) : formatMessage({id: 'generic_modal.next', defaultMessage: 'Next'});
const getConfirmButtonText = () => {
const isFinalStep = currentStep === 3;
if (!isFinalStep) {
return formatMessage({id: 'generic_modal.next', defaultMessage: 'Next'});
}
if (runOnce) {
return formatMessage({id: 'recaps.modal.startRecap', defaultMessage: 'Start recap'});
}
if (isEditMode) {
return formatMessage({id: 'recaps.modal.saveChanges', defaultMessage: 'Save changes'});
}
return formatMessage({id: 'recaps.modal.createSchedule', defaultMessage: 'Create schedule'});
};
const confirmButtonText = getConfirmButtonText();
const handleBotSelect = useCallback((botId: string) => {
setSelectedBotId(botId);
}, [setSelectedBotId]);
if (isEditMode) {
setEditSelectedBotId(botId);
}
setPreferredSelectedBotId(botId);
}, [isEditMode, setPreferredSelectedBotId]);
const handleAgentMenuToggle = useCallback((isOpen: boolean) => {
setIsAgentMenuOpen(isOpen);
@@ -182,8 +407,14 @@ const CreateRecapModal = ({onExited}: Props) => {
const headerText = (
<div className='create-recap-modal-header'>
<span>{formatMessage({id: 'recaps.modal.title', defaultMessage: 'Set up your recap'})}</span>
<span>
{isEditMode ?
formatMessage({id: 'recaps.modal.titleEdit', defaultMessage: 'Edit your recap'}) :
formatMessage({id: 'recaps.modal.title', defaultMessage: 'Set up your recap'})
}
</span>
<div className='create-recap-modal-header-actions'>
<RecapUsageBadge/>
<AgentDropdown
showLabel={true}
selectedBotId={selectedBotId}
@@ -230,6 +461,7 @@ const CreateRecapModal = ({onExited}: Props) => {
emphasis='primary'
onClick={handleConfirmClick}
disabled={!canProceed() || isSubmitting}
title={currentStep === 3 && runOnce && manualLimitBlockMessage ? manualLimitBlockMessage : undefined}
>
{confirmButtonText}
{currentStep < 3 && <ChevronRightIcon size={16}/>}
@@ -238,6 +470,8 @@ const CreateRecapModal = ({onExited}: Props) => {
</div>
);
const displayedError = error || (currentStep === 3 && runOnce ? manualLimitBlockMessage : null);
return (
<GenericModal
className='create-recap-modal'
@@ -250,10 +484,10 @@ const CreateRecapModal = ({onExited}: Props) => {
footerContent={footerContent}
>
<div className='create-recap-modal-body'>
{error && (
{displayedError && (
<div className='create-recap-modal-error'>
<i className='icon icon-alert-circle-outline'/>
<span>{error}</span>
<span>{displayedError}</span>
</div>
)}
{renderStep()}
@@ -263,4 +497,3 @@ const CreateRecapModal = ({onExited}: Props) => {
};
export default CreateRecapModal;
@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
import {SELECTOR_DAY_DESCRIPTORS} from 'components/recaps/day_descriptors';
type Props = {
value: number; // Bitmask of selected days
onChange: (value: number) => void;
disabled?: boolean;
error?: boolean;
};
const DayOfWeekSelector = ({value, onChange, disabled, error}: Props) => {
const {formatMessage} = useIntl();
const toggleDay = (dayBit: number) => {
if (disabled) {
return;
}
// XOR to toggle the bit
onChange(value ^ dayBit);
};
const isDaySelected = (dayBit: number): boolean => {
return (value & dayBit) !== 0;
};
return (
<div className={classNames('day-of-week-selector', {error})}>
{SELECTOR_DAY_DESCRIPTORS.map((day) => (
<button
key={day.bit}
type='button'
className={classNames('day-button', {
selected: isDaySelected(day.bit),
disabled,
})}
onClick={() => toggleDay(day.bit)}
disabled={disabled}
aria-pressed={isDaySelected(day.bit)}
aria-label={formatMessage(day.fullName)}
>
{formatMessage(day.shortLabel)}
</button>
))}
</div>
);
};
export default DayOfWeekSelector;
@@ -31,6 +31,8 @@ describe('RecapConfiguration', () => {
recapType: null as 'selected' | 'all_unreads' | null,
setRecapType: jest.fn(),
unreadChannels: mockUnreadChannels,
runOnce: false,
setRunOnce: jest.fn(),
};
describe('Recap Name Input', () => {
@@ -165,7 +167,7 @@ describe('RecapConfiguration', () => {
});
describe('Unread Channels Handling', () => {
it('should disable all unreads option when no unread channels', () => {
it('should allow selecting all unreads even when no unread channels exist', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
@@ -174,8 +176,7 @@ describe('RecapConfiguration', () => {
);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
expect(allUnreadsButton).toBeDisabled();
expect(allUnreadsButton).toHaveClass('disabled');
expect(allUnreadsButton).not.toBeDisabled();
});
it('should enable all unreads option when unread channels exist', () => {
@@ -183,10 +184,9 @@ describe('RecapConfiguration', () => {
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
expect(allUnreadsButton).not.toBeDisabled();
expect(allUnreadsButton).not.toHaveClass('disabled');
});
it('should not call setRecapType when all unreads is clicked with no unread channels', async () => {
it('should call setRecapType when all unreads is clicked with no unread channels', async () => {
const setRecapType = jest.fn();
renderWithContext(
<RecapConfiguration
@@ -199,19 +199,81 @@ describe('RecapConfiguration', () => {
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
await userEvent.click(allUnreadsButton!);
expect(setRecapType).not.toHaveBeenCalled();
expect(setRecapType).toHaveBeenCalledWith('all_unreads');
});
it('should show tooltip when all unreads option is disabled', () => {
it('should force runOnce off when selecting all unreads with no unread channels', async () => {
const setRecapType = jest.fn();
const setRunOnce = jest.fn();
renderWithContext(
<RecapConfiguration
{...defaultProps}
setRecapType={setRecapType}
setRunOnce={setRunOnce}
unreadChannels={[]}
runOnce={true}
/>,
);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
await userEvent.click(allUnreadsButton!);
expect(setRecapType).toHaveBeenCalledWith('all_unreads');
expect(setRunOnce).toHaveBeenCalledWith(false);
});
it('should not force runOnce off when selecting all unreads with unread channels', async () => {
const setRunOnce = jest.fn();
renderWithContext(
<RecapConfiguration
{...defaultProps}
setRunOnce={setRunOnce}
runOnce={true}
/>,
);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
await userEvent.click(allUnreadsButton!);
expect(setRunOnce).not.toHaveBeenCalled();
});
it('should disable run once toggle when all unreads is selected with no unread channels', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapType='all_unreads'
unreadChannels={[]}
/>,
);
// The WithTooltip component wraps the button when there are no unreads
expect(screen.getByText('Recap all my unreads')).toBeInTheDocument();
const toggle = screen.getByRole('button', {name: /run once/i});
expect(toggle).toBeDisabled();
});
it('should not disable run once toggle when all unreads is selected with unread channels', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapType='all_unreads'
/>,
);
const toggle = screen.getByRole('button', {name: /run once/i});
expect(toggle).not.toBeDisabled();
});
it('should not disable run once toggle when selected channels type is chosen with no unreads', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapType='selected'
unreadChannels={[]}
/>,
);
const toggle = screen.getByRole('button', {name: /run once/i});
expect(toggle).not.toBeDisabled();
});
});
@@ -8,6 +8,7 @@ import {ProductChannelsIcon, LightningBoltOutlineIcon, CheckCircleIcon} from '@m
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import type {Channel} from '@mattermost/types/channels';
import Toggle from 'components/toggle';
const RECAP_NAME_MAX_LENGTH = 100;
type Props = {
@@ -16,45 +17,60 @@ type Props = {
recapType: 'selected' | 'all_unreads' | null;
setRecapType: (type: 'selected' | 'all_unreads') => void;
unreadChannels: Channel[];
runOnce: boolean;
setRunOnce: (value: boolean) => void;
isEditMode?: boolean;
};
const RecapConfiguration = ({recapName, setRecapName, recapType, setRecapType, unreadChannels}: Props) => {
const RecapConfiguration = ({
recapName,
setRecapName,
recapType,
setRecapType,
unreadChannels,
runOnce,
setRunOnce,
isEditMode,
}: Props) => {
const {formatMessage} = useIntl();
const [touched, setTouched] = useState(false);
const hasUnreadChannels = unreadChannels.length > 0;
const runOnceDisabled = recapType === 'all_unreads' && !hasUnreadChannels;
const handleAllUnreadsClick = () => {
setRecapType('all_unreads');
if (!hasUnreadChannels && runOnce) {
setRunOnce(false);
}
};
const showError = touched && recapName.trim().length === 0;
const handleBlur = useCallback(() => {
setTouched(true);
}, []);
const allUnreadsButton = (
<button
type='button'
className={`recap-type-card ${recapType === 'all_unreads' ? 'selected' : ''} ${hasUnreadChannels ? '' : 'disabled'}`}
onClick={() => hasUnreadChannels && setRecapType('all_unreads')}
disabled={!hasUnreadChannels}
>
<div className='recap-type-card-icon'>
<LightningBoltOutlineIcon size={24}/>
</div>
<div className='recap-type-card-content'>
<div className='recap-type-card-title'>
<FormattedMessage
id='recaps.modal.allUnreads'
defaultMessage='Recap all my unreads'
/>
</div>
<div className='recap-type-card-description'>
<FormattedMessage
id='recaps.modal.allUnreadsDesc'
defaultMessage='Create a recap of all unread messages across your channels.'
/>
</div>
</div>
{recapType === 'all_unreads' && <CheckCircleIcon className='selected-icon'/>}
</button>
const runOnceToggle = (
<div className='run-once-toggle'>
<Toggle
id='run-once-toggle'
toggled={runOnce}
onToggle={() => setRunOnce(!runOnce)}
size='btn-sm'
toggleClassName='btn-toggle-primary'
disabled={runOnceDisabled}
/>
<label
htmlFor='run-once-toggle'
className='run-once-label'
>
<FormattedMessage
id='recaps.modal.runOnce'
defaultMessage='Run once'
/>
</label>
</div>
);
return (
@@ -130,16 +146,52 @@ const RecapConfiguration = ({recapName, setRecapName, recapType, setRecapType, u
{recapType === 'selected' && <CheckCircleIcon className='selected-icon'/>}
</button>
{hasUnreadChannels ? allUnreadsButton : (
<WithTooltip
title={formatMessage({id: 'recaps.modal.noUnreadsAvailable', defaultMessage: 'No unread channels available'})}
hint={formatMessage({id: 'recaps.modal.noUnreadsAvailableHint', defaultMessage: 'You currently have no unread messages in any channels'})}
>
{allUnreadsButton}
</WithTooltip>
)}
<button
type='button'
className={`recap-type-card ${recapType === 'all_unreads' ? 'selected' : ''}`}
onClick={handleAllUnreadsClick}
>
<div className='recap-type-card-icon'>
<LightningBoltOutlineIcon size={24}/>
</div>
<div className='recap-type-card-content'>
<div className='recap-type-card-title'>
<FormattedMessage
id='recaps.modal.allUnreads'
defaultMessage='Recap all my unreads'
/>
</div>
<div className='recap-type-card-description'>
<FormattedMessage
id='recaps.modal.allUnreadsDesc'
defaultMessage='Create a recap of all unread messages across your channels.'
/>
</div>
</div>
{recapType === 'all_unreads' && <CheckCircleIcon className='selected-icon'/>}
</button>
</div>
</div>
{/* Run once toggle - hidden in edit mode */}
{!isEditMode && (
<div className='form-group run-once-group'>
{runOnceDisabled ? (
<WithTooltip
title={formatMessage({id: 'recaps.modal.runOnceDisabledTitle', defaultMessage: 'No unread messages'})}
hint={formatMessage({id: 'recaps.modal.runOnceDisabledHint', defaultMessage: 'You have no unread messages to recap right now. Schedule this recap to run in the future instead.'})}
>
{runOnceToggle}
</WithTooltip>
) : runOnceToggle}
<div className='run-once-description'>
<FormattedMessage
id='recaps.modal.runOnceDescription'
defaultMessage='Create an immediate recap without scheduling'
/>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {DaysOfWeek, ScheduledRecapTimePeriods} from '@mattermost/types/recaps';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import ScheduleConfiguration from './schedule_configuration';
describe('ScheduleConfiguration', () => {
const baseProps = {
daysOfWeek: DaysOfWeek.Saturday,
setDaysOfWeek: jest.fn(),
timeOfDay: '09:00',
setTimeOfDay: jest.fn(),
timePeriod: ScheduledRecapTimePeriods.Last24h,
setTimePeriod: jest.fn(),
customInstructions: '',
setCustomInstructions: jest.fn(),
};
const getInitialState = (timezone: string) => ({
entities: {
users: {
currentUserId: 'user-1',
profiles: {
'user-1': {
id: 'user-1',
username: 'user-1',
timezone: {
useAutomaticTimezone: false,
automaticTimezone: '',
manualTimezone: timezone,
},
},
},
},
},
});
const renderComponent = (timezone: string, props = {}) => {
return renderWithContext(
<ScheduleConfiguration
{...baseProps}
{...props}
/>,
getInitialState(timezone),
);
};
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('shows Today when the selected timezone is already on the scheduled day', () => {
jest.setSystemTime(new Date('2026-03-06T18:00:00.000Z'));
renderComponent('Asia/Tokyo');
expect(screen.getByText(/Next recap: Today at /)).toBeInTheDocument();
expect(screen.queryByText(/Next recap: Tomorrow at /)).not.toBeInTheDocument();
});
it('shows Tomorrow when the selected timezone has not reached the scheduled day yet', () => {
jest.setSystemTime(new Date('2026-03-06T02:00:00.000Z'));
renderComponent('America/Los_Angeles', {
daysOfWeek: DaysOfWeek.Friday,
});
expect(screen.getByText(/Next recap: Tomorrow at /)).toBeInTheDocument();
expect(screen.queryByText(/Next recap: Today at /)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React, {useMemo} from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {ScheduledRecapTimePeriods} from '@mattermost/types/recaps';
import type {ScheduledRecapTimePeriod} from '@mattermost/types/recaps';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import DropdownInput from 'components/dropdown_input';
import {formatRelativeScheduleTime} from 'components/recaps/schedule_time_format';
import Input from 'components/widgets/inputs/input/input';
import DayOfWeekSelector from './day_of_week_selector';
type Props = {
daysOfWeek: number;
setDaysOfWeek: (days: number) => void;
timeOfDay: string;
setTimeOfDay: (time: string) => void;
timePeriod: ScheduledRecapTimePeriod;
setTimePeriod: (period: ScheduledRecapTimePeriod) => void;
customInstructions: string;
setCustomInstructions: (instructions: string) => void;
daysError?: boolean;
timeError?: boolean;
agentName?: string;
};
// Generate time options in 30-minute intervals
const generateTimeOptions = () => {
const options = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 30) {
const h = hour.toString().padStart(2, '0');
const m = minute.toString().padStart(2, '0');
options.push(`${h}:${m}`);
}
}
return options;
};
const TIME_OPTIONS = generateTimeOptions();
const ScheduleConfiguration = ({
daysOfWeek,
setDaysOfWeek,
timeOfDay,
setTimeOfDay,
timePeriod,
setTimePeriod,
customInstructions,
setCustomInstructions,
daysError,
timeError,
agentName,
}: Props) => {
const {formatMessage, formatTime, formatDate} = useIntl();
const userTimezone = useSelector(getCurrentTimezone);
// Time period options - must match server model constants
const timePeriodOptions = useMemo<Array<{value: ScheduledRecapTimePeriod; label: string}>>(() => [
{value: ScheduledRecapTimePeriods.Last24h, label: formatMessage({id: 'recaps.timePeriod.last24h', defaultMessage: 'Last 24 hours'})},
{value: ScheduledRecapTimePeriods.LastWeek, label: formatMessage({id: 'recaps.timePeriod.lastWeek', defaultMessage: 'Last 7 days'})},
{value: ScheduledRecapTimePeriods.SinceLastRead, label: formatMessage({id: 'recaps.timePeriod.sinceLastRead', defaultMessage: 'Since last read'})},
], [formatMessage]);
// Time dropdown options with locale-aware labels
const timeOptions = useMemo(() => {
return TIME_OPTIONS.map((time) => {
const [hours, minutes] = time.split(':').map(Number);
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return {
value: time,
label: formatTime(date, {hour: 'numeric', minute: '2-digit'}),
};
});
}, [formatTime]);
// Calculate next run preview
const nextRunPreview = useMemo(() => {
if (daysOfWeek === 0 || !timeOfDay) {
return null;
}
const [hours, minutes] = timeOfDay.split(':').map(Number);
const previewTimeZone = moment.tz.zone(userTimezone) ? userTimezone : undefined;
const now = previewTimeZone ? moment.tz(previewTimeZone) : moment();
const startOfToday = now.clone().startOf('day');
// Find the next occurrence
// Start from today and check each day
for (let daysAhead = 0; daysAhead < 8; daysAhead++) {
const checkMoment = startOfToday.clone().add(daysAhead, 'days').set({
hour: hours,
minute: minutes,
second: 0,
millisecond: 0,
});
// Get day of week (0 = Sunday, 1 = Monday, etc.)
const dayOfWeek = checkMoment.day();
const dayBit = 1 << dayOfWeek;
// Check if this day is selected
if ((daysOfWeek & dayBit) !== 0) {
// Check if the time hasn't passed yet (or it's a future day)
if (daysAhead > 0 || checkMoment.isAfter(now)) {
return formatRelativeScheduleTime(
{formatMessage, formatDate, formatTime},
checkMoment.valueOf(),
now.valueOf(),
previewTimeZone,
{includeTimezoneAbbreviation: true},
);
}
}
}
return null;
}, [daysOfWeek, timeOfDay, formatMessage, formatTime, formatDate, userTimezone]);
return (
<div className='step-three'>
{/* Section: Schedule configuration */}
<div className='schedule-section'>
{/* Days of week selection */}
<div className='form-group days-group'>
<label
htmlFor='daysOfWeek'
className='section-subtitle'
>
<FormattedMessage
id='recaps.modal.whichDays'
defaultMessage='On which days should your recap run?'
/>
</label>
<DayOfWeekSelector
value={daysOfWeek}
onChange={setDaysOfWeek}
error={daysError}
/>
{daysError && (
<div className='form-error'>
<FormattedMessage
id='recaps.modal.selectDaysRequired'
defaultMessage='Please select at least one day'
/>
</div>
)}
</div>
{/* Time of day selection with next run preview as helper text */}
<div className='form-group time-selection-group'>
<label
htmlFor='timeOfDay'
className='section-subtitle'
>
<FormattedMessage
id='recaps.modal.atWhatTime'
defaultMessage='At what time?'
/>
</label>
<DropdownInput
name='timeOfDay'
legend={formatMessage({id: 'recaps.modal.selectTime', defaultMessage: 'Select time'})}
value={timeOptions.find((o) => o.value === timeOfDay)}
options={timeOptions}
onChange={(val) => setTimeOfDay(val.value)}
required={true}
error={timeError ? formatMessage({id: 'recaps.modal.selectTimeRequired', defaultMessage: 'Please select a time'}) : undefined}
/>
{/* Next run preview - always rendered with fixed height to prevent modal jumping */}
<div className='next-run-preview-container'>
<div className={`next-run-preview${nextRunPreview ? '' : ' hidden'}`}>
{nextRunPreview ? (
<FormattedMessage
id='recaps.modal.nextRunPreview'
defaultMessage='Next recap: {preview}'
values={{preview: nextRunPreview}}
/>
) : (
'\u00A0'
)}
</div>
</div>
</div>
</div>
{/* Section: Time period to cover */}
<div className='schedule-section'>
<div className='form-group'>
<label
htmlFor='timePeriod'
className='section-subtitle'
>
<FormattedMessage
id='recaps.modal.selectTimePeriod'
defaultMessage='Select a time period for your recap to cover'
/>
</label>
<DropdownInput
name='timePeriod'
legend={formatMessage({id: 'recaps.modal.timePeriod', defaultMessage: 'Time period to cover'})}
value={timePeriodOptions.find((o) => o.value === timePeriod)}
options={timePeriodOptions}
onChange={(val) => setTimePeriod(val.value)}
required={true}
/>
</div>
</div>
{/* Section: Custom instructions */}
<div className='schedule-section'>
<div className='form-group'>
<label
htmlFor='customInstructions'
className='section-subtitle'
>
<FormattedMessage
id='recaps.modal.additionalInstructions'
defaultMessage='Additional instructions for {agentName}'
values={{agentName: agentName || 'Copilot'}}
/>
</label>
<Input
type='textarea'
name='customInstructions'
placeholder={formatMessage({id: 'recaps.modal.customInstructionsPlaceholder', defaultMessage: 'Add any specific instructions for the AI...'})}
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
rows={3}
limit={500}
/>
</div>
</div>
</div>
);
};
export default ScheduleConfiguration;
@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineMessages} from 'react-intl';
import type {MessageDescriptor} from 'react-intl';
import {DaysOfWeek} from '@mattermost/types/recaps';
// Static descriptors are required so the formatjs extractor can collect every day label; runtime-computed
// message IDs are silently dropped from the catalog and never become translatable.
const fullNameMessages = defineMessages({
sunday: {id: 'recaps.days.sunday', defaultMessage: 'Sunday'},
monday: {id: 'recaps.days.monday', defaultMessage: 'Monday'},
tuesday: {id: 'recaps.days.tuesday', defaultMessage: 'Tuesday'},
wednesday: {id: 'recaps.days.wednesday', defaultMessage: 'Wednesday'},
thursday: {id: 'recaps.days.thursday', defaultMessage: 'Thursday'},
friday: {id: 'recaps.days.friday', defaultMessage: 'Friday'},
saturday: {id: 'recaps.days.saturday', defaultMessage: 'Saturday'},
});
const shortLabelMessages = defineMessages({
sunday: {id: 'recaps.days.short.sunday', defaultMessage: 'Su'},
monday: {id: 'recaps.days.short.monday', defaultMessage: 'M'},
tuesday: {id: 'recaps.days.short.tuesday', defaultMessage: 'T'},
wednesday: {id: 'recaps.days.short.wednesday', defaultMessage: 'W'},
thursday: {id: 'recaps.days.short.thursday', defaultMessage: 'Th'},
friday: {id: 'recaps.days.short.friday', defaultMessage: 'F'},
saturday: {id: 'recaps.days.short.saturday', defaultMessage: 'Sa'},
});
const abbrevMessages = defineMessages({
sunday: {id: 'recaps.days.abbrev.sunday', defaultMessage: 'Sun'},
monday: {id: 'recaps.days.abbrev.monday', defaultMessage: 'Mon'},
tuesday: {id: 'recaps.days.abbrev.tuesday', defaultMessage: 'Tue'},
wednesday: {id: 'recaps.days.abbrev.wednesday', defaultMessage: 'Wed'},
thursday: {id: 'recaps.days.abbrev.thursday', defaultMessage: 'Thu'},
friday: {id: 'recaps.days.abbrev.friday', defaultMessage: 'Fri'},
saturday: {id: 'recaps.days.abbrev.saturday', defaultMessage: 'Sat'},
});
export type DayDescriptor = {
bit: number;
fullName: MessageDescriptor; // accessible label, e.g. "Monday"
shortLabel: MessageDescriptor; // toggle button text, e.g. "M"
abbrev: MessageDescriptor; // schedule summary text, e.g. "Mon"
};
// Ordered by the day-of-week bitmask (Sunday first) so the schedule summary lists days in a stable order.
export const DAY_DESCRIPTORS: DayDescriptor[] = [
{bit: DaysOfWeek.Sunday, fullName: fullNameMessages.sunday, shortLabel: shortLabelMessages.sunday, abbrev: abbrevMessages.sunday},
{bit: DaysOfWeek.Monday, fullName: fullNameMessages.monday, shortLabel: shortLabelMessages.monday, abbrev: abbrevMessages.monday},
{bit: DaysOfWeek.Tuesday, fullName: fullNameMessages.tuesday, shortLabel: shortLabelMessages.tuesday, abbrev: abbrevMessages.tuesday},
{bit: DaysOfWeek.Wednesday, fullName: fullNameMessages.wednesday, shortLabel: shortLabelMessages.wednesday, abbrev: abbrevMessages.wednesday},
{bit: DaysOfWeek.Thursday, fullName: fullNameMessages.thursday, shortLabel: shortLabelMessages.thursday, abbrev: abbrevMessages.thursday},
{bit: DaysOfWeek.Friday, fullName: fullNameMessages.friday, shortLabel: shortLabelMessages.friday, abbrev: abbrevMessages.friday},
{bit: DaysOfWeek.Saturday, fullName: fullNameMessages.saturday, shortLabel: shortLabelMessages.saturday, abbrev: abbrevMessages.saturday},
];
// Monday-first ordering for the toggle selector, which is more intuitive for work schedules.
export const SELECTOR_DAY_DESCRIPTORS: DayDescriptor[] = [
...DAY_DESCRIPTORS.slice(1),
DAY_DESCRIPTORS[0],
];
@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './recap_usage_badge';
@@ -0,0 +1,117 @@
.RecapUsageBadge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border: none;
border-radius: 4px;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
color: var(--center-channel-color);
cursor: pointer;
font-size: 11px;
font-weight: 600;
gap: 4px;
line-height: 16px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
}
.icon {
font-size: 12px;
}
&--warning {
background-color: rgba(var(--away-indicator-rgb), 0.16);
color: var(--away-indicator);
&:hover {
background-color: rgba(var(--away-indicator-rgb), 0.24);
}
}
&--error {
background-color: rgba(var(--error-text-rgb), 0.16);
color: var(--error-text);
&:hover {
background-color: rgba(var(--error-text-rgb), 0.24);
}
}
&__popover {
z-index: 2000;
min-width: 240px;
max-width: 300px;
padding: 12px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 8px;
background-color: var(--center-channel-bg);
box-shadow: var(--elevation-5);
}
&__popover-header {
margin-bottom: 8px;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
&__popover-body {
display: flex;
flex-direction: column;
gap: 8px;
}
&__popover-usage {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
&__popover-warning {
display: flex;
align-items: center;
color: var(--away-indicator);
font-size: 12px;
gap: 6px;
.icon {
font-size: 14px;
}
}
&__popover-error {
display: flex;
align-items: center;
color: var(--error-text);
font-size: 12px;
gap: 6px;
.icon {
font-size: 14px;
}
}
&__popover-cooldown {
display: flex;
align-items: center;
padding: 8px;
border-radius: 4px;
background-color: rgba(var(--error-text-rgb), 0.08);
color: var(--error-text);
font-size: 12px;
gap: 6px;
.icon {
font-size: 14px;
}
}
&__popover-reset {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
}
}
@@ -0,0 +1,220 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useHover,
useFocus,
useDismiss,
useInteractions,
FloatingPortal,
} from '@floating-ui/react';
import React, {useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {getRecapLimitStatus} from 'mattermost-redux/selectors/entities/recaps';
import './recap_usage_badge.scss';
type BadgeState = 'normal' | 'warning' | 'error';
const RecapUsageBadge = () => {
const {formatMessage, formatTime} = useIntl();
const limitStatus = useSelector(getRecapLimitStatus);
const [isOpen, setIsOpen] = useState(false);
const {refs, floatingStyles, context} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset(8), flip(), shift()],
whileElementsMounted: autoUpdate,
placement: 'bottom-end',
});
const hover = useHover(context, {move: false});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const {getReferenceProps, getFloatingProps} = useInteractions([
hover,
focus,
dismiss,
]);
if (!limitStatus) {
return null;
}
const {daily, cooldown} = limitStatus;
const isUnlimited = daily.limit === -1;
// Calculate badge state
let badgeState: BadgeState = 'normal';
if (!isUnlimited) {
const usageRatio = daily.used / daily.limit;
if (daily.used >= daily.limit) {
badgeState = 'error';
} else if (usageRatio >= 0.8) {
badgeState = 'warning';
}
}
// Also show error state if cooldown is active
if (cooldown.is_active) {
badgeState = 'error';
}
// Format badge text
const badgeText = isUnlimited ?
`${daily.used}` :
`${daily.used}/${daily.limit}`;
const unlimitedLabel = formatMessage({
id: 'recaps.usageBadge.unlimited',
defaultMessage: 'unlimited',
});
// Format reset time (midnight in user timezone)
const resetTime = new Date(daily.reset_at);
const formattedResetTime = formatTime(resetTime, {
hour: 'numeric',
minute: '2-digit',
});
// Format cooldown available time
const cooldownTime = cooldown.is_active ?
new Date(cooldown.available_at) :
null;
const formattedCooldownTime = cooldownTime ?
formatTime(cooldownTime, {hour: 'numeric', minute: '2-digit'}) :
null;
// Calculate relative cooldown time
const cooldownRelative = cooldown.is_active ?
formatCooldownRelative(cooldown.retry_after_seconds, formatMessage) :
null;
return (
<>
<button
type='button'
ref={refs.setReference}
className={`RecapUsageBadge RecapUsageBadge--${badgeState}`}
{...getReferenceProps()}
aria-label={formatMessage({
id: 'recaps.usageBadge.ariaLabel',
defaultMessage: 'Daily recap usage: {used} of {limit}',
}, {used: daily.used, limit: isUnlimited ? unlimitedLabel : daily.limit})}
>
<i className='icon icon-clock-outline'/>
<span className='RecapUsageBadge__text'>{badgeText}</span>
</button>
{isOpen && (
<FloatingPortal>
<div
ref={refs.setFloating}
className='RecapUsageBadge__popover'
style={floatingStyles}
{...getFloatingProps()}
>
<div className='RecapUsageBadge__popover-header'>
<FormattedMessage
id='recaps.usageBadge.popover.title'
defaultMessage='Daily recap usage'
/>
</div>
<div className='RecapUsageBadge__popover-body'>
<div className='RecapUsageBadge__popover-usage'>
{isUnlimited ? (
<FormattedMessage
id='recaps.usageBadge.popover.unlimited'
defaultMessage='{used} recaps today (no limit)'
values={{used: daily.used}}
/>
) : (
<FormattedMessage
id='recaps.usageBadge.popover.usage'
defaultMessage='{used} of {limit} recaps used today'
values={{used: daily.used, limit: daily.limit}}
/>
)}
</div>
{badgeState === 'warning' && !cooldown.is_active && (
<div className='RecapUsageBadge__popover-warning'>
<i className='icon icon-alert-outline'/>
<FormattedMessage
id='recaps.usageBadge.popover.approachingLimit'
defaultMessage='Approaching daily limit'
/>
</div>
)}
{badgeState === 'error' && daily.used >= daily.limit && !isUnlimited && (
<div className='RecapUsageBadge__popover-error'>
<i className='icon icon-alert-circle-outline'/>
<FormattedMessage
id='recaps.usageBadge.popover.limitReached'
defaultMessage='Daily limit reached. Resets at {time}.'
values={{time: formattedResetTime}}
/>
</div>
)}
{cooldown.is_active && (
<div className='RecapUsageBadge__popover-cooldown'>
<i className='icon icon-timer-outline'/>
<FormattedMessage
id='recaps.usageBadge.popover.cooldown'
defaultMessage='Available again in {relative} ({time})'
values={{
relative: cooldownRelative,
time: formattedCooldownTime,
}}
/>
</div>
)}
{!isUnlimited && badgeState !== 'error' && !cooldown.is_active && (
<div className='RecapUsageBadge__popover-reset'>
<FormattedMessage
id='recaps.usageBadge.popover.resetTime'
defaultMessage='Resets at {time}'
values={{time: formattedResetTime}}
/>
</div>
)}
</div>
</div>
</FloatingPortal>
)}
</>
);
};
// Helper to format cooldown as relative time
function formatCooldownRelative(
seconds: number,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return formatMessage(
{id: 'recaps.cooldown.hoursMinutes', defaultMessage: '~{hours}h {minutes}m'},
{hours, minutes},
);
}
return formatMessage(
{id: 'recaps.cooldown.minutes', defaultMessage: '~{minutes}m'},
{minutes: Math.max(1, minutes)},
);
}
export default RecapUsageBadge;
@@ -570,6 +570,65 @@
}
}
}
// Scheduled Recaps List Styles
.scheduled-recaps-list {
display: flex;
max-width: 966px;
flex-direction: column;
margin: 0 auto;
gap: 12px;
}
// Scheduled Recaps Empty State
.scheduled-recaps-empty-state {
display: flex;
max-width: 400px;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
margin: 0 auto;
text-align: center;
.empty-state-illustration {
margin-bottom: 24px;
.illustration-icons {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
.icon {
color: rgba(var(--center-channel-color-rgb), 0.48);
font-size: 32px;
}
}
}
.empty-state-title {
margin: 0 0 8px;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.empty-state-description {
margin: 0 0 24px;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 14px;
line-height: 20px;
}
.empty-state-cta {
display: flex;
align-items: center;
gap: 8px;
}
}
}
@keyframes spin {
@@ -581,3 +640,6 @@
}
}
.recap-add-button-wrapper {
display: inline-block;
}
@@ -13,6 +13,8 @@ import Recaps from './recaps';
const mockDispatch = jest.fn(() => Promise.resolve({data: []}));
const mockGetAgents = jest.fn(() => ({type: 'GET_AGENTS'}));
const mockGetRecaps = jest.fn((page: number, perPage: number) => ({type: 'GET_RECAPS', meta: {page, perPage}}));
const mockGetScheduledRecaps = jest.fn((page: number, perPage: number) => ({type: 'GET_SCHEDULED_RECAPS', meta: {page, perPage}}));
const mockFetchRecapLimitStatus = jest.fn(() => ({type: 'GET_RECAP_LIMIT_STATUS'}));
const mockMarkRecapsAsViewed = jest.fn(() => ({type: 'MARK_RECAPS_VIEWED'}));
const mockSelectLhsItem = jest.fn((type: string, id?: string) => {
return {type: 'SELECT_LHS_ITEM', meta: {lhsType: type, id}};
@@ -30,6 +32,8 @@ jest.mock('mattermost-redux/actions/agents', () => ({
jest.mock('mattermost-redux/actions/recaps', () => ({
getRecaps: (page: number, perPage: number) => mockGetRecaps(page, perPage),
getScheduledRecaps: (page: number, perPage: number) => mockGetScheduledRecaps(page, perPage),
getRecapLimitStatus: () => mockFetchRecapLimitStatus(),
markRecapsAsViewed: () => mockMarkRecapsAsViewed(),
}));
@@ -37,6 +41,8 @@ jest.mock('mattermost-redux/selectors/entities/recaps', () => ({
getAllRecaps: jest.fn(() => []),
getUnreadRecaps: jest.fn(() => []),
getReadRecaps: jest.fn(() => []),
getAllScheduledRecaps: jest.fn(() => []),
getRecapLimitStatus: jest.fn(() => null),
}));
jest.mock('actions/views/lhs', () => ({
@@ -57,6 +63,8 @@ describe('components/recaps/Recaps', () => {
mockDispatch.mockClear();
mockGetAgents.mockClear();
mockGetRecaps.mockClear();
mockGetScheduledRecaps.mockClear();
mockFetchRecapLimitStatus.mockClear();
mockMarkRecapsAsViewed.mockClear();
mockSelectLhsItem.mockClear();
});
@@ -70,10 +78,14 @@ describe('components/recaps/Recaps', () => {
expect(mockSelectLhsItem).toHaveBeenCalledWith(LhsItemType.Page, LhsPage.Recaps);
expect(mockGetRecaps).toHaveBeenCalledWith(0, 60);
expect(mockGetScheduledRecaps).toHaveBeenCalledWith(0, 60);
expect(mockGetAgents).toHaveBeenCalled();
expect(mockFetchRecapLimitStatus).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'SELECT_LHS_ITEM'}));
expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'GET_RECAPS'}));
expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'GET_SCHEDULED_RECAPS'}));
expect(mockDispatch).toHaveBeenCalledWith({type: 'GET_AGENTS'});
expect(mockDispatch).toHaveBeenCalledWith({type: 'GET_RECAP_LIMIT_STATUS'});
// markRecapsAsViewed runs asynchronously after getRecaps resolves.
await waitFor(() => expect(mockMarkRecapsAsViewed).toHaveBeenCalled());
+132 -54
View File
@@ -1,17 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {Redirect} from 'react-router-dom';
import {Redirect, useHistory, useLocation} from 'react-router-dom';
import {PlusIcon} from '@mattermost/compass-icons/components';
import {Button} from '@mattermost/shared/components/button';
import {getAgents} from 'mattermost-redux/actions/agents';
import {getRecaps, markRecapsAsViewed} from 'mattermost-redux/actions/recaps';
import {getAllRecaps, getUnreadRecaps, getReadRecaps} from 'mattermost-redux/selectors/entities/recaps';
import {getRecaps, getScheduledRecaps, getRecapLimitStatus as fetchRecapLimitStatus, markRecapsAsViewed} from 'mattermost-redux/actions/recaps';
import {getAllRecaps, getUnreadRecaps, getReadRecaps, getAllScheduledRecaps} from 'mattermost-redux/selectors/entities/recaps';
import {selectLhsItem} from 'actions/views/lhs';
import {openModal} from 'actions/views/modals';
@@ -21,44 +21,88 @@ import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagVal
import CreateRecapModal from 'components/create_recap_modal';
import {ModalIdentifiers} from 'utils/constants';
import {useQuery} from 'utils/http_utils';
import {LhsItemType, LhsPage} from 'types/store/lhs';
import AICopilotIntroSvg from './ai_copilot_intro_svg';
import RecapUsageBadge from './recap_usage_badge';
import RecapsList from './recaps_list';
import ScheduledRecapsList from './scheduled_recaps_list';
import './recaps.scss';
import './scheduled_recap_item.scss';
type TabName = 'unread' | 'read' | 'scheduled';
const isValidTab = (tab: string | null): tab is TabName => {
return tab === 'unread' || tab === 'read' || tab === 'scheduled';
};
const Recaps = () => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState<'unread' | 'read'>('unread');
const history = useHistory();
const location = useLocation();
const query = useQuery();
const tabParam = query.get('tab');
const [activeTab, setActiveTab] = useState<TabName>(() => {
return isValidTab(tabParam) ? tabParam : 'unread';
});
const [isLoading, setIsLoading] = useState(true);
// Handle tab change: update state and URL
const handleTabChange = useCallback((tab: TabName) => {
setActiveTab(tab);
// Update URL with the new tab parameter using replace to avoid polluting history
const newSearchParams = new URLSearchParams(location.search);
if (tab === 'unread') {
// Remove tab param for default tab to keep URL clean
newSearchParams.delete('tab');
} else {
newSearchParams.set('tab', tab);
}
const newSearch = newSearchParams.toString();
const newUrl = newSearch ? `${location.pathname}?${newSearch}` : location.pathname;
history.replace(newUrl);
}, [history, location.pathname, location.search]);
const enableAIRecaps = useGetFeatureFlagValue('EnableAIRecaps');
const agentsBridgeEnabled = useGetAgentsBridgeEnabled();
const allRecaps = useSelector(getAllRecaps);
const unreadRecaps = useSelector(getUnreadRecaps);
const readRecaps = useSelector(getReadRecaps);
const scheduledRecaps = useSelector(getAllScheduledRecaps);
const hasNoRecaps = !isLoading && allRecaps.length === 0;
// Sync activeTab with URL query parameter changes (e.g., when navigating via history.push)
useEffect(() => {
const urlTab = isValidTab(tabParam) ? tabParam : 'unread';
setActiveTab(urlTab);
}, [tabParam]);
useEffect(() => {
dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Recaps));
const fetchData = async () => {
const result = await dispatch(getRecaps(0, 60));
setIsLoading(false);
try {
const result = await dispatch(getRecaps(0, 60));
// Only mark viewed when getRecaps succeeded. Marking after the
// fetch (rather than in parallel) also prevents getRecaps's
// response from overwriting the viewed_at timestamps the
// WS-driven refresh is about to set.
if (!result.error) {
dispatch(markRecapsAsViewed());
// Only mark viewed when getRecaps succeeded. Marking after the
// fetch (rather than in parallel) also prevents getRecaps's
// response from overwriting the viewed_at timestamps the
// WS-driven refresh is about to set.
if (!result.error) {
dispatch(markRecapsAsViewed());
}
} finally {
setIsLoading(false);
}
};
fetchData();
dispatch(getScheduledRecaps(0, 60));
dispatch(getAgents());
dispatch(fetchRecapLimitStatus());
}, [dispatch]);
// Redirect if feature flag is disabled
@@ -73,7 +117,58 @@ const Recaps = () => {
}));
};
const handleEditScheduledRecap = (id: string) => {
// Find the scheduled recap to edit
const scheduledRecapToEdit = scheduledRecaps.find((sr) => sr.id === id);
if (!scheduledRecapToEdit) {
return;
}
dispatch(openModal({
modalId: ModalIdentifiers.CREATE_RECAP_MODAL,
dialogType: CreateRecapModal,
dialogProps: {
editScheduledRecap: scheduledRecapToEdit,
},
}));
};
const displayedRecaps = activeTab === 'unread' ? unreadRecaps : readRecaps;
let recapsContent: React.ReactNode;
if (activeTab === 'scheduled') {
recapsContent = (
<ScheduledRecapsList
scheduledRecaps={scheduledRecaps}
onEdit={handleEditScheduledRecap}
onCreateClick={handleAddRecap}
createDisabled={!agentsBridgeEnabled.available}
/>
);
} else if (hasNoRecaps) {
recapsContent = (
<div className='recaps-placeholder'>
<AICopilotIntroSvg/>
<h2 className='recaps-placeholder-title'>
{formatMessage({id: 'recaps.placeholder.title', defaultMessage: 'Set up your recap'})}
</h2>
<p className='recaps-placeholder-description'>
{formatMessage({id: 'recaps.placeholder.description', defaultMessage: 'Recaps help you get caught up quickly on discussions that are most important to you with a summarized report.'})}
</p>
<Button
emphasis='primary'
className='recaps-placeholder-button'
onClick={handleAddRecap}
disabled={!agentsBridgeEnabled.available}
title={agentsBridgeEnabled.available ? undefined : formatMessage({id: 'recaps.addRecap.disabled', defaultMessage: 'Agents Bridge is not enabled'})}
>
{formatMessage({id: 'recaps.placeholder.createRecap', defaultMessage: 'Create a recap'})}
</Button>
</div>
);
} else {
recapsContent = <RecapsList recaps={displayedRecaps}/>;
}
return (
<div className='recaps-container'>
@@ -84,25 +179,30 @@ const Recaps = () => {
<h1 className='recaps-title'>
{formatMessage({id: 'recaps.title', defaultMessage: 'Recaps'})}
</h1>
<RecapUsageBadge/>
</div>
<div className='recaps-tabs'>
<button
className={`recaps-tab ${activeTab === 'unread' ? 'active' : ''}`}
onClick={() => handleTabChange('unread')}
>
{formatMessage({id: 'recaps.unreadTab', defaultMessage: 'Unread'})}
</button>
<button
className={`recaps-tab ${activeTab === 'read' ? 'active' : ''}`}
onClick={() => handleTabChange('read')}
>
{formatMessage({id: 'recaps.readTab', defaultMessage: 'Read'})}
</button>
<button
className={`recaps-tab ${activeTab === 'scheduled' ? 'active' : ''}`}
onClick={() => handleTabChange('scheduled')}
>
{formatMessage({id: 'recaps.scheduled.tab', defaultMessage: 'Scheduled'})}
</button>
</div>
{!hasNoRecaps && (
<div className='recaps-tabs'>
<button
className={`recaps-tab ${activeTab === 'unread' ? 'active' : ''}`}
onClick={() => setActiveTab('unread')}
>
{formatMessage({id: 'recaps.unreadTab', defaultMessage: 'Unread'})}
</button>
<button
className={`recaps-tab ${activeTab === 'read' ? 'active' : ''}`}
onClick={() => setActiveTab('read')}
>
{formatMessage({id: 'recaps.readTab', defaultMessage: 'Read'})}
</button>
</div>
)}
</div>
{!hasNoRecaps && (
{(activeTab === 'scheduled' || !hasNoRecaps) && (
<Button
emphasis='tertiary'
className='recap-add-button'
@@ -117,32 +217,10 @@ const Recaps = () => {
</div>
<div className='recaps-content'>
{hasNoRecaps ? (
<div className='recaps-placeholder'>
<AICopilotIntroSvg/>
<h2 className='recaps-placeholder-title'>
{formatMessage({id: 'recaps.placeholder.title', defaultMessage: 'Set up your recap'})}
</h2>
<p className='recaps-placeholder-description'>
{formatMessage({id: 'recaps.placeholder.description', defaultMessage: 'Recaps help you get caught up quickly on discussions that are most important to you with a summarized report.'})}
</p>
<Button
emphasis='primary'
className='recaps-placeholder-button'
onClick={handleAddRecap}
disabled={!agentsBridgeEnabled.available}
title={agentsBridgeEnabled.available ? undefined : formatMessage({id: 'recaps.addRecap.disabled', defaultMessage: 'Agents Bridge is not enabled'})}
>
{formatMessage({id: 'recaps.placeholder.createRecap', defaultMessage: 'Create a recap'})}
</Button>
</div>
) : (
<RecapsList recaps={displayedRecaps}/>
)}
{recapsContent}
</div>
</div>
);
};
export default Recaps;
@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHookWithContext} from 'tests/react_testing_utils';
import {useScheduleDisplay} from './schedule_display';
describe('useScheduleDisplay', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-03-06T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
test('hides overdue next runs while preserving future ones', () => {
const {result} = renderHookWithContext(() => useScheduleDisplay());
expect(result.current.formatNextRun(new Date('2026-03-06T11:00:00.000Z').getTime(), true)).toBeNull();
expect(result.current.formatNextRun(new Date('2026-03-05T11:00:00.000Z').getTime(), true)).toBeNull();
const futureNextRun = result.current.formatNextRun(new Date('2026-03-06T13:00:00.000Z').getTime(), true);
expect(futureNextRun).not.toBeNull();
expect(futureNextRun as string).toContain('Next:');
});
test('formats the next run in the schedule timezone rather than the browser zone', () => {
const {result} = renderHookWithContext(() => useScheduleDisplay());
// 23:30 UTC is 18:30 EST on 2026-03-06 (before US DST), so the schedule timezone must win.
const nextRun = result.current.formatNextRun(
new Date('2026-03-06T23:30:00.000Z').getTime(),
true,
'America/New_York',
);
expect(nextRun).toContain('6:30');
expect(nextRun).toContain('EST');
expect(nextRun).not.toContain('11:30');
});
});
@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useIntl} from 'react-intl';
import {Weekdays, Weekend, EveryDay} from '@mattermost/types/recaps';
import {DAY_DESCRIPTORS} from './day_descriptors';
import {formatRelativeScheduleTime} from './schedule_time_format';
export function useScheduleDisplay() {
const {formatMessage, formatDate, formatTime} = useIntl();
const formatDaysOfWeek = (daysOfWeek: number): string => {
// Check for special groupings
if (daysOfWeek === EveryDay) {
return formatMessage({id: 'recaps.scheduled.days.everyday', defaultMessage: 'Every day'});
}
if (daysOfWeek === Weekdays) {
return formatMessage({id: 'recaps.scheduled.days.weekdays', defaultMessage: 'Weekdays'});
}
if (daysOfWeek === Weekend) {
return formatMessage({id: 'recaps.scheduled.days.weekend', defaultMessage: 'Weekends'});
}
// Build abbreviated day list from the shared static descriptors
const selectedDays = DAY_DESCRIPTORS.
filter((day) => (daysOfWeek & day.bit) !== 0).
map((day) => formatMessage(day.abbrev));
return selectedDays.join(', ');
};
const formatTimeOfDay = (timeOfDay: string): string => {
// timeOfDay is "HH:MM" format
const [hours, minutes] = timeOfDay.split(':').map(Number);
const date = new Date();
date.setHours(hours, minutes, 0, 0);
// Use locale-appropriate time format (12-hour by default)
return formatTime(date, {hour: 'numeric', minute: '2-digit'});
};
const formatSchedule = (daysOfWeek: number, timeOfDay: string): string => {
const days = formatDaysOfWeek(daysOfWeek);
const time = formatTimeOfDay(timeOfDay);
return formatMessage(
{id: 'recaps.scheduled.scheduleFormat', defaultMessage: '{days} at {time}'},
{days, time},
);
};
const formatNextRun = (nextRunAt: number, enabled: boolean, timezone?: string): string | null => {
// Paused schedules hide next run
if (!enabled || nextRunAt === 0) {
return null;
}
const now = Date.now();
if (nextRunAt <= now) {
return null;
}
// Format in the schedule's timezone so the list matches the time the user configured.
const dateStr = formatRelativeScheduleTime(
{formatMessage, formatDate, formatTime},
nextRunAt,
now,
timezone,
{includeTimezoneAbbreviation: true},
);
return formatMessage(
{id: 'recaps.scheduled.nextRun', defaultMessage: 'Next: {date}'},
{date: dateStr},
);
};
const formatLastRun = (lastRunAt: number): string => {
if (lastRunAt === 0) {
return formatMessage({id: 'recaps.scheduled.neverRun', defaultMessage: 'Never run'});
}
const date = new Date(lastRunAt);
const dateStr = formatDate(date, {month: 'short', day: 'numeric', year: 'numeric'});
return formatMessage(
{id: 'recaps.scheduled.lastRun', defaultMessage: 'Last run: {date}'},
{date: dateStr},
);
};
const formatRunCount = (count: number): string => {
return formatMessage(
{id: 'recaps.scheduled.runCount', defaultMessage: '{count} {count, plural, one {run} other {runs}}'},
{count},
);
};
return {
formatDaysOfWeek,
formatTimeOfDay,
formatSchedule,
formatNextRun,
formatLastRun,
formatRunCount,
};
}
export default useScheduleDisplay;
@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {defineMessages} from 'react-intl';
import type {IntlShape} from 'react-intl';
const messages = defineMessages({
today: {id: 'recaps.nextRun.today', defaultMessage: 'Today at {time}'},
tomorrow: {id: 'recaps.nextRun.tomorrow', defaultMessage: 'Tomorrow at {time}'},
dayAt: {id: 'recaps.nextRun.dayAt', defaultMessage: '{day} at {time}'},
dateAt: {id: 'recaps.nextRun.dateAt', defaultMessage: '{date} at {time}'},
});
type ScheduleIntl = Pick<IntlShape, 'formatMessage' | 'formatDate' | 'formatTime'>;
type FormatOptions = {
// Append the timezone abbreviation (e.g. "(EST)") so the time is unambiguous outside the browser zone.
includeTimezoneAbbreviation?: boolean;
};
// Only treat a timezone as usable when moment recognizes it; otherwise fall back to the browser zone.
function resolveZone(timezone?: string): string | undefined {
return timezone && moment.tz.zone(timezone) ? timezone : undefined;
}
function getTimezoneAbbreviation(zone: string, date: Date): string {
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: zone,
timeZoneName: 'short',
}).formatToParts(date).find((part) => part.type === 'timeZoneName')?.value || '';
} catch {
return '';
}
}
// formatRelativeScheduleTime renders an absolute instant as "Today/Tomorrow/Weekday/Date at {time}",
// computing the relative day and time in the schedule's timezone so the list and the create-modal
// preview stay consistent with the time the user configured.
export function formatRelativeScheduleTime(
intl: ScheduleIntl,
targetMs: number,
nowMs: number,
timezone?: string,
options: FormatOptions = {},
): string {
const {formatMessage, formatDate, formatTime} = intl;
const zone = resolveZone(timezone);
const target = new Date(targetMs);
// Compare calendar days in the schedule's zone so the relative label matches the displayed time.
const nowDay = (zone ? moment.tz(nowMs, zone) : moment(nowMs)).startOf('day');
const targetDay = (zone ? moment.tz(targetMs, zone) : moment(targetMs)).startOf('day');
const diffDays = targetDay.diff(nowDay, 'days');
const timeOptions: Intl.DateTimeFormatOptions = {hour: 'numeric', minute: '2-digit'};
if (zone) {
timeOptions.timeZone = zone;
}
const time = formatTime(target, timeOptions);
let dateStr: string;
if (diffDays <= 0) {
dateStr = formatMessage(messages.today, {time});
} else if (diffDays === 1) {
dateStr = formatMessage(messages.tomorrow, {time});
} else if (diffDays <= 7) {
const dayOptions: Intl.DateTimeFormatOptions = {weekday: 'long'};
if (zone) {
dayOptions.timeZone = zone;
}
dateStr = formatMessage(messages.dayAt, {day: formatDate(target, dayOptions), time});
} else {
const dateOptions: Intl.DateTimeFormatOptions = {month: 'short', day: 'numeric'};
if (zone) {
dateOptions.timeZone = zone;
}
dateStr = formatMessage(messages.dateAt, {date: formatDate(target, dateOptions), time});
}
if (options.includeTimezoneAbbreviation && zone) {
const abbrev = getTimezoneAbbreviation(zone, target);
if (abbrev) {
return `${dateStr} (${abbrev})`;
}
}
return dateStr;
}
@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.scheduled-recap-item {
padding: 16px 24px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
border-radius: var(--radius-s);
background-color: var(--center-channel-bg);
transition: all 0.2s;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.04);
}
.scheduled-recap-item-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.scheduled-recap-item-main {
min-width: 0;
flex: 1;
}
.scheduled-recap-item-title {
overflow: hidden;
margin: 0 0 4px;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.scheduled-recap-item-subtitle {
display: flex;
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
gap: 6px;
line-height: 16px;
.metadata-separator {
color: rgba(var(--center-channel-color-rgb), 0.40);
}
.next-run {
color: rgba(var(--center-channel-color-rgb), 0.72);
}
}
.scheduled-recap-item-actions {
display: flex;
align-items: center;
gap: 12px;
}
.scheduled-recap-run-stats {
display: flex;
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 11px;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
&.visible {
opacity: 1;
}
.metadata-separator {
color: rgba(var(--center-channel-color-rgb), 0.32);
}
}
.scheduled-recap-toggle {
display: flex;
align-items: center;
}
.scheduled-recap-menu-button {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--radius-s);
background: none;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
color: var(--center-channel-color);
}
}
}
@@ -0,0 +1,172 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback} from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {DotsHorizontalIcon, PencilOutlineIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
import type {ScheduledRecap} from '@mattermost/types/recaps';
import {pauseScheduledRecap, resumeScheduledRecap, deleteScheduledRecap} from 'mattermost-redux/actions/recaps';
import type {ActionResult} from 'mattermost-redux/types/actions';
import ConfirmModal from 'components/confirm_modal';
import * as Menu from 'components/menu';
import Toggle from 'components/toggle';
import {useScheduleDisplay} from './schedule_display';
import './scheduled_recap_item.scss';
type Props = {
scheduledRecap: ScheduledRecap;
onEdit: (id: string) => void;
};
const ScheduledRecapItem = ({scheduledRecap, onEdit}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isToggling, setIsToggling] = useState(false);
const {formatSchedule, formatNextRun, formatLastRun, formatRunCount} = useScheduleDisplay();
const scheduleText = formatSchedule(scheduledRecap.days_of_week, scheduledRecap.time_of_day);
const nextRunText = formatNextRun(scheduledRecap.next_run_at, scheduledRecap.enabled, scheduledRecap.timezone);
const lastRunText = formatLastRun(scheduledRecap.last_run_at);
const runCountText = formatRunCount(scheduledRecap.run_count);
const handleToggle = useCallback(async () => {
if (isToggling) {
return;
}
setIsToggling(true);
try {
const action = scheduledRecap.enabled ?
pauseScheduledRecap(scheduledRecap.id) :
resumeScheduledRecap(scheduledRecap.id);
await dispatch(action);
} finally {
setIsToggling(false);
}
}, [dispatch, scheduledRecap.id, scheduledRecap.enabled, isToggling]);
const handleDelete = useCallback(async () => {
const result = await dispatch(deleteScheduledRecap(scheduledRecap.id)) as ActionResult;
if (result?.error) {
return;
}
setShowDeleteConfirm(false);
}, [dispatch, scheduledRecap.id]);
const handleEdit = useCallback(() => {
onEdit(scheduledRecap.id);
}, [onEdit, scheduledRecap.id]);
const menuId = `scheduled-recap-menu-${scheduledRecap.id}`;
const buttonId = `${menuId}-button`;
return (
<div
className='scheduled-recap-item'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className='scheduled-recap-item-content'>
<div className='scheduled-recap-item-main'>
<h3 className='scheduled-recap-item-title'>{scheduledRecap.title}</h3>
<div className='scheduled-recap-item-subtitle'>
<span className='schedule-pattern'>{scheduleText}</span>
{nextRunText && (
<>
<span className='metadata-separator'>{'·'}</span>
<span className='next-run'>{nextRunText}</span>
</>
)}
</div>
</div>
<div className='scheduled-recap-item-actions'>
<div className={`scheduled-recap-run-stats ${isHovered ? 'visible' : ''}`}>
<span className='run-stat'>{lastRunText}</span>
<span className='metadata-separator'>{'·'}</span>
<span className='run-stat'>{runCountText}</span>
</div>
<div className='scheduled-recap-toggle'>
<Toggle
id={`toggle-${scheduledRecap.id}`}
toggled={scheduledRecap.enabled}
onToggle={handleToggle}
disabled={isToggling}
size='btn-sm'
toggleClassName='btn-toggle-primary'
ariaLabel={scheduledRecap.enabled ?
formatMessage({id: 'recaps.scheduled.toggle.active', defaultMessage: 'Active - click to pause'}) :
formatMessage({id: 'recaps.scheduled.toggle.paused', defaultMessage: 'Paused - click to resume'})
}
/>
</div>
<Menu.Container
menuButton={{
id: buttonId,
class: 'scheduled-recap-menu-button',
'aria-label': formatMessage({id: 'recaps.menu.ariaLabel', defaultMessage: 'Options for {title}'}, {title: scheduledRecap.title}),
children: <DotsHorizontalIcon size={16}/>,
}}
menu={{
id: menuId,
'aria-label': formatMessage({id: 'recaps.menu.ariaLabel', defaultMessage: 'Options for {title}'}, {title: scheduledRecap.title}),
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Menu.Item
leadingElement={<PencilOutlineIcon size={18}/>}
labels={<span>{formatMessage({id: 'recaps.scheduled.menu.edit', defaultMessage: 'Edit'})}</span>}
onClick={handleEdit}
/>
<Menu.Item
leadingElement={<TrashCanOutlineIcon size={18}/>}
labels={<span>{formatMessage({id: 'recaps.scheduled.menu.delete', defaultMessage: 'Delete'})}</span>}
onClick={() => setShowDeleteConfirm(true)}
isDestructive={true}
/>
</Menu.Container>
</div>
</div>
<ConfirmModal
show={showDeleteConfirm}
title={formatMessage({id: 'recaps.scheduled.delete.title', defaultMessage: 'Delete scheduled recap?'})}
message={
<FormattedMessage
id='recaps.scheduled.delete.message'
defaultMessage='Are you sure you want to delete <strong>{title}</strong>? This scheduled recap will stop running.'
values={{
title: scheduledRecap.title,
strong: (chunks: React.ReactNode) => <strong>{chunks}</strong>,
}}
/>
}
confirmButtonText={formatMessage({id: 'recaps.scheduled.delete.button', defaultMessage: 'Delete'})}
confirmButtonVariant='destructive'
onConfirm={handleDelete}
onCancel={() => setShowDeleteConfirm(false)}
onExited={() => setShowDeleteConfirm(false)}
/>
</div>
);
};
export default ScheduledRecapItem;
@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {PlusIcon} from '@mattermost/compass-icons/components';
import {Button} from '@mattermost/shared/components/button';
type Props = {
onCreateClick: () => void;
disabled?: boolean;
};
const ScheduledRecapsEmptyState = ({onCreateClick, disabled}: Props) => {
const {formatMessage} = useIntl();
return (
<div className='scheduled-recaps-empty-state'>
<div className='empty-state-illustration'>
<div className='illustration-icons'>
<i className='icon icon-message-text-outline'/>
<i className='icon icon-calendar-outline'/>
<i className='icon icon-file-document-outline'/>
</div>
</div>
<h2 className='empty-state-title'>
{formatMessage({id: 'recaps.scheduled.emptyState.title', defaultMessage: 'Set up your first recap'})}
</h2>
<p className='empty-state-description'>
{formatMessage({
id: 'recaps.scheduled.emptyState.description',
defaultMessage: 'Copilot recaps help you get caught up quickly on discussions that are most important to you with a summarized report.',
})}
</p>
<Button
emphasis='primary'
className='empty-state-cta'
onClick={onCreateClick}
disabled={disabled}
>
<PlusIcon size={16}/>
{formatMessage({id: 'recaps.scheduled.emptyState.cta', defaultMessage: 'Create a recap'})}
</Button>
</div>
);
};
export default ScheduledRecapsEmptyState;

Some files were not shown because too many files have changed in this diff Show More