mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-26 13:17:29 -05:00
[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:
co-authored by
Claude Opus 4.6
Mattermost Build
Cursor Agent
parent
518b6b2c39
commit
25f3a75cb7
@@ -167,6 +167,10 @@ docker-compose.override.yaml
|
||||
**/CLAUDE.md
|
||||
.claude
|
||||
.cursorrules
|
||||
|
||||
# Planning files (local only)
|
||||
.planning
|
||||
|
||||
.cursor/*
|
||||
!.cursor/README.md
|
||||
!.cursor/cursor.md
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()))),
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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
|
||||
);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_user_id;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_user_id ON ScheduledRecaps(UserId);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_next_run_at;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_next_run_at ON ScheduledRecaps(NextRunAt);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_enabled_next_run;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scheduled_recaps_enabled_next_run ON ScheduledRecaps(Enabled, DeleteAt, NextRunAt);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_scheduled_recaps_user_delete;
|
||||
+2
@@ -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 '';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_recaps_scheduled_recap_id;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_recaps_scheduled_recap_id ON Recaps(ScheduledRecapId);
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'}),
|
||||
|
||||
+171
@@ -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());
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user