Compare commits
1 Commits
chore/add-
...
chore/adde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeec5b2b84 |
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
237
.cursor/rules/antd-components.mdc
Normal file
237
.cursor/rules/antd-components.mdc
Normal file
@@ -0,0 +1,237 @@
|
||||
---
|
||||
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
2
.gitignore
vendored
@@ -36,8 +36,6 @@ lerna-debug.log*
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
# 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
|
||||
@@ -2297,60 +2297,3 @@ 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);
|
||||
|
||||
@@ -142,25 +142,3 @@ 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 $$;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,220 +0,0 @@
|
||||
-- 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();
|
||||
@@ -1,253 +0,0 @@
|
||||
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
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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, TRIAL_MEMBER_LIMIT} from "../shared/constants";
|
||||
import {statusExclude} from "../shared/constants";
|
||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||
|
||||
export default class ProjectMembersController extends WorklenzControllerBase {
|
||||
@@ -118,17 +118,6 @@ 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") {
|
||||
|
||||
@@ -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,43 +30,20 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
||||
// For custom date ranges
|
||||
if (dateRange.length === 2) {
|
||||
try {
|
||||
// Handle different date formats that might come from frontend
|
||||
let startDate, endDate;
|
||||
|
||||
// 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]);
|
||||
}
|
||||
|
||||
// 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`;
|
||||
// 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');
|
||||
|
||||
// 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 task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.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
|
||||
@@ -75,20 +52,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 "";
|
||||
@@ -97,7 +74,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 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`;
|
||||
}
|
||||
|
||||
return "";
|
||||
@@ -110,7 +87,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 = "YYYY-MM-DD HH:mm:ss") {
|
||||
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") {
|
||||
return moment.tz(date, userTimezone).format(format);
|
||||
}
|
||||
|
||||
@@ -127,12 +104,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;
|
||||
|
||||
@@ -164,38 +164,4 @@ 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));
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,10 @@ 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, TRIAL_MEMBER_LIMIT } from "../shared/constants";
|
||||
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } 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 {
|
||||
|
||||
@@ -76,8 +72,7 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`,
|
||||
"ERROR_SPAM_DETECTED": `Invitation blocked: {0}`
|
||||
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`
|
||||
}
|
||||
})
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
@@ -87,54 +82,6 @@ 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
|
||||
@@ -194,17 +141,6 @@ 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.
|
||||
@@ -1145,18 +1081,6 @@ 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") {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ 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`));
|
||||
}
|
||||
|
||||
// answer_text and answer_json are both optional - users can submit empty answers
|
||||
// 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`));
|
||||
}
|
||||
|
||||
// Validate answer_text if provided
|
||||
if (answer.answer_text && typeof answer.answer_text !== 'string') {
|
||||
|
||||
@@ -8,10 +8,6 @@ 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 = `
|
||||
@@ -53,111 +49,12 @@ 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) {
|
||||
|
||||
@@ -81,12 +81,5 @@
|
||||
"delete": "Fshi",
|
||||
"enterStatusName": "Shkruani emrin e statusit",
|
||||
"selectCategory": "Zgjidh kategorinë",
|
||||
"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}}"
|
||||
"close": "Mbyll"
|
||||
}
|
||||
|
||||
@@ -81,12 +81,5 @@
|
||||
"delete": "Löschen",
|
||||
"enterStatusName": "Statusnamen eingeben",
|
||||
"selectCategory": "Kategorie auswählen",
|
||||
"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}}"
|
||||
"close": "Schließen"
|
||||
}
|
||||
|
||||
@@ -81,12 +81,5 @@
|
||||
"delete": "Delete",
|
||||
"enterStatusName": "Enter status name",
|
||||
"selectCategory": "Select category",
|
||||
"close": "Close",
|
||||
"clearSort": "Clear Sort",
|
||||
"sortAscending": "Sort Ascending",
|
||||
"sortDescending": "Sort Descending",
|
||||
"sortByField": "Sort by {{field}}",
|
||||
"ascendingOrder": "Ascending",
|
||||
"descendingOrder": "Descending",
|
||||
"currentSort": "Current sort: {{field}} {{order}}"
|
||||
"close": "Close"
|
||||
}
|
||||
|
||||
@@ -77,12 +77,5 @@
|
||||
"delete": "Eliminar",
|
||||
"enterStatusName": "Introducir nombre del estado",
|
||||
"selectCategory": "Seleccionar categoría",
|
||||
"close": "Cerrar",
|
||||
"clearSort": "Limpiar Ordenamiento",
|
||||
"sortAscending": "Ordenar Ascendente",
|
||||
"sortDescending": "Ordenar Descendente",
|
||||
"sortByField": "Ordenar por {{field}}",
|
||||
"ascendingOrder": "Ascendente",
|
||||
"descendingOrder": "Descendente",
|
||||
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
|
||||
"close": "Cerrar"
|
||||
}
|
||||
|
||||
@@ -78,12 +78,5 @@
|
||||
"delete": "Excluir",
|
||||
"enterStatusName": "Digite o nome do status",
|
||||
"selectCategory": "Selecionar categoria",
|
||||
"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}}"
|
||||
"close": "Fechar"
|
||||
}
|
||||
|
||||
@@ -75,12 +75,5 @@
|
||||
"delete": "删除",
|
||||
"enterStatusName": "输入状态名称",
|
||||
"selectCategory": "选择类别",
|
||||
"close": "关闭",
|
||||
"clearSort": "清除排序",
|
||||
"sortAscending": "升序排列",
|
||||
"sortDescending": "降序排列",
|
||||
"sortByField": "按{{field}}排序",
|
||||
"ascendingOrder": "升序",
|
||||
"descendingOrder": "降序",
|
||||
"currentSort": "当前排序:{{field}} {{order}}"
|
||||
"close": "关闭"
|
||||
}
|
||||
@@ -1,127 +1,122 @@
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
import surveyApiRouter from "./survey-api-router";
|
||||
|
||||
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();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
api.use("/surveys", surveyApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
api.use("/logs", userActivityLogsApiRouter);
|
||||
api.use("/moderation", moderationApiRouter);
|
||||
export default api;
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
import surveyApiRouter from "./survey-api-router";
|
||||
|
||||
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";
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
api.use("/surveys", surveyApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
@@ -8,9 +8,6 @@ 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));
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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();
|
||||
|
||||
@@ -14,7 +13,7 @@ const teamMembersApiRouter = express.Router();
|
||||
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
|
||||
teamMembersApiRouter.get("/export/:id", idParamValidator, safeControllerFunction(TeamMembersController.exportByMember));
|
||||
|
||||
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(5, 15 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
|
||||
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
|
||||
teamMembersApiRouter.get("/", safeControllerFunction(TeamMembersController.get));
|
||||
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
|
||||
teamMembersApiRouter.get("/tree-map", safeControllerFunction(TeamMembersController.getTeamMembersTreeMap));
|
||||
@@ -31,6 +30,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, RateLimiter.inviteRateLimit(3, 10 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
|
||||
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
|
||||
|
||||
export default teamMembersApiRouter;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
@@ -160,9 +160,6 @@ 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";
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,4 @@ VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
|
||||
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
|
||||
VITE_ENABLE_GOOGLE_LOGIN=false
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
1
worklenz-frontend/.gitignore
vendored
1
worklenz-frontend/.gitignore
vendored
@@ -11,7 +11,6 @@
|
||||
# production
|
||||
/build
|
||||
/public/tinymce
|
||||
/docs
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
554
worklenz-frontend/package-lock.json
generated
554
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,7 @@
|
||||
"build": "vite build",
|
||||
"dev-build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui"
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^7.1.0",
|
||||
@@ -81,10 +77,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -76,27 +76,40 @@ 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 {
|
||||
background: #141414 !important;
|
||||
#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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
/* 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;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"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ë)",
|
||||
@@ -81,8 +80,6 @@
|
||||
"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",
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
"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",
|
||||
"cancel": "Anulo"
|
||||
"connection-restored": "U lidhët me serverin me sukses"
|
||||
}
|
||||
|
||||
@@ -41,22 +41,6 @@
|
||||
"list": "Listë",
|
||||
"calendar": "Kalendar",
|
||||
"tasks": "Detyrat",
|
||||
"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"
|
||||
"refresh": "Rifresko"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -84,12 +84,5 @@
|
||||
"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",
|
||||
"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}}"
|
||||
"ok": "OK"
|
||||
}
|
||||
|
||||
@@ -57,9 +57,6 @@
|
||||
|
||||
"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",
|
||||
@@ -136,11 +133,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"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)",
|
||||
@@ -91,8 +90,6 @@
|
||||
"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",
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"emailLabel": "E-Mail",
|
||||
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
|
||||
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
|
||||
"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",
|
||||
"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",
|
||||
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
|
||||
"passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!",
|
||||
"passwordMaxCharacterRequired": "Das Passwort darf maximal 32 Zeichen lang sein!",
|
||||
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
|
||||
"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.",
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
"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",
|
||||
"cancel": "Abbrechen"
|
||||
"connection-restored": "Erfolgreich mit dem Server verbunden"
|
||||
}
|
||||
|
||||
@@ -41,22 +41,6 @@
|
||||
"list": "Liste",
|
||||
"calendar": "Kalender",
|
||||
"tasks": "Aufgaben",
|
||||
"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"
|
||||
"refresh": "Aktualisieren"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -84,12 +84,5 @@
|
||||
"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",
|
||||
"clearSort": "Sortierung löschen",
|
||||
"sortAscending": "Aufsteigend sortieren",
|
||||
"sortDescending": "Absteigend sortieren",
|
||||
"sortByField": "Sortieren nach {{field}}",
|
||||
"ascendingOrder": "Aufsteigend",
|
||||
"descendingOrder": "Absteigend",
|
||||
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
|
||||
"ok": "OK"
|
||||
}
|
||||
|
||||
@@ -57,9 +57,6 @@
|
||||
|
||||
"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",
|
||||
@@ -136,11 +133,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"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)",
|
||||
@@ -89,8 +88,6 @@
|
||||
"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",
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
"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",
|
||||
"cancel": "Cancel"
|
||||
"connection-restored": "Connected to server successfully"
|
||||
}
|
||||
|
||||
@@ -41,22 +41,6 @@
|
||||
"list": "List",
|
||||
"calendar": "Calendar",
|
||||
"tasks": "Tasks",
|
||||
"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"
|
||||
"refresh": "Refresh"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -84,12 +84,5 @@
|
||||
"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",
|
||||
"clearSort": "Clear Sort",
|
||||
"sortAscending": "Sort Ascending",
|
||||
"sortDescending": "Sort Descending",
|
||||
"sortByField": "Sort by {{field}}",
|
||||
"ascendingOrder": "Ascending",
|
||||
"descendingOrder": "Descending",
|
||||
"currentSort": "Current sort: {{field}} {{order}}"
|
||||
"ok": "OK"
|
||||
}
|
||||
|
||||
@@ -57,9 +57,6 @@
|
||||
|
||||
"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",
|
||||
@@ -136,11 +133,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"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",
|
||||
|
||||
@@ -92,8 +91,6 @@
|
||||
"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",
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"emailLabel": "Correo electrónico",
|
||||
"emailPlaceholder": "Ingresa tu correo electrónico",
|
||||
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
|
||||
"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",
|
||||
"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",
|
||||
"passwordRequired": "¡Por favor ingresa tu contraseña!",
|
||||
"passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!",
|
||||
"passwordMaxCharacterRequired": "¡La contraseña debe tener como máximo 32 caracteres!",
|
||||
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
|
||||
"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.",
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
"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...",
|
||||
"cancel": "Cancelar"
|
||||
"connection-restored": "Conexión restaurada. Reconectando al servidor..."
|
||||
}
|
||||
|
||||
@@ -40,22 +40,6 @@
|
||||
"list": "Lista",
|
||||
"calendar": "Calendario",
|
||||
"tasks": "Tareas",
|
||||
"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"
|
||||
"refresh": "Actualizar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -84,12 +84,5 @@
|
||||
"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",
|
||||
"clearSort": "Limpiar Ordenamiento",
|
||||
"sortAscending": "Ordenar Ascendente",
|
||||
"sortDescending": "Ordenar Descendente",
|
||||
"sortByField": "Ordenar por {{field}}",
|
||||
"ascendingOrder": "Ascendente",
|
||||
"descendingOrder": "Descendente",
|
||||
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
|
||||
"ok": "OK"
|
||||
}
|
||||
|
||||
@@ -57,9 +57,6 @@
|
||||
|
||||
"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",
|
||||
@@ -136,11 +133,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"step3InputLabel": "Convidar por email",
|
||||
"addAnother": "Adicionar outro",
|
||||
"skipForNow": "Pular por enquanto",
|
||||
"skipping": "Pulando...",
|
||||
"formTitle": "Crie sua primeira tarefa.",
|
||||
"step3Title": "Convide sua equipe para trabalhar",
|
||||
|
||||
@@ -92,8 +91,6 @@
|
||||
"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",
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
"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...",
|
||||
"cancel": "Cancelar"
|
||||
"connection-restored": "Conexão restaurada. Reconectando ao servidor..."
|
||||
}
|
||||
|
||||
@@ -40,22 +40,6 @@
|
||||
"list": "Lista",
|
||||
"calendar": "Calendário",
|
||||
"tasks": "Tarefas",
|
||||
"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"
|
||||
"refresh": "Atualizar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -84,12 +84,5 @@
|
||||
"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",
|
||||
"clearSort": "Limpar Ordenação",
|
||||
"sortAscending": "Ordenar Crescente",
|
||||
"sortDescending": "Ordenar Decrescente",
|
||||
"sortByField": "Ordenar por {{field}}",
|
||||
"ascendingOrder": "Crescente",
|
||||
"descendingOrder": "Decrescente",
|
||||
"currentSort": "Ordenação atual: {{field}} {{order}}"
|
||||
"ok": "OK"
|
||||
}
|
||||
|
||||
@@ -57,9 +57,6 @@
|
||||
|
||||
"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",
|
||||
@@ -136,11 +133,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"step3InputLabel": "通过电子邮件邀请",
|
||||
"addAnother": "添加另一个",
|
||||
"skipForNow": "暂时跳过",
|
||||
"skipping": "跳过中...",
|
||||
"formTitle": "创建您的第一个任务。",
|
||||
"step3Title": "邀请您的团队一起工作",
|
||||
"maxMembers": "(您最多可以邀请 5 名成员)",
|
||||
@@ -90,8 +89,6 @@
|
||||
"discoveryQuestion": "您是如何听说我们的?",
|
||||
"allSetTitle": "一切就绪!",
|
||||
"allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧",
|
||||
"surveyCompleteTitle": "谢谢!",
|
||||
"surveyCompleteDescription": "您的反馈有助于我们为所有人改进 Worklenz",
|
||||
"aboutYouStepName": "关于您",
|
||||
"yourNeedsStepName": "您的需求",
|
||||
"discoveryStepName": "发现",
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
|
||||
"reconnecting": "与服务器断开连接。",
|
||||
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
||||
"connection-restored": "成功连接到服务器",
|
||||
"cancel": "取消"
|
||||
"connection-restored": "成功连接到服务器"
|
||||
}
|
||||
@@ -41,22 +41,6 @@
|
||||
"list": "列表",
|
||||
"calendar": "日历",
|
||||
"tasks": "任务",
|
||||
"refresh": "刷新",
|
||||
"recentActivity": "最近活动",
|
||||
"recentTasks": "最近任务",
|
||||
"recentTasksSegment": "最近任务",
|
||||
"timeLogged": "时间记录",
|
||||
"timeLoggedSegment": "时间记录",
|
||||
"noRecentTasks": "没有最近任务",
|
||||
"noTimeLoggedTasks": "没有时间记录任务",
|
||||
"activityTag": "活动",
|
||||
"timeLogTag": "时间记录",
|
||||
"timerTag": "计时器",
|
||||
"activitySingular": "活动",
|
||||
"activityPlural": "活动",
|
||||
"recentTaskAriaLabel": "最近任务:",
|
||||
"timeLoggedTaskAriaLabel": "时间记录任务:",
|
||||
"errorLoadingRecentTasks": "加载最近任务时出错",
|
||||
"errorLoadingTimeLoggedTasks": "加载时间记录任务时出错"
|
||||
"refresh": "刷新"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"modalTitle": "帮助我们提升您的体验",
|
||||
"skip": "暂时跳过",
|
||||
"previous": "上一步",
|
||||
"next": "下一步",
|
||||
"completeSurvey": "完成调查",
|
||||
"submitting": "正在提交您的回答...",
|
||||
"submitSuccessTitle": "谢谢!",
|
||||
"submitSuccessSubtitle": "您的反馈帮助我们改进 Worklenz。",
|
||||
"submitSuccessMessage": "感谢您完成调查!",
|
||||
"submitErrorMessage": "提交调查失败。请重试。",
|
||||
"submitErrorLog": "提交调查失败",
|
||||
"fetchErrorLog": "获取调查失败"
|
||||
}
|
||||
@@ -79,12 +79,5 @@
|
||||
"close": "关闭",
|
||||
"cannotMoveStatus": "无法移动状态",
|
||||
"cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。",
|
||||
"ok": "确定",
|
||||
"clearSort": "清除排序",
|
||||
"sortAscending": "升序排列",
|
||||
"sortDescending": "降序排列",
|
||||
"sortByField": "按{{field}}排序",
|
||||
"ascendingOrder": "升序",
|
||||
"descendingOrder": "降序",
|
||||
"currentSort": "当前排序:{{field}} {{order}}"
|
||||
"ok": "确定"
|
||||
}
|
||||
@@ -50,9 +50,6 @@
|
||||
"pendingInvitation": "待处理邀请",
|
||||
"contextMenu": {
|
||||
"assignToMe": "分配给我",
|
||||
"copyLink": "复制任务链接",
|
||||
"linkCopied": "链接已复制到剪贴板",
|
||||
"linkCopyFailed": "复制链接失败",
|
||||
"moveTo": "移动到",
|
||||
"unarchive": "取消归档",
|
||||
"archive": "归档",
|
||||
@@ -129,11 +126,5 @@
|
||||
"dependencies": "任务有依赖项",
|
||||
"recurring": "重复任务"
|
||||
}
|
||||
},
|
||||
|
||||
"timer": {
|
||||
"conflictTitle": "计时器已在运行",
|
||||
"conflictMessage": "您在项目\"{{projectName}}\"中的\"{{taskName}}\"任务正在运行计时器。您是否要停止该计时器并为此任务启动新的计时器?",
|
||||
"stopAndStart": "停止并启动新计时器"
|
||||
}
|
||||
}
|
||||
55
worklenz-frontend/sonar-project.properties
Normal file
55
worklenz-frontend/sonar-project.properties
Normal file
@@ -0,0 +1,55 @@
|
||||
# SonarQube Configuration for Worklenz Frontend
|
||||
sonar.projectKey=worklenz-frontend
|
||||
sonar.projectName=Worklenz Frontend
|
||||
sonar.projectVersion=1.0.0
|
||||
|
||||
# Source code configuration
|
||||
sonar.sources=src
|
||||
sonar.tests=src
|
||||
sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx
|
||||
|
||||
# Language-specific configurations
|
||||
sonar.typescript.node=node
|
||||
sonar.typescript.lcov.reportPaths=coverage/lcov.info
|
||||
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||
|
||||
# Exclusions
|
||||
sonar.exclusions=**/node_modules/**,\
|
||||
**/build/**,\
|
||||
**/dist/**,\
|
||||
**/public/**,\
|
||||
**/*.d.ts,\
|
||||
src/react-app-env.d.ts,\
|
||||
src/vite-env.d.ts,\
|
||||
**/*.config.js,\
|
||||
**/*.config.ts,\
|
||||
**/*.config.mts,\
|
||||
scripts/**
|
||||
|
||||
# Test exclusions from coverage
|
||||
sonar.coverage.exclusions=**/*.test.ts,\
|
||||
**/*.test.tsx,\
|
||||
**/*.spec.ts,\
|
||||
**/*.spec.tsx,\
|
||||
**/*.config.*,\
|
||||
src/index.tsx,\
|
||||
src/reportWebVitals.ts,\
|
||||
src/serviceWorkerRegistration.ts,\
|
||||
src/setupTests.ts
|
||||
|
||||
# Code quality rules
|
||||
sonar.qualitygate.wait=true
|
||||
|
||||
# File encoding
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# JavaScript/TypeScript specific settings
|
||||
sonar.javascript.environments=browser,node,jest
|
||||
sonar.typescript.tsconfigPath=tsconfig.json
|
||||
|
||||
# ESLint configuration (if available)
|
||||
# sonar.eslint.reportPaths=eslint-report.json
|
||||
|
||||
# Additional settings for React projects
|
||||
sonar.javascript.file.suffixes=.js,.jsx
|
||||
sonar.typescript.file.suffixes=.ts,.tsx
|
||||
@@ -1,46 +0,0 @@
|
||||
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;
|
||||
|
||||
|
||||
@@ -18,10 +18,5 @@ 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;
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@ 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`;
|
||||
|
||||
@@ -18,11 +17,7 @@ export interface IRunningTimer {
|
||||
|
||||
export const taskTimeLogsApiService = {
|
||||
getByTask: async (id: string): Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
||||
const session = getUserSession();
|
||||
const timezone = session?.timezone_name || 'UTC';
|
||||
const response = await apiClient.get(`${rootUrl}/task/${id}`, {
|
||||
params: { time_zone_name: timezone }
|
||||
});
|
||||
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -83,7 +82,6 @@ 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';
|
||||
@@ -92,7 +90,7 @@ export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
|
||||
}).concat(homePageApiService.middleware, projectsApi.middleware),
|
||||
reducer: {
|
||||
// Auth & User
|
||||
auth: authReducer,
|
||||
@@ -105,9 +103,6 @@ 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,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localS
|
||||
import { Button, ConfigProvider, Tooltip } from '@/shared/antd-imports';
|
||||
import { PushpinFilled, PushpinOutlined } from '@/shared/antd-imports';
|
||||
import { colors } from '../styles/colors';
|
||||
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes';
|
||||
import { navRoutes, NavRoutesType } from '../lib/navbar/navRoutes';
|
||||
|
||||
// Props type for the component
|
||||
type PinRouteToNavbarButtonProps = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form, Input, InputRef, Typography, Card, Tooltip, Alert } from '@/shared/antd-imports';
|
||||
import { Form, Input, InputRef, Typography, Card, Tooltip } 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;
|
||||
|
||||
@@ -30,7 +29,6 @@ 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(() => {
|
||||
@@ -46,19 +44,7 @@ export const OrganizationStep: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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('');
|
||||
}
|
||||
|
||||
const sanitizedValue = sanitizeInput(e.target.value);
|
||||
dispatch(setOrganizationName(sanitizedValue));
|
||||
};
|
||||
|
||||
@@ -74,25 +60,12 @@ 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: spamWarning ? token?.colorWarning : token?.colorPrimary,
|
||||
borderColor: token?.colorPrimary,
|
||||
backgroundColor: token?.colorBgContainer
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,6 @@ interface Props {
|
||||
styles: any;
|
||||
isDarkMode: boolean;
|
||||
token?: any;
|
||||
isModal?: boolean; // New prop to indicate if used in modal context
|
||||
}
|
||||
|
||||
interface SurveyPageProps {
|
||||
@@ -30,7 +29,6 @@ interface SurveyPageProps {
|
||||
surveyData: IAccountSetupSurveyData;
|
||||
handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void;
|
||||
handleUseCaseToggle?: (value: UseCase) => void;
|
||||
isModal?: boolean;
|
||||
}
|
||||
|
||||
// Page 1: About You
|
||||
@@ -237,7 +235,7 @@ const YourNeedsPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, h
|
||||
};
|
||||
|
||||
// Page 3: Discovery
|
||||
const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange, isModal }) => {
|
||||
const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange }) => {
|
||||
const { t } = useTranslation('account-setup');
|
||||
|
||||
const howHeardAboutOptions: { value: HowHeardAbout; label: string; icon: string }[] = [
|
||||
@@ -293,18 +291,14 @@ 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 }}>
|
||||
{isModal ? t('surveyCompleteTitle') : t('allSetTitle')}
|
||||
</Title>
|
||||
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>
|
||||
{isModal ? t('surveyCompleteDescription') : t('allSetDescription')}
|
||||
</Paragraph>
|
||||
<Title level={4} style={{ color: token?.colorText, marginBottom: 8 }}>{t('allSetTitle')}</Title>
|
||||
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>{t('allSetDescription')}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token, isModal = false }) => {
|
||||
export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token }) => {
|
||||
const { t } = useTranslation('account-setup');
|
||||
const dispatch = useDispatch();
|
||||
const { surveyData, surveySubStep } = useSelector((state: RootState) => state.accountSetupReducer);
|
||||
@@ -345,9 +339,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} 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} />
|
||||
<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} />
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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, Alert } from '@/shared/antd-imports';
|
||||
import { Card, Button, Tooltip, Typography } 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;
|
||||
@@ -17,7 +16,6 @@ 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);
|
||||
@@ -36,18 +34,7 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
|
||||
};
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
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('');
|
||||
}
|
||||
setNewName(e.target.value);
|
||||
};
|
||||
|
||||
const updateOrganizationName = async () => {
|
||||
@@ -75,16 +62,6 @@ 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 ? (
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { AutoComplete, Button, Drawer, Flex, Form, message, Modal, Select, Spin, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Flex,
|
||||
Form,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Spin,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
@@ -11,7 +21,6 @@ import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.se
|
||||
import { IJobTitle } from '@/types/job.types';
|
||||
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
|
||||
import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request';
|
||||
import { LinkOutlined } from '@ant-design/icons';
|
||||
|
||||
interface FormValues {
|
||||
email: string[];
|
||||
|
||||
@@ -17,7 +17,7 @@ interface InvitationItemProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const InvitationItem: React.FC<InvitationItemProps> = ({ item, isUnreadNotifications, t }) => {
|
||||
const InvitationItem = ({ item, isUnreadNotifications, t }: InvitationItemProps) => {
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -1,55 +1,60 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useState, useMemo, memo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from '@/shared/antd-imports';
|
||||
import { Col, ConfigProvider, Flex, Menu } from '@/shared/antd-imports';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
|
||||
import InviteTeamMembers from '../common/invite-team-members/invite-team-members';
|
||||
import InviteButton from './invite/InviteButton';
|
||||
import MobileMenuButton from './mobileMenu/MobileMenuButton';
|
||||
import NavbarLogo from './navbar-logo';
|
||||
import NotificationButton from '../../components/navbar/notifications/notifications-drawer/notification/notification-button';
|
||||
import ProfileButton from './user-profile/profile-button';
|
||||
import NavbarLogo from './NavbarLogo';
|
||||
import NotificationButton from './NotificationButton';
|
||||
import ProfileButton from './user-profile/ProfileButton';
|
||||
import SwitchTeamButton from './switchTeam/SwitchTeamButton';
|
||||
import UpgradePlanButton from './upgradePlan/UpgradePlanButton';
|
||||
import NotificationDrawer from '../../components/navbar/notifications/notifications-drawer/notification/notfication-drawer';
|
||||
import NotificationDrawer from './notifications/notifications-drawer/notfication-drawer';
|
||||
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import { getJSONFromLocalStorage } from '@/utils/localStorageFunctions';
|
||||
import { navRoutes, NavRoutesType } from './navRoutes';
|
||||
import { navRoutes, NavRoutesType } from '@/lib/navbar/navRoutes';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TimerButton from './timers/timer-button';
|
||||
import HelpButton from './help/HelpButton';
|
||||
|
||||
const Navbar = () => {
|
||||
const Navbar = memo(() => {
|
||||
const [current, setCurrent] = useState<string>('home');
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const authService = useAuthService();
|
||||
const currentSession = authService.getCurrentSession();
|
||||
const [daysUntilExpiry, setDaysUntilExpiry] = useState<number | null>(null);
|
||||
|
||||
const location = useLocation();
|
||||
const { isDesktop, isMobile, isTablet } = useResponsive();
|
||||
const { t } = useTranslation('navbar');
|
||||
const authService = useAuthService();
|
||||
const [navRoutesList, setNavRoutesList] = useState<NavRoutesType[]>(navRoutes);
|
||||
const [isOwnerOrAdmin, setIsOwnerOrAdmin] = useState<boolean>(authService.isOwnerOrAdmin());
|
||||
const showUpgradeTypes = [ISUBSCRIPTION_TYPE.TRIAL];
|
||||
const showUpgradeTypes = useMemo(() => [ISUBSCRIPTION_TYPE.TRIAL], []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
authApiService
|
||||
.verify()
|
||||
.then(authorizeResponse => {
|
||||
if (authorizeResponse.authenticated) {
|
||||
if (mounted && authorizeResponse.authenticated) {
|
||||
authService.setCurrentSession(authorizeResponse.user);
|
||||
setIsOwnerOrAdmin(!!(authorizeResponse.user.is_admin || authorizeResponse.user.owner));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Error during authorization', error);
|
||||
if (mounted) {
|
||||
logger.error('Error during authorization', error);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [authService]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedNavRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes;
|
||||
@@ -153,8 +158,8 @@ const Navbar = () => {
|
||||
<Flex align="center">
|
||||
<SwitchTeamButton />
|
||||
<NotificationButton />
|
||||
<TimerButton />
|
||||
{/* <HelpButton /> */}
|
||||
{/* <TimerButton /> */}
|
||||
<HelpButton />
|
||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -183,6 +188,8 @@ const Navbar = () => {
|
||||
{createPortal(<NotificationDrawer />, document.body, 'notification-drawer')}
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Navbar.displayName = 'Navbar';
|
||||
|
||||
export default Navbar;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/app/store';
|
||||
|
||||
const NavbarLogo = () => {
|
||||
const NavbarLogo = memo(() => {
|
||||
const { t } = useTranslation('navbar');
|
||||
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
@@ -23,6 +23,8 @@ const NavbarLogo = () => {
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NavbarLogo.displayName = 'NavbarLogo';
|
||||
|
||||
export default NavbarLogo;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { QuestionCircleOutlined } from '@/shared/antd-imports';
|
||||
import { Button, Tooltip } from '@/shared/antd-imports';
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './HelpButton.css';
|
||||
|
||||
const HelpButton = () => {
|
||||
const HelpButton = memo(() => {
|
||||
// localization
|
||||
const { t } = useTranslation('navbar');
|
||||
|
||||
@@ -18,6 +18,8 @@ const HelpButton = () => {
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
HelpButton.displayName = 'HelpButton';
|
||||
|
||||
export default HelpButton;
|
||||
@@ -1,12 +1,12 @@
|
||||
import { UsergroupAddOutlined } from '@/shared/antd-imports';
|
||||
import { Button, Tooltip } from '@/shared/antd-imports';
|
||||
import React from 'react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleInviteMemberDrawer } from '../../settings/member/memberSlice';
|
||||
import { toggleInviteMemberDrawer } from '../../../features/settings/member/memberSlice';
|
||||
|
||||
const InviteButton = () => {
|
||||
const InviteButton = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// localization
|
||||
@@ -21,12 +21,14 @@ const InviteButton = () => {
|
||||
color: colors.skyBlue,
|
||||
borderColor: colors.skyBlue,
|
||||
}}
|
||||
onClick={() => dispatch(toggleInviteMemberDrawer())}
|
||||
onClick={useCallback(() => dispatch(toggleInviteMemberDrawer()), [dispatch])}
|
||||
>
|
||||
{t('invite')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
InviteButton.displayName = 'InviteButton';
|
||||
|
||||
export default InviteButton;
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Flex,
|
||||
MenuProps,
|
||||
Space,
|
||||
Typography,
|
||||
HomeOutlined,
|
||||
MenuOutlined,
|
||||
ProjectOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReadOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import InviteButton from '@/components/navbar/invite/InviteButton';
|
||||
import SwitchTeamButton from '@/components/navbar/switchTeam/SwitchTeamButton';
|
||||
// custom css
|
||||
import './MobileMenuButton.css';
|
||||
|
||||
const MobileMenuButton = memo(() => {
|
||||
// localization
|
||||
const { t } = useTranslation('navbar');
|
||||
|
||||
const navLinks = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'home',
|
||||
icon: React.createElement(HomeOutlined),
|
||||
},
|
||||
{
|
||||
name: 'projects',
|
||||
icon: React.createElement(ProjectOutlined),
|
||||
},
|
||||
// {
|
||||
// name: 'schedule',
|
||||
// icon: React.createElement(ClockCircleOutlined),
|
||||
// },
|
||||
{
|
||||
name: 'reporting',
|
||||
icon: React.createElement(ReadOutlined),
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
icon: React.createElement(QuestionCircleOutlined),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const mobileMenu: MenuProps['items'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Card className="mobile-menu-card" bordered={false} style={{ width: 230 }}>
|
||||
{navLinks.map((navEl, index) => (
|
||||
<NavLink key={index} to={`/worklenz/${navEl.name}`}>
|
||||
<Typography.Text strong>
|
||||
<Space>
|
||||
{navEl.icon}
|
||||
{t(navEl.name)}
|
||||
</Space>
|
||||
</Typography.Text>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<Flex
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
width: '90%',
|
||||
marginInlineStart: 12,
|
||||
marginBlock: 6,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: colors.lightBeige,
|
||||
color: 'black',
|
||||
}}
|
||||
>
|
||||
{t('upgradePlan')}
|
||||
</Button>
|
||||
<InviteButton />
|
||||
<SwitchTeamButton />
|
||||
</Flex>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navLinks, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="mobile-menu-dropdown"
|
||||
menu={{ items: mobileMenu }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button className="borderless-icon-btn" icon={<MenuOutlined style={{ fontSize: 20 }} />} />
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
MobileMenuButton.displayName = 'MobileMenuButton';
|
||||
|
||||
export default MobileMenuButton;
|
||||
@@ -0,0 +1,128 @@
|
||||
# Notification Components Styling Fixes
|
||||
|
||||
## Issue Resolved
|
||||
Fixed missing spacing and borders in notification templates that occurred during performance optimization.
|
||||
|
||||
## Root Cause
|
||||
During the performance optimization, the CSS class references and styling approach were changed, which resulted in:
|
||||
- Missing borders around notification items
|
||||
- No spacing between notifications
|
||||
- Improper padding and margins
|
||||
|
||||
## Solutions Applied
|
||||
|
||||
### 1. Updated CSS Class Usage
|
||||
- **Before**: Used generic `ant-notification-notice` classes
|
||||
- **After**: Implemented proper Tailwind CSS classes with fallback styling
|
||||
|
||||
### 2. Tailwind CSS Classes Implementation
|
||||
|
||||
#### NotificationItem.tsx
|
||||
```jsx
|
||||
// Container classes with proper spacing and borders
|
||||
const containerClasses = [
|
||||
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
|
||||
'hover:shadow-md hover:bg-gray-50',
|
||||
notification.url ? 'cursor-pointer' : 'cursor-default',
|
||||
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
|
||||
].join(' ');
|
||||
|
||||
// Updated content structure
|
||||
<div className="notification-content">
|
||||
<div className="notification-description">
|
||||
<Text type="secondary" className="mb-2 flex items-center gap-2">
|
||||
<BankOutlined /> {notification.team}
|
||||
</Text>
|
||||
<div className="mb-2" dangerouslySetInnerHTML={safeMessageHtml} />
|
||||
{shouldShowProject && (
|
||||
<div className="mb-2">
|
||||
<Tag style={tagStyle}>{notification.project}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-2">
|
||||
{/* Footer content */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### NotificationTemplate.tsx
|
||||
Applied similar Tailwind classes for consistency:
|
||||
- `p-3` for padding
|
||||
- `mb-3` for bottom margin
|
||||
- `rounded` for border radius
|
||||
- `border border-gray-200` for borders
|
||||
- `shadow-sm` for subtle shadows
|
||||
- `transition-all duration-300` for smooth animations
|
||||
|
||||
#### NotificationDrawer.tsx
|
||||
Updated container classes:
|
||||
```jsx
|
||||
<div className="notification-list mt-4 px-2">
|
||||
{/* Notification items */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Responsive Design Support
|
||||
|
||||
#### Light Mode
|
||||
- Background: `bg-white`
|
||||
- Border: `border-gray-200`
|
||||
- Hover: `hover:bg-gray-50`
|
||||
- Shadow: `shadow-sm` → `hover:shadow-md`
|
||||
|
||||
#### Dark Mode
|
||||
- Background: `dark:bg-gray-800`
|
||||
- Border: `dark:border-gray-600`
|
||||
- Hover: `dark:hover:bg-gray-700`
|
||||
- Maintains proper contrast
|
||||
|
||||
### 4. CSS Imports Fixed
|
||||
- **NotificationItem.tsx**: Updated import from `PushNotificationTemplate.css` to `NotificationItem.css`
|
||||
- **NotificationTemplate.tsx**: Added proper CSS import for styling
|
||||
|
||||
### 5. Spacing Improvements
|
||||
|
||||
#### Margins and Padding
|
||||
- **Container**: `p-3` (12px padding)
|
||||
- **Bottom margin**: `mb-3` (12px between items)
|
||||
- **Internal spacing**: `mb-2` (8px between content sections)
|
||||
- **Text**: `text-xs` for timestamp
|
||||
|
||||
#### Layout Classes
|
||||
- **Flexbox**: `flex items-center gap-2` for inline elements
|
||||
- **Alignment**: `flex items-baseline justify-between` for footer
|
||||
- **Cursor**: `cursor-pointer` or `cursor-default` based on interactivity
|
||||
|
||||
## Visual Improvements
|
||||
|
||||
### Before Fix
|
||||
- No visible borders
|
||||
- Items touching each other
|
||||
- Poor visual hierarchy
|
||||
- Inconsistent spacing
|
||||
|
||||
### After Fix
|
||||
- ✅ Clear borders around each notification
|
||||
- ✅ Proper spacing between items
|
||||
- ✅ Good visual hierarchy
|
||||
- ✅ Consistent padding and margins
|
||||
- ✅ Smooth hover effects
|
||||
- ✅ Dark mode support
|
||||
- ✅ Responsive design
|
||||
|
||||
## Performance Maintained
|
||||
All performance optimizations (React.memo, useCallback, useMemo) remain intact while fixing the visual issues.
|
||||
|
||||
## Build Verification
|
||||
✅ Production build successful
|
||||
✅ No styling conflicts
|
||||
✅ Proper Tailwind CSS compilation
|
||||
✅ Cross-browser compatibility maintained
|
||||
|
||||
## Key Benefits
|
||||
1. **Consistent Design**: Unified styling across all notification components
|
||||
2. **Better UX**: Clear visual separation and proper interactive states
|
||||
3. **Maintainable**: Using Tailwind CSS classes reduces custom CSS
|
||||
4. **Accessible**: Proper contrast ratios and hover states
|
||||
5. **Performance**: No impact on optimized component performance
|
||||
@@ -1,3 +1,4 @@
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from '@/shared/antd-imports';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
fetchNotifications,
|
||||
setNotificationType,
|
||||
toggleDrawer,
|
||||
} from '../../../../../features/navbar/notificationSlice';
|
||||
} from '../../../../features/navbar/notificationSlice';
|
||||
import { NOTIFICATION_OPTION_READ, NOTIFICATION_OPTION_UNREAD } from '@/shared/constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
@@ -15,13 +16,13 @@ import { IWorklenzNotification } from '@/types/notifications/notifications.types
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import NotificationItem from './notification-item';
|
||||
import InvitationItem from './invitation-item';
|
||||
import NotificationItem from './notification/NotificationItem';
|
||||
import InvitationItem from '../../InvitationItem';
|
||||
import { notificationsApiService } from '@/api/notifications/notifications.api.service';
|
||||
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
|
||||
import { INotificationSettings } from '@/types/settings/notifications.types';
|
||||
import { toQueryString } from '@/utils/toQueryString';
|
||||
import { showNotification } from './push-notification-template';
|
||||
import { showNotification } from './notification/PushNotificationTemplate';
|
||||
import { teamsApiService } from '@/api/teams/teams.api.service';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { getUserSession } from '@/utils/session-helper';
|
||||
@@ -30,7 +31,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { createAuthService } from '@/services/auth/auth.service';
|
||||
const HTML_TAG_REGEXP = /<[^>]*>/g;
|
||||
|
||||
const NotificationDrawer = () => {
|
||||
const NotificationDrawer = memo(() => {
|
||||
const { isDrawerOpen, notificationType, notifications, invitations } = useAppSelector(
|
||||
state => state.notificationReducer
|
||||
);
|
||||
@@ -50,72 +51,88 @@ const NotificationDrawer = () => {
|
||||
const navigate = useNavigate();
|
||||
const authService = createAuthService(navigate);
|
||||
|
||||
const createPush = (message: string, title: string, teamId: string | null, url?: string) => {
|
||||
if (Notification.permission === 'granted' && showBrowserPush) {
|
||||
const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
|
||||
const notification = new Notification(title, {
|
||||
body: message.replace(HTML_TAG_REGEXP, ''),
|
||||
icon: img,
|
||||
badge: img,
|
||||
});
|
||||
const createPush = useCallback(
|
||||
(message: string, title: string, teamId: string | null, url?: string) => {
|
||||
if (Notification.permission === 'granted' && showBrowserPush) {
|
||||
const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
|
||||
const notification = new Notification(title, {
|
||||
body: message.replace(HTML_TAG_REGEXP, ''),
|
||||
icon: img,
|
||||
badge: img,
|
||||
});
|
||||
|
||||
notification.onclick = async event => {
|
||||
if (url) {
|
||||
window.focus();
|
||||
notification.onclick = async event => {
|
||||
if (url) {
|
||||
window.focus();
|
||||
|
||||
if (teamId) {
|
||||
await teamsApiService.setActiveTeam(teamId);
|
||||
if (teamId) {
|
||||
try {
|
||||
await teamsApiService.setActiveTeam(teamId);
|
||||
} catch (error) {
|
||||
logger.error('Error setting active team from notification', error);
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
[showBrowserPush]
|
||||
);
|
||||
|
||||
window.location.href = url;
|
||||
const handleInvitationsUpdate = useCallback(
|
||||
(data: ITeamInvitationViewModel[]) => {
|
||||
dispatch(fetchInvitations());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleNotificationsUpdate = useCallback(
|
||||
async (notification: IWorklenzNotification) => {
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
|
||||
if (isPushEnabled()) {
|
||||
const title = notification.team ? `${notification.team} | Worklenz` : 'Worklenz';
|
||||
let url = notification.url;
|
||||
if (url && notification.params && Object.keys(notification.params).length) {
|
||||
const q = toQueryString(notification.params);
|
||||
url += q;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvitationsUpdate = (data: ITeamInvitationViewModel[]) => {
|
||||
dispatch(fetchInvitations());
|
||||
};
|
||||
|
||||
const handleNotificationsUpdate = async (notification: IWorklenzNotification) => {
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
|
||||
if (isPushEnabled()) {
|
||||
const title = notification.team ? `${notification.team} | Worklenz` : 'Worklenz';
|
||||
let url = notification.url;
|
||||
if (url && notification.params && Object.keys(notification.params).length) {
|
||||
const q = toQueryString(notification.params);
|
||||
url += q;
|
||||
createPush(notification.message, title, notification.team_id, url);
|
||||
}
|
||||
|
||||
createPush(notification.message, title, notification.team_id, url);
|
||||
}
|
||||
// Show notification using the template
|
||||
showNotification(notification);
|
||||
},
|
||||
[dispatch, notificationType, isPushEnabled, createPush]
|
||||
);
|
||||
|
||||
// Show notification using the template
|
||||
showNotification(notification);
|
||||
};
|
||||
const handleTeamInvitationsUpdate = useCallback(
|
||||
async (data: ITeamInvitationViewModel) => {
|
||||
const notification: IWorklenzNotification = {
|
||||
id: data.id || '',
|
||||
team: data.team_name || '',
|
||||
team_id: data.team_id || '',
|
||||
message: `You have been invited to join ${data.team_name || 'a team'}`,
|
||||
};
|
||||
|
||||
const handleTeamInvitationsUpdate = async (data: ITeamInvitationViewModel) => {
|
||||
const notification: IWorklenzNotification = {
|
||||
id: data.id || '',
|
||||
team: data.team_name || '',
|
||||
team_id: data.team_id || '',
|
||||
message: `You have been invited to join ${data.team_name || 'a team'}`,
|
||||
};
|
||||
if (isPushEnabled()) {
|
||||
createPush(
|
||||
notification.message,
|
||||
notification.team || 'Worklenz',
|
||||
notification.team_id || null
|
||||
);
|
||||
}
|
||||
|
||||
if (isPushEnabled()) {
|
||||
createPush(
|
||||
notification.message,
|
||||
notification.team || 'Worklenz',
|
||||
notification.team_id || null
|
||||
);
|
||||
}
|
||||
|
||||
// Show notification using the template
|
||||
showNotification(notification);
|
||||
dispatch(fetchInvitations());
|
||||
};
|
||||
// Show notification using the template
|
||||
showNotification(notification);
|
||||
dispatch(fetchInvitations());
|
||||
},
|
||||
[isPushEnabled, createPush, dispatch]
|
||||
);
|
||||
|
||||
const askPushPermission = () => {
|
||||
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
|
||||
@@ -135,27 +152,40 @@ const NotificationDrawer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const markNotificationAsRead = async (id: string) => {
|
||||
if (!id) return;
|
||||
const markNotificationAsRead = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id) return;
|
||||
|
||||
const res = await notificationsApiService.updateNotification(id);
|
||||
if (res.done) {
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
try {
|
||||
const res = await notificationsApiService.updateNotification(id);
|
||||
if (res.done) {
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error marking notification as read', error);
|
||||
}
|
||||
},
|
||||
[dispatch, notificationType]
|
||||
);
|
||||
const handleVerifyAuth = useCallback(async () => {
|
||||
try {
|
||||
const result = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (result.authenticated) {
|
||||
dispatch(setUser(result.user));
|
||||
authService.setCurrentSession(result.user);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error verifying authentication', error);
|
||||
}
|
||||
};
|
||||
const handleVerifyAuth = async () => {
|
||||
const result = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (result.authenticated) {
|
||||
dispatch(setUser(result.user));
|
||||
authService.setCurrentSession(result.user);
|
||||
}
|
||||
};
|
||||
}, [dispatch, authService]);
|
||||
|
||||
const goToUrl = useCallback(
|
||||
async (event: React.MouseEvent, notification: IWorklenzNotification) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!notification.url) return;
|
||||
|
||||
const goToUrl = async (event: React.MouseEvent, notification: IWorklenzNotification) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (notification.url) {
|
||||
dispatch(toggleDrawer());
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -169,12 +199,13 @@ const NotificationDrawer = () => {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error navigating to URL:', error);
|
||||
logger.error('Error navigating to URL:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[dispatch, navigate, handleVerifyAuth]
|
||||
);
|
||||
|
||||
const fetchNotificationsSettings = async () => {
|
||||
try {
|
||||
@@ -190,11 +221,15 @@ const NotificationDrawer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
await notificationsApiService.readAllNotifications();
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
};
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
await notificationsApiService.readAllNotifications();
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
} catch (error) {
|
||||
logger.error('Error marking all notifications as read', error);
|
||||
}
|
||||
}, [dispatch, notificationType]);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate);
|
||||
@@ -242,12 +277,15 @@ const NotificationDrawer = () => {
|
||||
<Segmented<string>
|
||||
options={['Unread', 'Read']}
|
||||
defaultValue={NOTIFICATION_OPTION_UNREAD}
|
||||
onChange={(value: string) => {
|
||||
if (value === NOTIFICATION_OPTION_UNREAD)
|
||||
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
|
||||
if (value === NOTIFICATION_OPTION_READ)
|
||||
dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
|
||||
}}
|
||||
onChange={useCallback(
|
||||
(value: string) => {
|
||||
if (value === NOTIFICATION_OPTION_UNREAD)
|
||||
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
|
||||
if (value === NOTIFICATION_OPTION_READ)
|
||||
dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
|
||||
},
|
||||
[dispatch]
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="link" onClick={handleMarkAllAsRead}>
|
||||
@@ -261,7 +299,7 @@ const NotificationDrawer = () => {
|
||||
</div>
|
||||
)}
|
||||
{invitations && invitations.length > 0 && notificationType === NOTIFICATION_OPTION_UNREAD ? (
|
||||
<div className="notification-list mt-3">
|
||||
<div className="notification-list mt-4 px-2">
|
||||
{invitations.map(invitation => (
|
||||
<InvitationItem
|
||||
key={invitation.id}
|
||||
@@ -273,13 +311,13 @@ const NotificationDrawer = () => {
|
||||
</div>
|
||||
) : null}
|
||||
{notifications && notifications.length > 0 ? (
|
||||
<div className="notification-list mt-3">
|
||||
<div className="notification-list mt-4 px-2">
|
||||
{notifications.map(notification => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD}
|
||||
markNotificationAsRead={id => Promise.resolve(markNotificationAsRead(id))}
|
||||
markNotificationAsRead={markNotificationAsRead}
|
||||
goToUrl={goToUrl}
|
||||
/>
|
||||
))}
|
||||
@@ -288,16 +326,13 @@ const NotificationDrawer = () => {
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('notificationsDrawer.noNotifications')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginBlockStart: 32,
|
||||
}}
|
||||
className="flex flex-col items-center mt-8"
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NotificationDrawer.displayName = 'NotificationDrawer';
|
||||
|
||||
export default NotificationDrawer;
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { memo, useState, useCallback, useMemo } from 'react';
|
||||
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
|
||||
import { BankOutlined } from '@/shared/antd-imports';
|
||||
import { Button, Tag, Typography, theme } from '@/shared/antd-imports';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { fromNow } from '@/utils/dateUtils';
|
||||
import './NotificationItem.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: IWorklenzNotification;
|
||||
isUnreadNotifications?: boolean;
|
||||
markNotificationAsRead?: (id: string) => Promise<void>;
|
||||
goToUrl?: (e: React.MouseEvent, notification: IWorklenzNotification) => Promise<void>;
|
||||
}
|
||||
|
||||
const NotificationItem = memo<NotificationItemProps>(({
|
||||
notification,
|
||||
isUnreadNotifications = true,
|
||||
markNotificationAsRead,
|
||||
goToUrl,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isDarkMode = useMemo(
|
||||
() =>
|
||||
token.colorBgContainer === '#141414' ||
|
||||
token.colorBgContainer.includes('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark',
|
||||
[token.colorBgContainer]
|
||||
);
|
||||
|
||||
const handleNotificationClick = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
await goToUrl?.(e, notification);
|
||||
await markNotificationAsRead?.(notification.id);
|
||||
},
|
||||
[goToUrl, markNotificationAsRead, notification]
|
||||
);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!notification.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await markNotificationAsRead?.(notification.id);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[markNotificationAsRead, notification.id]
|
||||
);
|
||||
|
||||
const safeMessageHtml = useMemo(
|
||||
() => ({ __html: DOMPurify.sanitize(notification.message) }),
|
||||
[notification.message]
|
||||
);
|
||||
|
||||
const tagStyle = useMemo(() => {
|
||||
if (!notification.color) return {};
|
||||
|
||||
const bgColor = `${notification.color}4d`;
|
||||
|
||||
if (isDarkMode) {
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
color: '#ffffff',
|
||||
borderColor: 'transparent',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
borderColor: 'transparent',
|
||||
};
|
||||
}, [notification.color, isDarkMode]);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
border: notification.color ? `2px solid ${notification.color}4d` : undefined,
|
||||
}),
|
||||
[notification.color]
|
||||
);
|
||||
|
||||
const containerClasses = useMemo(
|
||||
() => [
|
||||
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
|
||||
'hover:shadow-md hover:bg-gray-50',
|
||||
notification.url ? 'cursor-pointer' : 'cursor-default',
|
||||
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
|
||||
].join(' '),
|
||||
[notification.url]
|
||||
);
|
||||
|
||||
const formattedDate = useMemo(
|
||||
() => (notification.created_at ? fromNow(notification.created_at) : ''),
|
||||
[notification.created_at]
|
||||
);
|
||||
|
||||
const shouldShowProject = useMemo(
|
||||
() => Boolean(notification.project),
|
||||
[notification.project]
|
||||
);
|
||||
|
||||
const shouldShowMarkAsRead = useMemo(
|
||||
() => Boolean(isUnreadNotifications && markNotificationAsRead),
|
||||
[isUnreadNotifications, markNotificationAsRead]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={containerStyle}
|
||||
onClick={handleNotificationClick}
|
||||
className={containerClasses}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-description">
|
||||
{/* Team name */}
|
||||
<div className="mb-2">
|
||||
<Text type="secondary" className="flex items-center gap-2">
|
||||
<BankOutlined /> {notification.team}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Message with HTML content */}
|
||||
<div className="mb-2" dangerouslySetInnerHTML={safeMessageHtml} />
|
||||
|
||||
{/* Project tag */}
|
||||
{shouldShowProject && (
|
||||
<div className="mb-2">
|
||||
<Tag style={tagStyle}>{notification.project}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with mark as read button and timestamp */}
|
||||
<div className="flex items-baseline justify-between mt-2">
|
||||
{shouldShowMarkAsRead && (
|
||||
<Button
|
||||
loading={loading}
|
||||
type="link"
|
||||
size="small"
|
||||
shape="round"
|
||||
className="p-0"
|
||||
onClick={handleMarkAsRead}
|
||||
>
|
||||
<u>Mark as read</u>
|
||||
</Button>
|
||||
)}
|
||||
<Text type="secondary" className="text-xs">
|
||||
{formattedDate}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NotificationItem.displayName = 'NotificationItem';
|
||||
|
||||
export default NotificationItem;
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { Button, Typography, Tag } from '@/shared/antd-imports';
|
||||
import { BankOutlined } from '@/shared/antd-imports';
|
||||
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleDrawer } from '@features/navbar/notificationSlice';
|
||||
import { teamsApiService } from '@/api/teams/teams.api.service';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { tagBackground } from '@/utils/colorUtils';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import './NotificationItem.css';
|
||||
|
||||
interface NotificationTemplateProps {
|
||||
item: IWorklenzNotification;
|
||||
isUnreadNotifications: boolean;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
loadersMap: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const NotificationTemplate = memo<NotificationTemplateProps>(({
|
||||
item,
|
||||
isUnreadNotifications,
|
||||
markNotificationAsRead,
|
||||
loadersMap,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const goToUrl = useCallback(
|
||||
async (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!item.url) return;
|
||||
|
||||
try {
|
||||
dispatch(toggleDrawer());
|
||||
|
||||
if (item.team_id) {
|
||||
await teamsApiService.setActiveTeam(item.team_id);
|
||||
}
|
||||
|
||||
navigate(item.url, {
|
||||
state: item.params || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error navigating to notification URL', error);
|
||||
}
|
||||
},
|
||||
[item.url, item.team_id, item.params, dispatch, navigate]
|
||||
);
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!item.created_at) return '';
|
||||
try {
|
||||
return formatDistanceToNow(new Date(item.created_at), { addSuffix: true });
|
||||
} catch (error) {
|
||||
logger.error('Error formatting date', error);
|
||||
return '';
|
||||
}
|
||||
}, [item.created_at]);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
markNotificationAsRead(item.id);
|
||||
},
|
||||
[markNotificationAsRead, item.id]
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
border: item.color ? `2px solid ${item.color}4d` : undefined,
|
||||
}),
|
||||
[item.color]
|
||||
);
|
||||
|
||||
const containerClassName = useMemo(
|
||||
() => [
|
||||
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
|
||||
'hover:shadow-md hover:bg-gray-50',
|
||||
item.url ? 'cursor-pointer' : 'cursor-default',
|
||||
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
|
||||
].join(' '),
|
||||
[item.url]
|
||||
);
|
||||
|
||||
const messageHtml = useMemo(
|
||||
() => ({ __html: item.message }),
|
||||
[item.message]
|
||||
);
|
||||
|
||||
const tagStyle = useMemo(
|
||||
() => (item.color ? { backgroundColor: tagBackground(item.color) } : {}),
|
||||
[item.color]
|
||||
);
|
||||
|
||||
const shouldShowProject = useMemo(
|
||||
() => Boolean(item.project && item.color),
|
||||
[item.project, item.color]
|
||||
);
|
||||
|
||||
const isLoading = useMemo(
|
||||
() => Boolean(loadersMap[item.id]),
|
||||
[loadersMap, item.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={containerStyle}
|
||||
onClick={goToUrl}
|
||||
className={containerClassName}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-description">
|
||||
<Typography.Text type="secondary" className="mb-2 flex items-center gap-2">
|
||||
<BankOutlined /> {item.team}
|
||||
</Typography.Text>
|
||||
<div className="mb-2" dangerouslySetInnerHTML={messageHtml} />
|
||||
{shouldShowProject && (
|
||||
<div className="mb-2">
|
||||
<Tag style={tagStyle}>{item.project}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline justify-between mt-2">
|
||||
{isUnreadNotifications && (
|
||||
<Button
|
||||
type="link"
|
||||
shape="round"
|
||||
size="small"
|
||||
loading={isLoading}
|
||||
onClick={handleMarkAsRead}
|
||||
>
|
||||
<u>Mark as read</u>
|
||||
</Button>
|
||||
)}
|
||||
<Typography.Text type="secondary" className="text-xs">
|
||||
{formattedDate}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NotificationTemplate.displayName = 'NotificationTemplate';
|
||||
|
||||
export default NotificationTemplate;
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { notification } from '@/shared/antd-imports';
|
||||
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
|
||||
import { teamsApiService } from '@/api/teams/teams.api.service';
|
||||
import { toQueryString } from '@/utils/toQueryString';
|
||||
import { BankOutlined } from '@/shared/antd-imports';
|
||||
import './PushNotificationTemplate.css';
|
||||
|
||||
interface PushNotificationTemplateProps {
|
||||
notification: IWorklenzNotification;
|
||||
}
|
||||
|
||||
const PushNotificationTemplate = memo(({
|
||||
notification: notificationData,
|
||||
}: PushNotificationTemplateProps) => {
|
||||
const handleClick = useCallback(async () => {
|
||||
if (!notificationData.url) return;
|
||||
|
||||
try {
|
||||
let url = notificationData.url;
|
||||
if (notificationData.params && Object.keys(notificationData.params).length) {
|
||||
const q = toQueryString(notificationData.params);
|
||||
url += q;
|
||||
}
|
||||
|
||||
if (notificationData.team_id) {
|
||||
await teamsApiService.setActiveTeam(notificationData.team_id);
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error('Error handling notification click:', error);
|
||||
}
|
||||
}, [notificationData.url, notificationData.params, notificationData.team_id]);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
cursor: notificationData.url ? 'pointer' : 'default',
|
||||
padding: '8px 0',
|
||||
borderRadius: '8px',
|
||||
}),
|
||||
[notificationData.url]
|
||||
);
|
||||
|
||||
const headerStyle = useMemo(
|
||||
() => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
color: '#262626',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const iconStyle = useMemo(
|
||||
() => ({ marginRight: '8px', color: '#1890ff' }),
|
||||
[]
|
||||
);
|
||||
|
||||
const messageStyle = useMemo(
|
||||
() => ({
|
||||
color: '#595959',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
marginTop: '4px',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const className = useMemo(
|
||||
() => `notification-content ${notificationData.url ? 'clickable' : ''}`,
|
||||
[notificationData.url]
|
||||
);
|
||||
|
||||
const messageHtml = useMemo(
|
||||
() => ({ __html: notificationData.message }),
|
||||
[notificationData.message]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div style={headerStyle}>
|
||||
{notificationData.team ? (
|
||||
<>
|
||||
<BankOutlined style={iconStyle} />
|
||||
{notificationData.team}
|
||||
</>
|
||||
) : (
|
||||
'Worklenz'
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={messageStyle}
|
||||
dangerouslySetInnerHTML={messageHtml}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PushNotificationTemplate.displayName = 'PushNotificationTemplate';
|
||||
|
||||
// Notification queue management
|
||||
class NotificationQueueManager {
|
||||
private queue: IWorklenzNotification[] = [];
|
||||
private isProcessing = false;
|
||||
private readonly maxQueueSize = 10;
|
||||
private readonly notificationStyle = {
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
padding: '12px 16px',
|
||||
minWidth: '300px',
|
||||
maxWidth: '400px',
|
||||
};
|
||||
|
||||
private processQueue = () => {
|
||||
if (this.isProcessing || this.queue.length === 0) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
const notificationData = this.queue.shift();
|
||||
|
||||
if (notificationData) {
|
||||
notification.info({
|
||||
message: null,
|
||||
description: <PushNotificationTemplate notification={notificationData} />,
|
||||
placement: 'topRight',
|
||||
duration: 5,
|
||||
style: this.notificationStyle,
|
||||
onClose: () => {
|
||||
this.isProcessing = false;
|
||||
// Use setTimeout to prevent stack overflow with rapid notifications
|
||||
setTimeout(() => this.processQueue(), 0);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
public addNotification = (notificationData: IWorklenzNotification) => {
|
||||
// Prevent queue overflow
|
||||
if (this.queue.length >= this.maxQueueSize) {
|
||||
console.warn('Notification queue is full, dropping oldest notification');
|
||||
this.queue.shift();
|
||||
}
|
||||
|
||||
this.queue.push(notificationData);
|
||||
this.processQueue();
|
||||
};
|
||||
|
||||
public clearQueue = () => {
|
||||
this.queue.length = 0;
|
||||
this.isProcessing = false;
|
||||
};
|
||||
|
||||
public getQueueLength = () => this.queue.length;
|
||||
}
|
||||
|
||||
const notificationManager = new NotificationQueueManager();
|
||||
|
||||
export const showNotification = (notificationData: IWorklenzNotification) => {
|
||||
notificationManager.addNotification(notificationData);
|
||||
};
|
||||
|
||||
export const clearNotificationQueue = () => {
|
||||
notificationManager.clearQueue();
|
||||
};
|
||||
|
||||
export const getNotificationQueueLength = () => {
|
||||
return notificationManager.getQueueLength();
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user