Compare commits

...

40 Commits

Author SHA1 Message Date
Chamika J
e5e56e48f8 feat(spam-moderation): implement spam detection and moderation for team invitations and signups
- Integrated SpamDetector utility to check for spam patterns in team names and user names during signup and invitation processes.
- Enhanced TeamMembersController to log and block obvious spam invitations while allowing suspicious ones for review.
- Updated passport-local-signup strategy to flag high-risk signups and log details for admin review.
- Added moderation routes to handle spam-related actions and integrated rate limiting for invitation requests.
- Improved frontend components to provide real-time spam warnings during organization name input, enhancing user feedback.
2025-07-31 15:52:08 +05:30
Chamika J
7635676289 feat(trial-user-limits): implement trial member limit checks in project and team controllers
- Added TRIAL_MEMBER_LIMIT constant to enforce a maximum number of trial users in project and team member controllers.
- Implemented logic to check current trial members against the limit during user addition, providing appropriate responses for exceeding limits.
- Updated relevant controllers to utilize the new trial member limit functionality, enhancing subscription management for trial users.
- Enhanced error messaging to guide users on upgrading their subscription for additional members.
2025-07-31 12:56:28 +05:30
chamikaJ
2bd6c19c13 refactor(layouts): simplify MainLayout and enhance styling
- Removed unused imports and performance monitoring hooks from MainLayout.
- Updated layout structure to improve responsiveness and styling, including sticky header and optimized content padding.
- Adjusted home page layout to reduce margin and improve spacing for better visual consistency.
- Enhanced TodoList component with collapsible sections for improved user interaction and task management.
- Streamlined project and schedule pages by removing unnecessary margin adjustments, ensuring a cleaner layout.
2025-07-30 17:20:20 +05:30
chamikaJ
374595261f feat(task-list-v2): enhance sticky column behavior and dark mode support
- Updated DropSpacer and EmptyGroupMessage components to accept an optional isDarkMode prop for improved styling in dark mode.
- Enhanced task rendering in TaskRow to dynamically adjust background colors based on dark mode and drag states.
- Refactored useTaskRowColumns to support sticky column positioning and hover effects, ensuring a consistent user experience across different themes.
- Improved overall visual feedback during task interactions, including drag-and-drop operations.
2025-07-30 16:25:29 +05:30
chamikaJ
b6c056dd1a feat(task-status-dropdown): enhance task status update and group movement handling
- Added logic to optimistically update task status in Redux for immediate feedback.
- Implemented group movement handling when tasks are updated based on their status, ensuring tasks are moved between groups as needed.
- Improved socket event emissions for real-time updates, including parent task handling.
- Refactored group selection logic to streamline target group identification based on status ID and group value.
2025-07-30 15:26:27 +05:30
chamikaJ
81e1872c1f refactor(task-list): simplify drag-and-drop functionality and enhance task rendering
- Removed droppable functionality from TaskGroupHeader and replaced it with a more streamlined approach in TaskListV2Table.
- Introduced DropSpacer component to improve visual feedback during task dragging.
- Updated task rendering logic in TaskRow to enhance user experience with clearer drop indicators.
- Refactored useDragAndDrop hook to manage drop positions more effectively, ensuring tasks can only be reordered within the same group.
- Improved socket event handling for task sorting to ensure accurate updates during drag-and-drop operations.
2025-07-30 15:08:28 +05:30
chamikaJ
5cce3bc613 feat(localization): add timer conflict handling and update translations
- Introduced a new hook `useTaskTimerWithConflictCheck` to manage timer conflicts, prompting users when a timer is already running for a different task.
- Updated localization files for Albanian, German, English, Spanish, Portuguese, and Chinese to include new translation keys related to timer conflict handling and cancellation.
- Refactored components to utilize the new timer hook, enhancing user experience by preventing overlapping timers.
2025-07-30 10:13:08 +05:30
chamikaJ
c53ab511bf Merge branch 'main' of https://github.com/Worklenz/worklenz into release-v2.1.4 2025-07-29 14:19:44 +05:30
Chamika J
7b9a16fd72 Merge pull request #307 from Worklenz/development
refactor: update .gitignore and remove obsolete files
2025-07-29 14:16:55 +05:30
chamikaJ
8830af2cbb refactor: update .gitignore and remove obsolete files
- Added .cursor and .claude directories to .gitignore to prevent tracking of temporary files.
- Deleted obsolete .claude/settings.local.json and .cursor/rules/antd-components.mdc files to clean up the repository and remove unnecessary configurations.
2025-07-29 14:16:52 +05:30
chamikaJ
b915de2b93 feat(reporting): enhance date handling and export functionality
- Improved date range handling in ReportingControllerBaseWithTimezone to support various date formats from the frontend, ensuring robust parsing and timezone conversion.
- Updated SQL queries to use consistent table aliases for clarity.
- Added export functionality for project members and tasks in ProjectReportsDrawer, allowing users to download relevant data.
- Enhanced the Excel export handler in ProjectsReports to streamline project data exports based on the current session's team name.
2025-07-29 13:05:55 +05:30
chamikaJ
29b8c1b2af feat(task-context-menu): add copy link functionality and update translations
- Implemented a new "Copy link to task" feature in the task context menu, allowing users to easily copy task links to the clipboard.
- Added corresponding success and error messages for link copying.
- Updated localization files for Albanian, German, English, Spanish, Portuguese, and Chinese to include new translation keys for the copy link feature.
2025-07-29 12:49:51 +05:30
chamikaJ
c2b231d5cc feat(survey-modal): add survey modal configuration to environment files
- Introduced a new environment variable VITE_ENABLE_SURVEY_MODAL to control the visibility of the survey modal.
- Updated .env.example to include the new variable with a default value of true.
- Modified SurveyPromptModal component to check the environment variable before displaying the modal, enhancing user experience by allowing toggling of the feature.
2025-07-29 11:13:08 +05:30
chamikaJ
53a28cf489 refactor(localization): update task-related translations and improve user activity feed
- Added new translation keys for recent tasks and time logged tasks in Albanian, German, English, Spanish, Portuguese, and Chinese localization files.
- Enhanced user activity feed to switch between recent tasks and time logged tasks, improving user experience.
- Updated the date formatting utility to support locale-specific formatting for better internationalization.
- Refactored task activity list and time logged task list components to utilize a table layout for improved readability.
2025-07-29 10:19:28 +05:30
Chamika J
e8ccc2a533 Merge pull request #306 from Worklenz/feature/task-activities-by-user
Feature/task activities by user
2025-07-29 08:46:13 +05:30
Chamika J
f24c0d8955 Merge branch 'release-v2.1.4' into feature/task-activities-by-user 2025-07-29 08:46:05 +05:30
Chamika J
01a580d992 Merge pull request #304 from Worklenz/fix/reporting-sidebar-style-fix
feat(survey-localization): add survey localization files for multiple…
2025-07-28 16:57:33 +05:30
chamikaJ
c2e670c9a2 feat(survey-localization): add survey localization files for multiple languages
- Introduced new localization JSON files for Albanian, German, English, Spanish, Portuguese, and Chinese to support the survey feature.
- Each file includes translations for modal titles, button texts, and feedback messages to enhance user experience across different languages.
- Updated the SurveyPromptModal component to utilize these localization entries for improved accessibility and user engagement.
2025-07-28 16:57:40 +05:30
Chamika J
25042baf71 Merge pull request #303 from Worklenz/fix/reporting-sidebar-style-fix
feat(account-setup): implement skip functionality and update localiza…
2025-07-28 16:31:53 +05:30
chamikaJ
e8d21ee187 feat(account-setup): implement skip functionality and update localization
- Added a state to manage the skipping process during account setup, enhancing user experience.
- Updated button behavior to reflect the skipping state and provide feedback to users.
- Included new localization entries for the "skipping" status in multiple languages (Albanian, German, English, Spanish, Portuguese, Chinese).
- Refined HubSpot widget styling to ensure better integration with the app's UI.
2025-07-28 16:30:21 +05:30
Chamika J
a8d1446b0d Merge pull request #302 from Worklenz/fix/reporting-sidebar-style-fix
feat(hubspot-integration): refine HubSpot widget styling and add acco…
2025-07-28 16:14:03 +05:30
chamikaJ
2082934cd5 feat(hubspot-integration): refine HubSpot widget styling and add account setup skip functionality
- Enhanced CSS targeting for HubSpot widget elements to prevent interference with the Worklenz app UI.
- Introduced a new function to allow users to bypass team member validation during account setup, improving user experience.
- Updated the button click handler to utilize the new skip functionality for a smoother setup process.
2025-07-28 16:12:12 +05:30
Chamika J
4debcd6aa5 Merge pull request #301 from Worklenz/fix/reporting-sidebar-style-fix
Fix/reporting sidebar style fix
2025-07-28 15:45:34 +05:30
chamikaJ
76adb89caf feat(task-filters): enhance sorting functionality and localization updates
- Added sorting options to task filters, including clear sort, sort ascending, sort descending, and sort by field.
- Updated localization files for multiple languages (Albanian, German, English, Spanish, Portuguese, Chinese) to include new sorting terms.
- Implemented a SortDropdown component for improved user experience in task management.
- Integrated sorting state management in the task management slice for better data handling.
2025-07-28 15:45:12 +05:30
chamikaJ
703a6425fe feat(surveys): add survey tables and initial data for account setup questionnaire
- Created tables for surveys, survey questions, survey responses, and survey answers to support the account setup process.
- Added default account setup survey and corresponding questions to the database.
- Implemented necessary indexes and constraints for data integrity and performance.
2025-07-28 15:17:21 +05:30
Chamika J
e2c9e19b83 Merge pull request #300 from Worklenz/fix/reporting-sidebar-style-fix
refactor(survey-submission): update validation logic and submission d…
2025-07-28 15:08:43 +05:30
chamikaJ
e2a749e0b6 refactor(survey-submission): update validation logic and submission data handling
- Modified the survey submission validator to make both answer_text and answer_json optional, allowing users to submit empty answers.
- Refactored the SurveyPromptModal component to only include answered questions in the submission data, improving data handling and clarity.
2025-07-28 15:07:09 +05:30
Chamika J
2c0b0ac4c5 Merge pull request #299 from Worklenz/fix/reporting-sidebar-style-fix
Fix/reporting sidebar style fix
2025-07-28 14:55:11 +05:30
chamikaJ
dd511b236f refactor(reporting-layout): streamline sidebar and content layout
- Replaced the existing sidebar implementation with a new ReportingSider component that accepts collapse state and toggle function as props.
- Simplified the ReportingCollapsedButton component for better readability and functionality.
- Updated layout styles to enhance responsiveness and maintain consistent margins.
- Removed unused CSS styles related to the sidebar for cleaner code.
2025-07-28 14:54:54 +05:30
chamikaJ
2c860b0cc8 feat(localization): update password-related translations in German and Spanish signup forms
- Translated password labels, guidelines, placeholders, and validation messages to improve user experience in both languages.
- Ensured consistency in terminology and clarity in password requirements for better user understanding.
2025-07-28 14:17:41 +05:30
Chamika J
1e6045c534 Merge pull request #297 from Worklenz/fix/task-time-log-timezone-fix
feat(task-time-logs): enhance time log retrieval and formatting with …
2025-07-28 09:48:08 +05:30
chamikaJ
2a9e12a495 feat(task-time-logs): enhance time log retrieval and formatting with user timezone
- Integrated user timezone handling in the task time logs API service to ensure accurate time representation.
- Added a new utility function to format date/time strings according to the user's profile timezone.
- Updated the TimeLogItem component to utilize the new formatting function for displaying timestamps.
2025-07-28 09:44:59 +05:30
Chamika J
fd2fc793df Merge pull request #295 from Worklenz/chore/added-sign-up-survey
Chore/added sign up survey
2025-07-25 15:23:03 +05:30
Chamika J
f3b7479770 Merge pull request #291 from Worklenz/chore/added-sign-up-survey
Chore/added sign up survey
2025-07-25 13:03:16 +05:30
chamikaJ
61461bb776 feat(user-activity): enhance user activity logs with additional data and improved queries
- Added optional fields for project color, task status, and status color in IUserRecentTask and IUserTimeLoggedTask interfaces.
- Optimized SQL queries to include team filtering and additional data such as project color and task status.
- Updated frontend components to support new data fields and improved styling for better user experience.
- Enhanced dark mode detection and styling in task activity lists.
- Implemented refetching of data on tab change in the user activity feed.
2025-07-14 13:26:28 +05:30
chamikaJ
2a7019c64c refactor(home-page): simplify layout by removing unnecessary Card component
- Removed the Card wrapper around the TasksList for a cleaner layout.
- Cleaned up whitespace in the home-page.tsx file for improved readability.
2025-07-14 12:48:13 +05:30
chamikaJ
5b1cbb0c46 Merge branch 'main' of https://github.com/Worklenz/worklenz into feature/task-activities-by-user 2025-07-14 12:46:18 +05:30
Chamika J
7c04598264 Merge pull request #253 from OminduHirushka/imp/user-activity-feed
user activity feed
2025-07-11 11:27:42 +05:30
Omindu Hirushka
5222d75064 user activity feed - frontend 2025-07-09 07:44:23 +05:30
Omindu Hirushka
2587b8afd9 user activity feed - backend 2025-07-09 07:43:44 +05:30
133 changed files with 6895 additions and 1235 deletions

View File

@@ -1,15 +0,0 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(npm run build:*)",
"Bash(npm run type-check:*)",
"Bash(npm run:*)",
"Bash(move:*)",
"Bash(mv:*)",
"Bash(grep:*)",
"Bash(rm:*)"
],
"deny": []
}
}

View File

@@ -1,237 +0,0 @@
---
alwaysApply: true
---
# Ant Design Import Rules for Worklenz
## 🚨 CRITICAL: Always Use Centralized Imports
**NEVER import Ant Design components directly from 'antd' or '@ant-design/icons'**
### ✅ Correct Import Pattern
```typescript
import { Button, Input, Select, EditOutlined, PlusOutlined } from '@antd-imports';
// or
import { Button, Input, Select, EditOutlined, PlusOutlined } from '@/shared/antd-imports';
```
### ❌ Forbidden Import Patterns
```typescript
// NEVER do this:
import { Button, Input, Select } from 'antd';
import { EditOutlined, PlusOutlined } from '@ant-design/icons';
```
## Why This Rule Exists
### Benefits of Centralized Imports:
- **Better Tree-Shaking**: Optimized bundle size through centralized management
- **Consistent React Context**: Proper context sharing across components
- **Type Safety**: Centralized TypeScript definitions
- **Maintainability**: Single source of truth for all Ant Design imports
- **Performance**: Reduced bundle size and improved loading times
## What's Available in `@antd-imports`
### Core Components
- **Layout**: Layout, Row, Col, Flex, Divider, Space
- **Navigation**: Menu, Tabs, Breadcrumb, Pagination
- **Data Entry**: Input, Select, DatePicker, TimePicker, Form, Checkbox, InputNumber
- **Data Display**: Table, List, Card, Tag, Avatar, Badge, Progress, Statistic
- **Feedback**: Modal, Drawer, Alert, Message, Notification, Spin, Skeleton, Result
- **Other**: Button, Typography, Tooltip, Popconfirm, Dropdown, ConfigProvider
### Icons
Common icons including: EditOutlined, DeleteOutlined, PlusOutlined, MoreOutlined, CheckOutlined, CloseOutlined, CalendarOutlined, UserOutlined, TeamOutlined, and many more.
### Utilities
- **appMessage**: Centralized message utility
- **appNotification**: Centralized notification utility
- **antdConfig**: Default Ant Design configuration
- **taskManagementAntdConfig**: Task-specific configuration
## Implementation Guidelines
### When Creating New Components:
1. **Always** import from `@/shared/antd-imports`
2. Use `appMessage` and `appNotification` for user feedback
3. Apply `antdConfig` for consistent styling
4. Use `taskManagementAntdConfig` for task-related components
### When Refactoring Existing Code:
1. Replace direct 'antd' imports with `@/shared/antd-imports`
2. Replace direct '@ant-design/icons' imports with `@/shared/antd-imports`
3. Update any custom message/notification calls to use the utilities
### File Location
The centralized import file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
## Examples
### Component Creation
```typescript
import React from 'react';
import { Button, Input, Modal, EditOutlined, appMessage } from '@antd-imports';
const MyComponent = () => {
const handleClick = () => {
appMessage.success('Operation completed!');
};
return (
<Button icon={<EditOutlined />} onClick={handleClick}>
Edit Item
</Button>
);
};
```
### Form Implementation
```typescript
import { Form, Input, Select, Button, DatePicker } from '@antd-imports';
const MyForm = () => {
return (
<Form layout="vertical">
<Form.Item label="Name" name="name">
<Input />
</Form.Item>
<Form.Item label="Type" name="type">
<Select options={options} />
</Form.Item>
<Form.Item label="Date" name="date">
<DatePicker />
</Form.Item>
</Form>
);
};
```
## Enforcement
This rule is **MANDATORY** and applies to:
- All new component development
- All code refactoring
- All bug fixes
- All feature implementations
**Violations will result in code review rejection.**
### File Path:
The centralized file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
# Ant Design Import Rules for Worklenz
## 🚨 CRITICAL: Always Use Centralized Imports
**NEVER import Ant Design components directly from 'antd' or '@ant-design/icons'**
### ✅ Correct Import Pattern
```typescript
import { Button, Input, Select, EditOutlined, PlusOutlined } from '@antd-imports';
// or
import { Button, Input, Select, EditOutlined, PlusOutlined } from '@/shared/antd-imports';
```
### ❌ Forbidden Import Patterns
```typescript
// NEVER do this:
import { Button, Input, Select } from 'antd';
import { EditOutlined, PlusOutlined } from '@ant-design/icons';
```
## Why This Rule Exists
### Benefits of Centralized Imports:
- **Better Tree-Shaking**: Optimized bundle size through centralized management
- **Consistent React Context**: Proper context sharing across components
- **Type Safety**: Centralized TypeScript definitions
- **Maintainability**: Single source of truth for all Ant Design imports
- **Performance**: Reduced bundle size and improved loading times
## What's Available in `@antd-imports`
### Core Components
- **Layout**: Layout, Row, Col, Flex, Divider, Space
- **Navigation**: Menu, Tabs, Breadcrumb, Pagination
- **Data Entry**: Input, Select, DatePicker, TimePicker, Form, Checkbox, InputNumber
- **Data Display**: Table, List, Card, Tag, Avatar, Badge, Progress, Statistic
- **Feedback**: Modal, Drawer, Alert, Message, Notification, Spin, Skeleton, Result
- **Other**: Button, Typography, Tooltip, Popconfirm, Dropdown, ConfigProvider
### Icons
Common icons including: EditOutlined, DeleteOutlined, PlusOutlined, MoreOutlined, CheckOutlined, CloseOutlined, CalendarOutlined, UserOutlined, TeamOutlined, and many more.
### Utilities
- **appMessage**: Centralized message utility
- **appNotification**: Centralized notification utility
- **antdConfig**: Default Ant Design configuration
- **taskManagementAntdConfig**: Task-specific configuration
## Implementation Guidelines
### When Creating New Components:
1. **Always** import from `@antd-imports` or `@/shared/antd-imports`
2. Use `appMessage` and `appNotification` for user feedback
3. Apply `antdConfig` for consistent styling
4. Use `taskManagementAntdConfig` for task-related components
### When Refactoring Existing Code:
1. Replace direct 'antd' imports with `@antd-imports`
2. Replace direct '@ant-design/icons' imports with `@antd-imports`
3. Update any custom message/notification calls to use the utilities
### File Location
The centralized import file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
## Examples
### Component Creation
```typescript
import React from 'react';
import { Button, Input, Modal, EditOutlined, appMessage } from '@antd-imports';
const MyComponent = () => {
const handleClick = () => {
appMessage.success('Operation completed!');
};
return (
<Button icon={<EditOutlined />} onClick={handleClick}>
Edit Item
</Button>
);
};
```
### Form Implementation
```typescript
import { Form, Input, Select, Button, DatePicker } from '@antd-imports';
const MyForm = () => {
return (
<Form layout="vertical">
<Form.Item label="Name" name="name">
<Input />
</Form.Item>
<Form.Item label="Type" name="type">
<Select options={options} />
</Form.Item>
<Form.Item label="Date" name="date">
<DatePicker />
</Form.Item>
</Form>
);
};
```
## Enforcement
This rule is **MANDATORY** and applies to:
- All new component development
- All code refactoring
- All bug fixes
- All feature implementations
**Violations will result in code review rejection.**
### File Path:
The centralized file is located at: `worklenz-frontend/src/shared/antd-imports.ts`

2
.gitignore vendored
View File

@@ -36,6 +36,8 @@ lerna-debug.log*
.vscode/*
!.vscode/extensions.json
.idea/
.cursor/
.claude/
.DS_Store
*.suo
*.ntvs*

View File

@@ -0,0 +1,220 @@
# Worklenz Spam Protection System Guide
## Overview
This guide documents the spam protection system implemented in Worklenz to prevent abuse of user invitations and registrations.
## System Components
### 1. Spam Detection (`/worklenz-backend/src/utils/spam-detector.ts`)
The core spam detection engine that analyzes text for suspicious patterns:
- **Flag-First Policy**: Suspicious content is flagged for review, not blocked
- **Selective Blocking**: Only extremely obvious spam (score > 80) gets blocked
- **URL Detection**: Identifies links, shortened URLs, and suspicious domains
- **Spam Phrases**: Detects common spam tactics (urgent, click here, win prizes)
- **Cryptocurrency Spam**: Identifies blockchain/crypto compensation scams
- **Formatting Issues**: Excessive capitals, special characters, emojis
- **Fake Name Detection**: Generic names (test, demo, fake, spam)
- **Whitelist Support**: Legitimate business names bypass all checks
- **Context-Aware**: Smart detection reduces false positives
### 2. Rate Limiting (`/worklenz-backend/src/middleware/rate-limiter.ts`)
Prevents volume-based attacks:
- **Invite Limits**: 5 invitations per 15 minutes per user
- **Organization Creation**: 3 attempts per hour
- **In-Memory Store**: Fast rate limit checking without database queries
### 3. Frontend Validation
Real-time feedback as users type:
- `/worklenz-frontend/src/components/account-setup/organization-step.tsx`
- `/worklenz-frontend/src/components/admin-center/overview/organization-name/organization-name.tsx`
- `/worklenz-frontend/src/components/settings/edit-team-name-modal.tsx`
### 4. Backend Enforcement
Blocks spam at API level:
- **Team Members Controller**: Validates organization/owner names before invites
- **Signup Process**: Blocks spam during registration
- **Logging**: All blocked attempts sent to Slack via winston logger
### 5. Database Schema
```sql
-- Teams table: Simple status field
ALTER TABLE teams ADD COLUMN status VARCHAR(20) DEFAULT 'active';
-- Moderation history tracking
CREATE TABLE team_moderation (
id UUID PRIMARY KEY,
team_id UUID REFERENCES teams(id),
status VARCHAR(20), -- 'flagged', 'suspended', 'restored'
reason TEXT,
moderator_id UUID,
created_at TIMESTAMP,
expires_at TIMESTAMP -- For temporary suspensions
);
-- Spam detection logs
CREATE TABLE spam_logs (
id UUID PRIMARY KEY,
team_id UUID,
content_type VARCHAR(50),
original_content TEXT,
spam_score INTEGER,
spam_reasons JSONB,
action_taken VARCHAR(50)
);
```
## Admin Tools
### API Endpoints
```
GET /api/moderation/flagged-organizations - View flagged teams
POST /api/moderation/flag-organization - Manually flag a team
POST /api/moderation/suspend-organization - Suspend a team
POST /api/moderation/unsuspend-organization - Restore a team
GET /api/moderation/scan-spam - Scan for spam in existing data
GET /api/moderation/stats - View moderation statistics
POST /api/moderation/bulk-scan - Bulk scan and auto-flag
```
## Slack Notifications
The system sends structured alerts to Slack for:
- 🚨 **Spam Detected** (score > 30)
- 🔥 **High Risk Content** (known spam domains)
- 🛑 **Blocked Attempts** (invitations/signups)
- ⚠️ **Rate Limit Exceeded**
Example Slack notification:
```json
{
"alert_type": "high_risk_content",
"team_name": "CLICK LINK: gclnk.com/spam",
"user_email": "spammer@example.com",
"spam_score": 95,
"reasons": ["Contains suspicious URLs", "Contains monetary references"],
"timestamp": "2024-01-15T10:30:00Z"
}
```
## Testing the System
### Test Spam Patterns
These will be **FLAGGED** for review (flag-first approach):
1. **Suspicious Words**: "Free Software Solutions" (flagged but allowed)
2. **URLs**: "Visit our site: bit.ly/win-prize" (flagged but allowed)
3. **Cryptocurrency**: "🔔 $50,000 BLOCKCHAIN COMPENSATION" (flagged but allowed)
4. **Urgency**: "URGENT! Click here NOW!!!" (flagged but allowed)
5. **Generic Names**: "Test Company", "Demo Organization" (flagged but allowed)
6. **Excessive Numbers**: "Company12345" (flagged but allowed)
7. **Single Emoji**: "Great Company 💰" (flagged but allowed)
### BLOCKED Patterns (zero-tolerance - score > 80):
1. **Known Spam Domains**: "CLICK LINK: gclnk.com/spam"
2. **Extreme Scam Patterns**: "🔔CHECK $213,953 BLOCKCHAIN COMPENSATION URGENT🔔"
3. **Obvious Spam URLs**: Content with bit.ly/scam patterns
### Whitelisted (Will NOT be flagged):
1. **Legitimate Business**: "Microsoft Corporation", "Free Software Company"
2. **Standard Suffixes**: "ABC Solutions Inc", "XYZ Consulting LLC"
3. **Tech Companies**: "DataTech Services", "The Design Studio"
4. **Context-Aware**: "Free Range Marketing", "Check Point Systems"
5. **Legitimate "Test"**: "TestDrive Automotive" (not generic)
### Expected Behavior
1. **Suspicious Signup**: Flagged in logs, user allowed to proceed
2. **Obvious Spam Signup**: Blocked with user-friendly message
3. **Suspicious Invitations**: Flagged in logs, invitation sent
4. **Obvious Spam Invitations**: Blocked with support contact suggestion
5. **Frontend**: Shows warning message for suspicious content
6. **Logger**: Sends Slack notification for all suspicious activity
7. **Database**: Records all activity in spam_logs table
## Database Migration
Run these SQL scripts in order:
1. `spam_protection_tables.sql` - Creates new schema
2. `fix_spam_protection_constraints.sql` - Fixes notification_settings constraints
## Configuration
### Environment Variables
No additional environment variables required. The system uses existing:
- `COOKIE_SECRET` - For session management
- Database connection settings
### Adjusting Thresholds
In `spam-detector.ts`:
```typescript
const isSpam = score >= 50; // Adjust threshold here
```
In `rate-limiter.ts`:
```typescript
inviteRateLimit(5, 15 * 60 * 1000) // 5 requests per 15 minutes
```
## Monitoring
### Check Spam Statistics
```sql
SELECT * FROM moderation_dashboard;
SELECT COUNT(*) FROM spam_logs WHERE created_at > NOW() - INTERVAL '24 hours';
```
### View Rate Limit Events
```sql
SELECT * FROM rate_limit_log WHERE blocked = true ORDER BY created_at DESC;
```
## Troubleshooting
### Issue: Legitimate users blocked
1. Check spam_logs for their content
2. Adjust spam patterns or scoring threshold
3. Whitelist specific domains if needed
### Issue: Notification settings error during signup
Run the fix script: `fix_spam_protection_constraints.sql`
### Issue: Slack notifications not received
1. Check winston logger configuration
2. Verify log levels in `logger.ts`
3. Ensure Slack webhook is configured
## Future Enhancements
1. **Machine Learning**: Train on spam_logs data
2. **IP Blocking**: Geographic or reputation-based blocking
3. **CAPTCHA Integration**: For suspicious signups
4. **Email Verification**: Stronger email validation
5. **Allowlist Management**: Pre-approved domains
## Security Considerations
- Logs contain sensitive data - ensure proper access controls
- Rate limit data stored in memory - consider Redis for scaling
- Spam patterns should be regularly updated
- Monitor for false positives and adjust accordingly

View File

@@ -2297,3 +2297,60 @@ ALTER TABLE organization_working_days
ALTER TABLE organization_working_days
ADD CONSTRAINT org_organization_id_fk
FOREIGN KEY (organization_id) REFERENCES organizations;
-- Survey tables for account setup questionnaire
CREATE TABLE IF NOT EXISTS surveys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS survey_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
question_key VARCHAR(100) NOT NULL,
question_type VARCHAR(50) NOT NULL,
is_required BOOLEAN DEFAULT FALSE NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
options JSONB,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS survey_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
started_at TIMESTAMP DEFAULT now() NOT NULL,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS survey_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
answer_text TEXT,
answer_json JSONB,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Survey table indexes
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
-- Survey table constraints
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);

View File

@@ -142,3 +142,25 @@ DROP FUNCTION sys_insert_license_types();
INSERT INTO timezones (name, abbrev, utc_offset)
SELECT name, abbrev, utc_offset
FROM pg_timezone_names;
-- Insert default account setup survey
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
ON CONFLICT DO NOTHING;
-- Insert survey questions for account setup survey
DO $$
DECLARE
survey_uuid UUID;
BEGIN
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
-- Insert survey questions
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
(survey_uuid, 'previous_tools', 'text', false, 4, null),
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
ON CONFLICT DO NOTHING;
END $$;

View File

@@ -0,0 +1,43 @@
-- Fix for notification_settings constraint issue during signup
-- This makes the team_id nullable temporarily during user creation
-- First, drop the existing NOT NULL constraint
ALTER TABLE notification_settings
ALTER COLUMN team_id DROP NOT NULL;
-- Add a constraint that ensures team_id is not null when there's no ongoing signup
ALTER TABLE notification_settings
ADD CONSTRAINT notification_settings_team_id_check
CHECK (team_id IS NOT NULL OR user_id IS NOT NULL);
-- Update the notification_settings trigger to handle null team_id gracefully
CREATE OR REPLACE FUNCTION notification_settings_insert_trigger_fn() RETURNS TRIGGER AS
$$
BEGIN
-- Only insert if team_id is not null
IF NEW.team_id IS NOT NULL AND
(NOT EXISTS(SELECT 1 FROM notification_settings WHERE team_id = NEW.team_id AND user_id = NEW.user_id)) AND
(NEW.active = TRUE)
THEN
INSERT INTO notification_settings (popup_notifications_enabled, show_unread_items_count, user_id,
email_notifications_enabled, team_id, daily_digest_enabled)
VALUES (TRUE, TRUE, NEW.user_id, TRUE, NEW.team_id, FALSE);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Also update the teams table to ensure the status column doesn't interfere with signup
ALTER TABLE teams
DROP CONSTRAINT IF EXISTS teams_status_check;
ALTER TABLE teams
ADD CONSTRAINT teams_status_check
CHECK (status IS NULL OR status IN ('active', 'flagged', 'suspended'));
-- Set default value for status
ALTER TABLE teams
ALTER COLUMN status SET DEFAULT 'active';
-- Update existing null values
UPDATE teams SET status = 'active' WHERE status IS NULL;

View File

@@ -0,0 +1,220 @@
-- Add minimal status column to teams table for performance
ALTER TABLE teams
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'flagged', 'suspended'));
-- Create separate moderation table for detailed tracking
CREATE TABLE IF NOT EXISTS team_moderation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK (status IN ('flagged', 'suspended', 'restored')),
reason TEXT,
moderator_id UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP, -- For temporary suspensions
metadata JSONB -- For additional context
);
-- Create indexes for efficient querying
CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status, created_at);
CREATE INDEX IF NOT EXISTS idx_team_moderation_team_id ON team_moderation(team_id);
CREATE INDEX IF NOT EXISTS idx_team_moderation_status ON team_moderation(status, created_at);
-- Create spam_logs table to track spam detection events
CREATE TABLE IF NOT EXISTS spam_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
content_type VARCHAR(50) NOT NULL, -- 'organization_name', 'owner_name', 'invitation'
original_content TEXT NOT NULL,
sanitized_content TEXT,
spam_score INTEGER NOT NULL DEFAULT 0,
spam_reasons JSONB,
is_high_risk BOOLEAN DEFAULT FALSE,
action_taken VARCHAR(50), -- 'blocked', 'flagged', 'allowed'
created_at TIMESTAMP DEFAULT NOW(),
ip_address INET
);
-- Create index for spam logs
CREATE INDEX IF NOT EXISTS idx_spam_logs_team_id ON spam_logs(team_id);
CREATE INDEX IF NOT EXISTS idx_spam_logs_created_at ON spam_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_spam_logs_content_type ON spam_logs(content_type);
-- Create rate_limit_log table to track rate limiting events
CREATE TABLE IF NOT EXISTS rate_limit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
ip_address INET NOT NULL,
action_type VARCHAR(50) NOT NULL, -- 'invite_attempt', 'org_creation'
blocked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
-- Create index for rate limit logs
CREATE INDEX IF NOT EXISTS idx_rate_limit_log_user_id ON rate_limit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_rate_limit_log_created_at ON rate_limit_log(created_at);
-- Add admin flag to users table if it doesn't exist
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
-- Function to log spam detection
CREATE OR REPLACE FUNCTION log_spam_detection(
p_team_id UUID,
p_user_id UUID,
p_content_type VARCHAR(50),
p_original_content TEXT,
p_sanitized_content TEXT,
p_spam_score INTEGER,
p_spam_reasons JSONB,
p_is_high_risk BOOLEAN,
p_action_taken VARCHAR(50),
p_ip_address INET
) RETURNS VOID AS $$
BEGIN
INSERT INTO spam_logs (
team_id, user_id, content_type, original_content, sanitized_content,
spam_score, spam_reasons, is_high_risk, action_taken, ip_address
) VALUES (
p_team_id, p_user_id, p_content_type, p_original_content, p_sanitized_content,
p_spam_score, p_spam_reasons, p_is_high_risk, p_action_taken, p_ip_address
);
END;
$$ LANGUAGE plpgsql;
-- Function to log rate limiting events
CREATE OR REPLACE FUNCTION log_rate_limit_event(
p_user_id UUID,
p_ip_address INET,
p_action_type VARCHAR(50),
p_blocked BOOLEAN
) RETURNS VOID AS $$
BEGIN
INSERT INTO rate_limit_log (user_id, ip_address, action_type, blocked)
VALUES (p_user_id, p_ip_address, p_action_type, p_blocked);
END;
$$ LANGUAGE plpgsql;
-- Function to get spam statistics for a team
CREATE OR REPLACE FUNCTION get_team_spam_stats(p_team_id UUID)
RETURNS TABLE (
total_detections BIGINT,
high_risk_detections BIGINT,
blocked_actions BIGINT,
latest_detection TIMESTAMP
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*) as total_detections,
COUNT(*) FILTER (WHERE is_high_risk = TRUE) as high_risk_detections,
COUNT(*) FILTER (WHERE action_taken = 'blocked') as blocked_actions,
MAX(created_at) as latest_detection
FROM spam_logs
WHERE team_id = p_team_id;
END;
$$ LANGUAGE plpgsql;
-- View for easy moderation dashboard
CREATE OR REPLACE VIEW moderation_dashboard AS
SELECT
t.id as team_id,
t.name as organization_name,
u.name as owner_name,
u.email as owner_email,
t.created_at as team_created_at,
t.status as current_status,
tm.status as last_moderation_action,
tm.reason as last_moderation_reason,
tm.created_at as last_moderation_date,
tm.expires_at as suspension_expires_at,
moderator.name as moderator_name,
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) as member_count,
(SELECT COUNT(*) FROM spam_logs WHERE team_id = t.id) as spam_detection_count,
(SELECT COUNT(*) FROM spam_logs WHERE team_id = t.id AND is_high_risk = TRUE) as high_risk_count
FROM teams t
INNER JOIN users u ON t.user_id = u.id
LEFT JOIN team_moderation tm ON t.id = tm.team_id
AND tm.created_at = (SELECT MAX(created_at) FROM team_moderation WHERE team_id = t.id)
LEFT JOIN users moderator ON tm.moderator_id = moderator.id
WHERE t.status != 'active' OR EXISTS(
SELECT 1 FROM spam_logs WHERE team_id = t.id AND created_at > NOW() - INTERVAL '7 days'
);
-- Function to update team status and create moderation records
CREATE OR REPLACE FUNCTION update_team_status(
p_team_id UUID,
p_new_status VARCHAR(20),
p_reason TEXT,
p_moderator_id UUID DEFAULT NULL,
p_expires_at TIMESTAMP DEFAULT NULL
) RETURNS VOID AS $$
BEGIN
-- Update team status
UPDATE teams SET status = p_new_status WHERE id = p_team_id;
-- Insert moderation record
INSERT INTO team_moderation (team_id, status, reason, moderator_id, expires_at)
VALUES (p_team_id, p_new_status, p_reason, p_moderator_id, p_expires_at);
END;
$$ LANGUAGE plpgsql;
-- Trigger to automatically flag teams with high spam scores
CREATE OR REPLACE FUNCTION auto_flag_spam_teams()
RETURNS TRIGGER AS $$
BEGIN
-- Auto-flag teams if they have high spam scores or multiple violations
IF NEW.spam_score > 80 OR NEW.is_high_risk = TRUE THEN
PERFORM update_team_status(
NEW.team_id,
'flagged',
'Auto-flagged: High spam score or high-risk content detected',
NULL
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Function to check and restore expired suspensions
CREATE OR REPLACE FUNCTION restore_expired_suspensions() RETURNS VOID AS $$
BEGIN
-- Find teams with expired suspensions
UPDATE teams
SET status = 'active'
WHERE id IN (
SELECT DISTINCT tm.team_id
FROM team_moderation tm
WHERE tm.status = 'suspended'
AND tm.expires_at IS NOT NULL
AND tm.expires_at < NOW()
AND NOT EXISTS (
SELECT 1 FROM team_moderation tm2
WHERE tm2.team_id = tm.team_id
AND tm2.created_at > tm.created_at
)
);
-- Log restoration records
INSERT INTO team_moderation (team_id, status, reason, moderator_id)
SELECT DISTINCT tm.team_id, 'restored', 'Auto-restored: suspension expired', NULL
FROM team_moderation tm
WHERE tm.status = 'suspended'
AND tm.expires_at IS NOT NULL
AND tm.expires_at < NOW()
AND NOT EXISTS (
SELECT 1 FROM team_moderation tm2
WHERE tm2.team_id = tm.team_id
AND tm2.created_at > tm.created_at
AND tm2.status = 'restored'
);
END;
$$ LANGUAGE plpgsql;
-- Create trigger for auto-flagging
DROP TRIGGER IF EXISTS trigger_auto_flag_spam ON spam_logs;
CREATE TRIGGER trigger_auto_flag_spam
AFTER INSERT ON spam_logs
FOR EACH ROW
EXECUTE FUNCTION auto_flag_spam_teams();

View File

@@ -0,0 +1,253 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import db from "../config/db";
import { SpamDetector } from "../utils/spam-detector";
import { RateLimiter } from "../middleware/rate-limiter";
export default class ModerationController extends WorklenzControllerBase {
@HandleExceptions()
public static async getFlaggedOrganizations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.is_admin) {
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
}
const q = `
SELECT * FROM moderation_dashboard
ORDER BY last_moderation_date DESC
LIMIT 100;
`;
const result = await db.query(q);
// Add spam analysis to each result
const flaggedTeams = result.rows.map(team => {
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
return {
...team,
org_spam_score: orgSpamCheck.score,
org_spam_reasons: orgSpamCheck.reasons,
owner_spam_score: ownerSpamCheck.score,
owner_spam_reasons: ownerSpamCheck.reasons,
is_high_risk: SpamDetector.isHighRiskContent(team.organization_name) ||
SpamDetector.isHighRiskContent(team.owner_name)
};
});
return res.status(200).send(new ServerResponse(true, flaggedTeams));
}
@HandleExceptions()
public static async flagOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.is_admin) {
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
}
const { teamId, reason } = req.body;
if (!teamId) {
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
}
const q = `SELECT update_team_status($1, 'flagged', $2, $3) as result`;
const result = await db.query(q, [teamId, reason || 'Spam/Abuse', req.user.id]);
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
const teamResult = await db.query(teamQuery, [teamId]);
if (teamResult.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
}
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization flagged successfully"));
}
@HandleExceptions()
public static async suspendOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.is_admin) {
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
}
const { teamId, reason, expiresAt } = req.body;
if (!teamId) {
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
}
const q = `SELECT update_team_status($1, 'suspended', $2, $3, $4) as result`;
const result = await db.query(q, [teamId, reason || 'Terms of Service Violation', req.user.id, expiresAt || null]);
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
const teamResult = await db.query(teamQuery, [teamId]);
if (teamResult.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
}
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization suspended successfully"));
}
@HandleExceptions()
public static async unsuspendOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.is_admin) {
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
}
const { teamId } = req.body;
if (!teamId) {
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
}
const q = `SELECT update_team_status($1, 'active', 'Manually restored by admin', $2) as result`;
const result = await db.query(q, [teamId, req.user.id]);
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
const teamResult = await db.query(teamQuery, [teamId]);
if (teamResult.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
}
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization restored successfully"));
}
@HandleExceptions()
public static async scanForSpam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.is_admin) {
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
}
const q = `
SELECT t.id, t.name as organization_name, u.name as owner_name, u.email as owner_email,
t.created_at
FROM teams t
INNER JOIN users u ON t.user_id = u.id
WHERE t.status = 'active'
AND t.created_at > NOW() - INTERVAL '7 days'
ORDER BY t.created_at DESC;
`;
const result = await db.query(q);
const suspiciousTeams = [];
for (const team of result.rows) {
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
if (orgSpamCheck.isSpam || ownerSpamCheck.isSpam ||
SpamDetector.isHighRiskContent(team.organization_name) ||
SpamDetector.isHighRiskContent(team.owner_name)) {
suspiciousTeams.push({
...team,
org_spam_score: orgSpamCheck.score,
org_spam_reasons: orgSpamCheck.reasons,
owner_spam_score: ownerSpamCheck.score,
owner_spam_reasons: ownerSpamCheck.reasons,
is_high_risk: SpamDetector.isHighRiskContent(team.organization_name) ||
SpamDetector.isHighRiskContent(team.owner_name)
});
}
}
return res.status(200).send(new ServerResponse(true, {
total_scanned: result.rows.length,
suspicious_count: suspiciousTeams.length,
suspicious_teams: suspiciousTeams
}));
}
@HandleExceptions()
public static async getModerationStats(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.is_admin) {
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
}
const statsQuery = `
SELECT
(SELECT COUNT(*) FROM teams WHERE status = 'flagged') as flagged_count,
(SELECT COUNT(*) FROM teams WHERE status = 'suspended') as suspended_count,
(SELECT COUNT(*) FROM teams WHERE created_at > NOW() - INTERVAL '24 hours') as new_teams_24h,
(SELECT COUNT(*) FROM teams WHERE created_at > NOW() - INTERVAL '7 days') as new_teams_7d
`;
const result = await db.query(statsQuery);
const stats = result.rows[0];
// Get rate limiting stats for recent activity
const recentInviteActivity = RateLimiter.getStats(req.user?.id || '');
return res.status(200).send(new ServerResponse(true, {
...stats,
rate_limit_stats: recentInviteActivity
}));
}
@HandleExceptions()
public static async bulkScanAndFlag(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.is_admin) {
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
}
const { autoFlag = false } = req.body;
const q = `
SELECT t.id, t.name as organization_name, u.name as owner_name
FROM teams t
INNER JOIN users u ON t.user_id = u.id
WHERE t.status = 'active'
AND t.created_at > NOW() - INTERVAL '30 days'
LIMIT 1000;
`;
const result = await db.query(q);
const flaggedTeams = [];
for (const team of result.rows) {
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
const isHighRisk = SpamDetector.isHighRiskContent(team.organization_name) ||
SpamDetector.isHighRiskContent(team.owner_name);
if ((orgSpamCheck.score > 70 || ownerSpamCheck.score > 70 || isHighRisk) && autoFlag) {
// Auto-flag high-confidence spam
const reasons = [
...orgSpamCheck.reasons,
...ownerSpamCheck.reasons,
...(isHighRisk ? ['High-risk content detected'] : [])
];
const flagQuery = `SELECT update_team_status($1, 'flagged', $2, $3) as result`;
await db.query(flagQuery, [
team.id,
`Auto-flagged: ${reasons.join(', ')}`,
req.user.id
]);
flaggedTeams.push({
...team,
action: 'flagged',
reasons: reasons
});
} else if (orgSpamCheck.isSpam || ownerSpamCheck.isSpam || isHighRisk) {
flaggedTeams.push({
...team,
action: 'review_needed',
org_spam_score: orgSpamCheck.score,
owner_spam_score: ownerSpamCheck.score,
reasons: [...orgSpamCheck.reasons, ...ownerSpamCheck.reasons, ...(isHighRisk ? ['High-risk content'] : [])]
});
}
}
return res.status(200).send(new ServerResponse(true, {
total_scanned: result.rows.length,
auto_flagged: flaggedTeams.filter(t => t.action === 'flagged').length,
needs_review: flaggedTeams.filter(t => t.action === 'review_needed').length,
teams: flaggedTeams
}));
}
}

View File

@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
import TeamMembersController from "./team-members-controller";
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
import {updateUsers} from "../shared/paddle-requests";
import {statusExclude} from "../shared/constants";
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
import {NotificationsService} from "../services/notifications/notifications.service";
export default class ProjectMembersController extends WorklenzControllerBase {
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
// if (subscriptionData.subscription_status === "active") {

View File

@@ -17,7 +17,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
JOIN timezones tz ON u.timezone_id = tz.id
WHERE u.id = $1`;
const result = await db.query(q, [userId]);
return result.rows[0]?.timezone || 'UTC';
return result.rows[0]?.timezone || "UTC";
}
/**
@@ -30,20 +30,43 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
// For custom date ranges
if (dateRange.length === 2) {
// Convert dates to user's timezone start/end of day
const start = moment.tz(dateRange[0], userTimezone).startOf('day');
const end = moment.tz(dateRange[1], userTimezone).endOf('day');
try {
// Handle different date formats that might come from frontend
let startDate, endDate;
// Convert to UTC for database comparison
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
// Try to parse the date - it might be a full JS Date string or ISO string
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
// Parse JavaScript Date toString() format
startDate = moment(new Date(dateRange[0]));
endDate = moment(new Date(dateRange[1]));
} else {
// Parse ISO format or other formats
startDate = moment(dateRange[0]);
endDate = moment(dateRange[1]);
}
if (start.isSame(end, 'day')) {
// Single day selection
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
// Convert to user's timezone and get start/end of day
const start = startDate.tz(userTimezone).startOf("day");
const end = endDate.tz(userTimezone).endOf("day");
// Convert to UTC for database comparison
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
if (start.isSame(end, "day")) {
// Single day selection
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
} catch (error) {
console.error("Error parsing date range:", error, { dateRange, userTimezone });
// Fallback to current date if parsing fails
const now = moment.tz(userTimezone);
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
}
// For predefined ranges, calculate based on user's timezone
@@ -52,20 +75,20 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
switch (key) {
case DATE_RANGES.YESTERDAY:
startDate = now.clone().subtract(1, 'day').startOf('day');
endDate = now.clone().subtract(1, 'day').endOf('day');
startDate = now.clone().subtract(1, "day").startOf("day");
endDate = now.clone().subtract(1, "day").endOf("day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = now.clone().subtract(1, 'week').startOf('week');
endDate = now.clone().subtract(1, 'week').endOf('week');
startDate = now.clone().subtract(1, "week").startOf("week");
endDate = now.clone().subtract(1, "week").endOf("week");
break;
case DATE_RANGES.LAST_MONTH:
startDate = now.clone().subtract(1, 'month').startOf('month');
endDate = now.clone().subtract(1, 'month').endOf('month');
startDate = now.clone().subtract(1, "month").startOf("month");
endDate = now.clone().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = now.clone().subtract(3, 'months').startOf('day');
endDate = now.clone().endOf('day');
startDate = now.clone().subtract(3, "months").startOf("day");
endDate = now.clone().endOf("day");
break;
default:
return "";
@@ -74,7 +97,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
if (startDate && endDate) {
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return "";
@@ -87,7 +110,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
* @param format - Moment format string
* @returns Formatted date string
*/
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") {
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
return moment.tz(date, userTimezone).format(format);
}
@@ -104,12 +127,12 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
let workingDays = 0;
const current = start.clone();
while (current.isSameOrBefore(end, 'day')) {
while (current.isSameOrBefore(end, "day")) {
// Monday = 1, Friday = 5
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
workingDays++;
}
current.add(1, 'day');
current.add(1, "day");
}
return workingDays;

View File

@@ -164,4 +164,38 @@ export default class SurveyController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, response));
}
@HandleExceptions()
public static async checkAccountSetupSurveyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const userId = req.user?.id;
if (!userId) {
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
}
const q = `
SELECT EXISTS(
SELECT 1
FROM survey_responses sr
INNER JOIN surveys s ON sr.survey_id = s.id
WHERE sr.user_id = $1
AND s.survey_type = 'account_setup'
AND sr.is_completed = true
) as is_completed,
(
SELECT sr.completed_at
FROM survey_responses sr
INNER JOIN surveys s ON sr.survey_id = s.id
WHERE sr.user_id = $1
AND s.survey_type = 'account_setup'
AND sr.is_completed = true
LIMIT 1
) as completed_at;
`;
const result = await db.query(q, [userId]);
const status = result.rows[0] || { is_completed: false, completed_at: null };
return res.status(200).send(new ServerResponse(true, status));
}
}

View File

@@ -13,10 +13,14 @@ import { SocketEvents } from "../socket.io/events";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, getColor } from "../shared/utils";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
import { updateUsers } from "../shared/paddle-requests";
import { NotificationsService } from "../services/notifications/notifications.service";
import { SpamDetector } from "../utils/spam-detector";
import loggerModule from "../utils/logger";
const { logger } = loggerModule;
export default class TeamMembersController extends WorklenzControllerBase {
@@ -72,7 +76,8 @@ export default class TeamMembersController extends WorklenzControllerBase {
@HandleExceptions({
raisedExceptions: {
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`,
"ERROR_SPAM_DETECTED": `Invitation blocked: {0}`
}
})
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
@@ -82,6 +87,54 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
}
// Validate organization name for spam - Flag suspicious, block only obvious spam
const orgSpamCheck = SpamDetector.detectSpam(req.user?.team_name || '');
const ownerSpamCheck = SpamDetector.detectSpam(req.user?.name || '');
// Only block extremely suspicious content for invitations (higher threshold)
const isObviousSpam = orgSpamCheck.score > 70 || ownerSpamCheck.score > 70 ||
SpamDetector.isHighRiskContent(req.user?.team_name || '') ||
SpamDetector.isHighRiskContent(req.user?.name || '');
if (isObviousSpam) {
logger.error('🛑 INVITATION BLOCKED - OBVIOUS SPAM', {
user_id: req.user?.id,
user_email: req.user?.email,
team_id: req.user?.team_id,
team_name: req.user?.team_name,
owner_name: req.user?.name,
org_spam_score: orgSpamCheck.score,
owner_spam_score: ownerSpamCheck.score,
org_reasons: orgSpamCheck.reasons,
owner_reasons: ownerSpamCheck.reasons,
ip_address: req.ip,
timestamp: new Date().toISOString(),
alert_type: 'obvious_spam_invitation_blocked'
});
return res.status(200).send(new ServerResponse(false, null, `Invitations temporarily disabled. Please contact support for assistance.`));
}
// Log suspicious but allow invitations
if (orgSpamCheck.score > 0 || ownerSpamCheck.score > 0) {
logger.warn('⚠️ SUSPICIOUS INVITATION ATTEMPT', {
user_id: req.user?.id,
user_email: req.user?.email,
team_id: req.user?.team_id,
team_name: req.user?.team_name,
owner_name: req.user?.name,
org_spam_score: orgSpamCheck.score,
owner_spam_score: ownerSpamCheck.score,
org_reasons: orgSpamCheck.reasons,
owner_reasons: ownerSpamCheck.reasons,
ip_address: req.ip,
timestamp: new Date().toISOString(),
alert_type: 'suspicious_invitation_flagged'
});
// Continue with invitation but flag for review
}
// High-risk content already checked above in isObviousSpam condition
/**
* Checks the subscription status of the team.
* @type {Object} subscriptionData - Object containing subscription information
@@ -141,6 +194,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
/**
* Checks subscription details and updates the user count if applicable.
* Sends a response if there is an issue with the subscription.
@@ -1081,6 +1145,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
const emailsToAdd = req.body.emails?.length || 1;
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active") {

View File

@@ -0,0 +1,117 @@
import moment from "moment";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, formatLogText, getColor } from "../shared/utils";
interface IUserRecentTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
last_activity_at: string;
activity_count: number;
project_color?: string;
task_status?: string;
status_color?: string;
}
interface IUserTimeLoggedTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
total_time_logged: number;
total_time_logged_string: string;
last_logged_at: string;
logged_by_timer: boolean;
project_color?: string;
task_status?: string;
status_color?: string;
log_entries_count?: number;
estimated_time?: number;
}
export default class UserActivityLogsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user) {
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
}
const { id: userId, team_id: teamId } = req.user;
const { offset = 0, limit = 10 } = req.query;
// Optimized query with better performance and team filtering
const q = `
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
MAX(tal.created_at) AS last_activity_at,
COUNT(DISTINCT tal.id) AS activity_count,
p.color_code AS project_color,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
FROM task_activity_logs tal
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
WHERE tal.user_id = $2
AND tal.created_at >= NOW() - INTERVAL '30 days'
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
ORDER BY MAX(tal.created_at) DESC
LIMIT $3 OFFSET $4;
`;
const result = await db.query(q, [teamId, userId, limit, offset]);
const tasks: IUserRecentTask[] = result.rows;
return res.status(200).send(new ServerResponse(true, tasks));
}
@HandleExceptions()
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user) {
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
}
const { id: userId, team_id: teamId } = req.user;
const { offset = 0, limit = 10 } = req.query;
// Optimized query with better performance, team filtering, and useful additional data
const q = `
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
SUM(twl.time_spent) AS total_time_logged,
MAX(twl.created_at) AS last_logged_at,
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
p.color_code AS project_color,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
COUNT(DISTINCT twl.id) AS log_entries_count,
(t.total_minutes * 60) AS estimated_time
FROM task_work_log twl
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
WHERE twl.user_id = $2
AND twl.created_at >= NOW() - INTERVAL '90 days'
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
HAVING SUM(twl.time_spent) > 0
ORDER BY MAX(twl.created_at) DESC
LIMIT $3 OFFSET $4;
`;
const result = await db.query(q, [teamId, userId, limit, offset]);
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
...task,
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
}));
return res.status(200).send(new ServerResponse(true, tasks));
}
}

View File

@@ -0,0 +1,141 @@
import { NextFunction } from "express";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import loggerModule from "../utils/logger";
const { logger } = loggerModule;
interface RateLimitStore {
[key: string]: {
count: number;
resetTime: number;
};
}
export class RateLimiter {
private static store: RateLimitStore = {};
private static cleanupInterval: NodeJS.Timeout;
static {
// Clean up expired entries every 5 minutes
this.cleanupInterval = setInterval(() => {
const now = Date.now();
Object.keys(this.store).forEach(key => {
if (this.store[key].resetTime < now) {
delete this.store[key];
}
});
}, 5 * 60 * 1000);
}
public static inviteRateLimit(
maxRequests = 5,
windowMs: number = 15 * 60 * 1000 // 15 minutes
) {
return (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) => {
const identifier = req.user?.id || req.ip;
const key = `invite_${identifier}`;
const now = Date.now();
if (!this.store[key] || this.store[key].resetTime < now) {
this.store[key] = {
count: 1,
resetTime: now + windowMs
};
return next();
}
if (this.store[key].count >= maxRequests) {
const remainingTime = Math.ceil((this.store[key].resetTime - now) / 1000);
// Log rate limit exceeded for Slack notifications
logger.warn("⚠️ RATE LIMIT EXCEEDED - INVITE ATTEMPTS", {
user_id: req.user?.id,
user_email: req.user?.email,
ip_address: req.ip,
attempts: this.store[key].count,
max_attempts: maxRequests,
remaining_time: remainingTime,
timestamp: new Date().toISOString(),
alert_type: "rate_limit_exceeded"
});
return res.status(429).send(
new ServerResponse(
false,
null,
`Too many invitation attempts. Please try again in ${remainingTime} seconds.`
)
);
}
this.store[key].count++;
next();
};
}
public static organizationCreationRateLimit(
maxRequests = 3,
windowMs: number = 60 * 60 * 1000 // 1 hour
) {
return (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) => {
const identifier = req.user?.id || req.ip;
const key = `org_creation_${identifier}`;
const now = Date.now();
if (!this.store[key] || this.store[key].resetTime < now) {
this.store[key] = {
count: 1,
resetTime: now + windowMs
};
return next();
}
if (this.store[key].count >= maxRequests) {
const remainingTime = Math.ceil((this.store[key].resetTime - now) / (1000 * 60));
// Log organization creation rate limit exceeded
logger.warn("⚠️ RATE LIMIT EXCEEDED - ORG CREATION", {
user_id: req.user?.id,
user_email: req.user?.email,
ip_address: req.ip,
attempts: this.store[key].count,
max_attempts: maxRequests,
remaining_time_minutes: remainingTime,
timestamp: new Date().toISOString(),
alert_type: "org_creation_rate_limit"
});
return res.status(429).send(
new ServerResponse(
false,
null,
`Too many organization creation attempts. Please try again in ${remainingTime} minutes.`
)
);
}
this.store[key].count++;
next();
};
}
public static getStats(identifier: string): { invites: number; orgCreations: number } {
const inviteKey = `invite_${identifier}`;
const orgKey = `org_creation_${identifier}`;
return {
invites: this.store[inviteKey]?.count || 0,
orgCreations: this.store[orgKey]?.count || 0
};
}
public static clearStats(identifier: string): void {
const inviteKey = `invite_${identifier}`;
const orgKey = `org_creation_${identifier}`;
delete this.store[inviteKey];
delete this.store[orgKey];
}
}

View File

@@ -27,10 +27,7 @@ export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IW
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Question ID is required and must be a string`));
}
// At least one of answer_text or answer_json should be provided
if (!answer.answer_text && !answer.answer_json) {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Either answer_text or answer_json is required`));
}
// answer_text and answer_json are both optional - users can submit empty answers
// Validate answer_text if provided
if (answer.answer_text && typeof answer.answer_text !== 'string') {

View File

@@ -8,6 +8,10 @@ import {log_error} from "../../shared/utils";
import db from "../../config/db";
import {Request} from "express";
import {ERROR_KEY, SUCCESS_KEY} from "./passport-constants";
import { SpamDetector } from "../../utils/spam-detector";
import loggerModule from "../../utils/logger";
const { logger } = loggerModule;
async function isGoogleAccountFound(email: string) {
const q = `
@@ -49,12 +53,111 @@ async function handleSignUp(req: Request, email: string, password: string, done:
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
// Check for spam in team name - Flag suspicious but allow signup
const teamNameSpamCheck = SpamDetector.detectSpam(team_name);
if (teamNameSpamCheck.score > 0 || teamNameSpamCheck.reasons.length > 0) {
logger.warn('⚠️ SUSPICIOUS SIGNUP - TEAM NAME', {
email,
team_name,
user_name: name,
spam_score: teamNameSpamCheck.score,
reasons: teamNameSpamCheck.reasons,
ip_address: req.ip,
timestamp: new Date().toISOString(),
alert_type: 'suspicious_signup_flagged'
});
// Continue with signup but flag for review
}
// Check for spam in user name - Flag suspicious but allow signup
const userNameSpamCheck = SpamDetector.detectSpam(name);
if (userNameSpamCheck.score > 0 || userNameSpamCheck.reasons.length > 0) {
logger.warn('⚠️ SUSPICIOUS SIGNUP - USER NAME', {
email,
team_name,
user_name: name,
spam_score: userNameSpamCheck.score,
reasons: userNameSpamCheck.reasons,
ip_address: req.ip,
timestamp: new Date().toISOString(),
alert_type: 'suspicious_signup_flagged'
});
// Continue with signup but flag for review
}
// Only block EXTREMELY high-risk content (known spam domains, obvious scams)
if (SpamDetector.isHighRiskContent(team_name) || SpamDetector.isHighRiskContent(name)) {
// Check if it's REALLY obvious spam (very high scores)
const isObviousSpam = teamNameSpamCheck.score > 80 || userNameSpamCheck.score > 80 ||
/gclnk\.com|bit\.ly\/scam|win.*\$\d+.*crypto/i.test(team_name + ' ' + name);
if (isObviousSpam) {
logger.error('🛑 SIGNUP BLOCKED - OBVIOUS SPAM', {
email,
team_name,
user_name: name,
team_spam_score: teamNameSpamCheck.score,
user_spam_score: userNameSpamCheck.score,
ip_address: req.ip,
timestamp: new Date().toISOString(),
alert_type: 'obvious_spam_blocked'
});
return done(null, null, req.flash(ERROR_KEY, "Registration temporarily unavailable. Please contact support if you need immediate access."));
} else {
// High-risk but not obviously spam - flag and allow
logger.error('🔥 HIGH RISK SIGNUP - FLAGGED', {
email,
team_name,
user_name: name,
team_spam_score: teamNameSpamCheck.score,
user_spam_score: userNameSpamCheck.score,
ip_address: req.ip,
timestamp: new Date().toISOString(),
alert_type: 'high_risk_signup_flagged'
});
// Continue with signup but flag for immediate review
}
}
const googleAccountFound = await isGoogleAccountFound(email);
if (googleAccountFound)
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
try {
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
// If signup was suspicious, flag the team for review after creation
const totalSuspicionScore = (teamNameSpamCheck.score || 0) + (userNameSpamCheck.score || 0);
if (totalSuspicionScore > 0) {
// Flag team for admin review (but don't block user)
const flagQuery = `
INSERT INTO spam_logs (team_id, user_id, content_type, original_content, spam_score, spam_reasons, action_taken, ip_address)
VALUES (
(SELECT team_id FROM users WHERE id = $1),
$1,
'signup_review',
$2,
$3,
$4,
'flagged_for_review',
$5
)
`;
try {
await db.query(flagQuery, [
user.id,
`Team: ${team_name} | User: ${name}`,
totalSuspicionScore,
JSON.stringify([...teamNameSpamCheck.reasons, ...userNameSpamCheck.reasons]),
req.ip
]);
} catch (flagError) {
// Don't fail signup if flagging fails
logger.warn('Failed to flag suspicious signup for review', { error: flagError, user_id: user.id });
}
}
sendWelcomeEmail(email, name);
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
} catch (error: any) {

View File

@@ -81,5 +81,12 @@
"delete": "Fshi",
"enterStatusName": "Shkruani emrin e statusit",
"selectCategory": "Zgjidh kategorinë",
"close": "Mbyll"
"close": "Mbyll",
"clearSort": "Pastro Renditjen",
"sortAscending": "Rendit në Rritje",
"sortDescending": "Rendit në Zbritje",
"sortByField": "Rendit sipas {{field}}",
"ascendingOrder": "Rritës",
"descendingOrder": "Zbritës",
"currentSort": "Renditja aktuale: {{field}} {{order}}"
}

View File

@@ -81,5 +81,12 @@
"delete": "Löschen",
"enterStatusName": "Statusnamen eingeben",
"selectCategory": "Kategorie auswählen",
"close": "Schließen"
"close": "Schließen",
"clearSort": "Sortierung löschen",
"sortAscending": "Aufsteigend sortieren",
"sortDescending": "Absteigend sortieren",
"sortByField": "Sortieren nach {{field}}",
"ascendingOrder": "Aufsteigend",
"descendingOrder": "Absteigend",
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
}

View File

@@ -81,5 +81,12 @@
"delete": "Delete",
"enterStatusName": "Enter status name",
"selectCategory": "Select category",
"close": "Close"
"close": "Close",
"clearSort": "Clear Sort",
"sortAscending": "Sort Ascending",
"sortDescending": "Sort Descending",
"sortByField": "Sort by {{field}}",
"ascendingOrder": "Ascending",
"descendingOrder": "Descending",
"currentSort": "Current sort: {{field}} {{order}}"
}

View File

@@ -77,5 +77,12 @@
"delete": "Eliminar",
"enterStatusName": "Introducir nombre del estado",
"selectCategory": "Seleccionar categoría",
"close": "Cerrar"
"close": "Cerrar",
"clearSort": "Limpiar Ordenamiento",
"sortAscending": "Ordenar Ascendente",
"sortDescending": "Ordenar Descendente",
"sortByField": "Ordenar por {{field}}",
"ascendingOrder": "Ascendente",
"descendingOrder": "Descendente",
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
}

View File

@@ -78,5 +78,12 @@
"delete": "Excluir",
"enterStatusName": "Digite o nome do status",
"selectCategory": "Selecionar categoria",
"close": "Fechar"
"close": "Fechar",
"clearSort": "Limpar Ordenação",
"sortAscending": "Ordenar Crescente",
"sortDescending": "Ordenar Decrescente",
"sortByField": "Ordenar por {{field}}",
"ascendingOrder": "Crescente",
"descendingOrder": "Decrescente",
"currentSort": "Ordenação atual: {{field}} {{order}}"
}

View File

@@ -75,5 +75,12 @@
"delete": "删除",
"enterStatusName": "输入状态名称",
"selectCategory": "选择类别",
"close": "关闭"
"close": "关闭",
"clearSort": "清除排序",
"sortAscending": "升序排列",
"sortDescending": "降序排列",
"sortByField": "按{{field}}排序",
"ascendingOrder": "升序",
"descendingOrder": "降序",
"currentSort": "当前排序:{{field}} {{order}}"
}

View File

@@ -57,7 +57,10 @@ import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
import moderationApiRouter from "./moderation-api-router";
const api = express.Router();
@@ -119,4 +122,6 @@ api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/logs", userActivityLogsApiRouter);
api.use("/moderation", moderationApiRouter);
export default api;

View File

@@ -0,0 +1,16 @@
import express from "express";
import ModerationController from "../../controllers/moderation-controller";
import safeControllerFunction from "../../shared/safe-controller-function";
const moderationApiRouter = express.Router();
// Admin-only routes for spam/abuse moderation
moderationApiRouter.get("/flagged-organizations", safeControllerFunction(ModerationController.getFlaggedOrganizations));
moderationApiRouter.post("/flag-organization", safeControllerFunction(ModerationController.flagOrganization));
moderationApiRouter.post("/suspend-organization", safeControllerFunction(ModerationController.suspendOrganization));
moderationApiRouter.post("/unsuspend-organization", safeControllerFunction(ModerationController.unsuspendOrganization));
moderationApiRouter.get("/scan-spam", safeControllerFunction(ModerationController.scanForSpam));
moderationApiRouter.get("/stats", safeControllerFunction(ModerationController.getModerationStats));
moderationApiRouter.post("/bulk-scan", safeControllerFunction(ModerationController.bulkScanAndFlag));
export default moderationApiRouter;

View File

@@ -8,6 +8,9 @@ const surveyApiRouter = express.Router();
// Get account setup survey with questions
surveyApiRouter.get("/account-setup", safeControllerFunction(SurveyController.getAccountSetupSurvey));
// Check if user has completed account setup survey
surveyApiRouter.get("/account-setup/status", safeControllerFunction(SurveyController.checkAccountSetupSurveyStatus));
// Submit survey response
surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse));

View File

@@ -6,6 +6,7 @@ import idParamValidator from "../../middlewares/validators/id-param-validator";
import teamMembersBodyValidator from "../../middlewares/validators/team-members-body-validator";
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
import { RateLimiter } from "../../middleware/rate-limiter";
const teamMembersApiRouter = express.Router();
@@ -13,7 +14,7 @@ const teamMembersApiRouter = express.Router();
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
teamMembersApiRouter.get("/export/:id", idParamValidator, safeControllerFunction(TeamMembersController.exportByMember));
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(5, 15 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
teamMembersApiRouter.get("/", safeControllerFunction(TeamMembersController.get));
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
teamMembersApiRouter.get("/tree-map", safeControllerFunction(TeamMembersController.getTeamMembersTreeMap));
@@ -30,6 +31,6 @@ teamMembersApiRouter.put("/:id", teamOwnerOrAdminValidator, idParamValidator, sa
teamMembersApiRouter.delete("/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.deleteById));
teamMembersApiRouter.get("/deactivate/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.toggleMemberActiveStatus));
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(3, 10 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
export default teamMembersApiRouter;

View File

@@ -0,0 +1,11 @@
import express from 'express';
import UserActivityLogsController from '../../controllers/user-activity-logs-controller';
import safeControllerFunction from "../../shared/safe-controller-function";
const userActivityLogsApiRouter = express.Router();
userActivityLogsApiRouter.get('/user-recent-tasks', safeControllerFunction(UserActivityLogsController.getRecentTasks));
userActivityLogsApiRouter.get('/user-time-logged-tasks', safeControllerFunction(UserActivityLogsController.getTimeLoggedTasks));
export default userActivityLogsApiRouter;

View File

@@ -160,6 +160,9 @@ export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercas
// paddle status to exclude
export const statusExclude = ["past_due", "paused", "deleted"];
// Trial user team member limit
export const TRIAL_MEMBER_LIMIT = 10;
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
export const UNMAPPED = "Unmapped";

View File

@@ -0,0 +1,244 @@
import loggerModule from "./logger";
const { logger } = loggerModule;
export interface SpamDetectionResult {
isSpam: boolean;
score: number;
reasons: string[];
}
export class SpamDetector {
// Whitelist for legitimate organizations that might trigger false positives
private static readonly WHITELIST_PATTERNS = [
/^(microsoft|google|apple|amazon|facebook|meta|twitter|linkedin|github|stackoverflow)$/i,
/^.*(inc|llc|ltd|corp|corporation|company|co|group|enterprises|solutions|services|consulting|tech|technologies|agency|studio|lab|labs|systems|software|development|designs?)$/i,
// Allow "free" when it's clearly about software/business
/free.*(software|source|lance|consulting|solutions|services|tech|development|range|market|trade)/i,
/(open|free).*(software|source)/i,
// Common legitimate business patterns
/^[a-z]+\s+(software|solutions|services|consulting|tech|technologies|systems|development|designs?|agency|studio|labs?|group|company)$/i,
/^(the\s+)?[a-z]+\s+(company|group|studio|agency|lab|labs)$/i
];
private static readonly SPAM_PATTERNS = [
// URLs and links
/https?:\/\//i,
/www\./i,
/\b\w+\.(com|net|org|io|co|me|ly|tk|ml|ga|cf|cc|to|us|biz|info|xyz)\b/i,
// Common spam phrases
/click\s*(here|link|now)/i,
/urgent|emergency|immediate|limited.time/i,
/win|won|winner|prize|reward|congratulations/i,
/free|bonus|gift|offer|special.offer/i,
/check\s*(out|this|pay)|verify|claim/i,
/blockchain|crypto|bitcoin|compensation|investment/i,
/cash|money|dollars?|\$\d+|earn.*money/i,
// Excessive special characters
/[!]{2,}/,
/[🔔⬅👆💰$💎🎁🎉⚡]{1,}/,
/\b[A-Z]{4,}\b/,
// Suspicious formatting
/\s{3,}/,
/[.]{3,}/,
// Additional suspicious patterns
/act.now|don.t.miss|guaranteed|limited.spots/i,
/download|install|app|software/i,
/survey|questionnaire|feedback/i,
/\d+%.*off|save.*\$|discount/i
];
private static readonly SUSPICIOUS_WORDS = [
"urgent", "emergency", "click", "link", "win", "winner", "prize",
"free", "bonus", "cash", "money", "blockchain", "crypto", "compensation",
"check", "pay", "reward", "offer", "gift", "congratulations", "claim",
"verify", "earn", "investment", "guaranteed", "limited", "exclusive",
"download", "install", "survey", "feedback", "discount", "save"
];
public static detectSpam(text: string): SpamDetectionResult {
if (!text || typeof text !== "string") {
return { isSpam: false, score: 0, reasons: [] };
}
const normalizedText = text.toLowerCase().trim();
const reasons: string[] = [];
let score = 0;
// Check for obviously fake organization names FIRST (before whitelist)
if (/^(test|example|demo|fake|spam|abuse|temp)\s*(company|org|corp|inc|llc)?$/i.test(text.trim()) ||
/(test|demo|fake|spam|abuse|temp)\s*(123|abc|xyz|\d+)/i.test(text)) {
score += 30;
reasons.push("Contains generic/test name patterns");
}
// Check whitelist - bypass remaining checks for whitelisted organizations
if (score === 0) { // Only check whitelist if no generic patterns found
for (const pattern of this.WHITELIST_PATTERNS) {
if (pattern.test(normalizedText)) {
return { isSpam: false, score: 0, reasons: [] };
}
}
}
// Check for URL patterns
for (const pattern of this.SPAM_PATTERNS) {
if (pattern.test(text)) {
score += 25; // Lowered from 30 to catch more suspicious content
if (pattern.toString().includes("https?") || pattern.toString().includes("www")) {
reasons.push("Contains suspicious URLs or links");
} else if (pattern.toString().includes("urgent|emergency")) {
reasons.push("Contains urgent/emergency language");
} else if (pattern.toString().includes("win|won|winner")) {
reasons.push("Contains prize/winning language");
} else if (pattern.toString().includes("cash|money")) {
reasons.push("Contains monetary references");
} else if (pattern.toString().includes("blockchain|crypto")) {
reasons.push("Contains cryptocurrency references");
} else if (pattern.toString().includes("[!]{3,}")) {
reasons.push("Excessive use of exclamation marks");
} else if (pattern.toString().includes("[🔔⬅👆💰$]")) {
reasons.push("Contains suspicious emojis or symbols");
} else if (pattern.toString().includes("[A-Z]{5,}")) {
reasons.push("Contains excessive capital letters");
}
}
}
// Check for excessive suspicious words - Now with context awareness
const suspiciousWords = this.SUSPICIOUS_WORDS.filter(word => {
if (!normalizedText.includes(word)) return false;
// Context-aware filtering for common false positives
if (word === 'free') {
// Allow "free" in legitimate software/business contexts
return !/free.*(software|source|lance|consulting|solutions|services|tech|development|range|market|trade)/i.test(text);
}
if (word === 'check') {
// Allow "check" in legitimate business contexts
return !/check.*(list|mark|point|out|up|in|book|ing|ed)/i.test(text);
}
if (word === 'save') {
// Allow "save" in legitimate business contexts
return !/save.*(data|file|document|time|energy|environment|earth)/i.test(text);
}
return true; // Other words are still suspicious
});
if (suspiciousWords.length >= 1) {
score += suspiciousWords.length * 20;
reasons.push(`Contains ${suspiciousWords.length} suspicious word${suspiciousWords.length > 1 ? 's' : ''}: ${suspiciousWords.join(', ')}`);
}
// Check text length - very short or very long names are suspicious
if (text.length < 2) {
score += 20;
reasons.push("Text too short");
} else if (text.length > 100) {
score += 25;
reasons.push("Text unusually long");
}
// Check for repeated characters
if (/(.)\1{4,}/.test(text)) {
score += 20;
reasons.push("Contains repeated characters");
}
// Check for mixed scripts (potential homograph attack)
const hasLatin = /[a-zA-Z]/.test(text);
const hasCyrillic = /[\u0400-\u04FF]/.test(text);
const hasGreek = /[\u0370-\u03FF]/.test(text);
if ((hasLatin && hasCyrillic) || (hasLatin && hasGreek)) {
score += 40;
reasons.push("Contains mixed character scripts");
}
// Generic name check already done above - skip duplicate check
// Check for excessive numbers in organization names (often spam)
if (/\d{4,}/.test(text)) {
score += 25;
reasons.push("Contains excessive numbers");
}
const isSpam = score >= 50;
// Log suspicious activity for Slack notifications
if (isSpam || score > 30) {
logger.warn("🚨 SPAM DETECTED", {
text: text.substring(0, 100),
score,
reasons: [...new Set(reasons)],
isSpam,
timestamp: new Date().toISOString(),
alert_type: "spam_detection"
});
}
return {
isSpam,
score,
reasons: [...new Set(reasons)] // Remove duplicates
};
}
public static isHighRiskContent(text: string): boolean {
const patterns = [
/gclnk\.com/i,
/bit\.ly\/scam/i, // More specific bit.ly patterns
/tinyurl\.com\/scam/i,
/\$\d{3,}.*crypto/i, // Money + crypto combination
/blockchain.*compensation.*urgent/i,
/win.*\$\d+.*urgent/i, // Win money urgent pattern
/click.*here.*\$\d+/i // Click here money pattern
];
const isHighRisk = patterns.some(pattern => pattern.test(text));
// Log high-risk content immediately
if (isHighRisk) {
logger.error("🔥 HIGH RISK CONTENT DETECTED", {
text: text.substring(0, 100),
matched_patterns: patterns.filter(pattern => pattern.test(text)).map(p => p.toString()),
timestamp: new Date().toISOString(),
alert_type: "high_risk_content"
});
}
return isHighRisk;
}
public static shouldBlockContent(text: string): boolean {
const result = this.detectSpam(text);
// Only block if extremely high score or high-risk patterns
return result.score > 80 || this.isHighRiskContent(text);
}
public static shouldFlagContent(text: string): boolean {
const result = this.detectSpam(text);
// Flag anything suspicious (score > 0) but not necessarily blocked
return result.score > 0 || result.reasons.length > 0;
}
public static sanitizeText(text: string): string {
if (!text || typeof text !== "string") return "";
return text
.trim()
.replace(/https?:\/\/[^\s]+/gi, "[URL_REMOVED]")
.replace(/www\.[^\s]+/gi, "[URL_REMOVED]")
.replace(/[🔔⬅👆💰$]{2,}/g, "")
.replace(/[!]{3,}/g, "!")
.replace(/\s{3,}/g, " ")
.substring(0, 100);
}
}

View File

@@ -16,3 +16,7 @@ VITE_WORKLENZ_SESSION_ID=worklenz-session-id
# Google Login
VITE_ENABLE_GOOGLE_LOGIN=false
# Survey Modal Configuration
# Set to true to enable the survey modal, false to disable it
VITE_ENABLE_SURVEY_MODAL=false

View File

@@ -0,0 +1,22 @@
VITE_API_URL=http://localhost:3000
VITE_SOCKET_URL=ws://localhost:3000
VITE_APP_TITLE=Worklenz
VITE_APP_ENV=development
# Mixpanel
VITE_MIXPANEL_TOKEN=mixpanel-token
# Recaptcha
VITE_ENABLE_RECAPTCHA=false
VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
# Session ID
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
# Google Login
VITE_ENABLE_GOOGLE_LOGIN=false
# Survey Modal Configuration
# Set to true to enable the survey modal, false to disable it
VITE_ENABLE_SURVEY_MODAL=false

View File

@@ -11,6 +11,7 @@
# production
/build
/public/tinymce
/docs
# misc
.DS_Store

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,11 @@
"build": "vite build",
"dev-build": "vite build",
"serve": "vite preview",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
},
"dependencies": {
"@ant-design/colors": "^7.1.0",
@@ -77,7 +81,10 @@
"@types/react-dom": "19.0.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
"jsdom": "^26.1.0",
"postcss": "^8.5.2",
"prettier-plugin-tailwindcss": "^0.6.13",
"rollup": "^4.40.2",

View File

@@ -76,40 +76,27 @@ class HubSpotManager {
style.id = this.styleId;
style.textContent = `
/* HubSpot Chat Widget Dark Mode Override */
/*
Note: We can only style the container backgrounds, not the widget UI inside the iframe.
HubSpot does not currently support external dark mode theming for the chat UI itself.
*/
#hubspot-conversations-inline-parent,
#hubspot-conversations-iframe-container,
.shadow-2xl.widget-align-right.widget-align-bottom,
[data-test-id="chat-widget"],
[class*="VizExCollapsedChat"],
[class*="VizExExpandedChat"],
iframe[src*="hubspot"] {
filter: invert(1) hue-rotate(180deg) !important;
background: transparent !important;
#hubspot-conversations-iframe-container {
background: #141414 !important;
}
/* Target HubSpot widget container backgrounds */
#hubspot-conversations-inline-parent div,
#hubspot-conversations-iframe-container div,
[data-test-id="chat-widget"] div {
background-color: transparent !important;
}
/* Prevent double inversion of images, avatars, and icons */
#hubspot-conversations-iframe-container img,
#hubspot-conversations-iframe-container [style*="background-image"],
#hubspot-conversations-iframe-container svg,
iframe[src*="hubspot"] img,
iframe[src*="hubspot"] svg,
[data-test-id="chat-widget"] img,
[data-test-id="chat-widget"] svg {
filter: invert(1) hue-rotate(180deg) !important;
}
/* Additional targeting for widget launcher and chat bubble */
div[class*="shadow-2xl"],
div[class*="widget-align"],
div[style*="position: fixed"] {
filter: invert(1) hue-rotate(180deg) !important;
/* Ensure Worklenz app elements are not affected by HubSpot styles */
.ant-menu,
.ant-menu *,
[class*="settings"],
[class*="sidebar"],
.worklenz-app *:not([id*="hubspot"]):not([class*="widget"]) {
filter: none !important;
}
`;
document.head.appendChild(style);

View File

@@ -41,6 +41,7 @@
"step3InputLabel": "Fto me email",
"addAnother": "Shto një tjetër",
"skipForNow": "Kalo për tani",
"skipping": "Duke kaluar...",
"formTitle": "Krijo detyrën tënde të parë.",
"step3Title": "Fto ekipin tënd për të punuar së bashku",
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
@@ -80,6 +81,8 @@
"discoveryQuestion": "Si dëgjove për ne?",
"allSetTitle": "Çdo gjë gati!",
"allSetDescription": "Le të krijojmë projektin tënd të parë dhe të fillojmë me Worklenz",
"surveyCompleteTitle": "Faleminderit!",
"surveyCompleteDescription": "Përgjigjet tuaja na ndihmojnë të përmirësojmë Worklenz për të gjithë",
"aboutYouStepName": "Rreth teje",
"yourNeedsStepName": "Nevojat e tua",
"discoveryStepName": "Zbulimi",

View File

@@ -5,5 +5,6 @@
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
"reconnecting": "Jeni shkëputur nga serveri.",
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
"connection-restored": "U lidhët me serverin me sukses"
"connection-restored": "U lidhët me serverin me sukses",
"cancel": "Anulo"
}

View File

@@ -41,6 +41,22 @@
"list": "Listë",
"calendar": "Kalendar",
"tasks": "Detyrat",
"refresh": "Rifresko"
"refresh": "Rifresko",
"recentActivity": "Aktiviteti i Fundit",
"recentTasks": "Detyrat e Fundit",
"recentTasksSegment": "Detyrat e Fundit",
"timeLogged": "Koha e Regjistruar",
"timeLoggedSegment": "Koha e Regjistruar",
"noRecentTasks": "Asnjë detyrë e fundit",
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
"activityTag": "Aktiviteti",
"timeLogTag": "Regjistrim Kohe",
"timerTag": "Kohëmatës",
"activitySingular": "aktivitet",
"activityPlural": "aktivitete",
"recentTaskAriaLabel": "Detyrë e fundit:",
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:",
"errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit",
"errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Ndihmoni të përmirësojmë përvojën tuaj",
"skip": "Kalo për tani",
"previous": "Prapa",
"next": "Tjetra",
"completeSurvey": "Përfundo Anketën",
"submitting": "Duke dërguar përgjigjet tuaja...",
"submitSuccessTitle": "Faleminderit!",
"submitSuccessSubtitle": "Feedback-u juaj na ndihmon të përmirësojmë Worklenz për të gjithë.",
"submitSuccessMessage": "Faleminderit që plotësuat anketën!",
"submitErrorMessage": "Dështoi dërgimi i anketës. Ju lutemi provoni përsëri.",
"submitErrorLog": "Dështoi dërgimi i anketës",
"fetchErrorLog": "Dështoi marrja e anketës"
}

View File

@@ -84,5 +84,12 @@
"close": "Mbyll",
"cannotMoveStatus": "Nuk mund të lëvizet statusi",
"cannotMoveStatusMessage": "Nuk mund të lëvizet ky status sepse do të linte kategorinë '{{categoryName}}' bosh. Çdo kategori duhet të ketë të paktën një status.",
"ok": "OK"
"ok": "OK",
"clearSort": "Pastro Renditjen",
"sortAscending": "Rendit në Rritje",
"sortDescending": "Rendit në Zbritje",
"sortByField": "Rendit sipas {{field}}",
"ascendingOrder": "Rritës",
"descendingOrder": "Zbritës",
"currentSort": "Renditja aktuale: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Cakto mua",
"copyLink": "Kopjo lidhjen e detyrës",
"linkCopied": "Lidhja u kopjua në clipboard",
"linkCopyFailed": "Dështoi kopjimi i lidhjes",
"moveTo": "Zhvendos në",
"unarchive": "Ç'arkivizo",
"archive": "Arkivizo",
@@ -133,5 +136,11 @@
"dependencies": "Detyra ka varësi",
"recurring": "Detyrë përsëritëse"
}
},
"timer": {
"conflictTitle": "Kronómetr Tashë Në Ecuri",
"conflictMessage": "Ju keni një kronómetr në ecuri për \"{{taskName}}\" në projektin \"{{projectName}}\". Dëshironi ta ndaloni atë kronómetr dhe të filloni një të ri për këtë detyrë?",
"stopAndStart": "Ndalo & Fillo Kronómetr të Ri"
}
}

View File

@@ -45,6 +45,7 @@
"step3InputLabel": "Per E-Mail einladen",
"addAnother": "Weitere hinzufügen",
"skipForNow": "Jetzt überspringen",
"skipping": "Überspringen...",
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
@@ -90,6 +91,8 @@
"discoveryQuestion": "Wie haben Sie von uns erfahren?",
"allSetTitle": "Sie sind bereit!",
"allSetDescription": "Lassen Sie uns Ihr erstes Projekt erstellen und mit Worklenz beginnen",
"surveyCompleteTitle": "Vielen Dank!",
"surveyCompleteDescription": "Ihr Feedback hilft uns, Worklenz für alle zu verbessern",
"aboutYouStepName": "Über Sie",
"yourNeedsStepName": "Ihre Bedürfnisse",
"discoveryStepName": "Entdeckung",

View File

@@ -7,12 +7,12 @@
"emailLabel": "E-Mail",
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordLabel": "Passwort",
"passwordGuideline": "Das Passwort muss mindestens 8 Zeichen lang sein und Groß- und Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten.",
"passwordPlaceholder": "Geben Sie Ihr Passwort ein",
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
"passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordMaxCharacterRequired": "Das Passwort darf maximal 32 Zeichen lang sein!",
"passwordPatternRequired": "Das Passwort entspricht nicht den Anforderungen!",
"strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben",
"passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.",

View File

@@ -5,5 +5,6 @@
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
"reconnecting": "Vom Server getrennt.",
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
"connection-restored": "Erfolgreich mit dem Server verbunden"
"connection-restored": "Erfolgreich mit dem Server verbunden",
"cancel": "Abbrechen"
}

View File

@@ -41,6 +41,22 @@
"list": "Liste",
"calendar": "Kalender",
"tasks": "Aufgaben",
"refresh": "Aktualisieren"
"refresh": "Aktualisieren",
"recentActivity": "Aktuelle Aktivitäten",
"recentTasks": "Aktuelle Aufgaben",
"recentTasksSegment": "Aktuelle Aufgaben",
"timeLogged": "Erfasste Zeit",
"timeLoggedSegment": "Erfasste Zeit",
"noRecentTasks": "Keine aktuellen Aufgaben",
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
"activityTag": "Aktivität",
"timeLogTag": "Zeiterfassung",
"timerTag": "Timer",
"activitySingular": "Aktivität",
"activityPlural": "Aktivitäten",
"recentTaskAriaLabel": "Aktuelle Aufgabe:",
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:",
"errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben",
"errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Helfen Sie uns, Ihre Erfahrung zu verbessern",
"skip": "Für jetzt überspringen",
"previous": "Zurück",
"next": "Weiter",
"completeSurvey": "Umfrage abschließen",
"submitting": "Ihre Antworten werden übermittelt...",
"submitSuccessTitle": "Danke!",
"submitSuccessSubtitle": "Ihr Feedback hilft uns, Worklenz für alle zu verbessern.",
"submitSuccessMessage": "Danke, dass Sie die Umfrage abgeschlossen haben!",
"submitErrorMessage": "Umfrage konnte nicht übermittelt werden. Bitte versuchen Sie es erneut.",
"submitErrorLog": "Umfrageübermittlung fehlgeschlagen",
"fetchErrorLog": "Umfrageabruf fehlgeschlagen"
}

View File

@@ -84,5 +84,12 @@
"close": "Schließen",
"cannotMoveStatus": "Status kann nicht verschoben werden",
"cannotMoveStatusMessage": "Dieser Status kann nicht verschoben werden, da die Kategorie '{{categoryName}}' leer bleiben würde. Jede Kategorie muss mindestens einen Status haben.",
"ok": "OK"
"ok": "OK",
"clearSort": "Sortierung löschen",
"sortAscending": "Aufsteigend sortieren",
"sortDescending": "Absteigend sortieren",
"sortByField": "Sortieren nach {{field}}",
"ascendingOrder": "Aufsteigend",
"descendingOrder": "Absteigend",
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Mir zuweisen",
"copyLink": "Link zur Aufgabe kopieren",
"linkCopied": "Link in die Zwischenablage kopiert",
"linkCopyFailed": "Fehler beim Kopieren des Links",
"moveTo": "Verschieben nach",
"unarchive": "Dearchivieren",
"archive": "Archivieren",
@@ -133,5 +136,11 @@
"dependencies": "Aufgabe hat Abhängigkeiten",
"recurring": "Wiederkehrende Aufgabe"
}
},
"timer": {
"conflictTitle": "Timer läuft bereits",
"conflictMessage": "Sie haben einen Timer für \"{{taskName}}\" im Projekt \"{{projectName}}\" laufen. Möchten Sie diesen Timer stoppen und einen neuen für diese Aufgabe starten?",
"stopAndStart": "Stoppen & Neuen Timer starten"
}
}

View File

@@ -44,6 +44,7 @@
"step3InputLabel": "Invite with email",
"addAnother": "Add another",
"skipForNow": "Skip for now",
"skipping": "Skipping...",
"formTitle": "Create your first task.",
"step3Title": "Invite your team to work with",
"maxMembers": " (You can invite up to 5 members)",
@@ -88,6 +89,8 @@
"discoveryQuestion": "How did you hear about us?",
"allSetTitle": "You're all set!",
"allSetDescription": "Let's create your first project and get started with Worklenz",
"surveyCompleteTitle": "Thank you!",
"surveyCompleteDescription": "Your feedback helps us improve Worklenz for everyone",
"aboutYouStepName": "About You",
"yourNeedsStepName": "Your Needs",
"discoveryStepName": "Discovery",

View File

@@ -5,5 +5,6 @@
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
"reconnecting": "Disconnected from server.",
"connection-lost": "Failed to connect to server. Please check your internet connection.",
"connection-restored": "Connected to server successfully"
"connection-restored": "Connected to server successfully",
"cancel": "Cancel"
}

View File

@@ -41,6 +41,22 @@
"list": "List",
"calendar": "Calendar",
"tasks": "Tasks",
"refresh": "Refresh"
"refresh": "Refresh",
"recentActivity": "Recent Activity",
"recentTasks": "Recent Tasks",
"recentTasksSegment": "Recent Tasks",
"timeLogged": "Time Logged",
"timeLoggedSegment": "Time Logged",
"noRecentTasks": "No recent tasks",
"noTimeLoggedTasks": "No time logged tasks",
"activityTag": "Activity",
"timeLogTag": "Time Log",
"timerTag": "Timer",
"activitySingular": "activity",
"activityPlural": "activities",
"recentTaskAriaLabel": "Recent task:",
"timeLoggedTaskAriaLabel": "Time logged task:",
"errorLoadingRecentTasks": "Error loading recent tasks",
"errorLoadingTimeLoggedTasks": "Error loading time logged tasks"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Help Us Improve Your Experience",
"skip": "Skip for now",
"previous": "Previous",
"next": "Next",
"completeSurvey": "Complete Survey",
"submitting": "Submitting your responses...",
"submitSuccessTitle": "Thank you!",
"submitSuccessSubtitle": "Your feedback helps us improve Worklenz for everyone.",
"submitSuccessMessage": "Thank you for completing the survey!",
"submitErrorMessage": "Failed to submit survey. Please try again.",
"submitErrorLog": "Failed to submit survey",
"fetchErrorLog": "Failed to fetch survey"
}

View File

@@ -84,5 +84,12 @@
"close": "Close",
"cannotMoveStatus": "Cannot Move Status",
"cannotMoveStatusMessage": "Cannot move this status because it would leave the '{{categoryName}}' category empty. Each category must have at least one status.",
"ok": "OK"
"ok": "OK",
"clearSort": "Clear Sort",
"sortAscending": "Sort Ascending",
"sortDescending": "Sort Descending",
"sortByField": "Sort by {{field}}",
"ascendingOrder": "Ascending",
"descendingOrder": "Descending",
"currentSort": "Current sort: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Assign to me",
"copyLink": "Copy link to task",
"linkCopied": "Link copied to clipboard",
"linkCopyFailed": "Failed to copy link",
"moveTo": "Move to",
"unarchive": "Unarchive",
"archive": "Archive",
@@ -133,5 +136,11 @@
"dependencies": "Task has dependencies",
"recurring": "Recurring task"
}
},
"timer": {
"conflictTitle": "Timer Already Running",
"conflictMessage": "You have a timer running for \"{{taskName}}\" in project \"{{projectName}}\". Would you like to stop that timer and start a new one for this task?",
"stopAndStart": "Stop & Start New Timer"
}
}

View File

@@ -45,6 +45,7 @@
"step3InputLabel": "Invitar por correo electrónico",
"addAnother": "Agregar otro",
"skipForNow": "Omitir por ahora",
"skipping": "Omitiendo...",
"formTitle": "Crea tu primera tarea.",
"step3Title": "Invita a tu equipo a trabajar",
@@ -91,6 +92,8 @@
"discoveryQuestion": "¿Cómo te enteraste de nosotros?",
"allSetTitle": "¡Ya estás listo!",
"allSetDescription": "Vamos a crear tu primer proyecto y comenzar con Worklenz",
"surveyCompleteTitle": "¡Gracias!",
"surveyCompleteDescription": "Tu retroalimentación nos ayuda a mejorar Worklenz para todos",
"aboutYouStepName": "Sobre ti",
"yourNeedsStepName": "Tus necesidades",
"discoveryStepName": "Descubrimiento",

View File

@@ -7,12 +7,12 @@
"emailLabel": "Correo electrónico",
"emailPlaceholder": "Ingresa tu correo electrónico",
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordLabel": "Contraseña",
"passwordGuideline": "La contraseña debe tener al menos 8 caracteres, incluir letras mayúsculas y minúsculas, un número y un carácter especial.",
"passwordPlaceholder": "Ingresa tu contraseña",
"passwordRequired": "¡Por favor ingresa tu contraseña!",
"passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordMaxCharacterRequired": "¡La contraseña debe tener como máximo 32 caracteres!",
"passwordPatternRequired": "¡La contraseña no cumple con los requisitos!",
"strongPasswordPlaceholder": "Ingresa una contraseña más segura",
"passwordValidationAltText": "La contraseña debe incluir al menos 8 caracteres con letras mayúsculas y minúsculas, un número y un símbolo.",

View File

@@ -5,5 +5,6 @@
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
"reconnecting": "Reconectando al servidor...",
"connection-lost": "Conexión perdida. Intentando reconectarse...",
"connection-restored": "Conexión restaurada. Reconectando al servidor..."
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
"cancel": "Cancelar"
}

View File

@@ -40,6 +40,22 @@
"list": "Lista",
"calendar": "Calendario",
"tasks": "Tareas",
"refresh": "Actualizar"
"refresh": "Actualizar",
"recentActivity": "Actividad Reciente",
"recentTasks": "Tareas Recientes",
"recentTasksSegment": "Tareas Recientes",
"timeLogged": "Tiempo Registrado",
"timeLoggedSegment": "Tiempo Registrado",
"noRecentTasks": "No hay tareas recientes",
"noTimeLoggedTasks": "No hay tareas con tiempo registrado",
"activityTag": "Actividad",
"timeLogTag": "Registro de Tiempo",
"timerTag": "Temporizador",
"activitySingular": "actividad",
"activityPlural": "actividades",
"recentTaskAriaLabel": "Tarea reciente:",
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:",
"errorLoadingRecentTasks": "Error al cargar tareas recientes",
"errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Ayúdanos a mejorar tu experiencia",
"skip": "Omitir por ahora",
"previous": "Anterior",
"next": "Siguiente",
"completeSurvey": "Completar encuesta",
"submitting": "Enviando tus respuestas...",
"submitSuccessTitle": "¡Gracias!",
"submitSuccessSubtitle": "Tus comentarios nos ayudan a mejorar Worklenz para todos.",
"submitSuccessMessage": "¡Gracias por completar la encuesta!",
"submitErrorMessage": "No se pudo enviar la encuesta. Por favor, inténtalo de nuevo.",
"submitErrorLog": "Error al enviar la encuesta",
"fetchErrorLog": "Error al obtener la encuesta"
}

View File

@@ -84,5 +84,12 @@
"close": "Cerrar",
"cannotMoveStatus": "No se puede mover el estado",
"cannotMoveStatusMessage": "No se puede mover este estado porque dejaría vacía la categoría '{{categoryName}}'. Cada categoría debe tener al menos un estado.",
"ok": "OK"
"ok": "OK",
"clearSort": "Limpiar Ordenamiento",
"sortAscending": "Ordenar Ascendente",
"sortDescending": "Ordenar Descendente",
"sortByField": "Ordenar por {{field}}",
"ascendingOrder": "Ascendente",
"descendingOrder": "Descendente",
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Asignar a mí",
"copyLink": "Copiar enlace a la tarea",
"linkCopied": "Enlace copiado al portapapeles",
"linkCopyFailed": "Error al copiar el enlace",
"moveTo": "Mover a",
"unarchive": "Desarchivar",
"archive": "Archivar",
@@ -133,5 +136,11 @@
"dependencies": "La tarea tiene dependencias",
"recurring": "Tarea recurrente"
}
},
"timer": {
"conflictTitle": "Temporizador Ya En Ejecución",
"conflictMessage": "Tiene un temporizador ejecutándose para \"{{taskName}}\" en el proyecto \"{{projectName}}\". ¿Le gustaría detener ese temporizador e iniciar uno nuevo para esta tarea?",
"stopAndStart": "Detener e Iniciar Nuevo Temporizador"
}
}

View File

@@ -45,6 +45,7 @@
"step3InputLabel": "Convidar por email",
"addAnother": "Adicionar outro",
"skipForNow": "Pular por enquanto",
"skipping": "Pulando...",
"formTitle": "Crie sua primeira tarefa.",
"step3Title": "Convide sua equipe para trabalhar",
@@ -91,6 +92,8 @@
"discoveryQuestion": "Como você soube sobre nós?",
"allSetTitle": "Você está pronto!",
"allSetDescription": "Vamos criar seu primeiro projeto e começar com o Worklenz",
"surveyCompleteTitle": "Obrigado!",
"surveyCompleteDescription": "Seu feedback nos ajuda a melhorar o Worklenz para todos",
"aboutYouStepName": "Sobre você",
"yourNeedsStepName": "Suas necessidades",
"discoveryStepName": "Descoberta",

View File

@@ -5,5 +5,6 @@
"signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.",
"reconnecting": "Reconectando ao servidor...",
"connection-lost": "Conexão perdida. Tentando reconectar...",
"connection-restored": "Conexão restaurada. Reconectando ao servidor..."
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
"cancel": "Cancelar"
}

View File

@@ -40,6 +40,22 @@
"list": "Lista",
"calendar": "Calendário",
"tasks": "Tarefas",
"refresh": "Atualizar"
"refresh": "Atualizar",
"recentActivity": "Atividade Recente",
"recentTasks": "Tarefas Recentes",
"recentTasksSegment": "Tarefas Recentes",
"timeLogged": "Tempo Registrado",
"timeLoggedSegment": "Tempo Registrado",
"noRecentTasks": "Nenhuma tarefa recente",
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
"activityTag": "Atividade",
"timeLogTag": "Registro de Tempo",
"timerTag": "Cronômetro",
"activitySingular": "atividade",
"activityPlural": "atividades",
"recentTaskAriaLabel": "Tarefa recente:",
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:",
"errorLoadingRecentTasks": "Erro ao carregar tarefas recentes",
"errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Ajude-nos a melhorar sua experiência",
"skip": "Pular por enquanto",
"previous": "Anterior",
"next": "Próximo",
"completeSurvey": "Concluir Pesquisa",
"submitting": "Enviando suas respostas...",
"submitSuccessTitle": "Obrigado!",
"submitSuccessSubtitle": "Seu feedback nos ajuda a melhorar o Worklenz para todos.",
"submitSuccessMessage": "Obrigado por completar a pesquisa!",
"submitErrorMessage": "Falha ao enviar a pesquisa. Por favor, tente novamente.",
"submitErrorLog": "Falha ao enviar a pesquisa",
"fetchErrorLog": "Falha ao buscar a pesquisa"
}

View File

@@ -84,5 +84,12 @@
"close": "Fechar",
"cannotMoveStatus": "Não é possível mover o status",
"cannotMoveStatusMessage": "Não é possível mover este status porque deixaria a categoria '{{categoryName}}' vazia. Cada categoria deve ter pelo menos um status.",
"ok": "OK"
"ok": "OK",
"clearSort": "Limpar Ordenação",
"sortAscending": "Ordenar Crescente",
"sortDescending": "Ordenar Decrescente",
"sortByField": "Ordenar por {{field}}",
"ascendingOrder": "Crescente",
"descendingOrder": "Decrescente",
"currentSort": "Ordenação atual: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Atribuir a mim",
"copyLink": "Copiar link da tarefa",
"linkCopied": "Link copiado para a área de transferência",
"linkCopyFailed": "Falha ao copiar o link",
"moveTo": "Mover para",
"unarchive": "Desarquivar",
"archive": "Arquivar",
@@ -133,5 +136,11 @@
"dependencies": "A tarefa tem dependências",
"recurring": "Tarefa recorrente"
}
},
"timer": {
"conflictTitle": "Temporizador Já Em Execução",
"conflictMessage": "Você tem um temporizador executando para \"{{taskName}}\" no projeto \"{{projectName}}\". Gostaria de parar esse temporizador e iniciar um novo para esta tarefa?",
"stopAndStart": "Parar e Iniciar Novo Temporizador"
}
}

View File

@@ -44,6 +44,7 @@
"step3InputLabel": "通过电子邮件邀请",
"addAnother": "添加另一个",
"skipForNow": "暂时跳过",
"skipping": "跳过中...",
"formTitle": "创建您的第一个任务。",
"step3Title": "邀请您的团队一起工作",
"maxMembers": "(您最多可以邀请 5 名成员)",
@@ -89,6 +90,8 @@
"discoveryQuestion": "您是如何听说我们的?",
"allSetTitle": "一切就绪!",
"allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧",
"surveyCompleteTitle": "谢谢!",
"surveyCompleteDescription": "您的反馈有助于我们为所有人改进 Worklenz",
"aboutYouStepName": "关于您",
"yourNeedsStepName": "您的需求",
"discoveryStepName": "发现",

View File

@@ -5,5 +5,6 @@
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
"reconnecting": "与服务器断开连接。",
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
"connection-restored": "成功连接到服务器"
"connection-restored": "成功连接到服务器",
"cancel": "取消"
}

View File

@@ -41,6 +41,22 @@
"list": "列表",
"calendar": "日历",
"tasks": "任务",
"refresh": "刷新"
"refresh": "刷新",
"recentActivity": "最近活动",
"recentTasks": "最近任务",
"recentTasksSegment": "最近任务",
"timeLogged": "时间记录",
"timeLoggedSegment": "时间记录",
"noRecentTasks": "没有最近任务",
"noTimeLoggedTasks": "没有时间记录任务",
"activityTag": "活动",
"timeLogTag": "时间记录",
"timerTag": "计时器",
"activitySingular": "活动",
"activityPlural": "活动",
"recentTaskAriaLabel": "最近任务:",
"timeLoggedTaskAriaLabel": "时间记录任务:",
"errorLoadingRecentTasks": "加载最近任务时出错",
"errorLoadingTimeLoggedTasks": "加载时间记录任务时出错"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "帮助我们提升您的体验",
"skip": "暂时跳过",
"previous": "上一步",
"next": "下一步",
"completeSurvey": "完成调查",
"submitting": "正在提交您的回答...",
"submitSuccessTitle": "谢谢!",
"submitSuccessSubtitle": "您的反馈帮助我们改进 Worklenz。",
"submitSuccessMessage": "感谢您完成调查!",
"submitErrorMessage": "提交调查失败。请重试。",
"submitErrorLog": "提交调查失败",
"fetchErrorLog": "获取调查失败"
}

View File

@@ -79,5 +79,12 @@
"close": "关闭",
"cannotMoveStatus": "无法移动状态",
"cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。",
"ok": "确定"
"ok": "确定",
"clearSort": "清除排序",
"sortAscending": "升序排列",
"sortDescending": "降序排列",
"sortByField": "按{{field}}排序",
"ascendingOrder": "升序",
"descendingOrder": "降序",
"currentSort": "当前排序:{{field}} {{order}}"
}

View File

@@ -50,6 +50,9 @@
"pendingInvitation": "待处理邀请",
"contextMenu": {
"assignToMe": "分配给我",
"copyLink": "复制任务链接",
"linkCopied": "链接已复制到剪贴板",
"linkCopyFailed": "复制链接失败",
"moveTo": "移动到",
"unarchive": "取消归档",
"archive": "归档",
@@ -126,5 +129,11 @@
"dependencies": "任务有依赖项",
"recurring": "重复任务"
}
},
"timer": {
"conflictTitle": "计时器已在运行",
"conflictMessage": "您在项目\"{{projectName}}\"中的\"{{taskName}}\"任务正在运行计时器。您是否要停止该计时器并为此任务启动新的计时器?",
"stopAndStart": "停止并启动新计时器"
}
}

View File

@@ -0,0 +1,46 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { API_BASE_URL } from '@/shared/constants';
import { getCsrfToken } from '../api-client';
import { IUserRecentTask, IUserTimeLoggedTask } from '@/types/home/user-activity.types';
import config from '@/config/env';
const rootUrl = '/logs';
export const userActivityApiService = createApi({
reducerPath: 'userActivityApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: (headers) => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),
tagTypes: ['UserRecentTasks', 'UserTimeLoggedTasks'],
endpoints: (builder) => ({
getUserRecentTasks: builder.query<IUserRecentTask[], { limit?: number; offset?: number }>({
query: ({ limit = 10, offset = 0 }) => ({
url: `${rootUrl}/user-recent-tasks`,
params: { limit, offset },
method: 'GET',
}),
providesTags: ['UserRecentTasks'],
}),
getUserTimeLoggedTasks: builder.query<IUserTimeLoggedTask[], { limit?: number; offset?: number }>({
query: ({ limit = 10, offset = 0 }) => ({
url: `${rootUrl}/user-time-logged-tasks`,
params: { limit, offset },
method: 'GET',
}),
providesTags: ['UserTimeLoggedTasks'],
}),
}),
});
export const {
useGetUserRecentTasksQuery,
useGetUserTimeLoggedTasksQuery,
} = userActivityApiService;

View File

@@ -18,5 +18,10 @@ export const surveyApiService = {
async getUserSurveyResponse(surveyId: string): Promise<IServerResponse<ISurveyResponse>> {
const response = await apiClient.get<IServerResponse<ISurveyResponse>>(`${API_BASE_URL}/surveys/responses/${surveyId}`);
return response.data;
},
async checkAccountSetupSurveyStatus(): Promise<IServerResponse<{ is_completed: boolean; completed_at?: string }>> {
const response = await apiClient.get<IServerResponse<{ is_completed: boolean; completed_at?: string }>>(`${API_BASE_URL}/surveys/account-setup/status`);
return response.data;
}
};

View File

@@ -2,6 +2,7 @@ import { API_BASE_URL } from '@/shared/constants';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
import { getUserSession } from '@/utils/session-helper';
const rootUrl = `${API_BASE_URL}/task-time-log`;
@@ -17,7 +18,11 @@ export interface IRunningTimer {
export const taskTimeLogsApiService = {
getByTask: async (id: string): Promise<IServerResponse<ITaskLogViewModel[]>> => {
const response = await apiClient.get(`${rootUrl}/task/${id}`);
const session = getUserSession();
const timezone = session?.timezone_name || 'UTC';
const response = await apiClient.get(`${rootUrl}/task/${id}`, {
params: { time_zone_name: timezone }
});
return response.data;
},

View File

@@ -7,6 +7,7 @@ import userReducer from '@features/user/userSlice';
// Home Page
import homePageReducer from '@features/home-page/home-page.slice';
import userActivityReducer from '@features/home-page/user-activity.slice';
// Account Setup
import accountSetupReducer from '@features/account-setup/account-setup.slice';
@@ -82,6 +83,7 @@ import groupingReducer from '@/features/task-management/grouping.slice';
import selectionReducer from '@/features/task-management/selection.slice';
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
import projectViewReducer from '@features/project/project-view-slice';
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
@@ -90,7 +92,7 @@ export const store = configureStore({
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(homePageApiService.middleware, projectsApi.middleware),
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
reducer: {
// Auth & User
auth: authReducer,
@@ -103,6 +105,9 @@ export const store = configureStore({
homePageReducer: homePageReducer,
[homePageApiService.reducerPath]: homePageApiService.reducer,
[projectsApi.reducerPath]: projectsApi.reducer,
userActivityReducer: userActivityReducer,
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
// Core UI
themeReducer: themeReducer,
localesReducer: localesReducer,

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports';
import { Form, Input, InputRef, Typography, Card, Tooltip, Alert } from '@/shared/antd-imports';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
import { RootState } from '@/app/store';
import { sanitizeInput } from '@/utils/sanitizeInput';
import { SpamDetector } from '@/utils/spamDetector';
const { Title, Paragraph, Text } = Typography;
@@ -29,6 +30,7 @@ export const OrganizationStep: React.FC<Props> = ({
const dispatch = useDispatch();
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
const inputRef = useRef<InputRef>(null);
const [spamWarning, setSpamWarning] = useState<string>('');
// Autofill organization name if not already set
useEffect(() => {
@@ -44,7 +46,19 @@ export const OrganizationStep: React.FC<Props> = ({
};
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = sanitizeInput(e.target.value);
const rawValue = e.target.value;
const sanitizedValue = sanitizeInput(rawValue);
// Check for spam patterns
const spamCheck = SpamDetector.detectSpam(rawValue);
if (spamCheck.isSpam) {
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
} else if (SpamDetector.isHighRiskContent(rawValue)) {
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
} else {
setSpamWarning('');
}
dispatch(setOrganizationName(sanitizedValue));
};
@@ -60,12 +74,25 @@ export const OrganizationStep: React.FC<Props> = ({
</Paragraph>
</div>
{/* Spam Warning */}
{spamWarning && (
<div className="mb-4">
<Alert
message={spamWarning}
type="warning"
showIcon
closable
onClose={() => setSpamWarning('')}
/>
</div>
)}
{/* Main Form Card */}
<div className="mb-6">
<Card
className="border-2 hover:shadow-md transition-all duration-200"
style={{
borderColor: token?.colorPrimary,
borderColor: spamWarning ? token?.colorWarning : token?.colorPrimary,
backgroundColor: token?.colorBgContainer
}}
>

View File

@@ -20,6 +20,7 @@ interface Props {
styles: any;
isDarkMode: boolean;
token?: any;
isModal?: boolean; // New prop to indicate if used in modal context
}
interface SurveyPageProps {
@@ -29,6 +30,7 @@ interface SurveyPageProps {
surveyData: IAccountSetupSurveyData;
handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void;
handleUseCaseToggle?: (value: UseCase) => void;
isModal?: boolean;
}
// Page 1: About You
@@ -235,7 +237,7 @@ const YourNeedsPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, h
};
// Page 3: Discovery
const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange }) => {
const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange, isModal }) => {
const { t } = useTranslation('account-setup');
const howHeardAboutOptions: { value: HowHeardAbout; label: string; icon: string }[] = [
@@ -291,14 +293,18 @@ const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, h
<div className="mt-12 p-1.5 rounded-lg text-center" style={{ backgroundColor: token?.colorSuccessBg, borderColor: token?.colorSuccessBorder, border: '1px solid' }}>
<div className="text-4xl mb-3">🎉</div>
<Title level={4} style={{ color: token?.colorText, marginBottom: 8 }}>{t('allSetTitle')}</Title>
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>{t('allSetDescription')}</Paragraph>
<Title level={4} style={{ color: token?.colorText, marginBottom: 8 }}>
{isModal ? t('surveyCompleteTitle') : t('allSetTitle')}
</Title>
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>
{isModal ? t('surveyCompleteDescription') : t('allSetDescription')}
</Paragraph>
</div>
</div>
);
};
export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token }) => {
export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token, isModal = false }) => {
const { t } = useTranslation('account-setup');
const dispatch = useDispatch();
const { surveyData, surveySubStep } = useSelector((state: RootState) => state.accountSetupReducer);
@@ -339,9 +345,9 @@ export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token
};
const surveyPages = [
<AboutYouPage key="about-you" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} />,
<YourNeedsPage key="your-needs" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} handleUseCaseToggle={handleUseCaseToggle} />,
<DiscoveryPage key="discovery" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} />
<AboutYouPage key="about-you" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} isModal={isModal} />,
<YourNeedsPage key="your-needs" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} handleUseCaseToggle={handleUseCaseToggle} isModal={isModal} />,
<DiscoveryPage key="discovery" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} isModal={isModal} />
];
React.useEffect(() => {

View File

@@ -1,10 +1,11 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
import { Card, Button, Tooltip, Typography } from '@/shared/antd-imports';
import { Card, Button, Tooltip, Typography, Alert } from '@/shared/antd-imports';
import TextArea from 'antd/es/input/TextArea';
import { TFunction } from 'i18next';
import { useState, useEffect } from 'react';
import { SpamDetector } from '@/utils/spamDetector';
interface OrganizationNameProps {
themeMode: string;
@@ -16,6 +17,7 @@ interface OrganizationNameProps {
const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps) => {
const [isEditable, setIsEditable] = useState(false);
const [newName, setNewName] = useState(name);
const [spamWarning, setSpamWarning] = useState<string>('');
useEffect(() => {
setNewName(name);
@@ -34,7 +36,18 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
};
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewName(e.target.value);
const value = e.target.value;
setNewName(value);
// Check for spam patterns
const spamCheck = SpamDetector.detectSpam(value);
if (spamCheck.isSpam) {
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
} else if (SpamDetector.isHighRiskContent(value)) {
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
} else {
setSpamWarning('');
}
};
const updateOrganizationName = async () => {
@@ -62,6 +75,16 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
{t('name')}
</Typography.Title>
{spamWarning && (
<Alert
message={spamWarning}
type="warning"
showIcon
closable
onClose={() => setSpamWarning('')}
style={{ marginBottom: '8px' }}
/>
)}
<div style={{ paddingTop: '8px' }}>
<div style={{ marginBottom: '8px' }}>
{isEditable ? (

View File

@@ -1,9 +1,10 @@
import { Divider, Form, Input, message, Modal, Typography } from '@/shared/antd-imports';
import { Divider, Form, Input, message, Modal, Typography, Alert } from '@/shared/antd-imports';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { editTeamName, fetchTeams } from '@/features/teams/teamSlice';
import { ITeamGetResponse } from '@/types/teams/team.type';
import { SpamDetector } from '@/utils/spamDetector';
interface EditTeamNameModalProps {
team: ITeamGetResponse | null;
@@ -16,6 +17,7 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
const dispatch = useAppDispatch();
const [form] = Form.useForm();
const [updating, setUpdating] = useState(false);
const [spamWarning, setSpamWarning] = useState<string>('');
useEffect(() => {
if (team) {
@@ -67,6 +69,16 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
destroyOnClose={true}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
{spamWarning && (
<Alert
message={spamWarning}
type="warning"
showIcon
closable
onClose={() => setSpamWarning('')}
style={{ marginBottom: '16px' }}
/>
)}
<Form.Item
name="name"
label={t('name')}
@@ -77,7 +89,20 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
},
]}
>
<Input placeholder={t('namePlaceholder')} />
<Input
placeholder={t('namePlaceholder')}
onChange={(e) => {
const value = e.target.value;
const spamCheck = SpamDetector.detectSpam(value);
if (spamCheck.isSpam) {
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
} else if (SpamDetector.isHighRiskContent(value)) {
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
} else {
setSpamWarning('');
}
}}
/>
</Form.Item>
</Form>
</Modal>

View File

@@ -0,0 +1,269 @@
import React, { useState, useEffect, useRef } from 'react';
import { Modal, Button, Result, Spin, Flex } from '@/shared/antd-imports';
import { SurveyStep } from '@/components/account-setup/survey-step';
import { useSurveyStatus } from '@/hooks/useSurveyStatus';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { surveyApiService } from '@/api/survey/survey.api.service';
import { appMessage } from '@/shared/antd-imports';
import { ISurveySubmissionRequest } from '@/types/account-setup/survey.types';
import logger from '@/utils/errorLogger';
import { resetSurveyData, setSurveySubStep } from '@/features/account-setup/account-setup.slice';
interface SurveyPromptModalProps {
forceShow?: boolean;
onClose?: () => void;
}
export const SurveyPromptModal: React.FC<SurveyPromptModalProps> = ({ forceShow = false, onClose }) => {
const { t } = useTranslation('survey');
const dispatch = useAppDispatch();
const [visible, setVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [surveyCompleted, setSurveyCompleted] = useState(false);
const [surveyInfo, setSurveyInfo] = useState<{ id: string; questions: any[] } | null>(null);
const { hasCompletedSurvey, loading, refetch } = useSurveyStatus();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const surveyData = useAppSelector(state => state.accountSetupReducer.surveyData);
const surveySubStep = useAppSelector(state => state.accountSetupReducer.surveySubStep);
const isDarkMode = themeMode === 'dark';
useEffect(() => {
// Check if survey modal is disabled via environment variable
if (import.meta.env.VITE_ENABLE_SURVEY_MODAL !== 'true' && !forceShow) {
return; // Don't show modal if disabled in environment
}
// Check if survey was skipped recently (within 7 days)
const skippedAt = localStorage.getItem('survey_skipped_at');
if (!forceShow && skippedAt) {
const skippedDate = new Date(skippedAt);
const now = new Date();
const diffDays = (now.getTime() - skippedDate.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays < 3) {
return; // Don't show modal if skipped within 7 days
}
}
if (forceShow) {
setVisible(true);
dispatch(resetSurveyData());
dispatch(setSurveySubStep(0));
// Fetch survey info
const fetchSurvey = async () => {
try {
const response = await surveyApiService.getAccountSetupSurvey();
if (response.done && response.body) {
setSurveyInfo({
id: response.body.id,
questions: response.body.questions || []
});
}
} catch (error) {
logger.error(t('survey:fetchErrorLog'), error);
}
};
fetchSurvey();
} else if (!loading && hasCompletedSurvey === false) {
dispatch(resetSurveyData());
dispatch(setSurveySubStep(0));
// Fetch survey info
const fetchSurvey = async () => {
try {
const response = await surveyApiService.getAccountSetupSurvey();
if (response.done && response.body) {
setSurveyInfo({
id: response.body.id,
questions: response.body.questions || []
});
}
} catch (error) {
logger.error(t('survey:fetchErrorLog'), error);
}
};
fetchSurvey();
// Show modal after a 5 second delay to not interrupt user immediately
const timer = setTimeout(() => {
setVisible(true);
}, 5000);
return () => clearTimeout(timer);
}
}, [loading, hasCompletedSurvey, dispatch, forceShow, t]);
const handleComplete = async () => {
try {
setSubmitting(true);
if (!surveyData || !surveyInfo) {
throw new Error('Survey data not found');
}
// Create a map of question keys to IDs
const questionMap = surveyInfo.questions.reduce((acc, q) => {
acc[q.question_key] = q.id;
return acc;
}, {} as Record<string, string>);
// Prepare submission data with actual question IDs - only include answered questions
const answers: any[] = [];
if (surveyData.organization_type && questionMap['organization_type']) {
answers.push({
question_id: questionMap['organization_type'],
answer_text: surveyData.organization_type
});
}
if (surveyData.user_role && questionMap['user_role']) {
answers.push({
question_id: questionMap['user_role'],
answer_text: surveyData.user_role
});
}
if (surveyData.main_use_cases && surveyData.main_use_cases.length > 0 && questionMap['main_use_cases']) {
answers.push({
question_id: questionMap['main_use_cases'],
answer_json: surveyData.main_use_cases
});
}
if (surveyData.previous_tools && questionMap['previous_tools']) {
answers.push({
question_id: questionMap['previous_tools'],
answer_text: surveyData.previous_tools
});
}
if (surveyData.how_heard_about && questionMap['how_heard_about']) {
answers.push({
question_id: questionMap['how_heard_about'],
answer_text: surveyData.how_heard_about
});
}
const submissionData: ISurveySubmissionRequest = {
survey_id: surveyInfo.id,
answers
};
const response = await surveyApiService.submitSurveyResponse(submissionData);
if (response.done) {
setSurveyCompleted(true);
appMessage.success(t('survey:submitSuccessMessage'));
// Wait a moment before closing
setTimeout(() => {
setVisible(false);
refetch(); // Update the survey status
}, 2000);
} else {
throw new Error(response.message || t('survey:submitErrorMessage'));
}
} catch (error) {
logger.error(t('survey:submitErrorLog'), error);
appMessage.error(t('survey:submitErrorMessage'));
} finally {
setSubmitting(false);
}
};
const handleSkip = () => {
setVisible(false);
// Optionally, you can set a flag in localStorage to not show again for some time
localStorage.setItem('survey_skipped_at', new Date().toISOString());
onClose?.();
};
const isCurrentStepValid = () => {
switch (surveySubStep) {
case 0:
return surveyData.organization_type && surveyData.user_role;
case 1:
return surveyData.main_use_cases && surveyData.main_use_cases.length > 0;
case 2:
return surveyData.how_heard_about;
default:
return false;
}
};
const handleNext = () => {
if (surveySubStep < 2) {
dispatch(setSurveySubStep(surveySubStep + 1));
} else {
handleComplete();
}
};
const handlePrevious = () => {
if (surveySubStep > 0) {
dispatch(setSurveySubStep(surveySubStep - 1));
}
};
if (loading) {
return null;
}
return (
<Modal
open={visible}
title={surveyCompleted ? null : t('survey:modalTitle')}
onCancel={handleSkip}
footer={
surveyCompleted ? null : (
<Flex justify="space-between" align="center">
<div>
<Button onClick={handleSkip}>
{t('survey:skip')}
</Button>
</div>
<Flex gap={8}>
{surveySubStep > 0 && (
<Button onClick={handlePrevious}>
{t('survey:previous')}
</Button>
)}
<Button
type="primary"
onClick={handleNext}
disabled={!isCurrentStepValid()}
loading={submitting && surveySubStep === 2}
>
{surveySubStep === 2 ? t('survey:completeSurvey') : t('survey:next')}
</Button>
</Flex>
</Flex>
)
}
width={800}
maskClosable={false}
centered
>
{submitting ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>{t('survey:submitting')}</p>
</div>
) : surveyCompleted ? (
<Result
status="success"
title={t('survey:submitSuccessTitle')}
subTitle={t('survey:submitSuccessSubtitle')}
/>
) : (
<div style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<SurveyStep
onEnter={() => {}} // Empty function since we handle navigation via buttons
styles={{}}
isDarkMode={isDarkMode}
isModal={true} // Pass true to indicate modal context
/>
</div>
)}
</Modal>
);
};

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { Card, Button, Result, Alert } from '@/shared/antd-imports';
import { CheckCircleOutlined, FormOutlined } from '@/shared/antd-imports';
import { useSurveyStatus } from '@/hooks/useSurveyStatus';
import { SurveyPromptModal } from './SurveyPromptModal';
import { useTranslation } from 'react-i18next';
export const SurveySettingsCard: React.FC = () => {
const { t } = useTranslation('settings');
const [showModal, setShowModal] = useState(false);
const { hasCompletedSurvey, loading } = useSurveyStatus();
if (loading) {
return (
<Card loading={true} />
);
}
return (
<>
<Card
title={
<span>
<FormOutlined style={{ marginRight: 8 }} />
Personalization Survey
</span>
}
extra={
hasCompletedSurvey && (
<Button type="link" onClick={() => setShowModal(true)}>
Update Responses
</Button>
)
}
>
{hasCompletedSurvey ? (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="Survey Completed"
subTitle="Thank you for completing the personalization survey. Your responses help us improve Worklenz."
extra={
<Button onClick={() => setShowModal(true)}>
Update Your Responses
</Button>
}
/>
) : (
<>
<Alert
message="Help us personalize your experience"
description="Take a quick survey to tell us about your organization and how you use Worklenz."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<div style={{ textAlign: 'center' }}>
<Button type="primary" size="large" onClick={() => setShowModal(true)}>
Take Survey Now
</Button>
</div>
</>
)}
</Card>
{showModal && (
<SurveyPromptModal
forceShow={true}
onClose={() => setShowModal(false)}
/>
)}
</>
);
};

View File

@@ -0,0 +1,2 @@
export { SurveyPromptModal } from './SurveyPromptModal';
export { SurveySettingsCard } from './SurveySettingsCard';

View File

@@ -12,7 +12,7 @@ import TimeLogList from './time-log-list';
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
import { useTaskTimer } from '@/hooks/useTaskTimer';
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
import logger from '@/utils/errorLogger';
interface TaskDrawerTimeLogProps {
@@ -31,7 +31,7 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
state => state.taskDrawerReducer
);
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
selectedTaskId || '',
taskFormViewModel?.task?.timer_start_time || null
);

View File

@@ -3,7 +3,7 @@ import { Button, Divider, Flex, Popconfirm, Typography, Space } from '@/shared/a
import { colors } from '@/styles/colors';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
import { formatDateTimeWithUserTimezone } from '@/utils/format-date-time-with-user-timezone';
import { calculateTimeGap } from '@/utils/calculate-time-gap';
import './time-log-item.css';
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
@@ -101,7 +101,7 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
{renderLoggedByTimer()} {calculateTimeGap(created_at || '')}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTimeWithLocale(created_at || '')}
{formatDateTimeWithUserTimezone(created_at || '', currentSession?.timezone_name)}
</Typography.Text>
</Flex>
{renderActionButtons()}

View File

@@ -1,5 +1,4 @@
import React, { useMemo, useCallback, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types
import {
ChevronDownIcon,
@@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
t,
]);
// Make the group header droppable
const { isOver, setNodeRef } = useDroppable({
id: group.id,
data: {
type: 'group',
group,
},
});
return (
<div className="relative flex items-center">
<div
ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
className="inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2"
style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
backgroundColor: headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,

View File

@@ -9,8 +9,9 @@ import {
KeyboardSensor,
TouchSensor,
closestCenter,
useDroppable,
Modifier,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
@@ -67,106 +68,132 @@ import TaskListSkeleton from './components/TaskListSkeleton';
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
// Empty Group Drop Zone Component
const EmptyGroupDropZone: React.FC<{
groupId: string;
visibleColumns: any[];
t: (key: string) => string;
}> = ({ groupId, visibleColumns, t }) => {
const { setNodeRef, isOver, active } = useDroppable({
id: `empty-group-${groupId}`,
data: {
type: 'group',
groupId: groupId,
isEmpty: true,
},
});
return (
<div
ref={setNodeRef}
className={`relative w-full transition-colors duration-200 ${
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
};
// Show text in the title column
if (column.id === 'title') {
return (
<div
key={`empty-${column.id}`}
className="flex items-center pl-1"
style={emptyColumnStyle}
>
<span className="text-sm text-gray-500 dark:text-gray-400">
No tasks in this group
</span>
</div>
);
}
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</div>
{isOver && active && (
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
)}
</div>
);
};
// Placeholder Drop Indicator Component
const PlaceholderDropIndicator: React.FC<{
isVisible: boolean;
visibleColumns: any[];
}> = ({ isVisible, visibleColumns }) => {
// Drop Spacer Component - creates space between tasks when dragging
const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[]; isDarkMode?: boolean }> = ({
isVisible,
visibleColumns,
isDarkMode = false
}) => {
if (!isVisible) return null;
return (
<div
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
style={{ minWidth: 'max-content', height: '40px' }}
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 transition-all duration-200 ease-in-out"
style={{
height: isVisible ? '40px' : '0px',
opacity: isVisible ? 1 : 0,
marginTop: isVisible ? '2px' : '0px',
marginBottom: isVisible ? '2px' : '0px',
overflow: 'hidden',
}}
>
{visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const columnStyle = {
width: column.width,
flexShrink: 0,
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 10,
backgroundColor: 'inherit', // Inherit from parent spacer
}),
};
if (column.id === 'title') {
return (
<div
key={`spacer-${column.id}`}
className="flex items-center pl-1 border-r border-blue-300 dark:border-blue-600"
style={columnStyle}
>
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
Drop here
</span>
</div>
);
}
return (
<div
key={`placeholder-${column.id}`}
className="flex items-center justify-center h-full"
key={`spacer-${column.id}`}
className="border-r border-blue-300 dark:border-blue-600"
style={columnStyle}
>
{/* Show "Drop task here" message in the title column */}
{column.id === 'title' && (
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
Drop task here
</div>
)}
{/* Show subtle placeholder content in other columns */}
{column.id !== 'title' && column.id !== 'dragHandle' && (
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
)}
</div>
/>
);
})}
</div>
);
};
// Empty Group Message Component
const EmptyGroupMessage: React.FC<{ visibleColumns: any[]; isDarkMode?: boolean }> = ({
visibleColumns,
isDarkMode = false
}) => {
return (
<div className="flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
{visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 10,
backgroundColor: 'inherit', // Inherit from parent container
}),
};
// Show text in the title column
if (column.id === 'title') {
return (
<div
key={`empty-${column.id}`}
className="flex items-center pl-1 border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
>
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
No tasks in this group
</span>
</div>
);
}
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</div>
);
};
// Hooks and utilities
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useSocket } from '@/socket/socketContext';
@@ -229,7 +256,7 @@ const TaskListV2Section: React.FC = () => {
);
// Custom hooks
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
allTasks,
groups
);
@@ -564,16 +591,12 @@ const TaskListV2Section: React.FC = () => {
projectId={urlProjectId || ''}
/>
{isGroupEmpty && !isGroupCollapsed && (
<EmptyGroupDropZone
groupId={group.id}
visibleColumns={visibleColumns}
t={t}
/>
<EmptyGroupMessage visibleColumns={visibleColumns} isDarkMode={isDarkMode} />
)}
</div>
);
},
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode]
);
const renderTask = useCallback(
@@ -614,19 +637,40 @@ const TaskListV2Section: React.FC = () => {
const renderColumnHeaders = useCallback(
() => (
<div
className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
style={{ width: '100%', minWidth: 'max-content' }}
className="border-b border-gray-200 dark:border-gray-700"
style={{
width: '100%',
minWidth: 'max-content',
backgroundColor: isDarkMode ? '#141414' : '#f9fafb'
}}
>
<div
className="flex items-center px-1 py-3 w-full"
style={{ minWidth: 'max-content', height: '44px' }}
>
{visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const columnStyle: ColumnStyle = {
width: column.width,
flexShrink: 0,
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 15,
backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50
}),
};
return (
@@ -791,13 +835,25 @@ const TaskListV2Section: React.FC = () => {
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<>
{/* CSS for sticky column hover effects */}
<style>
{`
.hover\\:bg-gray-50:hover .sticky-column-hover,
.dark .hover\\:bg-gray-800:hover .sticky-column-hover {
background-color: var(--hover-bg) !important;
}
`}
</style>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis]}
>
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
{/* Table Container */}
<div
@@ -851,40 +907,20 @@ const TaskListV2Section: React.FC = () => {
// Check if this is the first actual task in the group (not AddTaskRow)
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
// Check if we should show drop indicators
const isTaskBeingDraggedOver = overId === task.id;
const isGroupBeingDraggedOver = overId === group.id;
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
// Check if we should show drop spacer
const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task);
const showDropSpacerBefore = isOverThisTask && dropPosition === 'before';
const showDropSpacerAfter = isOverThisTask && dropPosition === 'after';
return (
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
{/* Placeholder drop indicator before first task in group */}
{isFirstTaskInGroupBeingDraggedOver && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{/* Placeholder drop indicator between tasks */}
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{showDropSpacerBefore && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
{/* Placeholder drop indicator at end of group when dragging over group */}
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{showDropSpacerAfter && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
</div>
);
})
) : (
// Handle empty groups with placeholder drop indicator
overId === group.id && (
<div style={{ minWidth: 'max-content' }}>
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
</div>
)
)
) : null
)}
</div>
))}
@@ -894,15 +930,15 @@ const TaskListV2Section: React.FC = () => {
</div>
{/* Drag Overlay */}
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
{activeId ? (
<div
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90"
className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 opacity-95"
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
>
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<HolderOutlined className="text-gray-400 dark:text-gray-500 text-xs" />
<div className="px-4 py-3">
<div className="flex items-center gap-3">
<HolderOutlined className="text-blue-500 dark:text-blue-400 text-sm" />
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
{allTasks.find(task => task.id === activeId)?.name ||
allTasks.find(task => task.id === activeId)?.title ||
@@ -954,6 +990,7 @@ const TaskListV2Section: React.FC = () => {
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
</div>
</DndContext>
</>
);
};

View File

@@ -75,7 +75,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
});
// Drag and drop functionality - only enable for parent tasks
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({
id: task.id,
data: {
type: 'task',
@@ -116,24 +116,41 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging
}), [transform, transition, isDragging]);
return (
<div
ref={setNodeRef}
style={{ ...style, height: '40px' }}
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
} ${
isDragging ? 'shadow-lg border border-blue-300' : ''
isDragging ? 'opacity-50' : ''
} ${
isOver && !isDragging ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
{visibleColumns.map((column, index) => (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width, column.isSticky, index)}
</React.Fragment>
))}
{visibleColumns.map((column, index) => {
// Calculate background state for sticky columns - custom dark mode colors
const rowBackgrounds = {
normal: isDarkMode ? '#1e1e1e' : '#ffffff', // custom dark : bg-white
hover: isDarkMode ? '#1f2937' : '#f9fafb', // slightly lighter dark : bg-gray-50
dragOver: isDarkMode ? '#1e3a8a33' : '#dbeafe', // bg-blue-900/20 : bg-blue-50
};
let currentBg = rowBackgrounds.normal;
if (isOver && !isDragging) {
currentBg = rowBackgrounds.dragOver;
}
// Note: hover state is handled by CSS, so we'll use a CSS custom property
return (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width, column.isSticky, index, currentBg, rowBackgrounds)}
</React.Fragment>
);
})}
</div>
);
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
import { useTaskTimer } from '@/hooks/useTaskTimer';
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
interface TaskTimeTrackingProps {
taskId: string;
@@ -8,7 +8,7 @@ interface TaskTimeTrackingProps {
}
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
taskId,
null // The hook will get the timer start time from Redux
);

View File

@@ -32,6 +32,8 @@ import {
RetweetOutlined,
UserAddOutlined,
LoadingOutlined,
CopyOutlined,
message,
} from '@/shared/antd-imports';
interface TaskContextMenuProps {
@@ -325,6 +327,21 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
}
}, [task?.id, projectId, dispatch, onClose]);
const handleCopyLink = useCallback(async () => {
if (!projectId || !task.id) return;
try {
const taskLink = `${window.location.origin}/worklenz/projects/${projectId}?tab=tasks-list&pinned_tab=tasks-list&task=${task.id}`;
await navigator.clipboard.writeText(taskLink);
message.success(t('contextMenu.linkCopied'));
} catch (error) {
logger.error('Error copying link:', error);
message.error(t('contextMenu.linkCopyFailed'));
} finally {
onClose();
}
}, [projectId, task.id, onClose, t]);
const menuItems = useMemo(() => {
const items = [
{
@@ -344,6 +361,18 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
</button>
),
},
{
key: 'copyLink',
label: (
<button
onClick={handleCopyLink}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
<CopyOutlined className="text-gray-500 dark:text-gray-400" />
<span>{t('contextMenu.copyLink')}</span>
</button>
),
},
];
// Add Move To submenu if there are options
@@ -500,6 +529,7 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
handleArchive,
handleDelete,
handleConvertToTask,
handleCopyLink,
getMoveToOptions,
dispatch,
t,

View File

@@ -364,7 +364,7 @@ interface ReporterColumnProps {
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
{reporter ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{reporter}</span>
<span className="text-sm text-gray-500 dark:text-gray-400 truncate" title={reporter}>{reporter}</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
)}

View File

@@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const currentSession = useAuthService().getCurrentSession();
const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
// Helper function to emit socket event for persistence
// Helper function to emit socket event for persistence (within-group only)
const emitTaskSortChange = useCallback(
(taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => {
(taskId: string, group: TaskGroup, insertIndex: number) => {
if (!socket || !connected || !projectId) {
logger.warning('Socket not connected or missing project ID');
return;
@@ -40,54 +41,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
const taskUpdates: any[] = [];
// Create a copy of all groups and perform the move operation
const updatedGroups = groups.map(group => ({
...group,
taskIds: [...group.taskIds]
// Create a copy of all groups
const updatedGroups = groups.map(g => ({
...g,
taskIds: [...g.taskIds]
}));
// Find the source and target groups in our copy
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
// Find the group in our copy
const groupCopy = updatedGroups.find(g => g.id === group.id)!;
if (sourceGroup.id === targetGroup.id) {
// Same group - reorder within the group
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
// Remove task from old position
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
// Insert at new position
sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId);
} else {
// Different groups - move task between groups
// Remove from source group
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
// Add to target group
targetGroupCopy.taskIds.splice(insertIndex, 0, taskId);
}
// Reorder within the group
const sourceIndex = groupCopy.taskIds.indexOf(taskId);
// Remove task from old position
groupCopy.taskIds.splice(sourceIndex, 1);
// Insert at new position
groupCopy.taskIds.splice(insertIndex, 0, taskId);
// Now assign sequential sort orders to ALL tasks across ALL groups
let currentSortOrder = 0;
updatedGroups.forEach(group => {
group.taskIds.forEach(id => {
const update: any = {
updatedGroups.forEach(grp => {
grp.taskIds.forEach(id => {
taskUpdates.push({
task_id: id,
sort_order: currentSortOrder
};
// Add group-specific fields for the moved task if it changed groups
if (id === taskId && sourceGroup.id !== targetGroup.id) {
if (currentGrouping === 'status') {
update.status_id = targetGroup.id;
} else if (currentGrouping === 'priority') {
update.priority_id = targetGroup.id;
} else if (currentGrouping === 'phase') {
update.phase_id = targetGroup.id;
}
}
taskUpdates.push(update);
});
currentSortOrder++;
});
});
@@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
project_id: projectId,
group_by: currentGrouping || 'status',
task_updates: taskUpdates,
from_group: sourceGroup.id,
to_group: targetGroup.id,
from_group: group.id,
to_group: group.id,
task: {
id: task.id,
project_id: projectId,
@@ -108,32 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
};
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
// Also emit the specific grouping field change event for the moved task
if (sourceGroup.id !== targetGroup.id) {
if (currentGrouping === 'phase') {
// Emit phase change event
socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: taskId,
phase_id: targetGroup.id,
parent_task: task.parent_task_id || null,
});
} else if (currentGrouping === 'priority') {
// Emit priority change event
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({
task_id: taskId,
priority_id: targetGroup.id,
team_id: teamId,
}));
} else if (currentGrouping === 'status') {
// Emit status change event
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({
task_id: taskId,
status_id: targetGroup.id,
team_id: teamId,
}));
}
}
},
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
);
@@ -148,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
if (!over) {
setOverId(null);
setDropPosition(null);
return;
}
const activeId = active.id;
const overId = over.id;
const activeTask = allTasks.find(task => task.id === active.id);
const overTask = allTasks.find(task => task.id === over.id);
// Set the overId for drop indicators
setOverId(overId as string);
if (activeTask && overTask) {
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) return;
// Only set overId if both tasks are in the same group
if (activeGroup && overGroup && activeGroup.id === overGroup.id) {
setOverId(over.id as string);
// Check if we're dragging over a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Calculate drop position based on task indices
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
let targetGroup = overGroup;
if (overTask) {
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (activeIndex < overIndex) {
setDropPosition('after');
} else {
setDropPosition('before');
}
} else {
setOverId(null);
setDropPosition(null);
}
} else {
setOverId(null);
setDropPosition(null);
}
if (!activeGroup || !targetGroup) return;
},
[allTasks, groups]
);
@@ -183,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const { active, over } = event;
setActiveId(null);
setOverId(null);
setDropPosition(null);
if (!over || active.id === over.id) {
return;
@@ -198,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
return;
}
// Find the groups
// Find the active task's group
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
if (!activeGroup) {
logger.error('Could not find active group for task:', activeId);
return;
}
// Check if we're dropping on a task, group, or empty group
// Only allow dropping on tasks in the same group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Check if dropping on empty group drop zone
const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-');
const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null;
const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null;
let targetGroup = overGroup || emptyGroup;
let insertIndex = 0;
if (overTask) {
// Dropping on a task
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (targetGroup) {
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
}
} else if (overGroup) {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
} else if (emptyGroup) {
// Dropping on an empty group
targetGroup = emptyGroup;
insertIndex = 0; // First position in empty group
}
if (!targetGroup) {
logger.error('Could not find target group');
if (!overTask) {
return;
}
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (!overGroup || overGroup.id !== activeGroup.id) {
return;
}
const isCrossGroup = activeGroup.id !== targetGroup.id;
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
if (isCrossGroup) {
// Moving task between groups
console.log('Moving task between groups:', {
task: activeTask.name || activeTask.title,
from: activeGroup.title,
to: targetGroup.title,
newPosition: insertIndex,
});
// reorderTasksInGroup handles both same-group and cross-group moves
// No need for separate moveTaskBetweenGroups call
if (activeIndex !== overIndex) {
// Reorder task within same group
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
destinationTaskId: overId as string,
sourceGroupId: activeGroup.id,
destinationGroupId: targetGroup.id,
destinationGroupId: activeGroup.id,
})
);
// Emit socket event for persistence
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
} else {
if (activeIndex !== insertIndex) {
// Reorder task within same group at drop position
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: activeGroup.id,
})
);
// Emit socket event for persistence
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
// Calculate the final index after reordering for socket emission
let finalIndex = overIndex;
if (activeIndex < overIndex) {
// When dragging down, the task ends up just after the destination
finalIndex = overIndex;
} else {
// When dragging up, the task ends up at the destination position
finalIndex = overIndex;
}
// Emit socket event for persistence
emitTaskSortChange(activeId as string, activeGroup, finalIndex);
}
},
[allTasks, groups, dispatch, emitTaskSortChange]
@@ -286,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
return {
activeId,
overId,
dropPosition,
handleDragStart,
handleDragOver,
handleDragEnd,

View File

@@ -90,17 +90,39 @@ export const useTaskRowColumns = ({
depth = 0,
}: UseTaskRowColumnsProps) => {
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
switch (columnId) {
case 'dragHandle':
return (
<DragHandleColumn
width={width}
isSubtask={isSubtask}
attributes={attributes}
listeners={listeners}
/>
);
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number, currentBg?: string, rowBackgrounds?: any) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (isSticky && typeof index === 'number') {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
// Create wrapper style for sticky positioning
const wrapperStyle = isSticky ? {
position: 'sticky' as const,
left: leftPosition,
zIndex: 5, // Lower than header but above regular content
backgroundColor: currentBg || (isDarkMode ? '#1e1e1e' : '#ffffff'), // Use dynamic background or fallback
overflow: 'hidden', // Prevent content from spilling over
width: width, // Ensure the wrapper respects column width
} : {};
const renderColumnContent = () => {
switch (columnId) {
case 'dragHandle':
return (
<DragHandleColumn
width={width}
isSubtask={isSubtask}
attributes={attributes}
listeners={listeners}
/>
);
case 'checkbox':
return (
@@ -294,7 +316,27 @@ export const useTaskRowColumns = ({
);
}
return null;
}
};
// Wrap content with sticky positioning if needed
const content = renderColumnContent();
if (isSticky) {
const hoverBg = rowBackgrounds?.hover || (isDarkMode ? '#2a2a2a' : '#f9fafb');
return (
<div
style={{
...wrapperStyle,
'--hover-bg': hoverBg,
} as React.CSSProperties}
className="border-r border-gray-200 dark:border-gray-700 overflow-hidden sticky-column-hover hover:bg-[var(--hover-bg)]"
>
{content}
</div>
);
}
return content;
}, [
task,
projectId,
@@ -319,6 +361,7 @@ export const useTaskRowColumns = ({
handleTaskNameEdit,
attributes,
listeners,
depth,
]);
return { renderColumn };

View File

@@ -14,6 +14,8 @@ import {
EyeOutlined,
InboxOutlined,
CheckOutlined,
SortAscendingOutlined,
SortDescendingOutlined,
} from '@/shared/antd-imports';
import { RootState } from '@/app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
@@ -30,6 +32,12 @@ import {
setArchived as setTaskManagementArchived,
toggleArchived as toggleTaskManagementArchived,
selectArchived,
setSort,
setSortField,
setSortOrder,
selectSort,
selectSortField,
selectSortOrder,
} from '@/features/task-management/task-management.slice';
import {
setCurrentGrouping,
@@ -44,11 +52,13 @@ import {
setLabels,
setSearch,
setPriorities,
setFields,
} from '@/features/tasks/tasks.slice';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { ITaskListColumn } from '@/types/tasks/taskList.types';
import { IGroupBy } from '@/features/tasks/tasks.slice';
import { ITaskListSortableColumn } from '@/types/tasks/taskListFilters.types';
// --- Enhanced Kanban imports ---
import {
setGroupBy as setKanbanGroupBy,
@@ -84,6 +94,12 @@ const FILTER_DEBOUNCE_DELAY = 300; // ms
const SEARCH_DEBOUNCE_DELAY = 500; // ms
const MAX_FILTER_OPTIONS = 100;
// Sort order enum
enum SORT_ORDER {
ASCEND = 'ascend',
DESCEND = 'descend',
}
// Limit options to prevent UI lag
// Optimized selectors with proper transformation logic
@@ -740,6 +756,192 @@ const SearchFilter: React.FC<{
);
};
// Sort Dropdown Component - Simplified version using task-management slice
const SortDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
themeClasses,
isDarkMode,
}) => {
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
const { projectId } = useAppSelector(state => state.projectReducer);
// Get current sort state from task-management slice
const currentSortField = useAppSelector(selectSortField);
const currentSortOrder = useAppSelector(selectSortOrder);
const [open, setOpen] = React.useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
React.useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const sortFieldsList = [
{ label: t('taskText'), key: 'name' },
{ label: t('statusText'), key: 'status' },
{ label: t('priorityText'), key: 'priority' },
{ label: t('startDateText'), key: 'start_date' },
{ label: t('endDateText'), key: 'end_date' },
{ label: t('completedDateText'), key: 'completed_at' },
{ label: t('createdDateText'), key: 'created_at' },
{ label: t('lastUpdatedText'), key: 'updated_at' },
];
const handleSortFieldChange = (fieldKey: string) => {
// If clicking the same field, toggle order, otherwise set new field with ASC
if (currentSortField === fieldKey) {
const newOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC';
dispatch(setSort({ field: fieldKey, order: newOrder }));
} else {
dispatch(setSort({ field: fieldKey, order: 'ASC' }));
}
// Fetch updated tasks
if (projectId) {
dispatch(fetchTasksV3(projectId));
}
setOpen(false);
};
const clearSort = () => {
dispatch(setSort({ field: '', order: 'ASC' }));
if (projectId) {
dispatch(fetchTasksV3(projectId));
}
};
const isActive = currentSortField !== '';
const currentFieldLabel = sortFieldsList.find(f => f.key === currentSortField)?.label;
const orderText = currentSortOrder === 'ASC' ? t('ascendingOrder') : t('descendingOrder');
return (
<div className="relative" ref={dropdownRef}>
{/* Trigger Button - matching FilterDropdown style */}
<button
onClick={() => setOpen(!open)}
title={
isActive
? t('currentSort', { field: currentFieldLabel, order: orderText })
: t('sortText')
}
className={`
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
border transition-all duration-200 ease-in-out
${
isActive
? isDarkMode
? 'bg-gray-600 text-white border-gray-500'
: 'bg-gray-200 text-gray-800 border-gray-300 font-semibold'
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
}
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
`}
aria-expanded={open}
aria-haspopup="true"
>
{currentSortOrder === 'ASC' ? (
<SortAscendingOutlined className="w-3.5 h-3.5" />
) : (
<SortDescendingOutlined className="w-3.5 h-3.5" />
)}
<span className="hidden sm:inline">{t('sortText')}</span>
{isActive && currentFieldLabel && (
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} max-w-16 truncate hidden md:inline`}>
{currentFieldLabel}
</span>
)}
<DownOutlined
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown Panel - matching FilterDropdown style */}
{open && (
<div
className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}
>
{/* Clear Sort Option */}
{isActive && (
<div className={`p-2 border-b ${themeClasses.dividerBorder}`}>
<button
onClick={clearSort}
className={`w-full text-left px-2 py-1.5 text-xs rounded transition-colors duration-150 ${themeClasses.optionText} ${themeClasses.optionHover}`}
>
{t('clearSort')}
</button>
</div>
)}
{/* Options List */}
<div className="max-h-48 overflow-y-auto">
<div className="p-0.5">
{sortFieldsList.map(sortField => {
const isSelected = currentSortField === sortField.key;
return (
<button
key={sortField.key}
onClick={() => handleSortFieldChange(sortField.key)}
className={`
w-full flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded
transition-colors duration-150 text-left
${
isSelected
? isDarkMode
? 'bg-gray-600 text-white'
: 'bg-gray-200 text-gray-800 font-semibold'
: `${themeClasses.optionText} ${themeClasses.optionHover}`
}
`}
title={
isSelected
? t('currentSort', {
field: sortField.label,
order: orderText
}) + ` - ${t('sortDescending')}`
: t('sortByField', { field: sortField.label }) + ` - ${t('sortAscending')}`
}
>
<div className="flex items-center gap-2">
<span className="truncate">{sortField.label}</span>
{isSelected && (
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
({orderText})
</span>
)}
</div>
<div className="flex items-center gap-1">
{isSelected ? (
currentSortOrder === 'ASC' ? (
<SortAscendingOutlined className="w-3.5 h-3.5" />
) : (
<SortDescendingOutlined className="w-3.5 h-3.5" />
)
) : (
<SortAscendingOutlined className="w-3.5 h-3.5 opacity-50" />
)}
</div>
</button>
);
})}
</div>
</div>
</div>
)}
</div>
);
};
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
@@ -1050,14 +1252,20 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
};
}, [dispatch, projectView]);
// Get sort fields for active count calculation
const sortFields = useAppSelector(state => state.taskReducer.fields);
const taskManagementSortField = useAppSelector(selectSortField);
// Calculate active filters count - memoized to prevent unnecessary recalculations
const calculatedActiveFiltersCount = useMemo(() => {
const count = filterSections.reduce(
(acc, section) => (section.id === 'groupBy' ? acc : acc + section.selectedValues.length),
0
);
return count + (searchValue ? 1 : 0);
}, [filterSections, searchValue]);
const sortFieldsCount = position === 'list' ? sortFields.length : 0;
const taskManagementSortCount = position === 'list' && taskManagementSortField ? 1 : 0;
return count + (searchValue ? 1 : 0) + sortFieldsCount + taskManagementSortCount;
}, [filterSections, searchValue, sortFields, taskManagementSortField, position]);
useEffect(() => {
if (activeFiltersCount !== calculatedActiveFiltersCount) {
@@ -1231,6 +1439,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
// Clear priority filters
dispatch(setPriorities([]));
// Clear sort fields
dispatch(setFields([]));
// Clear sort from task-management slice
dispatch(setSort({ field: '', order: 'ASC' }));
// Clear archived state based on position
if (position === 'list') {
dispatch(setTaskManagementArchived(false));
@@ -1276,9 +1490,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
<div
className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-1.5 shadow-sm ${className}`}
>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center justify-between gap-2 min-h-[36px]">
{/* Left Section - Main Filters */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-0">
{/* Search */}
<SearchFilter
value={searchValue}
@@ -1287,6 +1501,11 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
themeClasses={themeClasses}
/>
{/* Sort Filter Button (for list view) - appears after search */}
{position === 'list' && (
<SortDropdown themeClasses={themeClasses} isDarkMode={isDarkMode} />
)}
{/* Filter Dropdowns - Only render when data is loaded */}
{isDataLoaded ? (
filterSectionsData.map(section => (
@@ -1316,7 +1535,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
</div>
{/* Right Section - Additional Controls */}
<div className="flex items-center gap-2 ml-auto">
<div className="flex flex-wrap items-center gap-2 ml-auto min-w-0 shrink-0">
{/* Active Filters Indicator */}
{activeFiltersCount > 0 && (
<div className="flex items-center gap-1.5">

View File

@@ -8,6 +8,8 @@ import { Task } from '@/types/task-management.types';
import {
updateTask,
selectCurrentGroupingV3,
selectGroups,
moveTaskBetweenGroups,
} from '@/features/task-management/task-management.slice';
interface TaskStatusDropdownProps {
@@ -30,6 +32,7 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
const statusList = useAppSelector(state => state.taskStatusReducer.status);
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
const groups = useAppSelector(selectGroups);
// Find current status details
const currentStatus = useMemo(() => {
@@ -44,21 +47,53 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
(statusId: string, statusName: string) => {
if (!task.id || !statusId || !connected) return;
console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName });
// Optimistic update: immediately update the task status in Redux for instant feedback
const updatedTask = {
...task,
status: statusId,
updatedAt: new Date().toISOString(),
};
dispatch(updateTask(updatedTask));
// Handle group movement if grouping by status
if (currentGroupingV3 === 'status' && groups && groups.length > 0) {
// Find current group containing the task
const currentGroup = groups.find(group => group.taskIds.includes(task.id));
// Find target group based on the new status ID
let targetGroup = groups.find(group => group.id === statusId);
// If not found by status ID, try matching with group value
if (!targetGroup) {
targetGroup = groups.find(group => group.groupValue === statusId);
}
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
// Move task between groups immediately for instant feedback
dispatch(
moveTaskBetweenGroups({
taskId: task.id,
sourceGroupId: currentGroup.id,
targetGroupId: targetGroup.id,
})
);
}
}
// Emit socket event for server-side update and real-time sync
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
status_id: statusId,
parent_task: null, // Assuming top-level tasks for now
team_id: projectId, // Using projectId as teamId
parent_task: task.parent_task_id || null,
team_id: projectId,
})
);
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
setIsOpen(false);
},
[task.id, connected, socket, projectId]
[task, connected, socket, projectId, dispatch, currentGroupingV3, groups]
);
// Calculate dropdown position and handle outside clicks

View File

@@ -61,6 +61,10 @@ const accountSetupSlice = createSlice({
setSurveySubStep: (state, action: PayloadAction<number>) => {
state.surveySubStep = action.payload;
},
resetSurveyData: (state) => {
state.surveyData = {};
state.surveySubStep = 0;
},
resetAccountSetup: () => initialState,
},
});
@@ -74,6 +78,7 @@ export const {
setCurrentStep,
setSurveyData,
setSurveySubStep,
resetSurveyData,
resetAccountSetup,
} = accountSetupSlice.actions;

View File

@@ -0,0 +1,59 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ActivityFeedType } from '@/types/home/user-activity.types';
interface ActivityItem {
id: string;
type: string;
description: string;
timestamp: string;
}
interface UserActivityState {
activeTab: ActivityFeedType;
activities: ActivityItem[];
loading: boolean;
error: string | null;
}
const initialState: UserActivityState = {
activeTab: ActivityFeedType.TIME_LOGGED_TASKS,
activities: [],
loading: false,
error: null,
};
const userActivitySlice = createSlice({
name: 'userActivity',
initialState,
reducers: {
setActiveTab(state, action: PayloadAction<ActivityFeedType>) {
state.activeTab = action.payload;
},
fetchActivitiesStart(state) {
state.loading = true;
state.error = null;
},
fetchActivitiesSuccess(state, action: PayloadAction<ActivityItem[]>) {
state.activities = action.payload;
state.loading = false;
state.error = null;
},
fetchActivitiesFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
clearActivities(state) {
state.activities = [];
},
},
});
export const {
setActiveTab,
fetchActivitiesStart,
fetchActivitiesSuccess,
fetchActivitiesFailure,
clearActivities,
} = userActivitySlice.actions;
export default userActivitySlice.reducer;

View File

@@ -153,8 +153,8 @@ const Navbar = () => {
<Flex align="center">
<SwitchTeamButton />
<NotificationButton />
{/* <TimerButton /> */}
<HelpButton />
<TimerButton />
{/* <HelpButton /> */}
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
</Flex>
</Flex>

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